@lotargo/memory_plugin 1.6.6 → 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.
- package/CHANGELOG.md +28 -0
- package/README.md +576 -443
- package/mcp-server/cli/direct_commands.js +39 -0
- package/mcp-server/cli.js +16 -5
- package/mcp-server/cli_boot.js +4 -1
- package/mcp-server/client_cli.js +73 -0
- package/mcp-server/client_paths.js +44 -0
- package/mcp-server/client_registration.js +38 -0
- package/mcp-server/codex_config.js +86 -8
- package/mcp-server/db/database.js +14 -21
- package/mcp-server/db/migrations.js +66 -77
- package/mcp-server/db/rag_blob_transport.js +143 -0
- package/mcp-server/db/rag_sync.js +284 -0
- package/mcp-server/db/sync_queue.js +219 -307
- package/mcp-server/dev_link.js +142 -0
- package/mcp-server/fact_format.js +44 -12
- package/mcp-server/index.js +17 -7
- package/mcp-server/ingest/exporter.js +44 -38
- package/mcp-server/ingest/pipeline.js +260 -248
- package/mcp-server/persona_migration.js +39 -0
- package/mcp-server/prompt_manager.js +162 -55
- package/mcp-server/retrieval/retriever.js +99 -64
- package/mcp-server/setup.js +150 -100
- package/mcp-server/storage/blob_store.js +53 -1
- package/mcp-server/tools/core/knowledge_read_core.js +163 -0
- package/mcp-server/tools/core/memory_core.js +24 -4
- package/mcp-server/tools/core/memory_routing.js +10 -0
- package/mcp-server/tools/core/note_core.js +53 -0
- package/mcp-server/tools/core/rag_query_core.js +169 -0
- package/mcp-server/tools/index.js +11 -9
- package/mcp-server/tools/memory_tools.js +4 -1
- package/mcp-server/tools/note_tools.js +35 -0
- package/mcp-server/tools/rag_tools.js +211 -364
- package/mcp-server/uninstall.js +627 -0
- package/opencode-plugin/index.js +80 -12
- package/opencode-plugin/main.js +136 -0
- package/package.json +16 -12
- package/skills/using-memory/SKILL.md +28 -19
|
@@ -3,7 +3,6 @@ const MIGRATIONS = [
|
|
|
3
3
|
version: 1,
|
|
4
4
|
name: "001_initial_rag_schema",
|
|
5
5
|
up: async (db) => {
|
|
6
|
-
// 1. Documents Table
|
|
7
6
|
await db.exec(`
|
|
8
7
|
CREATE TABLE IF NOT EXISTS documents (
|
|
9
8
|
id TEXT PRIMARY KEY,
|
|
@@ -17,8 +16,6 @@ const MIGRATIONS = [
|
|
|
17
16
|
updated_at INTEGER NOT NULL
|
|
18
17
|
);
|
|
19
18
|
`);
|
|
20
|
-
|
|
21
|
-
// 2. Sections Table
|
|
22
19
|
await db.exec(`
|
|
23
20
|
CREATE TABLE IF NOT EXISTS sections (
|
|
24
21
|
id TEXT PRIMARY KEY,
|
|
@@ -29,8 +26,6 @@ const MIGRATIONS = [
|
|
|
29
26
|
token_count INTEGER NOT NULL
|
|
30
27
|
);
|
|
31
28
|
`);
|
|
32
|
-
|
|
33
|
-
// 3. Micro-Chunks Table
|
|
34
29
|
await db.exec(`
|
|
35
30
|
CREATE TABLE IF NOT EXISTS micro_chunks (
|
|
36
31
|
id TEXT PRIMARY KEY,
|
|
@@ -41,8 +36,6 @@ const MIGRATIONS = [
|
|
|
41
36
|
token_count INTEGER NOT NULL
|
|
42
37
|
);
|
|
43
38
|
`);
|
|
44
|
-
|
|
45
|
-
// 4. Full-Text Search (BM25 Index via SQLite FTS5)
|
|
46
39
|
await db.exec(`
|
|
47
40
|
CREATE VIRTUAL TABLE IF NOT EXISTS micro_chunks_fts USING fts5(
|
|
48
41
|
id UNINDEXED,
|
|
@@ -50,8 +43,6 @@ const MIGRATIONS = [
|
|
|
50
43
|
breadcrumbs
|
|
51
44
|
);
|
|
52
45
|
`);
|
|
53
|
-
|
|
54
|
-
// 5. GraphRAG Lite Edges Table
|
|
55
46
|
await db.exec(`
|
|
56
47
|
CREATE TABLE IF NOT EXISTS graph_edges (
|
|
57
48
|
source_id TEXT NOT NULL,
|
|
@@ -66,13 +57,8 @@ const MIGRATIONS = [
|
|
|
66
57
|
version: 2,
|
|
67
58
|
name: "002_agent_knowledge_graph",
|
|
68
59
|
up: async (db) => {
|
|
69
|
-
try {
|
|
70
|
-
|
|
71
|
-
} catch (e) {}
|
|
72
|
-
try {
|
|
73
|
-
await db.exec(`ALTER TABLE graph_edges ADD COLUMN created_at INTEGER;`);
|
|
74
|
-
} catch (e) {}
|
|
75
|
-
|
|
60
|
+
try { await db.exec(`ALTER TABLE graph_edges ADD COLUMN metadata_json TEXT;`); } catch (e) {}
|
|
61
|
+
try { await db.exec(`ALTER TABLE graph_edges ADD COLUMN created_at INTEGER;`); } catch (e) {}
|
|
76
62
|
await db.exec(`
|
|
77
63
|
CREATE TABLE IF NOT EXISTS knowledge_links (
|
|
78
64
|
id TEXT PRIMARY KEY,
|
|
@@ -104,10 +90,7 @@ const MIGRATIONS = [
|
|
|
104
90
|
created_at INTEGER
|
|
105
91
|
);
|
|
106
92
|
`);
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
await db.exec(`ALTER TABLE micro_chunks ADD COLUMN medium_id TEXT REFERENCES medium_chunks(id) ON DELETE CASCADE;`);
|
|
110
|
-
} catch (e) {}
|
|
93
|
+
try { await db.exec(`ALTER TABLE micro_chunks ADD COLUMN medium_id TEXT REFERENCES medium_chunks(id) ON DELETE CASCADE;`); } catch (e) {}
|
|
111
94
|
},
|
|
112
95
|
},
|
|
113
96
|
{
|
|
@@ -116,65 +99,81 @@ const MIGRATIONS = [
|
|
|
116
99
|
up: async (db) => {
|
|
117
100
|
await db.exec(`
|
|
118
101
|
CREATE TABLE IF NOT EXISTS project_identities (
|
|
119
|
-
key
|
|
120
|
-
name
|
|
102
|
+
key TEXT PRIMARY KEY,
|
|
103
|
+
name TEXT NOT NULL,
|
|
121
104
|
primary_remote TEXT,
|
|
122
|
-
created_at
|
|
123
|
-
updated_at
|
|
105
|
+
created_at INTEGER NOT NULL,
|
|
106
|
+
updated_at INTEGER NOT NULL
|
|
124
107
|
);
|
|
125
108
|
`);
|
|
126
|
-
|
|
127
109
|
await db.exec(`
|
|
128
110
|
CREATE TABLE IF NOT EXISTS project_aliases (
|
|
129
|
-
alias
|
|
111
|
+
alias TEXT PRIMARY KEY,
|
|
130
112
|
identity_key TEXT NOT NULL REFERENCES project_identities(key) ON DELETE CASCADE,
|
|
131
|
-
kind
|
|
132
|
-
created_at
|
|
113
|
+
kind TEXT NOT NULL,
|
|
114
|
+
created_at INTEGER NOT NULL
|
|
133
115
|
);
|
|
134
116
|
`);
|
|
135
|
-
|
|
117
|
+
await db.exec(`CREATE INDEX IF NOT EXISTS idx_project_aliases_identity ON project_aliases(identity_key);`);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
version: 5,
|
|
122
|
+
name: "005_retrieval_policy",
|
|
123
|
+
up: async (db) => {
|
|
124
|
+
try { await db.exec(`ALTER TABLE micro_chunks ADD COLUMN retrieval_policy TEXT DEFAULT 'micro_chunk';`); } catch (e) {}
|
|
125
|
+
try { await db.exec(`ALTER TABLE micro_chunks ADD COLUMN policy_source_id TEXT;`); } catch (e) {}
|
|
126
|
+
await db.exec(`CREATE INDEX IF NOT EXISTS idx_micro_chunks_retrieval_policy ON micro_chunks(retrieval_policy);`);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
version: 6,
|
|
131
|
+
name: "006_project_scoped_rag",
|
|
132
|
+
up: async (db) => {
|
|
136
133
|
await db.exec(`
|
|
137
|
-
CREATE
|
|
134
|
+
CREATE TABLE IF NOT EXISTS document_scopes (
|
|
135
|
+
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
136
|
+
scope_key TEXT NOT NULL,
|
|
137
|
+
created_at INTEGER NOT NULL,
|
|
138
|
+
PRIMARY KEY (doc_id, scope_key)
|
|
139
|
+
);
|
|
140
|
+
`);
|
|
141
|
+
await db.exec(`CREATE INDEX IF NOT EXISTS idx_document_scopes_scope ON document_scopes(scope_key, doc_id);`);
|
|
142
|
+
await db.exec(`
|
|
143
|
+
INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at)
|
|
144
|
+
SELECT id, 'global', created_at FROM documents;
|
|
138
145
|
`);
|
|
139
146
|
},
|
|
140
147
|
},
|
|
141
|
-
{
|
|
142
|
-
version:
|
|
143
|
-
name: "
|
|
148
|
+
{
|
|
149
|
+
version: 7,
|
|
150
|
+
name: "007_rag_blob_transport",
|
|
144
151
|
up: async (db) => {
|
|
145
|
-
try {
|
|
146
|
-
await db.exec(`ALTER TABLE micro_chunks ADD COLUMN retrieval_policy TEXT DEFAULT 'micro_chunk';`);
|
|
147
|
-
} catch (e) {}
|
|
148
|
-
try {
|
|
149
|
-
await db.exec(`ALTER TABLE micro_chunks ADD COLUMN policy_source_id TEXT;`);
|
|
150
|
-
} catch (e) {}
|
|
151
152
|
await db.exec(`
|
|
152
|
-
CREATE
|
|
153
|
+
CREATE TABLE IF NOT EXISTS rag_blobs (
|
|
154
|
+
hash TEXT PRIMARY KEY,
|
|
155
|
+
gzip_base64 TEXT NOT NULL,
|
|
156
|
+
raw_size INTEGER NOT NULL DEFAULT 0,
|
|
157
|
+
created_at INTEGER NOT NULL
|
|
158
|
+
);
|
|
153
159
|
`);
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
version:
|
|
158
|
-
name: "
|
|
159
|
-
up: async (db) => {
|
|
160
|
-
await db.exec(`
|
|
161
|
-
CREATE TABLE IF NOT EXISTS
|
|
162
|
-
doc_id
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
`);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
await db.exec(`
|
|
172
|
-
INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at)
|
|
173
|
-
SELECT id, 'global', created_at FROM documents;
|
|
174
|
-
`);
|
|
175
|
-
},
|
|
176
|
-
},
|
|
177
|
-
];
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
version: 8,
|
|
164
|
+
name: "008_rag_document_tombstones",
|
|
165
|
+
up: async (db) => {
|
|
166
|
+
await db.exec(`
|
|
167
|
+
CREATE TABLE IF NOT EXISTS rag_document_tombstones (
|
|
168
|
+
doc_id TEXT PRIMARY KEY,
|
|
169
|
+
path TEXT,
|
|
170
|
+
deleted_at INTEGER NOT NULL
|
|
171
|
+
);
|
|
172
|
+
`);
|
|
173
|
+
await db.exec(`CREATE INDEX IF NOT EXISTS idx_rag_tombstones_path ON rag_document_tombstones(path);`);
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
];
|
|
178
177
|
|
|
179
178
|
export async function runMigrations(db) {
|
|
180
179
|
let currentVersion = 0;
|
|
@@ -188,12 +187,7 @@ export async function runMigrations(db) {
|
|
|
188
187
|
} catch (e2) {}
|
|
189
188
|
}
|
|
190
189
|
|
|
191
|
-
|
|
192
|
-
try {
|
|
193
|
-
await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY);`);
|
|
194
|
-
} catch (e) {}
|
|
195
|
-
|
|
196
|
-
// Create notebooks table if not exists
|
|
190
|
+
try { await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY);`); } catch (e) {}
|
|
197
191
|
try {
|
|
198
192
|
await db.exec(`
|
|
199
193
|
CREATE TABLE IF NOT EXISTS notebooks (
|
|
@@ -211,19 +205,14 @@ export async function runMigrations(db) {
|
|
|
211
205
|
await migration.up(db);
|
|
212
206
|
await db.prepare("INSERT INTO schema_migrations (version) VALUES (?);").run(migration.version);
|
|
213
207
|
await db.exec("COMMIT;");
|
|
214
|
-
try {
|
|
215
|
-
await db.exec(`PRAGMA user_version = ${migration.version};`);
|
|
216
|
-
} catch (e) {}
|
|
208
|
+
try { await db.exec(`PRAGMA user_version = ${migration.version};`); } catch (e) {}
|
|
217
209
|
} catch (err) {
|
|
218
|
-
try {
|
|
219
|
-
await db.exec("ROLLBACK;");
|
|
220
|
-
} catch (e) {}
|
|
210
|
+
try { await db.exec("ROLLBACK;"); } catch (e) {}
|
|
221
211
|
throw new Error(`Migration ${migration.name} failed: ${err.message}`);
|
|
222
212
|
}
|
|
223
213
|
}
|
|
224
214
|
}
|
|
225
215
|
|
|
226
|
-
// Defensive table & column check for medium_chunks hierarchy
|
|
227
216
|
try {
|
|
228
217
|
await db.exec(`
|
|
229
218
|
CREATE TABLE IF NOT EXISTS medium_chunks (
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { BLOBS_DIR } from "./database.js";
|
|
2
|
+
import {
|
|
3
|
+
blobExists,
|
|
4
|
+
readBlobTransport,
|
|
5
|
+
saveBlobTransport,
|
|
6
|
+
} from "../storage/blob_store.js";
|
|
7
|
+
|
|
8
|
+
async function executeCloud(db, sql, args = []) {
|
|
9
|
+
if (!db?.cloudClient && !db?.failoverClient) {
|
|
10
|
+
throw new Error("Cloud database client is not available for RAG blob transport");
|
|
11
|
+
}
|
|
12
|
+
if (typeof db.runWithRetry === "function") {
|
|
13
|
+
return await db.runWithRetry(async (client) => client.execute({ sql, args }));
|
|
14
|
+
}
|
|
15
|
+
return await db.cloudClient.execute({ sql, args });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function pushBlobToCloud(db, hash, baseDir = BLOBS_DIR) {
|
|
19
|
+
if (!hash) return { skipped: true, reason: "missing_hash" };
|
|
20
|
+
if (!db || db.mode === "only-local") return { skipped: true, reason: "local_mode" };
|
|
21
|
+
|
|
22
|
+
const transport = await readBlobTransport(hash, baseDir);
|
|
23
|
+
await executeCloud(
|
|
24
|
+
db,
|
|
25
|
+
`INSERT INTO rag_blobs (hash, gzip_base64, raw_size, created_at)
|
|
26
|
+
VALUES (?, ?, ?, ?)
|
|
27
|
+
ON CONFLICT(hash) DO NOTHING;`,
|
|
28
|
+
[transport.hash, transport.gzipBase64, transport.rawSize, Date.now()]
|
|
29
|
+
);
|
|
30
|
+
return { pushed: true, hash, rawSize: transport.rawSize };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Upgrade/backfill path for documents that existed before portable cloud blobs.
|
|
35
|
+
* Existing cloud hashes are fetched once, then only missing local blobs are sent.
|
|
36
|
+
* A cloud deletion tombstone newer than the local document blocks backfill so a
|
|
37
|
+
* stale machine cannot re-upload an orphaned raw payload after another machine
|
|
38
|
+
* deliberately deleted the document/note.
|
|
39
|
+
*/
|
|
40
|
+
export async function backfillCloudBlobsFromLocal(db, baseDir = BLOBS_DIR) {
|
|
41
|
+
if (!db || db.mode === "only-local") return { skipped: true };
|
|
42
|
+
|
|
43
|
+
const docs = await db.prepare(`
|
|
44
|
+
SELECT id, path, blob_hash, updated_at
|
|
45
|
+
FROM documents
|
|
46
|
+
WHERE blob_hash IS NOT NULL;
|
|
47
|
+
`).all();
|
|
48
|
+
const [cloudBlobRes, tombstoneRes] = await Promise.all([
|
|
49
|
+
executeCloud(db, "SELECT hash FROM rag_blobs;"),
|
|
50
|
+
executeCloud(db, "SELECT doc_id, path, deleted_at FROM rag_document_tombstones;"),
|
|
51
|
+
]);
|
|
52
|
+
const existing = new Set((cloudBlobRes?.rows || []).map((row) => row.hash));
|
|
53
|
+
const tombById = new Map((tombstoneRes?.rows || []).map((row) => [row.doc_id, row]));
|
|
54
|
+
const tombByPath = new Map(
|
|
55
|
+
(tombstoneRes?.rows || []).filter((row) => row.path).map((row) => [row.path, row])
|
|
56
|
+
);
|
|
57
|
+
const summary = {
|
|
58
|
+
candidates: docs.length,
|
|
59
|
+
pushed: 0,
|
|
60
|
+
existing: 0,
|
|
61
|
+
tombstoned: 0,
|
|
62
|
+
missingLocal: 0,
|
|
63
|
+
errors: 0,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
for (const row of docs) {
|
|
67
|
+
const hash = row.blob_hash;
|
|
68
|
+
if (!hash) continue;
|
|
69
|
+
|
|
70
|
+
const tombstone = tombById.get(row.id) || tombByPath.get(row.path);
|
|
71
|
+
if (tombstone && Number(tombstone.deleted_at || 0) >= Number(row.updated_at || 0)) {
|
|
72
|
+
summary.tombstoned++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (existing.has(hash)) {
|
|
77
|
+
summary.existing++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!(await blobExists(hash, baseDir))) {
|
|
81
|
+
summary.missingLocal++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await pushBlobToCloud(db, hash, baseDir);
|
|
86
|
+
existing.add(hash);
|
|
87
|
+
summary.pushed++;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
summary.errors++;
|
|
90
|
+
console.warn(`Failed to backfill RAG blob ${hash}: ${err.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return summary;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function materializeBlobFromCloud(db, hash, baseDir = BLOBS_DIR) {
|
|
98
|
+
if (!hash) return { materialized: false, reason: "missing_hash" };
|
|
99
|
+
if (await blobExists(hash, baseDir)) {
|
|
100
|
+
return { materialized: false, existing: true, hash };
|
|
101
|
+
}
|
|
102
|
+
if (!db || db.mode === "only-local") {
|
|
103
|
+
return { materialized: false, reason: "local_mode", hash };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const res = await executeCloud(
|
|
107
|
+
db,
|
|
108
|
+
"SELECT gzip_base64, raw_size FROM rag_blobs WHERE hash = ?;",
|
|
109
|
+
[hash]
|
|
110
|
+
);
|
|
111
|
+
const row = res?.rows?.[0];
|
|
112
|
+
if (!row?.gzip_base64) {
|
|
113
|
+
return { materialized: false, reason: "cloud_blob_missing", hash };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const saved = await saveBlobTransport(hash, row.gzip_base64, baseDir);
|
|
117
|
+
return {
|
|
118
|
+
materialized: true,
|
|
119
|
+
hash,
|
|
120
|
+
rawSize: Number(row.raw_size || saved.size || 0),
|
|
121
|
+
path: saved.path,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function deleteCloudBlobIfUnreferenced(db, hash) {
|
|
126
|
+
if (!hash || !db || db.mode === "only-local") return { deleted: false };
|
|
127
|
+
const refs = await executeCloud(
|
|
128
|
+
db,
|
|
129
|
+
"SELECT COUNT(*) AS cnt FROM documents WHERE blob_hash = ?;",
|
|
130
|
+
[hash]
|
|
131
|
+
);
|
|
132
|
+
const count = Number(refs?.rows?.[0]?.cnt || 0);
|
|
133
|
+
if (count > 0) return { deleted: false, references: count };
|
|
134
|
+
|
|
135
|
+
await executeCloud(db, "DELETE FROM rag_blobs WHERE hash = ?;", [hash]);
|
|
136
|
+
return { deleted: true, references: 0 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function cloudBlobExists(db, hash) {
|
|
140
|
+
if (!hash || !db || db.mode === "only-local") return false;
|
|
141
|
+
const res = await executeCloud(db, "SELECT hash FROM rag_blobs WHERE hash = ?;", [hash]);
|
|
142
|
+
return Boolean(res?.rows?.length);
|
|
143
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { toVectorBytes } from "../retrieval/retriever.js";
|
|
2
|
+
import { deleteBlob } from "../storage/blob_store.js";
|
|
3
|
+
import { materializeBlobFromCloud } from "./rag_blob_transport.js";
|
|
4
|
+
|
|
5
|
+
async function executeCloud(db, sql, args = []) {
|
|
6
|
+
if (!db?.cloudClient && !db?.failoverClient) {
|
|
7
|
+
throw new Error("Cloud database client is not available for RAG reverse sync");
|
|
8
|
+
}
|
|
9
|
+
if (typeof db.runWithRetry === "function") {
|
|
10
|
+
return await db.runWithRetry(async (client) => client.execute({ sql, args }));
|
|
11
|
+
}
|
|
12
|
+
return await db.cloudClient.execute({ sql, args });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function recordCloudDocumentTombstone(db, { docId, path = null, deletedAt = Date.now() }) {
|
|
16
|
+
if (!docId || !db || db.mode === "only-local") return { recorded: false };
|
|
17
|
+
await executeCloud(
|
|
18
|
+
db,
|
|
19
|
+
`INSERT INTO rag_document_tombstones (doc_id, path, deleted_at)
|
|
20
|
+
VALUES (?, ?, ?)
|
|
21
|
+
ON CONFLICT(doc_id) DO UPDATE SET path = excluded.path, deleted_at = excluded.deleted_at;`,
|
|
22
|
+
[docId, path || null, deletedAt]
|
|
23
|
+
);
|
|
24
|
+
return { recorded: true, docId, path, deletedAt };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function clearCloudDocumentTombstone(db, { docId, path = null }) {
|
|
28
|
+
if (!db || db.mode === "only-local") return { cleared: false };
|
|
29
|
+
if (!docId && !path) return { cleared: false };
|
|
30
|
+
await executeCloud(
|
|
31
|
+
db,
|
|
32
|
+
"DELETE FROM rag_document_tombstones WHERE doc_id = ? OR (? IS NOT NULL AND path = ?);",
|
|
33
|
+
[docId || "", path || null, path || null]
|
|
34
|
+
);
|
|
35
|
+
return { cleared: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function fetchRemoteDocumentBundle(db, doc) {
|
|
39
|
+
const [sectionsRes, mediumRes, microRes, scopesRes, linksRes, edgesRes] = await Promise.all([
|
|
40
|
+
executeCloud(db, "SELECT * FROM sections WHERE doc_id = ? ORDER BY id;", [doc.id]),
|
|
41
|
+
executeCloud(db, "SELECT * FROM medium_chunks WHERE doc_id = ? ORDER BY id;", [doc.id]),
|
|
42
|
+
executeCloud(db, "SELECT * FROM micro_chunks WHERE doc_id = ? ORDER BY id;", [doc.id]),
|
|
43
|
+
executeCloud(db, "SELECT * FROM document_scopes WHERE doc_id = ? ORDER BY scope_key;", [doc.id]),
|
|
44
|
+
executeCloud(db, "SELECT * FROM knowledge_links WHERE doc_id = ? ORDER BY id;", [doc.id]),
|
|
45
|
+
executeCloud(
|
|
46
|
+
db,
|
|
47
|
+
`SELECT * FROM graph_edges
|
|
48
|
+
WHERE source_id = ? OR target_id = ?
|
|
49
|
+
OR source_id GLOB ? OR target_id GLOB ?
|
|
50
|
+
OR source_id IN (SELECT id FROM sections WHERE doc_id = ?)
|
|
51
|
+
OR target_id IN (SELECT id FROM sections WHERE doc_id = ?)
|
|
52
|
+
OR source_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
|
|
53
|
+
OR target_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
|
|
54
|
+
OR source_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?)
|
|
55
|
+
OR target_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);`,
|
|
56
|
+
[doc.id, doc.id, `${doc.id}:L*`, `${doc.id}:L*`, doc.id, doc.id, doc.id, doc.id, doc.id, doc.id]
|
|
57
|
+
),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
document: doc,
|
|
62
|
+
sections: sectionsRes.rows || [],
|
|
63
|
+
medium_chunks: mediumRes.rows || [],
|
|
64
|
+
micro_chunks: microRes.rows || [],
|
|
65
|
+
document_scopes: scopesRes.rows || [],
|
|
66
|
+
knowledge_links: linksRes.rows || [],
|
|
67
|
+
graph_edges: edgesRes.rows || [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function collectOwnedIds(db, docId) {
|
|
72
|
+
const rows = await db.prepare(`
|
|
73
|
+
SELECT id FROM sections WHERE doc_id = ?
|
|
74
|
+
UNION SELECT id FROM medium_chunks WHERE doc_id = ?
|
|
75
|
+
UNION SELECT id FROM micro_chunks WHERE doc_id = ?;
|
|
76
|
+
`).all(docId, docId, docId);
|
|
77
|
+
return [docId, ...rows.map((row) => row.id)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function clearLocalDocumentRelations(db, docId) {
|
|
81
|
+
const ownedIds = await collectOwnedIds(db, docId);
|
|
82
|
+
try {
|
|
83
|
+
await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(docId);
|
|
84
|
+
} catch {}
|
|
85
|
+
await db.prepare("DELETE FROM knowledge_links WHERE doc_id = ?;").run(docId);
|
|
86
|
+
for (const id of ownedIds) {
|
|
87
|
+
await db.prepare(
|
|
88
|
+
"DELETE FROM graph_edges WHERE source_id = ? OR target_id = ? OR source_id GLOB ? OR target_id GLOB ?;"
|
|
89
|
+
).run(id, id, `${id}:L*`, `${id}:L*`);
|
|
90
|
+
}
|
|
91
|
+
await db.prepare("DELETE FROM micro_chunks WHERE doc_id = ?;").run(docId);
|
|
92
|
+
await db.prepare("DELETE FROM medium_chunks WHERE doc_id = ?;").run(docId);
|
|
93
|
+
await db.prepare("DELETE FROM sections WHERE doc_id = ?;").run(docId);
|
|
94
|
+
await db.prepare("DELETE FROM document_scopes WHERE doc_id = ?;").run(docId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function cleanupOrphanBlob(db, hash) {
|
|
98
|
+
if (!hash) return;
|
|
99
|
+
const refs = await db.prepare("SELECT COUNT(*) AS cnt FROM documents WHERE blob_hash = ?;").get(hash);
|
|
100
|
+
if (Number(refs?.cnt || 0) === 0) {
|
|
101
|
+
try { await deleteBlob(hash); } catch {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function applyRemoteTombstone(db, tombstone) {
|
|
106
|
+
const local = await db.prepare(
|
|
107
|
+
"SELECT id, path, blob_hash, updated_at FROM documents WHERE id = ? OR (? IS NOT NULL AND path = ?);"
|
|
108
|
+
).get(tombstone.doc_id, tombstone.path || null, tombstone.path || null);
|
|
109
|
+
if (!local) return "absent";
|
|
110
|
+
if (Number(local.updated_at || 0) > Number(tombstone.deleted_at || 0)) return "local_newer";
|
|
111
|
+
|
|
112
|
+
await db.exec("BEGIN IMMEDIATE;");
|
|
113
|
+
try {
|
|
114
|
+
await clearLocalDocumentRelations(db, local.id);
|
|
115
|
+
await db.prepare("DELETE FROM documents WHERE id = ?;").run(local.id);
|
|
116
|
+
await db.prepare(`
|
|
117
|
+
INSERT INTO rag_document_tombstones (doc_id, path, deleted_at)
|
|
118
|
+
VALUES (?, ?, ?)
|
|
119
|
+
ON CONFLICT(doc_id) DO UPDATE SET path = excluded.path, deleted_at = excluded.deleted_at;
|
|
120
|
+
`).run(tombstone.doc_id, tombstone.path || local.path || null, tombstone.deleted_at || Date.now());
|
|
121
|
+
await db.exec("COMMIT;");
|
|
122
|
+
} catch (err) {
|
|
123
|
+
try { await db.exec("ROLLBACK;"); } catch {}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
await cleanupOrphanBlob(db, local.blob_hash);
|
|
127
|
+
return "deleted";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function removeConflictingLocalDocument(db, doc) {
|
|
131
|
+
const conflict = await db.prepare("SELECT id, blob_hash, updated_at FROM documents WHERE path = ? AND id != ?;").get(doc.path, doc.id);
|
|
132
|
+
if (!conflict) return null;
|
|
133
|
+
if (Number(conflict.updated_at || 0) > Number(doc.updated_at || 0)) {
|
|
134
|
+
return { blocked: true, conflict };
|
|
135
|
+
}
|
|
136
|
+
await clearLocalDocumentRelations(db, conflict.id);
|
|
137
|
+
await db.prepare("DELETE FROM documents WHERE id = ?;").run(conflict.id);
|
|
138
|
+
return { blocked: false, conflict };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function applyRemoteDocumentBundle(db, bundle) {
|
|
142
|
+
const doc = bundle.document;
|
|
143
|
+
const local = await db.prepare("SELECT id, path, blob_hash, updated_at FROM documents WHERE id = ?;").get(doc.id);
|
|
144
|
+
const remoteUpdated = Number(doc.updated_at || 0);
|
|
145
|
+
const localUpdated = Number(local?.updated_at || 0);
|
|
146
|
+
|
|
147
|
+
if (local && localUpdated > remoteUpdated) return { action: "local_newer" };
|
|
148
|
+
if (local && localUpdated === remoteUpdated && local.blob_hash === doc.blob_hash && local.path === doc.path) {
|
|
149
|
+
return { action: "unchanged" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let orphanCandidate = null;
|
|
153
|
+
await db.exec("BEGIN IMMEDIATE;");
|
|
154
|
+
try {
|
|
155
|
+
const conflictResult = await removeConflictingLocalDocument(db, doc);
|
|
156
|
+
if (conflictResult?.blocked) {
|
|
157
|
+
await db.exec("ROLLBACK;");
|
|
158
|
+
return { action: "path_conflict_local_newer" };
|
|
159
|
+
}
|
|
160
|
+
orphanCandidate = conflictResult?.conflict?.blob_hash || null;
|
|
161
|
+
|
|
162
|
+
if (local) await clearLocalDocumentRelations(db, doc.id);
|
|
163
|
+
|
|
164
|
+
await db.prepare(`
|
|
165
|
+
INSERT INTO documents (id, path, blob_hash, title, checksum, toc_json, metadata_json, created_at, updated_at)
|
|
166
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
167
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
168
|
+
path = excluded.path,
|
|
169
|
+
blob_hash = excluded.blob_hash,
|
|
170
|
+
title = excluded.title,
|
|
171
|
+
checksum = excluded.checksum,
|
|
172
|
+
toc_json = excluded.toc_json,
|
|
173
|
+
metadata_json = excluded.metadata_json,
|
|
174
|
+
created_at = excluded.created_at,
|
|
175
|
+
updated_at = excluded.updated_at;
|
|
176
|
+
`).run(doc.id, doc.path, doc.blob_hash, doc.title, doc.checksum, doc.toc_json || null, doc.metadata_json || null, doc.created_at || Date.now(), doc.updated_at || Date.now());
|
|
177
|
+
|
|
178
|
+
await db.prepare("DELETE FROM rag_document_tombstones WHERE doc_id = ? OR path = ?;").run(doc.id, doc.path);
|
|
179
|
+
|
|
180
|
+
const scopes = bundle.document_scopes.length ? bundle.document_scopes : [{ scope_key: "global", created_at: doc.created_at || Date.now() }];
|
|
181
|
+
for (const scope of scopes) {
|
|
182
|
+
await db.prepare("INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at) VALUES (?, ?, ?);").run(doc.id, scope.scope_key || "global", scope.created_at || Date.now());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const section of bundle.sections) {
|
|
186
|
+
await db.prepare("INSERT INTO sections (id, doc_id, heading, breadcrumbs, content, token_count) VALUES (?, ?, ?, ?, ?, ?);").run(section.id, doc.id, section.heading || null, section.breadcrumbs || null, section.content || "", Number(section.token_count || 0));
|
|
187
|
+
}
|
|
188
|
+
for (const medium of bundle.medium_chunks) {
|
|
189
|
+
await db.prepare("INSERT INTO medium_chunks (id, section_id, doc_id, content, block_type, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);").run(medium.id, medium.section_id, doc.id, medium.content || "", medium.block_type || "paragraph", Number(medium.token_count || 0), medium.created_at || Date.now());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sectionBreadcrumbs = new Map(bundle.sections.map((section) => [section.id, section.breadcrumbs || ""]));
|
|
193
|
+
for (const chunk of bundle.micro_chunks) {
|
|
194
|
+
const bytes = toVectorBytes(chunk.vector);
|
|
195
|
+
const vector = bytes ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Buffer.alloc(0);
|
|
196
|
+
await db.prepare(`INSERT INTO micro_chunks
|
|
197
|
+
(id, section_id, doc_id, content, vector, token_count, medium_id, retrieval_policy, policy_source_id)
|
|
198
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`).run(chunk.id, chunk.section_id, doc.id, chunk.content || "", vector, Number(chunk.token_count || 0), chunk.medium_id || null, chunk.retrieval_policy || "micro_chunk", chunk.policy_source_id || null);
|
|
199
|
+
await db.prepare("INSERT INTO micro_chunks_fts (id, content, breadcrumbs) VALUES (?, ?, ?);").run(chunk.id, chunk.content || "", sectionBreadcrumbs.get(chunk.section_id) || "");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const edge of bundle.graph_edges) {
|
|
203
|
+
await db.prepare(`INSERT OR REPLACE INTO graph_edges
|
|
204
|
+
(source_id, target_id, relation_type, metadata_json, created_at)
|
|
205
|
+
VALUES (?, ?, ?, ?, ?);`).run(edge.source_id, edge.target_id, edge.relation_type, edge.metadata_json || null, edge.created_at || null);
|
|
206
|
+
}
|
|
207
|
+
for (const link of bundle.knowledge_links) {
|
|
208
|
+
await db.prepare(`INSERT OR REPLACE INTO knowledge_links
|
|
209
|
+
(id, fact_key, fact_text, doc_id, section_id, start_line, end_line, relation_type, metadata_json, created_at)
|
|
210
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`).run(link.id, link.fact_key, link.fact_text, doc.id, link.section_id || null, link.start_line || null, link.end_line || null, link.relation_type || "LINKS_TO", link.metadata_json || null, link.created_at || Date.now());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await db.exec("COMMIT;");
|
|
214
|
+
} catch (err) {
|
|
215
|
+
try { await db.exec("ROLLBACK;"); } catch {}
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (orphanCandidate) await cleanupOrphanBlob(db, orphanCandidate);
|
|
220
|
+
if (local?.blob_hash && local.blob_hash !== doc.blob_hash) await cleanupOrphanBlob(db, local.blob_hash);
|
|
221
|
+
return { action: local ? "updated" : "pulled" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function pullRagFromCloud(db) {
|
|
225
|
+
if (!db || db.mode !== "hybrid-sync" || !db.cloudClient) return { skipped: true };
|
|
226
|
+
|
|
227
|
+
const tombRes = await executeCloud(db, "SELECT * FROM rag_document_tombstones ORDER BY deleted_at ASC;");
|
|
228
|
+
const tombstones = tombRes.rows || [];
|
|
229
|
+
const tombById = new Map(tombstones.map((t) => [t.doc_id, t]));
|
|
230
|
+
const tombByPath = new Map(tombstones.filter((t) => t.path).map((t) => [t.path, t]));
|
|
231
|
+
|
|
232
|
+
const docsRes = await executeCloud(db, "SELECT * FROM documents ORDER BY updated_at ASC;");
|
|
233
|
+
const docs = docsRes.rows || [];
|
|
234
|
+
const summary = {
|
|
235
|
+
remoteDocuments: docs.length,
|
|
236
|
+
remoteTombstones: tombstones.length,
|
|
237
|
+
pulled: 0,
|
|
238
|
+
updated: 0,
|
|
239
|
+
deleted: 0,
|
|
240
|
+
unchanged: 0,
|
|
241
|
+
localNewer: 0,
|
|
242
|
+
pathConflicts: 0,
|
|
243
|
+
blobsMaterialized: 0,
|
|
244
|
+
missingBlobs: 0,
|
|
245
|
+
errors: 0,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
for (const tombstone of tombstones) {
|
|
249
|
+
try {
|
|
250
|
+
const action = await applyRemoteTombstone(db, tombstone);
|
|
251
|
+
if (action === "deleted") summary.deleted++;
|
|
252
|
+
else if (action === "local_newer") summary.localNewer++;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
summary.errors++;
|
|
255
|
+
console.warn(`Failed to apply RAG tombstone ${tombstone.doc_id}: ${err.message}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const doc of docs) {
|
|
260
|
+
try {
|
|
261
|
+
const tombstone = tombById.get(doc.id) || tombByPath.get(doc.path);
|
|
262
|
+
if (tombstone && Number(tombstone.deleted_at || 0) >= Number(doc.updated_at || 0)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const bundle = await fetchRemoteDocumentBundle(db, doc);
|
|
267
|
+
const result = await applyRemoteDocumentBundle(db, bundle);
|
|
268
|
+
if (result.action === "pulled") summary.pulled++;
|
|
269
|
+
else if (result.action === "updated") summary.updated++;
|
|
270
|
+
else if (result.action === "unchanged") summary.unchanged++;
|
|
271
|
+
else if (result.action === "local_newer") summary.localNewer++;
|
|
272
|
+
else if (result.action === "path_conflict_local_newer") summary.pathConflicts++;
|
|
273
|
+
|
|
274
|
+
const blobResult = await materializeBlobFromCloud(db, doc.blob_hash);
|
|
275
|
+
if (blobResult.materialized) summary.blobsMaterialized++;
|
|
276
|
+
else if (blobResult.reason === "cloud_blob_missing") summary.missingBlobs++;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
summary.errors++;
|
|
279
|
+
console.warn(`Failed to reverse-sync RAG document ${doc.id}: ${err.message}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return summary;
|
|
284
|
+
}
|