@lotargo/memory_plugin 1.6.5 → 1.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +576 -443
  3. package/mcp-server/benchmarks/fetch_real_corpus.js +351 -0
  4. package/mcp-server/benchmarks/gpu_profile_benchmark.js +170 -0
  5. package/mcp-server/benchmarks/policy_dominance_test.js +221 -0
  6. package/mcp-server/benchmarks/quality_evaluator.js +598 -0
  7. package/mcp-server/benchmarks/raw_corpus_data.js +613 -0
  8. package/mcp-server/benchmarks/run_benchmarks.js +366 -0
  9. package/mcp-server/benchmarks/stress_ingestion.js +195 -0
  10. package/mcp-server/benchmarks/table_code_retrieval.js +453 -0
  11. package/mcp-server/benchmarks/test_dual_layer.js +141 -0
  12. package/mcp-server/cli/direct_commands.js +39 -0
  13. package/mcp-server/cli.js +16 -5
  14. package/mcp-server/cli_boot.js +4 -1
  15. package/mcp-server/client_cli.js +73 -0
  16. package/mcp-server/client_paths.js +44 -0
  17. package/mcp-server/client_registration.js +38 -0
  18. package/mcp-server/codex_config.js +86 -8
  19. package/mcp-server/db/database.js +14 -21
  20. package/mcp-server/db/migrations.js +66 -77
  21. package/mcp-server/db/rag_blob_transport.js +143 -0
  22. package/mcp-server/db/rag_sync.js +284 -0
  23. package/mcp-server/db/sync_queue.js +219 -307
  24. package/mcp-server/dev_link.js +142 -0
  25. package/mcp-server/fact_format.js +44 -12
  26. package/mcp-server/index.js +17 -7
  27. package/mcp-server/ingest/exporter.js +44 -38
  28. package/mcp-server/ingest/pipeline.js +260 -248
  29. package/mcp-server/persona_migration.js +39 -0
  30. package/mcp-server/prompt_manager.js +162 -55
  31. package/mcp-server/rag_scope.js +83 -0
  32. package/mcp-server/retrieval/retriever.js +99 -64
  33. package/mcp-server/setup.js +150 -100
  34. package/mcp-server/storage/blob_store.js +53 -1
  35. package/mcp-server/tools/core/knowledge_read_core.js +163 -0
  36. package/mcp-server/tools/core/memory_core.js +24 -4
  37. package/mcp-server/tools/core/memory_routing.js +10 -0
  38. package/mcp-server/tools/core/note_core.js +53 -0
  39. package/mcp-server/tools/core/rag_query_core.js +169 -0
  40. package/mcp-server/tools/index.js +11 -9
  41. package/mcp-server/tools/memory_tools.js +4 -1
  42. package/mcp-server/tools/note_tools.js +35 -0
  43. package/mcp-server/tools/rag_tools.js +211 -364
  44. package/mcp-server/uninstall.js +627 -0
  45. package/opencode-plugin/index.js +80 -12
  46. package/opencode-plugin/main.js +136 -0
  47. package/package.json +17 -34
  48. package/skills/using-memory/SKILL.md +28 -19
@@ -8,10 +8,38 @@ import { buildTripleHierarchy } from "./chunker.js";
8
8
  import { embedBatch, vectorToBuffer } from "../ml/model_manager.js";
9
9
  import { buildGraphEdges, saveGraphEdges } from "../graph/graph_extractor.js";
10
10
  import { getConfig } from "../config/config_manager.js";
11
- import { assertIngestPathAllowed } from "../security/path_guard.js";
12
- import { logger } from "../logger.js";
13
- import { GLOBAL_KEY } from "../memory.js";
14
- import { addDocumentScope } from "../rag_scope.js";
11
+ import { assertIngestPathAllowed } from "../security/path_guard.js";
12
+ import { logger } from "../logger.js";
13
+ import { GLOBAL_KEY } from "../memory.js";
14
+ import { addDocumentScope } from "../rag_scope.js";
15
+
16
+ export const RAG_NOTE_KINDS = Object.freeze(["decision", "research", "context", "handoff", "note"]);
17
+ const RAG_NOTE_KIND_SET = new Set(RAG_NOTE_KINDS);
18
+
19
+ export function normalizeNoteKind(kind = "note") {
20
+ const normalized = String(kind || "note").trim().toLowerCase();
21
+ return RAG_NOTE_KIND_SET.has(normalized) ? normalized : "note";
22
+ }
23
+
24
+ export function normalizeNoteTags(tags) {
25
+ const values = Array.isArray(tags) ? tags : String(tags || "").split(",");
26
+ return [...new Set(values.map((tag) => String(tag).trim().toLowerCase()).filter(Boolean))].sort();
27
+ }
28
+
29
+ export function createNoteVirtualPath() {
30
+ return `memory://note/${randomUUID()}`;
31
+ }
32
+
33
+ function mergeMetadata(baseMetadata, metadataOverrides) {
34
+ if (!metadataOverrides || typeof metadataOverrides !== "object" || Array.isArray(metadataOverrides)) return baseMetadata;
35
+ return { ...baseMetadata, ...metadataOverrides };
36
+ }
37
+
38
+ async function clearOnlyCloudTombstone(db, docId, path) {
39
+ if (getConfig().mode !== "only-cloud") return;
40
+ const { clearCloudDocumentTombstone } = await import("../db/rag_sync.js");
41
+ await clearCloudDocumentTombstone(db, { docId, path });
42
+ }
15
43
 
