Skip to content

Commit 4f8a9a0

Browse files
fix(ci): include moltis memory crate in vendored source
1 parent b2309a7 commit 4f8a9a0

21 files changed

Lines changed: 5214 additions & 1 deletion

.docker/moltis/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/target
22
.superset
3-
memory
3+
/memory
44
prompts
55
.envrc
66
.env
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[package]
2+
edition.workspace = true
3+
name = "moltis-memory"
4+
version.workspace = true
5+
6+
[features]
7+
default = ["file-watcher"]
8+
file-watcher = ["dep:notify-debouncer-full"]
9+
local-embeddings = ["dep:llama-cpp-2"]
10+
metrics = ["dep:moltis-metrics"]
11+
12+
[dependencies]
13+
anyhow = { workspace = true }
14+
async-trait = { workspace = true }
15+
chrono = { features = ["serde"], workspace = true }
16+
directories.workspace = true
17+
llama-cpp-2 = { optional = true, workspace = true }
18+
moltis-agents = { workspace = true }
19+
moltis-common = { workspace = true }
20+
moltis-metrics = { optional = true, workspace = true }
21+
notify-debouncer-full = { optional = true, workspace = true }
22+
reqwest = { workspace = true }
23+
secrecy = { workspace = true }
24+
serde = { workspace = true }
25+
serde_json = { workspace = true }
26+
sha2 = { workspace = true }
27+
sqlx = { workspace = true }
28+
tokio = { workspace = true }
29+
tracing = { workspace = true }
30+
walkdir = { workspace = true }
31+
32+
[dev-dependencies]
33+
tempfile = { workspace = true }
34+
35+
[lints]
36+
workspace = true
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
-- Memory system schema
2+
3+
CREATE TABLE IF NOT EXISTS files (
4+
path TEXT NOT NULL PRIMARY KEY,
5+
source TEXT NOT NULL,
6+
hash TEXT NOT NULL,
7+
mtime INTEGER NOT NULL,
8+
size INTEGER NOT NULL
9+
);
10+
11+
CREATE TABLE IF NOT EXISTS chunks (
12+
id TEXT NOT NULL PRIMARY KEY,
13+
path TEXT NOT NULL,
14+
source TEXT NOT NULL,
15+
start_line INTEGER NOT NULL,
16+
end_line INTEGER NOT NULL,
17+
hash TEXT NOT NULL,
18+
model TEXT NOT NULL,
19+
text TEXT NOT NULL,
20+
embedding BLOB,
21+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
22+
FOREIGN KEY (path) REFERENCES files(path) ON DELETE CASCADE
23+
);
24+
25+
CREATE TABLE IF NOT EXISTS embedding_cache (
26+
provider TEXT NOT NULL,
27+
model TEXT NOT NULL,
28+
provider_key TEXT NOT NULL,
29+
hash TEXT NOT NULL,
30+
embedding BLOB NOT NULL,
31+
dims INTEGER NOT NULL,
32+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
33+
PRIMARY KEY (provider, model, provider_key, hash)
34+
);
35+
36+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
37+
text,
38+
content=chunks,
39+
content_rowid=rowid
40+
);
41+
42+
-- Triggers to keep FTS in sync
43+
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
44+
INSERT INTO chunks_fts(rowid, text) VALUES (new.rowid, new.text);
45+
END;
46+
47+
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
48+
INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
49+
END;
50+
51+
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
52+
INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
53+
INSERT INTO chunks_fts(rowid, text) VALUES (new.rowid, new.text);
54+
END;
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! Split markdown text into overlapping chunks by line, targeting a token count.
2+
//!
3+
//! Tokens are approximated as whitespace-split words.
4+
5+
/// A chunk produced by the chunker.
6+
#[derive(Debug, Clone, PartialEq, Eq)]
7+
pub struct Chunk {
8+
pub text: String,
9+
pub start_line: usize,
10+
pub end_line: usize,
11+
}
12+
13+
/// Split `text` into chunks of approximately `chunk_size` tokens with `overlap` token overlap.
14+
///
15+
/// Lines are never split mid-line. Each chunk records its 1-based start and end line numbers.
16+
pub fn chunk_markdown(text: &str, chunk_size: usize, overlap: usize) -> Vec<Chunk> {
17+
if text.is_empty() || chunk_size == 0 {
18+
return vec![];
19+
}
20+
21+
let lines: Vec<&str> = text.lines().collect();
22+
if lines.is_empty() {
23+
return vec![];
24+
}
25+
26+
let line_tokens: Vec<usize> = lines
27+
.iter()
28+
.map(|l| l.split_whitespace().count().max(1)) // empty lines count as 1 token
29+
.collect();
30+
31+
let mut chunks = Vec::new();
32+
let mut start = 0;
33+
34+
while start < lines.len() {
35+
let mut end = start;
36+
let mut tokens = 0;
37+
38+
// Accumulate lines until we reach chunk_size tokens
39+
while end < lines.len() && tokens + line_tokens[end] <= chunk_size {
40+
tokens += line_tokens[end];
41+
end += 1;
42+
}
43+
44+
// If we couldn't fit even one line, take it anyway
45+
if end == start {
46+
end = start + 1;
47+
}
48+
49+
let chunk_text: String = lines[start..end].join("\n");
50+
chunks.push(Chunk {
51+
text: chunk_text,
52+
start_line: start + 1, // 1-based
53+
end_line: end, // 1-based inclusive
54+
});
55+
56+
if end >= lines.len() {
57+
break;
58+
}
59+
60+
// Move start forward, keeping `overlap` tokens of context
61+
let mut overlap_tokens = 0;
62+
let mut new_start = end;
63+
while new_start > start && overlap_tokens < overlap {
64+
new_start -= 1;
65+
overlap_tokens += line_tokens[new_start];
66+
}
67+
68+
// Ensure progress
69+
if new_start <= start {
70+
new_start = start + 1;
71+
}
72+
start = new_start;
73+
}
74+
75+
chunks
76+
}
77+
78+
#[allow(clippy::unwrap_used, clippy::expect_used)]
79+
#[cfg(test)]
80+
mod tests {
81+
use super::*;
82+
83+
#[test]
84+
fn test_empty() {
85+
assert!(chunk_markdown("", 400, 80).is_empty());
86+
}
87+
88+
#[test]
89+
fn test_single_small_chunk() {
90+
let text = "hello world\nfoo bar";
91+
let chunks = chunk_markdown(text, 400, 80);
92+
assert_eq!(chunks.len(), 1);
93+
assert_eq!(chunks[0].start_line, 1);
94+
assert_eq!(chunks[0].end_line, 2);
95+
assert_eq!(chunks[0].text, text);
96+
}
97+
98+
#[test]
99+
fn test_multiple_chunks_with_overlap() {
100+
// Create text with ~10 tokens per line, 5 lines = 50 tokens
101+
let lines: Vec<String> = (0..10)
102+
.map(|i| format!("line {} has several words in it here now ok", i))
103+
.collect();
104+
let text = lines.join("\n");
105+
106+
let chunks = chunk_markdown(&text, 20, 5);
107+
assert!(chunks.len() > 1);
108+
109+
// Verify overlap: last lines of chunk N should overlap with first lines of chunk N+1
110+
for i in 0..chunks.len() - 1 {
111+
assert!(
112+
chunks[i + 1].start_line <= chunks[i].end_line,
113+
"chunk {} end_line {} should overlap with chunk {} start_line {}",
114+
i,
115+
chunks[i].end_line,
116+
i + 1,
117+
chunks[i + 1].start_line
118+
);
119+
}
120+
121+
// Verify all lines are covered
122+
assert_eq!(chunks[0].start_line, 1);
123+
assert_eq!(chunks.last().unwrap().end_line, 10);
124+
}
125+
126+
#[test]
127+
fn test_line_numbers_are_1_based() {
128+
let text = "a\nb\nc";
129+
let chunks = chunk_markdown(text, 1, 0);
130+
assert_eq!(chunks[0].start_line, 1);
131+
assert_eq!(chunks[0].end_line, 1);
132+
}
133+
134+
#[test]
135+
fn test_zero_chunk_size() {
136+
assert!(chunk_markdown("hello", 0, 0).is_empty());
137+
}
138+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
use std::path::PathBuf;
2+
3+
/// Citation mode for memory search results.
4+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5+
pub enum CitationMode {
6+
/// Always include citations in search results.
7+
On,
8+
/// Never include citations.
9+
Off,
10+
/// Auto: include citations when results come from multiple files.
11+
#[default]
12+
Auto,
13+
}
14+
15+
impl std::str::FromStr for CitationMode {
16+
type Err = std::convert::Infallible;
17+
18+
/// Parse from string (case-insensitive). Never fails - defaults to Auto.
19+
fn from_str(s: &str) -> Result<Self, Self::Err> {
20+
Ok(match s.to_lowercase().as_str() {
21+
"on" | "true" | "yes" | "always" => Self::On,
22+
"off" | "false" | "no" | "never" => Self::Off,
23+
_ => Self::Auto,
24+
})
25+
}
26+
}
27+
28+
/// Configuration for the memory subsystem.
29+
#[derive(Debug, Clone)]
30+
pub struct MemoryConfig {
31+
/// Path to the SQLite database file (or `:memory:` for tests).
32+
pub db_path: String,
33+
/// Root data directory for writing memory files (e.g. `~/.moltis/`).
34+
/// Required for `MemoryWriter` support. `None` disables writes.
35+
pub data_dir: Option<PathBuf>,
36+
/// Directories to scan for markdown files.
37+
pub memory_dirs: Vec<PathBuf>,
38+
/// Target chunk size in tokens (approximate, counted as whitespace-split words).
39+
pub chunk_size: usize,
40+
/// Overlap between consecutive chunks in tokens.
41+
pub chunk_overlap: usize,
42+
/// Weight for vector similarity in hybrid search (0.0–1.0).
43+
pub vector_weight: f32,
44+
/// Weight for keyword/FTS similarity in hybrid search (0.0–1.0).
45+
pub keyword_weight: f32,
46+
/// Path to a local GGUF model file for offline embeddings.
47+
/// If `None`, the default model path will be used when `local-embeddings` feature is enabled.
48+
pub local_model_path: Option<PathBuf>,
49+
/// Whether to enable batch embedding via the OpenAI batch API (opt-in).
50+
pub batch_embeddings: bool,
51+
/// Minimum number of texts before switching to batch API (default: 50).
52+
pub batch_threshold: usize,
53+
/// Citation mode for search results.
54+
pub citations: CitationMode,
55+
/// Whether to enable LLM reranking for hybrid search results.
56+
pub llm_reranking: bool,
57+
}
58+
59+
impl Default for MemoryConfig {
60+
fn default() -> Self {
61+
Self {
62+
db_path: "memory.db".into(),
63+
data_dir: None,
64+
memory_dirs: vec![PathBuf::from("memory")],
65+
chunk_size: 400,
66+
chunk_overlap: 80,
67+
vector_weight: 0.7,
68+
keyword_weight: 0.3,
69+
local_model_path: None,
70+
batch_embeddings: false,
71+
batch_threshold: 50,
72+
citations: CitationMode::default(),
73+
llm_reranking: false,
74+
}
75+
}
76+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/// Provider-agnostic embedding trait for generating vectors from text.
2+
use async_trait::async_trait;
3+
4+
#[async_trait]
5+
pub trait EmbeddingProvider: Send + Sync {
6+
/// Generate an embedding for a single text.
7+
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>>;
8+
9+
/// Generate embeddings for a batch of texts.
10+
/// Default implementation calls `embed` sequentially.
11+
async fn embed_batch(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
12+
let mut results = Vec::with_capacity(texts.len());
13+
for text in texts {
14+
results.push(self.embed(text).await?);
15+
}
16+
Ok(results)
17+
}
18+
19+
/// The model name used by this provider (e.g. "text-embedding-3-small").
20+
fn model_name(&self) -> &str;
21+
22+
/// The dimensionality of the embeddings produced.
23+
fn dimensions(&self) -> usize;
24+
25+
/// A stable key identifying this provider configuration for cache discrimination.
26+
/// Different providers or the same provider with different settings should return
27+
/// different keys.
28+
fn provider_key(&self) -> &str;
29+
}

0 commit comments

Comments
 (0)