16
44
  export async function ingestDocument({
17
45
  content,
@@ -19,10 +47,11 @@ export async function ingestDocument({
19
47
  path = null,
20
48
  title = null,
21
49
  customDb = null,
22
- customBlobDir = BLOBS_DIR,
23
- generateEmbeddings = true,
24
- projectScope = GLOBAL_KEY,
25
- }) {
50
+ customBlobDir = BLOBS_DIR,
51
+ generateEmbeddings = true,
52
+ projectScope = GLOBAL_KEY,
53
+ metadataOverrides = null,
54
+ }) {
26
55
  const db = customDb || await getDatabase();
27
56
 
28
57
  let effectiveType = type;
@@ -47,285 +76,286 @@ export async function ingestDocument({
47
76
  }
48
77
  }
49
78
 
50
- const { markdown, title: docTitle, metadata } = await normalizeContent({ content, type: effectiveType, path: effectivePath, title: effectiveTitle });
51
- if (type === "url") metadata.source_type = "url";
52
-
53
- const blobRes = await saveBlob(markdown, customBlobDir);
54
- const blobHash = blobRes.hash;
55
-
56
- const generatedDocId = `doc_${randomUUID().replace(/-/g, "").substring(0, 12)}`;
57
- const docPath = effectivePath || `virtual://${type}/${generatedDocId}`;
58
- const existingDoc = await db.prepare("SELECT * FROM documents WHERE path = ?").get(docPath);
59
- const docId = existingDoc?.id || generatedDocId;
60
- const now = Date.now();
61
-
62
- // Identical re-ingestion only adds the new project/global scope. Keeping the
63
- // existing document id preserves every Notebook link and avoids recomputing vectors.
64
- if (existingDoc && existingDoc.checksum === blobHash) {
65
- await db.exec("BEGIN IMMEDIATE;");
66
- try {
67
- await db
68
- .prepare("UPDATE documents SET title = ?, metadata_json = ?, updated_at = ? WHERE id = ?;")
69
- .run(docTitle, JSON.stringify(metadata), now, docId);
70
- const assignedScope = await addDocumentScope(db, docId, projectScope);
71
- await db.exec("COMMIT;");
72
-
73
- const sectionsRow = await db.prepare("SELECT COUNT(*) AS cnt FROM sections WHERE doc_id = ?").get(docId);
74
- const chunksRow = await db.prepare("SELECT COUNT(*) AS cnt FROM micro_chunks WHERE doc_id = ?").get(docId);
75
- if (getConfig().mode === "hybrid-sync") {
76
- try {
77
- const { exportDocumentData } = await import("./exporter.js");
78
- const { enqueueSyncTask } = await import("../db/sync_queue.js");
79
- await enqueueSyncTask("ingest_document", docId, await exportDocumentData(docId, db));
80
- } catch (err) {
81
- logger.error("Failed to queue document scope sync task:", err.message);
82
- }
83
- }
84
- return {
85
- docId,
86
- doc_id: docId,
87
- path: docPath,
88
- blobHash,
89
- blob_hash: blobHash,
90
- title: docTitle,
91
- sectionsCount: sectionsRow?.cnt || 0,
92
- sections_count: sectionsRow?.cnt || 0,
93
- microChunksCount: chunksRow?.cnt || 0,
94
- micro_chunks_count: chunksRow?.cnt || 0,
95
- deduplicated: true,
96
- projectScope: assignedScope,
97
- };
98
- } catch (err) {
99
- await db.exec("ROLLBACK;");
100
- throw new Error(`Ingestion scope transaction failed: ${err.message}`);
101
- }
102
- }
103
-
104
- const hierarchy = buildTripleHierarchy(markdown, docId, docTitle);
79
+ const normalized = await normalizeContent({
80
+ content,
81
+ type: effectiveType,
82
+ path: effectivePath,
83
+ title: effectiveTitle,
84
+ });
85
+ const markdown = normalized.markdown;
86
+ const docTitle = normalized.title;
87
+ if (type === "url") normalized.metadata.source_type = "url";
88
+ const metadata = mergeMetadata(normalized.metadata, metadataOverrides);
89
+
90
+ const blobRes = await saveBlob(markdown, customBlobDir);
91
+ const blobHash = blobRes.hash;
92
+
93
+ if (getConfig().mode === "only-cloud") {
94
+ const { pushBlobToCloud } = await import("../db/rag_blob_transport.js");
95
+ await pushBlobToCloud(db, blobHash, customBlobDir);
96
+ }
97
+
98
+ const generatedDocId = `doc_${randomUUID().replace(/-/g, "").substring(0, 12)}`;
99
+ const docPath = effectivePath || `virtual://${type}/${generatedDocId}`;
100
+ const existingDoc = await db.prepare("SELECT * FROM documents WHERE path = ?").get(docPath);
101
+ const docId = existingDoc?.id || generatedDocId;
102
+ const now = Date.now();
103
+
104
+ if (existingDoc && existingDoc.checksum === blobHash) {
105
+ let assignedScope = projectScope || GLOBAL_KEY;
106
+ await db.exec("BEGIN IMMEDIATE;");
107
+ try {
108
+ await db.prepare("UPDATE documents SET title = ?, metadata_json = ?, updated_at = ? WHERE id = ?;")
109
+ .run(docTitle, JSON.stringify(metadata), now, docId);
110
+ assignedScope = await addDocumentScope(db, docId, projectScope);
111
+ await db.exec("COMMIT;");
112
+ } catch (err) {
113
+ try { await db.exec("ROLLBACK;"); } catch {}
114
+ throw new Error(`Ingestion scope transaction failed: ${err.message}`);
115
+ }
116
+ await clearOnlyCloudTombstone(db, docId, docPath);
117
+
118
+ const sectionsRow = await db.prepare("SELECT COUNT(*) AS cnt FROM sections WHERE doc_id = ?").get(docId);
119
+ const chunksRow = await db.prepare("SELECT COUNT(*) AS cnt FROM micro_chunks WHERE doc_id = ?").get(docId);
120
+ if (getConfig().mode === "hybrid-sync") {
121
+ try {
122
+ const { exportDocumentData } = await import("./exporter.js");
123
+ const { enqueueSyncTask } = await import("../db/sync_queue.js");
124
+ await enqueueSyncTask("ingest_document", docId, await exportDocumentData(docId, db));
125
+ } catch (err) {
126
+ logger.error("Failed to queue document scope sync task:", err.message);
127
+ }
128
+ }
129
+ return {
130
+ docId, doc_id: docId, path: docPath, blobHash, blob_hash: blobHash, title: docTitle,
131
+ sectionsCount: sectionsRow?.cnt || 0, sections_count: sectionsRow?.cnt || 0,
132
+ microChunksCount: chunksRow?.cnt || 0, micro_chunks_count: chunksRow?.cnt || 0,
133
+ deduplicated: true, projectScope: assignedScope,
134
+ };
135
+ }
136
+
137
+ const hierarchy = buildTripleHierarchy(markdown, docId, docTitle);
105
138
 
106
139
  if (generateEmbeddings && hierarchy.microChunks.length > 0) {
107
140
  const BATCH_SIZE = getConfig().batchSize || 12;
108
-
109
- // Smart Batching: Sort micro-chunks by character/token length to minimize ONNX zero-padding overhead
110
141
  const indexedItems = hierarchy.microChunks.map((micro, idx) => ({
111
142
  index: idx,
112
143
  text: micro.breadcrumbs
113
144
  ? `${micro.content}\n\nContext: ${docTitle} > ${micro.breadcrumbs}`
114
145
  : `${micro.content}\n\nContext: ${docTitle}`,
115
146
  }));
116
-
117
147
  indexedItems.sort((a, b) => a.text.length - b.text.length);
118
-
119
148
  for (let i = 0; i < indexedItems.length; i += BATCH_SIZE) {
120
149
  const batch = indexedItems.slice(i, i + BATCH_SIZE);
121
- const batchTexts = batch.map((item) => item.text);
122
- const batchVecs = await embedBatch(batchTexts, false);
150
+ const batchVecs = await embedBatch(batch.map((item) => item.text), false);
123
151
  for (let j = 0; j < batchVecs.length; j++) {
124
- const origIdx = batch[j].index;
125
- hierarchy.microChunks[origIdx].vector = vectorToBuffer(batchVecs[j]);
152
+ hierarchy.microChunks[batch[j].index].vector = vectorToBuffer(batchVecs[j]);
126
153
  }
127
154
  }
128
155
  } else {
129
- for (const micro of hierarchy.microChunks) {
130
- micro.vector = Buffer.alloc(0);
131
- }
156
+ for (const micro of hierarchy.microChunks) micro.vector = Buffer.alloc(0);
132
157
  }
133
158
 
134
- await db.exec("BEGIN IMMEDIATE;");
135
- try {
136
- if (existingDoc) {
137
- const ownedRows = await db.prepare(`
138
- SELECT id FROM sections WHERE doc_id = ?
139
- UNION SELECT id FROM medium_chunks WHERE doc_id = ?
140
- UNION SELECT id FROM micro_chunks WHERE doc_id = ?;
141
- `).all(docId, docId, docId);
142
- const ownedIds = [docId, ...ownedRows.map((row) => row.id)];
143
- try {
144
- await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(docId);
145
- } catch {}
146
- if (ownedIds.length > 0) {
147
- const placeholders = ownedIds.map(() => "?").join(",");
148
- await db
149
- .prepare(`DELETE FROM graph_edges WHERE source_id IN (${placeholders}) OR target_id IN (${placeholders});`)
150
- .run(...ownedIds, ...ownedIds);
151
- }
152
- await db.prepare("DELETE FROM micro_chunks WHERE doc_id = ?;").run(docId);
153
- await db.prepare("DELETE FROM medium_chunks WHERE doc_id = ?;").run(docId);
154
- await db.prepare("DELETE FROM sections WHERE doc_id = ?;").run(docId);
155
- await db.prepare(`
156
- UPDATE documents
157
- SET blob_hash = ?, title = ?, checksum = ?, toc_json = ?, metadata_json = ?, updated_at = ?
158
- WHERE id = ?;
159
- `).run(blobHash, docTitle, blobHash, hierarchy.toc, JSON.stringify(metadata), now, docId);
160
- } else {
161
- await db.prepare(`
162
- INSERT INTO documents (id, path, blob_hash, title, checksum, toc_json, metadata_json, created_at, updated_at)
163
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
164
- `).run(
165
- docId,
166
- docPath,
167
- blobHash,
168
- docTitle,
169
- blobHash,
170
- hierarchy.toc,
171
- JSON.stringify(metadata),
172
- now,
173
- now
174
- );
175
- }
176
- const assignedScope = await addDocumentScope(db, docId, projectScope);
177
-
178
- const insertSectionStmt = db.prepare(`
179
- INSERT INTO sections (id, doc_id, heading, breadcrumbs, content, token_count)
180
- VALUES (?, ?, ?, ?, ?, ?);
181
- `);
159
+ let assignedScope = projectScope || GLOBAL_KEY;
160
+ await db.exec("BEGIN IMMEDIATE;");
161
+ try {
162
+ if (existingDoc) {
163
+ const ownedRows = await db.prepare(`
164
+ SELECT id FROM sections WHERE doc_id = ?
165
+ UNION SELECT id FROM medium_chunks WHERE doc_id = ?
166
+ UNION SELECT id FROM micro_chunks WHERE doc_id = ?;
167
+ `).all(docId, docId, docId);
168
+ const ownedIds = [docId, ...ownedRows.map((row) => row.id)];
169
+ try { await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(docId); } catch {}
170
+ if (ownedIds.length > 0) {
171
+ const placeholders = ownedIds.map(() => "?").join(",");
172
+ await db.prepare(`DELETE FROM graph_edges WHERE source_id IN (${placeholders}) OR target_id IN (${placeholders});`)
173
+ .run(...ownedIds, ...ownedIds);
174
+ }
175
+ await db.prepare("DELETE FROM micro_chunks WHERE doc_id = ?;").run(docId);
176
+ await db.prepare("DELETE FROM medium_chunks WHERE doc_id = ?;").run(docId);
177
+ await db.prepare("DELETE FROM sections WHERE doc_id = ?;").run(docId);
178
+ await db.prepare(`UPDATE documents
179
+ SET blob_hash = ?, title = ?, checksum = ?, toc_json = ?, metadata_json = ?, updated_at = ?
180
+ WHERE id = ?;`).run(blobHash, docTitle, blobHash, hierarchy.toc, JSON.stringify(metadata), now, docId);
181
+ } else {
182
+ await db.prepare(`INSERT INTO documents
183
+ (id, path, blob_hash, title, checksum, toc_json, metadata_json, created_at, updated_at)
184
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`)
185
+ .run(docId, docPath, blobHash, docTitle, blobHash, hierarchy.toc, JSON.stringify(metadata), now, now);
186
+ }
187
+ assignedScope = await addDocumentScope(db, docId, projectScope);
188
+
189
+ const insertSectionStmt = db.prepare(`INSERT INTO sections
190
+ (id, doc_id, heading, breadcrumbs, content, token_count) VALUES (?, ?, ?, ?, ?, ?);`);
182
191
  for (const sec of hierarchy.sections) {
183
192
  await insertSectionStmt.run(sec.id, sec.doc_id, sec.heading, sec.breadcrumbs, sec.content, sec.token_count);
184
193
  }
185
194
 
186
- if (hierarchy.mediumChunks && hierarchy.mediumChunks.length > 0) {
187
- const insertMediumStmt = db.prepare(`
188
- INSERT INTO medium_chunks (id, section_id, doc_id, content, block_type, token_count, created_at)
189
- VALUES (?, ?, ?, ?, ?, ?, ?);
190
- `);
195
+ if (hierarchy.mediumChunks?.length) {
196
+ const insertMediumStmt = db.prepare(`INSERT INTO medium_chunks
197
+ (id, section_id, doc_id, content, block_type, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);`);
191
198
  for (const med of hierarchy.mediumChunks) {
192
199
  await insertMediumStmt.run(med.id, med.section_id, med.doc_id, med.content, med.block_type, med.token_count, now);
193
200
  }
194
201
  }
195
202
 
196
- const insertMicroStmt = db.prepare(`
197
- INSERT INTO micro_chunks (id, section_id, doc_id, content, vector, token_count, medium_id, retrieval_policy, policy_source_id)
198
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
199
- `);
200
- const insertFtsStmt = db.prepare(`
201
- INSERT INTO micro_chunks_fts (id, content, breadcrumbs)
202
- VALUES (?, ?, ?);
203
- `);
204
-
203
+ const insertMicroStmt = db.prepare(`INSERT INTO micro_chunks
204
+ (id, section_id, doc_id, content, vector, token_count, medium_id, retrieval_policy, policy_source_id)
205
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`);
206
+ const insertFtsStmt = db.prepare(`INSERT INTO micro_chunks_fts (id, content, breadcrumbs) VALUES (?, ?, ?);`);
205
207
  for (const micro of hierarchy.microChunks) {
206
208
  await insertMicroStmt.run(micro.id, micro.section_id, micro.doc_id, micro.content, micro.vector, micro.token_count, micro.medium_id || null, micro.retrieval_policy || "micro_chunk", micro.policy_source_id || null);
207
209
  await insertFtsStmt.run(micro.id, micro.content, micro.breadcrumbs);
208
210
  }
209
211
 
210
- const edges = buildGraphEdges(docId, hierarchy);
211
- await saveGraphEdges(db, edges);
212
-
213
- // Recreate graph projections for preserved Notebook links after replacing
214
- // the document's structural chunks. The knowledge_links rows themselves
215
- // remain stable because the document id remains stable.
216
- if (existingDoc) {
217
- const links = await db.prepare("SELECT * FROM knowledge_links WHERE doc_id = ?").all(docId);
218
- const insertLinkEdge = db.prepare(`
219
- INSERT OR IGNORE INTO graph_edges (source_id, target_id, relation_type, metadata_json, created_at)
220
- VALUES (?, ?, ?, ?, ?);
221
- `);
222
- for (const link of links) {
223
- const targetSpec = link.start_line
224
- ? `${docId}:L${link.start_line}-${link.end_line || link.start_line}`
225
- : docId;
226
- await insertLinkEdge.run(
227
- `fact:${link.fact_key}:${link.fact_text.substring(0, 30)}`,
228
- targetSpec,
229
- link.relation_type || "LINKS_TO",
230
- JSON.stringify({ linkId: link.id }),
231
- link.created_at || now
232
- );
233
- }
234
- }
235
-
236
- await db.exec("COMMIT;");
212
+ await saveGraphEdges(db, buildGraphEdges(docId, hierarchy));
213
+
214
+ if (existingDoc) {
215
+ const links = await db.prepare("SELECT * FROM knowledge_links WHERE doc_id = ?").all(docId);
216
+ const insertLinkEdge = db.prepare(`INSERT OR IGNORE INTO graph_edges
217
+ (source_id, target_id, relation_type, metadata_json, created_at) VALUES (?, ?, ?, ?, ?);`);
218
+ for (const link of links) {
219
+ const targetSpec = link.start_line ? `${docId}:L${link.start_line}-${link.end_line || link.start_line}` : docId;
220
+ await insertLinkEdge.run(`fact:${link.fact_key}:${link.fact_text.substring(0, 30)}`, targetSpec, link.relation_type || "LINKS_TO", JSON.stringify({ linkId: link.id }), link.created_at || now);
221
+ }
222
+ }
223
+
224
+ await db.exec("COMMIT;");
237
225
  } catch (err) {
238
- await db.exec("ROLLBACK;");
226
+ try { await db.exec("ROLLBACK;"); } catch {}
239
227
  throw new Error(`Ingestion transaction failed: ${err.message}`);
240
- }
241
-
242
- if (existingDoc?.blob_hash && existingDoc.blob_hash !== blobHash) {
243
- const refs = await db.prepare("SELECT COUNT(*) AS cnt FROM documents WHERE blob_hash = ?").get(existingDoc.blob_hash);
244
- if (!refs?.cnt) {
245
- try {
246
- await deleteBlob(existingDoc.blob_hash, customBlobDir);
247
- } catch {}
248
- }
249
- }
228
+ }
229
+ await clearOnlyCloudTombstone(db, docId, docPath);
230
+
231
+ if (existingDoc?.blob_hash && existingDoc.blob_hash !== blobHash) {
232
+ const refs = await db.prepare("SELECT COUNT(*) AS cnt FROM documents WHERE blob_hash = ?").get(existingDoc.blob_hash);
233
+ if (!refs?.cnt) {
234
+ try { await deleteBlob(existingDoc.blob_hash, customBlobDir); } catch {}
235
+ if (getConfig().mode === "only-cloud") {
236
+ try {
237
+ const { deleteCloudBlobIfUnreferenced } = await import("../db/rag_blob_transport.js");
238
+ await deleteCloudBlobIfUnreferenced(db, existingDoc.blob_hash);
239
+ } catch (err) {
240
+ logger.error("Failed to clean replaced cloud RAG blob:", err.message);
241
+ }
242
+ }
243
+ }
244
+ }
250
245
 
251
246
  if (getConfig().mode === "hybrid-sync") {
252
247
  try {
253
248
  const { exportDocumentData } = await import("./exporter.js");
254
249
  const { enqueueSyncTask } = await import("../db/sync_queue.js");
255
- const exportedData = await exportDocumentData(docId, db);
256
- await enqueueSyncTask("ingest_document", docId, exportedData);
250
+ await enqueueSyncTask("ingest_document", docId, await exportDocumentData(docId, db));
257
251
  } catch (err) {
258
252
  logger.error("Failed to queue document ingest sync task:", err.message);
259
253
  }
260
254
  }
261
255
 
262
256
  return {
263
- docId,
264
- doc_id: docId,
265
- path: docPath,
266
- blobHash,
267
- blob_hash: blobHash,
268
- title: docTitle,
269
- sectionsCount: hierarchy.sections.length,
270
- sections_count: hierarchy.sections.length,
271
- microChunksCount: hierarchy.microChunks.length,
272
- micro_chunks_count: hierarchy.microChunks.length,
273
- deduplicated: blobRes.deduplicated,
274
- projectScope: projectScope || GLOBAL_KEY,
275
- };
276
- }
257
+ docId, doc_id: docId, path: docPath, blobHash, blob_hash: blobHash, title: docTitle,
258
+ sectionsCount: hierarchy.sections.length, sections_count: hierarchy.sections.length,
259
+ microChunksCount: hierarchy.microChunks.length, micro_chunks_count: hierarchy.microChunks.length,
260
+ deduplicated: blobRes.deduplicated, projectScope: assignedScope,
261
+ };
262
+ }
263
+
264
+ export async function ingestNote({
265
+ title,
266
+ content,
267
+ kind = "note",
268
+ tags = null,
269
+ customDb = null,
270
+ customBlobDir = BLOBS_DIR,
271
+ generateEmbeddings = true,
272
+ projectScope = GLOBAL_KEY,
273
+ }) {
274
+ const noteTitle = String(title ?? "").trim();
275
+ const noteContent = String(content ?? "");
276
+ if (!noteTitle) throw new Error("RAG Memory Note title must not be empty");
277
+ if (!noteContent.trim()) throw new Error("RAG Memory Note content must not be empty");
278
+
279
+ const noteKind = normalizeNoteKind(kind);
280
+ const normalizedTags = normalizeNoteTags(tags);
281
+ const notePath = createNoteVirtualPath();
282
+ const noteMetadata = { source_type: "note", note_kind: noteKind, tags: normalizedTags };
283
+
284
+ const result = await ingestDocument({
285
+ content: noteContent,
286
+ type: "text",
287
+ path: notePath,
288
+ title: noteTitle,
289
+ customDb,
290
+ customBlobDir,
291
+ generateEmbeddings,
292
+ projectScope,
293
+ metadataOverrides: noteMetadata,
294
+ });
295
+
296
+ return {
297
+ ...result,
298
+ sourceType: "note", source_type: "note", kind: noteKind, noteKind, note_kind: noteKind,
299
+ tags: normalizedTags, metadata: noteMetadata,
300
+ };
301
+ }
277
302
 
278
303
  export async function deleteDocument(docIdOrPath, customDb = null, customBlobDir = BLOBS_DIR) {
279
304
  const db = customDb || await getDatabase();
280
- const doc = await db.prepare("SELECT * FROM documents WHERE id = ? OR path = ? OR title = ?").get(docIdOrPath, docIdOrPath, docIdOrPath);
281
- if (!doc) {
282
- return { deleted: false, reason: "Document not found" };
283
- }
305
+ const doc = await db.prepare("SELECT * FROM documents WHERE id = ? OR path = ? OR title = ?")
306
+ .get(docIdOrPath, docIdOrPath, docIdOrPath);
307
+ if (!doc) return { deleted: false, reason: "Document not found" };
284
308
 
285
- // Collect every id owned by this document so we can purge dangling graph edges
286
- // (graph_edges has no FK constraints, so section/chunk/doc references would otherwise leak).
309
+ const deletedAt = Date.now();
287
310
  const ownedIds = [doc.id];
288
311
  for (const table of ["sections", "medium_chunks", "micro_chunks"]) {
289
312
  const rows = await db.prepare(`SELECT id FROM ${table} WHERE doc_id = ?`).all(doc.id);
290
- for (const r of rows) ownedIds.push(r.id);
313
+ for (const row of rows) ownedIds.push(row.id);
291
314
  }
292
315
 
293
316
  await db.exec("BEGIN IMMEDIATE;");
294
317
  try {
295
- try {
296
- await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(doc.id);
297
- } catch {}
298
-
299
- // Auto-clean Agent knowledge graph links pointing at this document.
318
+ try { await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(doc.id); } catch {}
300
319
  await db.prepare("DELETE FROM knowledge_links WHERE doc_id = ?").run(doc.id);
301
-
302
320
  for (const id of ownedIds) {
303
- // GLOB: '*' suffix is exact (unlike LIKE, '_' stays literal in ids like doc_xxx).
304
- await db.prepare(
305
- "DELETE FROM graph_edges WHERE source_id = ? OR target_id = ? OR source_id GLOB ? OR target_id GLOB ?"
306
- ).run(id, id, `${id}*`, `${id}*`);
321
+ await db.prepare("DELETE FROM graph_edges WHERE source_id = ? OR target_id = ? OR source_id GLOB ? OR target_id GLOB ?")
322
+ .run(id, id, `${id}*`, `${id}*`);
307
323
  }
308
-
309
- await db.prepare("DELETE FROM documents WHERE id = ?").run(doc.id);
310
-
324
+ await db.prepare("DELETE FROM documents WHERE id = ?;").run(doc.id);
311
325
  await db.exec("COMMIT;");
312
326
  } catch (err) {
313
- await db.exec("ROLLBACK;");
327
+ try { await db.exec("ROLLBACK;"); } catch {}
314
328
  throw err;
315
329
  }
316
330
 
331
+ if (getConfig().mode === "only-cloud") {
332
+ try {
333
+ const { recordCloudDocumentTombstone } = await import("../db/rag_sync.js");
334
+ await recordCloudDocumentTombstone(db, { docId: doc.id, path: doc.path, deletedAt });
335
+ } catch (err) {
336
+ logger.error("Failed to record cloud RAG deletion tombstone:", err.message);
337
+ }
338
+ }
339
+
317
340
  if (doc.blob_hash) {
318
341
  const refCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM documents WHERE blob_hash = ?").get(doc.blob_hash);
319
- const refCount = refCountRow ? refCountRow.cnt : 0;
320
- if (refCount === 0) {
342
+ if (Number(refCountRow?.cnt || 0) === 0) {
321
343
  await deleteBlob(doc.blob_hash, customBlobDir);
344
+ if (getConfig().mode === "only-cloud") {
345
+ try {
346
+ const { deleteCloudBlobIfUnreferenced } = await import("../db/rag_blob_transport.js");
347
+ await deleteCloudBlobIfUnreferenced(db, doc.blob_hash);
348
+ } catch (err) {
349
+ logger.error("Failed to delete orphaned cloud RAG blob:", err.message);
350
+ }
351
+ }
322
352
  }
323
353
  }
324
354
 
325
355
  if (getConfig().mode === "hybrid-sync") {
326
356
  try {
327
357
  const { enqueueSyncTask } = await import("../db/sync_queue.js");
328
- await enqueueSyncTask("delete_document", docIdOrPath);
358
+ await enqueueSyncTask("delete_document", doc.id, { path: doc.path, deletedAt });
329
359
  } catch (err) {
330
360
  logger.error("Failed to queue document delete sync task:", err.message);
331
361
  }
@@ -350,71 +380,53 @@ export async function reindexEmbeddings({
350
380
  const total = countRow ? countRow.cnt : 0;
351
381
  if (total === 0) return { reindexed: 0, documentsAffected: 0, model: targetModel, dimension: targetDim };
352
382
 
353
- const rows = await db.prepare(`
354
- SELECT m.id, m.doc_id, m.content, s.breadcrumbs, d.title as doc_title
383
+ const rows = await db.prepare(`SELECT m.id, m.doc_id, m.content, s.breadcrumbs, d.title as doc_title
355
384
  FROM micro_chunks m
356
385
  LEFT JOIN sections s ON m.section_id = s.id
357
386
  LEFT JOIN documents d ON m.doc_id = d.id
358
- ORDER BY m.doc_id, m.id
359
- `).all();
360
-
361
- const items = rows.map((r) => ({
362
- id: r.id,
363
- doc_id: r.doc_id,
364
- text: r.breadcrumbs
365
- ? `${r.content}\n\nContext: ${r.doc_title || ""} > ${r.breadcrumbs}`
366
- : `${r.content}\n\nContext: ${r.doc_title || ""}`,
387
+ ORDER BY m.doc_id, m.id`).all();
388
+ const items = rows.map((row) => ({
389
+ id: row.id,
390
+ doc_id: row.doc_id,
391
+ text: row.breadcrumbs
392
+ ? `${row.content}\n\nContext: ${row.doc_title || ""} > ${row.breadcrumbs}`
393
+ : `${row.content}\n\nContext: ${row.doc_title || ""}`,
367
394
  }));
368
395
 
369
- const defaultEmbed = async (texts) =>
370
- embedBatch(texts, false, targetModel, progressCallback, null, {}, targetDim || null);
396
+ const defaultEmbed = async (texts) => embedBatch(texts, false, targetModel, progressCallback, null, {}, targetDim || null);
371
397
  const embed = embedFn || defaultEmbed;
372
-
373
398
  const BATCH_SIZE = config.batchSize || 12;
374
399
  const vectors = [];
375
400
  for (let i = 0; i < items.length; i += BATCH_SIZE) {
376
401
  const batch = items.slice(i, i + BATCH_SIZE);
377
- const batchVecs = await embed(batch.map((b) => b.text));
402
+ const batchVecs = await embed(batch.map((item) => item.text));
378
403
  if (!batchVecs || batchVecs.length !== batch.length) {
379
404
  throw new Error(`Embedding batch returned ${batchVecs ? batchVecs.length : 0} vectors, expected ${batch.length}`);
380
405
  }
381
- for (let j = 0; j < batch.length; j++) {
382
- vectors.push({ id: batch[j].id, doc_id: batch[j].doc_id, vector: vectorToBuffer(batchVecs[j]) });
383
- }
406
+ for (let j = 0; j < batch.length; j++) vectors.push({ id: batch[j].id, doc_id: batch[j].doc_id, vector: vectorToBuffer(batchVecs[j]) });
384
407
  if (progressCallback) progressCallback({ done: vectors.length, total });
385
408
  }
386
409
 
387
410
  await db.exec("BEGIN IMMEDIATE;");
388
411
  try {
389
412
  const stmt = db.prepare("UPDATE micro_chunks SET vector = ? WHERE id = ?;");
390
- for (const v of vectors) {
391
- await stmt.run(v.vector, v.id);
392
- }
413
+ for (const vector of vectors) await stmt.run(vector.vector, vector.id);
393
414
  await db.exec("COMMIT;");
394
415
  } catch (err) {
395
- await db.exec("ROLLBACK;");
416
+ try { await db.exec("ROLLBACK;"); } catch {}
396
417
  throw new Error(`Re-index transaction failed: ${err.message}`);
397
418
  }
398
419
 
399
- const affectedDocIds = [...new Set(items.map((i) => i.doc_id).filter(Boolean))];
400
-
420
+ const affectedDocIds = [...new Set(items.map((item) => item.doc_id).filter(Boolean))];
401
421
  if (config.mode === "hybrid-sync") {
402
422
  try {
403
423
  const { enqueueSyncTask } = await import("../db/sync_queue.js");
404
424
  const { exportDocumentData } = await import("./exporter.js");
405
- for (const docId of affectedDocIds) {
406
- const exportedData = await exportDocumentData(docId, db);
407
- await enqueueSyncTask("ingest_document", docId, exportedData);
408
- }
425
+ for (const docId of affectedDocIds) await enqueueSyncTask("ingest_document", docId, await exportDocumentData(docId, db));
409
426
  } catch (err) {
410
427
  logger.error("Failed to queue document re-index sync tasks:", err.message);
411
428
  }
412
429
  }
413
430
 
414
- return {
415
- reindexed: vectors.length,
416
- documentsAffected: affectedDocIds.length,
417
- model: targetModel,
418
- dimension: targetDim,
419
- };
431
+ return { reindexed: vectors.length, documentsAffected: affectedDocIds.length, model: targetModel, dimension: targetDim };
420
432
  }