rag-memory-epf-mcp 3.5.2 → 3.6.0
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/README.md +10 -0
- package/dist/index.d.ts +50 -6
- package/dist/index.js +782 -188
- package/dist/src/backfillCoordinator.d.ts +59 -0
- package/dist/src/backfillCoordinator.js +552 -0
- package/dist/src/embeddingGate.d.ts +68 -0
- package/dist/src/embeddingGate.js +227 -0
- package/dist/src/migrations/migrations.js +71 -0
- package/dist/src/modelCache.d.ts +33 -0
- package/dist/src/modelCache.js +235 -0
- package/docs/UPDATING.md +121 -0
- package/package.json +8 -4
package/dist/index.js
CHANGED
|
@@ -24,10 +24,52 @@ import { MigrationManager } from './src/migrations/migration-manager.js';
|
|
|
24
24
|
// Import chunk text algorithm (extracted for publish-time invariant testing)
|
|
25
25
|
import { chunkText as splitTextIntoChunks } from './src/chunkText.js';
|
|
26
26
|
import { migrations } from './src/migrations/migrations.js';
|
|
27
|
+
// v3.6 lite install: model lifecycle + version-independent cache (A′ boundary)
|
|
28
|
+
import { EmbeddingGate, GateNotReadyError, GateDisabledError, TerminalConfigError } from './src/embeddingGate.js';
|
|
29
|
+
import { resolveModelCacheDir, preflightCacheDir, artifactKey, ModelDownloadLock, handleLoaderFailure } from './src/modelCache.js';
|
|
30
|
+
import { BackfillCoordinator } from './src/backfillCoordinator.js';
|
|
31
|
+
import os from 'node:os';
|
|
27
32
|
import { createHash } from 'crypto';
|
|
28
33
|
import { createRequire } from 'module';
|
|
29
34
|
const require = createRequire(import.meta.url);
|
|
30
35
|
const PKG_VERSION = require('../package.json').version;
|
|
36
|
+
// v3.6: runtime Node floor (engines is advisory only under default npm config).
|
|
37
|
+
// Limitation: static native imports above may fail before this runs on very old
|
|
38
|
+
// Node — documented in docs/UPDATING.md.
|
|
39
|
+
const NODE_MAJOR = Number(process.versions.node.split('.')[0]);
|
|
40
|
+
function assertNodeVersion() {
|
|
41
|
+
if (NODE_MAJOR < 24) {
|
|
42
|
+
console.error(`❌ rag-memory-epf-mcp v${PKG_VERSION} requires Node >= 24 (current: ${process.versions.node}).`);
|
|
43
|
+
console.error(' See docs/UPDATING.md for the supported runtime matrix.');
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
throw new Error('unsupported Node version');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// v3.6: strip tokens / auth material / long URLs from operator-facing error text.
|
|
49
|
+
function sanitizeErrorMessage(msg) {
|
|
50
|
+
return msg
|
|
51
|
+
.replace(/(hf_|api[_-]?key=|authorization:\s*)\S+/gi, '$1[redacted]')
|
|
52
|
+
.replace(/https?:\/\/\S{60,}/g, '[url]')
|
|
53
|
+
.slice(0, 500);
|
|
54
|
+
}
|
|
55
|
+
// v3.6: startup self-report banner (version reliability — spec §8).
|
|
56
|
+
function printBanner(opts) {
|
|
57
|
+
console.error(`🚀 rag-memory-epf-mcp v${PKG_VERSION} | node v${process.versions.node} | model ${opts.model}@${opts.revision} (${opts.dtype}) | cache ${opts.cachePath} | db ${opts.dbPath}`);
|
|
58
|
+
}
|
|
59
|
+
// v3.6 (spec §5): ONE FTS5 literal-query compiler shared by chunk and entity
|
|
60
|
+
// search — raw user input can never produce MATCH syntax errors or trigger
|
|
61
|
+
// operators (every term is double-quoted; special characters stripped exactly
|
|
62
|
+
// as the pre-3.6 hybridSearch sanitizer did). Returns null when nothing
|
|
63
|
+
// searchable remains (contract: caller returns empty results + warning).
|
|
64
|
+
export function compileFtsLiteralQuery(q) {
|
|
65
|
+
const sanitized = q.replace(/["\*\(\)\-]/g, ' ').trim();
|
|
66
|
+
if (!sanitized)
|
|
67
|
+
return null;
|
|
68
|
+
const terms = sanitized.split(/\s+/).filter(t => t.length > 0);
|
|
69
|
+
if (terms.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
return terms.map(t => `"${t}"`).join(' OR ');
|
|
72
|
+
}
|
|
31
73
|
// Configure Hugging Face transformers for better compatibility
|
|
32
74
|
if (env.backends?.onnx?.wasm) {
|
|
33
75
|
env.backends.onnx.wasm.wasmPaths = './node_modules/@huggingface/transformers/dist/';
|
|
@@ -40,6 +82,21 @@ const DB_FILE_PATH = process.env.DB_FILE_PATH
|
|
|
40
82
|
: path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.DB_FILE_PATH)
|
|
41
83
|
: defaultDbPath;
|
|
42
84
|
const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'Xenova/bge-m3';
|
|
85
|
+
// v3.5 default model config — grandfathering legacy vectors is only automatic
|
|
86
|
+
// when the current config matches this (spec §6b custom-model guard). An
|
|
87
|
+
// EXPLICIT `EMBEDDING_MODEL=Xenova/bge-m3` counts as default: same weights,
|
|
88
|
+
// same pin, same grandfather policy (beta 1R consistency fix).
|
|
89
|
+
const IS_DEFAULT_MODEL_CONFIG = !process.env.EMBEDDING_MODEL || process.env.EMBEDDING_MODEL === 'Xenova/bge-m3';
|
|
90
|
+
// Default model pinned to an upstream commit (spec §6c): a shared version-
|
|
91
|
+
// independent cache must never silently swap weights under 'main'. Verified
|
|
92
|
+
// 2026-07-18 via `git ls-remote https://huggingface.co/Xenova/bge-m3` — the
|
|
93
|
+
// same revision the local v3.5 cache was downloaded from. Custom models stay
|
|
94
|
+
// on 'main' (their vectors are never auto-grandfathered anyway).
|
|
95
|
+
const MODEL_REVISION = IS_DEFAULT_MODEL_CONFIG ? '4de13258303883538bd53b696b452bf8099f0858' : 'main';
|
|
96
|
+
const MODEL_DTYPE = 'fp16';
|
|
97
|
+
// Entity embedding text builder version — mixed into entity input hashes so a
|
|
98
|
+
// builder change re-backfills entities without touching chunk vectors (spec §6c).
|
|
99
|
+
const TEXT_BUILDER_VERSION = 'tb1';
|
|
43
100
|
// Safe rowid for vec0 virtual tables (require literal integer, not parameterized)
|
|
44
101
|
// Trim incomplete UTF-8 multi-byte sequences at chunk boundaries.
|
|
45
102
|
// Continuation bytes match 10xxxxxx (0x80-0xBF); lead bytes indicate how many
|
|
@@ -59,18 +116,23 @@ function safeRowid(value) {
|
|
|
59
116
|
export class RAGKnowledgeGraphManager {
|
|
60
117
|
db = null;
|
|
61
118
|
encoding = null;
|
|
62
|
-
|
|
63
|
-
|
|
119
|
+
gate;
|
|
120
|
+
embeddingsMode = 'lazy';
|
|
121
|
+
currentProfileId = 0;
|
|
122
|
+
// Automatic grandfathering of legacy vectors is only allowed under the v3.5
|
|
123
|
+
// default model config, or with the explicit trust opt-in (spec §6b guard).
|
|
124
|
+
grandfatherAllowed = IS_DEFAULT_MODEL_CONFIG || process.env.RAG_MEMORY_TRUST_LEGACY_VECTORS === '1';
|
|
125
|
+
coordinator = null;
|
|
64
126
|
embeddingCache = new Map();
|
|
65
127
|
EMBEDDING_CACHE_MAX = 500;
|
|
66
128
|
dictionaryCache = null;
|
|
129
|
+
// v3.6 (spec §3): initialize = DB + migrations + profile only. The embedding
|
|
130
|
+
// model is NEVER awaited here — main() connects the MCP server first and the
|
|
131
|
+
// gate loads in the background (lazy) or is awaited explicitly (eager).
|
|
67
132
|
async initialize(opts = {}) {
|
|
68
133
|
console.error('🚀 Initializing RAG Knowledge Graph MCP Server...');
|
|
69
|
-
// Initialize database
|
|
70
134
|
this.db = new Database(DB_FILE_PATH);
|
|
71
|
-
// Load sqlite-vec extension
|
|
72
135
|
sqliteVec.load(this.db);
|
|
73
|
-
// SQLite performance & safety optimizations
|
|
74
136
|
this.db.pragma('journal_mode = WAL');
|
|
75
137
|
this.db.pragma('synchronous = NORMAL');
|
|
76
138
|
this.db.pragma('busy_timeout = 5000');
|
|
@@ -78,37 +140,286 @@ export class RAGKnowledgeGraphManager {
|
|
|
78
140
|
this.db.pragma('temp_store = MEMORY');
|
|
79
141
|
this.db.pragma('mmap_size = 268435456');
|
|
80
142
|
this.db.pragma('foreign_keys = ON');
|
|
81
|
-
// Initialize tiktoken
|
|
82
143
|
this.encoding = get_encoding("cl100k_base");
|
|
83
|
-
// Initialize embedding model (skippable for tests / FTS-only environments)
|
|
84
|
-
if (!opts.skipModel) {
|
|
85
|
-
await this.initializeEmbeddingModel();
|
|
86
|
-
}
|
|
87
|
-
else {
|
|
88
|
-
console.error('⏭️ Skipping embedding model load (skipModel=true)');
|
|
89
|
-
}
|
|
90
|
-
// Run database migrations
|
|
91
144
|
await this.runMigrations();
|
|
92
|
-
|
|
93
|
-
|
|
145
|
+
this.currentProfileId = this.ensureCurrentProfile();
|
|
146
|
+
this.embeddingsMode = opts.skipModel
|
|
147
|
+
? 'off'
|
|
148
|
+
: (process.env.RAG_MEMORY_EMBEDDINGS || 'lazy');
|
|
149
|
+
if (!['lazy', 'eager', 'off'].includes(this.embeddingsMode))
|
|
150
|
+
this.embeddingsMode = 'lazy';
|
|
151
|
+
this.gate = opts.gate ?? new EmbeddingGate({
|
|
152
|
+
mode: this.embeddingsMode,
|
|
153
|
+
loadModel: () => this.buildRealLoader(),
|
|
154
|
+
onReady: () => this.coordinator?.kick(),
|
|
155
|
+
});
|
|
156
|
+
// Late-bound deps (closures): tests swap manager.gate / flip the guard.
|
|
157
|
+
this.coordinator = new BackfillCoordinator({
|
|
158
|
+
db: () => this.db,
|
|
159
|
+
gateIsReady: () => this.gate.isReady,
|
|
160
|
+
gateIsDisabled: () => this.gate.isDisabled,
|
|
161
|
+
mode: () => this.embeddingsMode,
|
|
162
|
+
grandfatherAllowed: () => this.grandfatherAllowed,
|
|
163
|
+
currentProfileId: () => this.currentProfileId,
|
|
164
|
+
buildEntityInputHash: (entityId) => this.entityInputHash(entityId),
|
|
165
|
+
hashEntityText: (text) => this.hashWithBuilderVersion(text),
|
|
166
|
+
chunkInputHash: (text) => createHash('sha256').update(text).digest('hex'),
|
|
167
|
+
reembedEntity: async (entityId) => this.embedEntity(entityId, 'backfill'),
|
|
168
|
+
reembedChunk: async (rowid) => this.reembedChunkByRowid(rowid),
|
|
169
|
+
});
|
|
170
|
+
console.error('✅ RAG-enabled knowledge graph initialized (embedding model deferred)');
|
|
94
171
|
const systemInfo = getSystemInfo();
|
|
95
172
|
console.error(`📊 System Info: ${systemInfo.toolCounts.total} tools available (${systemInfo.toolCounts.knowledgeGraph} knowledge graph, ${systemInfo.toolCounts.rag} RAG, ${systemInfo.toolCounts.graphQuery} query)`);
|
|
96
173
|
}
|
|
97
|
-
|
|
174
|
+
// Upsert the stored-vector compatibility profile (spec §6c layer 2) and record
|
|
175
|
+
// retrieval config in server_meta (layer 3 — never a backfill trigger).
|
|
176
|
+
ensureCurrentProfile() {
|
|
177
|
+
if (!this.db)
|
|
178
|
+
throw new Error('Database not initialized');
|
|
179
|
+
const dims = 1024;
|
|
180
|
+
if (dims !== 1024)
|
|
181
|
+
throw new Error('unsupported embedding dims (vec0 tables are fixed at 1024)'); // fail-fast contract
|
|
182
|
+
this.db.prepare(`INSERT OR IGNORE INTO embedding_profiles
|
|
183
|
+
(model_id, revision, dtype, dims, pooling, normalize) VALUES (?,?,?,?,?,?)`)
|
|
184
|
+
.run(EMBEDDING_MODEL, MODEL_REVISION, MODEL_DTYPE, dims, 'cls', 1);
|
|
185
|
+
const row = this.db.prepare(`SELECT id FROM embedding_profiles
|
|
186
|
+
WHERE model_id=? AND revision=? AND dtype=? AND dims=? AND pooling=? AND normalize=?`)
|
|
187
|
+
.get(EMBEDDING_MODEL, MODEL_REVISION, MODEL_DTYPE, dims, 'cls', 1);
|
|
188
|
+
this.db.prepare(`INSERT INTO server_meta(key,value) VALUES('current_profile_id',?)
|
|
189
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(row.id));
|
|
190
|
+
this.db.prepare(`INSERT INTO server_meta(key,value) VALUES('query_prefix_version','1')
|
|
191
|
+
ON CONFLICT(key) DO NOTHING`).run();
|
|
192
|
+
return row.id;
|
|
193
|
+
}
|
|
194
|
+
// Real model loader used by the gate: version-independent cache dir with a
|
|
195
|
+
// cross-process download lock. Preflight failure throws (gate -> failed);
|
|
196
|
+
// silently falling back to the package-internal cache is forbidden (spec §7).
|
|
197
|
+
async buildRealLoader() {
|
|
198
|
+
const cacheDir = resolveModelCacheDir(process.env, process.platform, os.homedir());
|
|
199
|
+
const pf = preflightCacheDir(cacheDir);
|
|
200
|
+
if (!pf.ok)
|
|
201
|
+
throw new Error(`model cache dir not writable (${cacheDir}): ${pf.error}`);
|
|
202
|
+
const key = artifactKey(EMBEDDING_MODEL, MODEL_REVISION, MODEL_DTYPE);
|
|
203
|
+
const lock = new ModelDownloadLock(cacheDir, key);
|
|
204
|
+
// Shutdown aborts the lock wait via the gate's AbortController (spec §3).
|
|
205
|
+
console.error('⏳ acquiring model download lock...'); // deterministic lock-wait marker (5R test residual)
|
|
206
|
+
const role = await lock.acquireOrWait({ timeoutMs: 10 * 60_000, signal: this.gate.abort.signal });
|
|
98
207
|
try {
|
|
99
|
-
|
|
100
|
-
// Configure environment to allow remote model downloads
|
|
208
|
+
this.gate.markDownloading();
|
|
101
209
|
env.allowRemoteModels = true;
|
|
102
210
|
env.allowLocalModels = true;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
211
|
+
console.error(`🤖 Loading embedding model: ${EMBEDDING_MODEL} (1024-dim, cache=${cacheDir})...`);
|
|
212
|
+
const model = await pipeline('feature-extraction', EMBEDDING_MODEL, { revision: MODEL_REVISION, dtype: MODEL_DTYPE, cache_dir: cacheDir });
|
|
213
|
+
// dims fail-fast (spec §2 / beta B8): probe the ACTUAL output length — a
|
|
214
|
+
// 384/768-dim custom model must fail here with a clear message, not at
|
|
215
|
+
// every subsequent vector write.
|
|
216
|
+
const probe = await model('dimension probe', { pooling: 'cls', normalize: true });
|
|
217
|
+
const actualDims = probe.data.length;
|
|
218
|
+
if (actualDims !== 1024) {
|
|
219
|
+
// Config incompatibility, NOT cache corruption (beta 2R B3): the
|
|
220
|
+
// download and load both succeeded — quarantining or retrying cannot
|
|
221
|
+
// change the model's dimensions.
|
|
222
|
+
if (role === 'owner')
|
|
223
|
+
lock.markComplete(); // cache itself is valid
|
|
224
|
+
throw new TerminalConfigError(`embedding model ${EMBEDDING_MODEL} outputs ${actualDims} dims — this engine's vec0 tables are fixed at 1024. Use a 1024-dim model.`);
|
|
225
|
+
}
|
|
226
|
+
if (role === 'owner')
|
|
227
|
+
lock.markComplete();
|
|
228
|
+
console.error(`✅ ${EMBEDDING_MODEL} model loaded (${MODEL_DTYPE})`);
|
|
229
|
+
return async (text, dims, isQuery) => {
|
|
230
|
+
const input = isQuery ? `Represent this sentence for searching relevant passages: ${text}` : text;
|
|
231
|
+
const r = await model(input, { pooling: 'cls', normalize: true });
|
|
232
|
+
return new Float32Array(r.data.slice(0, dims));
|
|
233
|
+
};
|
|
106
234
|
}
|
|
107
|
-
catch (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
235
|
+
catch (e) {
|
|
236
|
+
// Cache policy by CAUSE and ROLE (beta 2R B3 -> 4R M1 -> 5R M1), unit-
|
|
237
|
+
// tested in modelCache: config errors touch nothing; integrity errors
|
|
238
|
+
// invalidate the marker, and only a lock-holding OWNER may quarantine
|
|
239
|
+
// (a ready-role process racing other readers never deletes shared
|
|
240
|
+
// files); OOM/network/unknown preserve everything.
|
|
241
|
+
const action = handleLoaderFailure({
|
|
242
|
+
role, error: e, lock, cacheDir, modelId: EMBEDDING_MODEL,
|
|
243
|
+
terminal: e instanceof TerminalConfigError,
|
|
244
|
+
});
|
|
245
|
+
if (action === 'quarantined')
|
|
246
|
+
console.error('🧹 cache-integrity failure (owner) — model cache quarantined');
|
|
247
|
+
else if (action === 'marker-invalidated')
|
|
248
|
+
console.error('… cache-integrity failure (reader) — marker dropped, next retry re-proves as locked owner');
|
|
249
|
+
else if (!(e instanceof TerminalConfigError))
|
|
250
|
+
console.error('… non-integrity load failure — model cache preserved');
|
|
251
|
+
throw e;
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
lock.release();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Background provenance reconciliation (spec §6b). Runs in parallel with the
|
|
258
|
+
// model load; vector search and automatic backfill stay closed until it
|
|
259
|
+
// settles (eligibility barrier in the coordinator).
|
|
260
|
+
async startReconciliation() {
|
|
261
|
+
if (!this.coordinator)
|
|
262
|
+
return;
|
|
263
|
+
await this.coordinator.runReconciliation();
|
|
264
|
+
this.coordinator.sweepStart();
|
|
265
|
+
}
|
|
266
|
+
// sha256 with the entity text-builder version mixed in: a builder change
|
|
267
|
+
// re-backfills entities only, never chunks (spec §6c N2).
|
|
268
|
+
hashWithBuilderVersion(text) {
|
|
269
|
+
return createHash('sha256').update(`${TEXT_BUILDER_VERSION}\n${text}`).digest('hex');
|
|
270
|
+
}
|
|
271
|
+
// Rebuild the CURRENT embedding input hash for an entity. null = entity gone
|
|
272
|
+
// or malformed observations — reconciliation fail-closes to missing.
|
|
273
|
+
entityInputHash(entityId) {
|
|
274
|
+
try {
|
|
275
|
+
const entity = this.db.prepare(`SELECT name, entityType, observations FROM entities WHERE id = ?`)
|
|
276
|
+
.get(entityId);
|
|
277
|
+
if (!entity)
|
|
278
|
+
return null;
|
|
279
|
+
const built = this.buildEntityEmbeddingText({
|
|
280
|
+
name: entity.name,
|
|
281
|
+
entityType: entity.entityType,
|
|
282
|
+
observations: JSON.parse(entity.observations),
|
|
283
|
+
});
|
|
284
|
+
return this.hashWithBuilderVersion(built.text);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Mutation-path embedding wrapper (spec §5): CRUD success never depends on
|
|
291
|
+
// model availability. On not-ready/disabled the stale vector is deleted in
|
|
292
|
+
// the same breath (dirty = missing, §6a-1) and the coordinator is kicked so
|
|
293
|
+
// the row is recovered without a restart (§5 kick column).
|
|
294
|
+
async tryEmbedEntity(entityId, priority = 'bulk') {
|
|
295
|
+
try {
|
|
296
|
+
const ok = await this.embedEntity(entityId, priority);
|
|
297
|
+
if (ok) {
|
|
298
|
+
// Success clears any stale backfill-failure record for this target.
|
|
299
|
+
this.db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'entity' AND target_id = ?`).run(entityId);
|
|
300
|
+
this.coordinator?.invalidateCoverage();
|
|
301
|
+
return 'embedded';
|
|
302
|
+
}
|
|
303
|
+
this.invalidateEntityVector(entityId);
|
|
304
|
+
this.coordinator?.kick();
|
|
305
|
+
return 'queued';
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
// Any embedding-layer failure (not-ready, disabled, OR a ready-state
|
|
309
|
+
// inference error) must not fail the CRUD that already committed. The
|
|
310
|
+
// vector is invalidated (§6a-1) and recovery is owned by the backfill
|
|
311
|
+
// scanner with its attempts cap — never by rethrowing here (spec §5).
|
|
312
|
+
if (!(e instanceof GateNotReadyError) && !(e instanceof GateDisabledError)) {
|
|
313
|
+
console.error(`⚠️ embedding failed for ${entityId} (queued for backfill): ${e instanceof Error ? e.message : e}`);
|
|
314
|
+
}
|
|
315
|
+
this.invalidateEntityVector(entityId);
|
|
316
|
+
this.coordinator?.kick();
|
|
317
|
+
return e instanceof GateDisabledError ? 'disabled' : 'queued';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// §6a-1 (beta B2): the entity change and the stale-vector removal commit in
|
|
321
|
+
// ONE synchronous transaction, BEFORE any inference await. No window exists
|
|
322
|
+
// where another tool call can retrieve the pre-mutation vector, and a crash
|
|
323
|
+
// between mutation and re-embed leaves a clean missing state (backfill
|
|
324
|
+
// target), never a stale-searchable one.
|
|
325
|
+
mutateEntityAndInvalidate(entityId, mutate) {
|
|
326
|
+
const tx = this.db.transaction(() => {
|
|
327
|
+
mutate();
|
|
328
|
+
const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
|
|
329
|
+
.get(entityId);
|
|
330
|
+
if (meta) {
|
|
331
|
+
this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
|
|
332
|
+
this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
tx();
|
|
336
|
+
this.coordinator?.invalidateCoverage();
|
|
337
|
+
}
|
|
338
|
+
// §6a-1 invariant: when an entity's embedding input changed but re-embedding
|
|
339
|
+
// is unavailable, its old vector must not stay searchable.
|
|
340
|
+
invalidateEntityVector(entityId) {
|
|
341
|
+
if (!this.db)
|
|
342
|
+
return;
|
|
343
|
+
const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
|
|
344
|
+
.get(entityId);
|
|
345
|
+
if (!meta)
|
|
346
|
+
return;
|
|
347
|
+
const tx = this.db.transaction(() => {
|
|
348
|
+
this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
|
|
349
|
+
this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
|
|
350
|
+
});
|
|
351
|
+
tx();
|
|
352
|
+
this.coordinator?.invalidateCoverage();
|
|
353
|
+
}
|
|
354
|
+
// Backfill callback: re-embed one chunk and commit vector + provenance in a
|
|
355
|
+
// single transaction (§6a-2).
|
|
356
|
+
async reembedChunkByRowid(rowid) {
|
|
357
|
+
if (!this.db)
|
|
358
|
+
return false;
|
|
359
|
+
const row = this.db.prepare(`SELECT text FROM chunk_metadata WHERE rowid = ?`)
|
|
360
|
+
.get(rowid);
|
|
361
|
+
if (!row || row.text === null)
|
|
362
|
+
return false;
|
|
363
|
+
try {
|
|
364
|
+
const embedding = await this.generateEmbedding(row.text, 1024, false, 'backfill');
|
|
365
|
+
const hash = createHash('sha256').update(row.text).digest('hex');
|
|
366
|
+
const safe = Number(rowid);
|
|
367
|
+
// Write-back CAS (beta 2R B1): the rowid may have been deleted and reused
|
|
368
|
+
// by a re-sync while inference ran — re-read the CURRENT text in the
|
|
369
|
+
// transaction and only write when it still matches what was embedded.
|
|
370
|
+
const tx = this.db.transaction(() => {
|
|
371
|
+
const cur = this.db.prepare(`SELECT text FROM chunk_metadata WHERE rowid = ?`).get(rowid);
|
|
372
|
+
if (!cur || cur.text !== row.text)
|
|
373
|
+
return false; // superseded — discard
|
|
374
|
+
this.db.exec(`DELETE FROM chunks WHERE rowid = ${safe}`);
|
|
375
|
+
this.db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${safe}, ?)`).run(Buffer.from(embedding.buffer));
|
|
376
|
+
this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
|
|
377
|
+
.run(hash, this.currentProfileId, rowid);
|
|
378
|
+
this.db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id = ?`).run(String(rowid));
|
|
379
|
+
return true;
|
|
380
|
+
});
|
|
381
|
+
const written = tx();
|
|
382
|
+
this.coordinator?.invalidateCoverage();
|
|
383
|
+
return written;
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
if (e instanceof GateNotReadyError || e instanceof GateDisabledError)
|
|
387
|
+
return false;
|
|
388
|
+
throw e;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// spec §3 shutdown order (beta B1): block new batches -> settle coordinator
|
|
392
|
+
// (INCLUDING an in-flight reconciliation pass) -> settle gate (INCLUDING an
|
|
393
|
+
// in-flight model load, bounded) -> close DB -> natural exit.
|
|
394
|
+
//
|
|
395
|
+
// Bounded-exit rationale, re-derived after beta 1R: the exit decision is made
|
|
396
|
+
// AFTER the settle wait, not before — if the load completed during settling
|
|
397
|
+
// (ONNX session now exists) we take the natural-exit path. Only when the load
|
|
398
|
+
// is STILL pending after the deadline (dominant case: the 1.2GB download,
|
|
399
|
+
// which is un-abortable through transformers.js and would hold the event
|
|
400
|
+
// loop indefinitely) do we exit(). At that point the DB is already closed
|
|
401
|
+
// cleanly, so even the residual worst case — the load being inside ONNX
|
|
402
|
+
// session construction at exit — risks an ugly abort message, never data
|
|
403
|
+
// loss. Hanging forever is the alternative and is worse.
|
|
404
|
+
async shutdownAll() {
|
|
405
|
+
console.error('\n🧹 Cleaning up...');
|
|
406
|
+
try {
|
|
407
|
+
await this.coordinator?.shutdown(5000);
|
|
408
|
+
}
|
|
409
|
+
catch { /* settle best-effort */ }
|
|
410
|
+
try {
|
|
411
|
+
await this.gate?.shutdown(5000);
|
|
412
|
+
}
|
|
413
|
+
catch { /* settle best-effort */ }
|
|
414
|
+
const loadStillPending = this.gate?.loadInFlight ?? false;
|
|
415
|
+
try {
|
|
416
|
+
this.cleanup();
|
|
417
|
+
}
|
|
418
|
+
catch { /* DB close */ }
|
|
419
|
+
process.exitCode = process.exitCode ?? 0;
|
|
420
|
+
if (loadStillPending) {
|
|
421
|
+
console.error('… model load/download still in flight after settle deadline — bounded exit (DB already closed)');
|
|
422
|
+
process.exit(process.exitCode);
|
|
112
423
|
}
|
|
113
424
|
}
|
|
114
425
|
async runMigrations() {
|
|
@@ -140,11 +451,6 @@ export class RAGKnowledgeGraphManager {
|
|
|
140
451
|
this.encoding.free();
|
|
141
452
|
this.encoding = null;
|
|
142
453
|
}
|
|
143
|
-
if (this.embeddingModel) {
|
|
144
|
-
// Clean up the embedding model if it has cleanup methods
|
|
145
|
-
this.embeddingModel = null;
|
|
146
|
-
this.modelInitialized = false;
|
|
147
|
-
}
|
|
148
454
|
this.embeddingCache.clear();
|
|
149
455
|
if (this.db) {
|
|
150
456
|
this.db.close();
|
|
@@ -173,10 +479,11 @@ export class RAGKnowledgeGraphManager {
|
|
|
173
479
|
// Try insert first
|
|
174
480
|
const insertResult = insertStmt.run(entityId, entity.name, entity.entityType, JSON.stringify(timestamped), '{}');
|
|
175
481
|
if (insertResult.changes > 0) {
|
|
176
|
-
// New entity created
|
|
177
|
-
|
|
482
|
+
// New entity created. CRUD success is independent of model readiness
|
|
483
|
+
// (spec §5): not-ready -> row stays vectorless (queued for backfill).
|
|
178
484
|
console.error(`🔮 Generating embedding for new entity: ${entity.name}`);
|
|
179
|
-
await this.
|
|
485
|
+
const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
|
|
486
|
+
result.push({ ...entity, observations: timestamped, embedding_status });
|
|
180
487
|
}
|
|
181
488
|
else {
|
|
182
489
|
// Entity already exists — upsert: merge observations and update entityType
|
|
@@ -192,11 +499,13 @@ export class RAGKnowledgeGraphManager {
|
|
|
192
499
|
if (newObs.length > 0 || needsTypeUpdate) {
|
|
193
500
|
const mergedObs = [...currentObs, ...newObs];
|
|
194
501
|
const updatedType = needsTypeUpdate ? entity.entityType : existing.entityType;
|
|
195
|
-
this.
|
|
196
|
-
.
|
|
502
|
+
this.mutateEntityAndInvalidate(entityId, () => {
|
|
503
|
+
this.db.prepare(`UPDATE entities SET observations = ?, entityType = ? WHERE id = ?`)
|
|
504
|
+
.run(JSON.stringify(mergedObs), updatedType, entityId);
|
|
505
|
+
});
|
|
197
506
|
console.error(`♻️ Upserted entity: ${entity.name} (+${newObs.length} obs${needsTypeUpdate ? ', type→' + updatedType : ''})`);
|
|
198
|
-
await this.
|
|
199
|
-
result.push({ ...entity, observations: mergedObs });
|
|
507
|
+
const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
|
|
508
|
+
result.push({ ...entity, observations: mergedObs, embedding_status });
|
|
200
509
|
}
|
|
201
510
|
}
|
|
202
511
|
}
|
|
@@ -208,24 +517,32 @@ export class RAGKnowledgeGraphManager {
|
|
|
208
517
|
throw new Error('Database not initialized');
|
|
209
518
|
const newRelations = [];
|
|
210
519
|
for (const relation of relations) {
|
|
211
|
-
// Ensure entities exist
|
|
212
|
-
|
|
520
|
+
// Ensure entities exist. v3.6 (spec §5c): auto-created endpoints may be
|
|
521
|
+
// embedded/queued/disabled independently — report per endpoint; 'n/a'
|
|
522
|
+
// means the endpoint already existed (no embedding work happened here).
|
|
523
|
+
const ensured = await this.createEntities([
|
|
213
524
|
{ name: relation.from, entityType: 'CONCEPT', observations: [] },
|
|
214
525
|
{ name: relation.to, entityType: 'CONCEPT', observations: [] }
|
|
215
526
|
]);
|
|
527
|
+
const statusOf = (name) => {
|
|
528
|
+
const hit = ensured.find(e => e.name === name);
|
|
529
|
+
return hit?.embedding_status ?? 'n/a';
|
|
530
|
+
};
|
|
531
|
+
const endpoint_embedding_status = { from: statusOf(relation.from), to: statusOf(relation.to) };
|
|
216
532
|
const sourceId = `entity_${relation.from.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
|
|
217
533
|
const targetId = `entity_${relation.to.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
|
|
218
534
|
const relationId = `rel_${sourceId}_${relation.relationType}_${targetId}`.toLowerCase();
|
|
219
535
|
const stmt = this.db.prepare(`
|
|
220
|
-
INSERT OR IGNORE INTO relationships
|
|
536
|
+
INSERT OR IGNORE INTO relationships
|
|
221
537
|
(id, source_entity, target_entity, relationType, confidence, metadata)
|
|
222
538
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
223
539
|
`);
|
|
224
540
|
const result = stmt.run(relationId, sourceId, targetId, relation.relationType, 1.0, '{}');
|
|
225
541
|
if (result.changes > 0) {
|
|
226
|
-
newRelations.push(relation);
|
|
542
|
+
newRelations.push({ ...relation, endpoint_embedding_status });
|
|
227
543
|
}
|
|
228
544
|
}
|
|
545
|
+
this.coordinator?.kick();
|
|
229
546
|
return newRelations;
|
|
230
547
|
}
|
|
231
548
|
async addObservations(observations) {
|
|
@@ -248,12 +565,16 @@ export class RAGKnowledgeGraphManager {
|
|
|
248
565
|
const newObservations = timestamped.filter(c => !currentBare.has(stripDate(c)));
|
|
249
566
|
if (newObservations.length > 0) {
|
|
250
567
|
const updatedObservations = [...currentObservations, ...newObservations];
|
|
251
|
-
this.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
568
|
+
this.mutateEntityAndInvalidate(entityId, () => {
|
|
569
|
+
this.db.prepare(`
|
|
570
|
+
UPDATE entities SET observations = ? WHERE id = ?
|
|
571
|
+
`).run(JSON.stringify(updatedObservations), entityId);
|
|
572
|
+
});
|
|
573
|
+
// Regenerate embedding for the updated entity (queued when not ready)
|
|
255
574
|
console.error(`🔮 Regenerating embedding for updated entity: ${obs.entityName}`);
|
|
256
|
-
await this.
|
|
575
|
+
const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
|
|
576
|
+
results.push({ entityName: obs.entityName, addedObservations: newObservations, embedding_status });
|
|
577
|
+
continue;
|
|
257
578
|
}
|
|
258
579
|
results.push({ entityName: obs.entityName, addedObservations: newObservations });
|
|
259
580
|
}
|
|
@@ -322,22 +643,40 @@ export class RAGKnowledgeGraphManager {
|
|
|
322
643
|
}
|
|
323
644
|
console.error(`✅ Entity deletion process completed`);
|
|
324
645
|
}
|
|
646
|
+
// v3.6 (spec §5c, breaking): structured per-entity results + re-embedding.
|
|
647
|
+
// Pre-3.6 this method silently left STALE entity vectors behind (the input
|
|
648
|
+
// text changed but the vector was never regenerated) — fixed via
|
|
649
|
+
// tryEmbedEntity, which also covers the not-ready dirty contract.
|
|
325
650
|
async deleteObservations(deletions) {
|
|
326
651
|
if (!this.db)
|
|
327
652
|
throw new Error('Database not initialized');
|
|
653
|
+
const results = [];
|
|
654
|
+
let total = 0;
|
|
328
655
|
for (const deletion of deletions) {
|
|
329
656
|
const entityId = `entity_${deletion.entityName.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
|
|
330
657
|
const entity = this.db.prepare(`
|
|
331
658
|
SELECT observations FROM entities WHERE id = ?
|
|
332
659
|
`).get(entityId);
|
|
333
|
-
if (entity) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
this.db.prepare(`
|
|
337
|
-
UPDATE entities SET observations = ? WHERE id = ?
|
|
338
|
-
`).run(JSON.stringify(filteredObservations), entityId);
|
|
660
|
+
if (!entity) {
|
|
661
|
+
results.push({ entityName: deletion.entityName, deleted: 0, embedding_status: 'n/a' });
|
|
662
|
+
continue;
|
|
339
663
|
}
|
|
664
|
+
const currentObservations = JSON.parse(entity.observations);
|
|
665
|
+
const filteredObservations = currentObservations.filter((obs) => !deletion.observations.includes(obs));
|
|
666
|
+
const deleted = currentObservations.length - filteredObservations.length;
|
|
667
|
+
if (deleted === 0) {
|
|
668
|
+
results.push({ entityName: deletion.entityName, deleted: 0, embedding_status: 'n/a' });
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
this.mutateEntityAndInvalidate(entityId, () => {
|
|
672
|
+
this.db.prepare(`UPDATE entities SET observations = ? WHERE id = ?`)
|
|
673
|
+
.run(JSON.stringify(filteredObservations), entityId);
|
|
674
|
+
});
|
|
675
|
+
const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
|
|
676
|
+
total += deleted;
|
|
677
|
+
results.push({ entityName: deletion.entityName, deleted, embedding_status });
|
|
340
678
|
}
|
|
679
|
+
return { results, total_deleted: total };
|
|
341
680
|
}
|
|
342
681
|
async deleteRelations(relations) {
|
|
343
682
|
if (!this.db)
|
|
@@ -524,10 +863,24 @@ export class RAGKnowledgeGraphManager {
|
|
|
524
863
|
console.error(`✅ getNeighbors: Found ${entities.length} entities, ${relations.length} relations, ${paths.length} paths (depth=${effectiveDepth})`);
|
|
525
864
|
return { entities, relations, paths };
|
|
526
865
|
}
|
|
866
|
+
// v3.6 (spec §5·§5c, additive): FTS lexical fallback when vector search is
|
|
867
|
+
// not eligible, hybrid-partial merge while backfill is catching up, and
|
|
868
|
+
// top-level state fields on every response.
|
|
527
869
|
async searchNodes(query, limit = 10, since, until) {
|
|
528
870
|
if (!this.db)
|
|
529
871
|
throw new Error('Database not initialized');
|
|
530
872
|
console.error(`🔍 Semantic entity search: "${query}"`);
|
|
873
|
+
const covS = this.coordinator?.coverage();
|
|
874
|
+
const entityPct = covS && covS.entity.total > 0 ? Math.round((covS.entity.embedded / covS.entity.total) * 100) : 100;
|
|
875
|
+
const stateFields = () => ({
|
|
876
|
+
model_state: this.gate.status.state,
|
|
877
|
+
coverage: { entity_pct: entityPct },
|
|
878
|
+
});
|
|
879
|
+
if (!(this.coordinator?.eligible ?? false)) {
|
|
880
|
+
// No waiting on the model (spec §5) — lexical entities_fts fallback.
|
|
881
|
+
return { ...this.searchNodesFts(query, limit, since, until), search_mode: 'fts-only',
|
|
882
|
+
...stateFields(), degradation_reason: this.degradationReason() };
|
|
883
|
+
}
|
|
531
884
|
const queryVariants = this.buildCrossLingualVariants(query);
|
|
532
885
|
if (queryVariants.length > 1) {
|
|
533
886
|
console.error(`🌐 searchNodes variants: ${queryVariants.slice(1).join(' | ')}`);
|
|
@@ -551,16 +904,26 @@ export class RAGKnowledgeGraphManager {
|
|
|
551
904
|
`).all(Buffer.from(embedding.buffer), k);
|
|
552
905
|
};
|
|
553
906
|
const resultMap = new Map();
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
907
|
+
try {
|
|
908
|
+
for (const variant of queryVariants) {
|
|
909
|
+
const embedding = await this.generateEmbedding(variant, 1024, true);
|
|
910
|
+
const variantResults = searchEntities(embedding, limit * 2);
|
|
911
|
+
for (const result of variantResults) {
|
|
912
|
+
const existing = resultMap.get(result.entity_id);
|
|
913
|
+
if (!existing || result.distance < existing.distance) {
|
|
914
|
+
resultMap.set(result.entity_id, result);
|
|
915
|
+
}
|
|
561
916
|
}
|
|
562
917
|
}
|
|
563
918
|
}
|
|
919
|
+
catch (embErr) {
|
|
920
|
+
// Ready-state inference failure degrades to FTS instead of failing the
|
|
921
|
+
// tool (beta B6) — same contract as hybridSearch. The gate's own
|
|
922
|
+
// consecutive-failure counter handles the systemic transition.
|
|
923
|
+
console.error(`⚠️ searchNodes vector path failed — FTS fallback:`, embErr instanceof Error ? embErr.message : embErr);
|
|
924
|
+
return { ...this.searchNodesFts(query, limit, since, until), search_mode: 'fts-only',
|
|
925
|
+
...stateFields(), degradation_reason: this.degradationReason() ?? 'inference_error' };
|
|
926
|
+
}
|
|
564
927
|
const entityResults = Array.from(resultMap.values()).sort((a, b) => a.distance - b.distance).slice(0, limit);
|
|
565
928
|
// Filter by temporal range if specified
|
|
566
929
|
let filteredResults = entityResults;
|
|
@@ -576,35 +939,79 @@ export class RAGKnowledgeGraphManager {
|
|
|
576
939
|
return true;
|
|
577
940
|
});
|
|
578
941
|
}
|
|
579
|
-
if (filteredResults.length === 0) {
|
|
580
|
-
console.error(`ℹ️ No semantic matches found for "${query}"`);
|
|
581
|
-
return { entities: [], relations: [] };
|
|
582
|
-
}
|
|
583
942
|
const entities = filteredResults.map(result => ({
|
|
584
943
|
name: result.name,
|
|
585
944
|
entityType: result.entityType,
|
|
586
945
|
observations: JSON.parse(result.observations),
|
|
587
946
|
similarity: Math.max(0, 1 - result.distance / 2) // Convert cosine distance (0-2) to similarity (1-0)
|
|
588
947
|
}));
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
948
|
+
// hybrid-partial (spec §4): entities without vectors must not vanish from
|
|
949
|
+
// search while backfill catches up — merge lexical FTS hits for the gap.
|
|
950
|
+
let search_mode = 'hybrid';
|
|
951
|
+
if (entityPct < 100) {
|
|
952
|
+
search_mode = 'hybrid-partial';
|
|
953
|
+
const seen = new Set(entities.map(e => e.name));
|
|
954
|
+
const ftsExtra = this.searchNodesFts(query, limit, since, until);
|
|
955
|
+
for (const e of ftsExtra.entities) {
|
|
956
|
+
if (entities.length >= limit)
|
|
957
|
+
break;
|
|
958
|
+
if (!seen.has(e.name)) {
|
|
959
|
+
seen.add(e.name);
|
|
960
|
+
entities.push(e);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (entities.length === 0) {
|
|
965
|
+
console.error(`ℹ️ No semantic matches found for "${query}"`);
|
|
966
|
+
return { entities: [], relations: [], search_mode, ...stateFields() };
|
|
967
|
+
}
|
|
968
|
+
const relations = this.relationsAmong(entities.map(e => e.name));
|
|
969
|
+
console.error(`✅ Found ${entities.length} semantically similar entities with ${relations.length} relationships`);
|
|
970
|
+
return { entities, relations, search_mode, ...stateFields() };
|
|
971
|
+
}
|
|
972
|
+
// Lexical entity search over entities_fts (spec §5 contract: name /
|
|
973
|
+
// observations / entityType lexical match — no semantic-equivalence claim).
|
|
974
|
+
// Temporal filters apply in SQL so LIMIT is not distorted.
|
|
975
|
+
searchNodesFts(query, limit, since, until) {
|
|
976
|
+
const expr = compileFtsLiteralQuery(query);
|
|
977
|
+
if (expr === null) {
|
|
978
|
+
return { entities: [], relations: [], warning: 'query has no searchable terms' };
|
|
979
|
+
}
|
|
980
|
+
const rows = this.db.prepare(`
|
|
981
|
+
SELECT e.name, e.entityType, e.observations
|
|
982
|
+
FROM entities_fts f
|
|
983
|
+
JOIN entities e ON f.rowid = e.rowid
|
|
984
|
+
WHERE entities_fts MATCH @expr
|
|
985
|
+
${since ? 'AND e.created_at >= @since' : ''}
|
|
986
|
+
${until ? 'AND e.created_at <= @until' : ''}
|
|
987
|
+
ORDER BY bm25(entities_fts)
|
|
988
|
+
LIMIT @limit
|
|
989
|
+
`).all({ expr, since, until, limit });
|
|
990
|
+
const entities = rows.map(r => ({
|
|
991
|
+
name: r.name,
|
|
992
|
+
entityType: r.entityType,
|
|
993
|
+
observations: JSON.parse(r.observations),
|
|
994
|
+
}));
|
|
995
|
+
return { entities, relations: this.relationsAmong(entities.map(e => e.name)) };
|
|
996
|
+
}
|
|
997
|
+
relationsAmong(entityNames) {
|
|
998
|
+
if (entityNames.length === 0)
|
|
999
|
+
return [];
|
|
1000
|
+
return this.db.prepare(`
|
|
1001
|
+
SELECT
|
|
593
1002
|
e1.name as from_name,
|
|
594
1003
|
e2.name as to_name,
|
|
595
1004
|
r.relationType
|
|
596
1005
|
FROM relationships r
|
|
597
1006
|
JOIN entities e1 ON r.source_entity = e1.id
|
|
598
1007
|
JOIN entities e2 ON r.target_entity = e2.id
|
|
599
|
-
WHERE e1.name IN (${entityNames.map(() => '?').join(',')})
|
|
1008
|
+
WHERE e1.name IN (${entityNames.map(() => '?').join(',')})
|
|
600
1009
|
AND e2.name IN (${entityNames.map(() => '?').join(',')})
|
|
601
1010
|
`).all(...entityNames, ...entityNames).map((row) => ({
|
|
602
1011
|
from: row.from_name,
|
|
603
1012
|
to: row.to_name,
|
|
604
1013
|
relationType: row.relationType
|
|
605
1014
|
}));
|
|
606
|
-
console.error(`✅ Found ${entities.length} semantically similar entities with ${relations.length} relationships`);
|
|
607
|
-
return { entities, relations };
|
|
608
1015
|
}
|
|
609
1016
|
async openNodes(names) {
|
|
610
1017
|
if (!this.db)
|
|
@@ -800,7 +1207,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
800
1207
|
};
|
|
801
1208
|
}
|
|
802
1209
|
// Generate and store embedding for a single entity
|
|
803
|
-
async embedEntity(entityId) {
|
|
1210
|
+
async embedEntity(entityId, priority = 'bulk') {
|
|
804
1211
|
if (!this.db)
|
|
805
1212
|
throw new Error('Database not initialized');
|
|
806
1213
|
// Get entity data
|
|
@@ -822,28 +1229,43 @@ export class RAGKnowledgeGraphManager {
|
|
|
822
1229
|
// char size (identity excluded), and embed duration. `capped` = some observation chars dropped.
|
|
823
1230
|
const capped = built.cappedObsChars < built.filteredObsChars;
|
|
824
1231
|
const embedStart = Date.now();
|
|
825
|
-
const embedding = await this.generateEmbedding(embeddingText);
|
|
1232
|
+
const embedding = await this.generateEmbedding(embeddingText, 1024, false, priority);
|
|
826
1233
|
const embedMs = Date.now() - embedStart;
|
|
827
1234
|
console.error(`[embed] ${entity.name}: ${built.selectedObsCount}/${built.totalObsCount} obs, ${built.filteredObsChars}ch -> ${built.cappedObsChars}ch${capped ? ' (capped)' : ''}, ${embedMs}ms`);
|
|
828
1235
|
try {
|
|
829
|
-
//
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1236
|
+
// v3.6 (§6a-2): vector replace + provenance stamp commit atomically.
|
|
1237
|
+
// Write-back CAS (beta 2R B1): the entity may have been mutated again
|
|
1238
|
+
// while THIS inference was in flight — a late writer must never
|
|
1239
|
+
// re-insert a vector for superseded content as 'verified'. Inside the
|
|
1240
|
+
// write transaction the CURRENT entity text is rebuilt and hashed; on
|
|
1241
|
+
// mismatch the result is discarded and the row stays missing/queued for
|
|
1242
|
+
// the backfill pass that the newer mutation already kicked.
|
|
1243
|
+
const inputHash = this.hashWithBuilderVersion(embeddingText);
|
|
1244
|
+
const writeTx = this.db.transaction(() => {
|
|
1245
|
+
const currentHash = this.entityInputHash(entityId);
|
|
1246
|
+
if (currentHash !== inputHash)
|
|
1247
|
+
return false; // superseded — discard
|
|
1248
|
+
const existingMetadata = this.db.prepare(`
|
|
1249
|
+
SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?
|
|
1250
|
+
`).get(entityId);
|
|
1251
|
+
if (existingMetadata) {
|
|
1252
|
+
this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(existingMetadata.rowid)}`);
|
|
1253
|
+
this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
|
|
1254
|
+
}
|
|
1255
|
+
const result = this.db.prepare(`
|
|
1256
|
+
INSERT INTO entity_embeddings (embedding) VALUES (?)
|
|
1257
|
+
`).run(Buffer.from(embedding.buffer));
|
|
1258
|
+
this.db.prepare(`
|
|
1259
|
+
INSERT INTO entity_embedding_metadata (rowid, entity_id, embedding_text, input_hash, profile_id, provenance_state)
|
|
1260
|
+
VALUES (?, ?, ?, ?, ?, 'verified')
|
|
1261
|
+
`).run(result.lastInsertRowid, entityId, embeddingText, inputHash, this.currentProfileId);
|
|
1262
|
+
return true;
|
|
1263
|
+
});
|
|
1264
|
+
const written = writeTx();
|
|
1265
|
+
if (!written) {
|
|
1266
|
+
console.error(`⏭️ discarded superseded embedding for ${entityId} (entity changed during inference)`);
|
|
836
1267
|
}
|
|
837
|
-
|
|
838
|
-
const result = this.db.prepare(`
|
|
839
|
-
INSERT INTO entity_embeddings (embedding) VALUES (?)
|
|
840
|
-
`).run(Buffer.from(embedding.buffer));
|
|
841
|
-
// Store metadata
|
|
842
|
-
this.db.prepare(`
|
|
843
|
-
INSERT INTO entity_embedding_metadata (rowid, entity_id, embedding_text)
|
|
844
|
-
VALUES (?, ?, ?)
|
|
845
|
-
`).run(result.lastInsertRowid, entityId, embeddingText);
|
|
846
|
-
return true;
|
|
1268
|
+
return written;
|
|
847
1269
|
}
|
|
848
1270
|
catch (error) {
|
|
849
1271
|
console.error(`Failed to embed entity ${entityId}:`, error);
|
|
@@ -866,6 +1288,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
866
1288
|
embeddedCount += results.filter(Boolean).length;
|
|
867
1289
|
}
|
|
868
1290
|
console.error(`✅ Entity embeddings completed: ${embeddedCount}/${entities.length} entities embedded`);
|
|
1291
|
+
this.coordinator?.invalidateCoverage();
|
|
869
1292
|
return {
|
|
870
1293
|
totalEntities: entities.length,
|
|
871
1294
|
embeddedEntities: embeddedCount
|
|
@@ -945,15 +1368,20 @@ export class RAGKnowledgeGraphManager {
|
|
|
945
1368
|
const errors = [];
|
|
946
1369
|
for (const chunk of chunks) {
|
|
947
1370
|
// Generate embedding
|
|
948
|
-
const embedding = await this.generateEmbedding(chunk.text);
|
|
1371
|
+
const embedding = await this.generateEmbedding(chunk.text, 1024, false, 'bulk');
|
|
949
1372
|
const rowid = safeRowid(chunk.rowid);
|
|
950
1373
|
try {
|
|
951
|
-
//
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1374
|
+
// vector + verified provenance in one transaction (§6a-2) — KG chunks
|
|
1375
|
+
// must never become vector-bearing provenance-NULL rows post-recon.
|
|
1376
|
+
const tx = this.db.transaction(() => {
|
|
1377
|
+
this.db.exec(`DELETE FROM chunks WHERE rowid = ${rowid}`);
|
|
1378
|
+
this.db.prepare(`
|
|
1379
|
+
INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)
|
|
1380
|
+
`).run(Buffer.from(embedding.buffer));
|
|
1381
|
+
this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
|
|
1382
|
+
.run(createHash('sha256').update(chunk.text).digest('hex'), this.currentProfileId, chunk.rowid);
|
|
1383
|
+
});
|
|
1384
|
+
tx();
|
|
957
1385
|
embeddedCount++;
|
|
958
1386
|
}
|
|
959
1387
|
catch (error) {
|
|
@@ -962,6 +1390,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
962
1390
|
errors.push(errMsg);
|
|
963
1391
|
}
|
|
964
1392
|
}
|
|
1393
|
+
this.coordinator?.invalidateCoverage();
|
|
965
1394
|
console.error(`✅ Knowledge graph chunks embedded: ${embeddedCount}/${chunks.length}`);
|
|
966
1395
|
return { embeddedChunks: embeddedCount, totalChunks: chunks.length, ...(errors.length > 0 && { errors: errors.slice(0, 5) }) };
|
|
967
1396
|
}
|
|
@@ -1185,37 +1614,23 @@ export class RAGKnowledgeGraphManager {
|
|
|
1185
1614
|
}
|
|
1186
1615
|
// Generate embeddings using sentence transformers
|
|
1187
1616
|
// isQuery: true for search queries (adds instruction prefix), false for documents/entities
|
|
1188
|
-
async generateEmbedding(text, dimensions = 1024, isQuery = false) {
|
|
1617
|
+
async generateEmbedding(text, dimensions = 1024, isQuery = false, priority = 'interactive') {
|
|
1189
1618
|
// Check cache first (hash-based key to avoid collisions on long texts)
|
|
1190
1619
|
const cacheKey = createHash('md5').update(`${text}_${dimensions}_${isQuery}`).digest('hex');
|
|
1191
1620
|
const cached = this.embeddingCache.get(cacheKey);
|
|
1192
1621
|
if (cached)
|
|
1193
1622
|
return cached;
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
if (this.embeddingCache.size >= this.EMBEDDING_CACHE_MAX) {
|
|
1206
|
-
const firstKey = this.embeddingCache.keys().next().value;
|
|
1207
|
-
if (firstKey)
|
|
1208
|
-
this.embeddingCache.delete(firstKey);
|
|
1209
|
-
}
|
|
1210
|
-
this.embeddingCache.set(cacheKey, modelResult);
|
|
1211
|
-
return modelResult;
|
|
1212
|
-
}
|
|
1213
|
-
catch (error) {
|
|
1214
|
-
console.error(`⚠️ Embedding model failed for text "${text.slice(0, 50)}...":`, error instanceof Error ? error.message : error);
|
|
1215
|
-
throw new Error(`Embedding model not available. Ensure the model is loaded. Original error: ${error instanceof Error ? error.message : error}`);
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
throw new Error('Embedding model not initialized. The server may still be loading the model — retry in a few seconds.');
|
|
1623
|
+
// v3.6: all inference goes through the gate — state check + execution in one
|
|
1624
|
+
// atomic boundary (TOCTOU-safe). GateNotReadyError / GateDisabledError
|
|
1625
|
+
// propagate so each consumer honors its own not-ready contract (spec §5).
|
|
1626
|
+
const modelResult = await this.gate.embed(text, { dims: dimensions, isQuery, priority });
|
|
1627
|
+
if (this.embeddingCache.size >= this.EMBEDDING_CACHE_MAX) {
|
|
1628
|
+
const firstKey = this.embeddingCache.keys().next().value;
|
|
1629
|
+
if (firstKey)
|
|
1630
|
+
this.embeddingCache.delete(firstKey);
|
|
1631
|
+
}
|
|
1632
|
+
this.embeddingCache.set(cacheKey, modelResult);
|
|
1633
|
+
return modelResult;
|
|
1219
1634
|
}
|
|
1220
1635
|
// === NEW SEPARATE TOOLS ===
|
|
1221
1636
|
async syncDocumentFromFile(filePath, documentId, options = {}) {
|
|
@@ -1243,31 +1658,56 @@ export class RAGKnowledgeGraphManager {
|
|
|
1243
1658
|
catch { /* ignore */ }
|
|
1244
1659
|
if (existingHash === contentHash) {
|
|
1245
1660
|
const cmCount = this.db.prepare(`SELECT count(*) AS n FROM chunk_metadata WHERE document_id = ?`).get(documentId).n;
|
|
1661
|
+
// "Embedded" for dedup completeness = vector exists AND its profile is
|
|
1662
|
+
// current (or legacy-NULL awaiting grandfather). Raw vector counts
|
|
1663
|
+
// would misjudge old-profile rows as complete (beta 1R supplement).
|
|
1246
1664
|
const embCount = this.db.prepare(`
|
|
1247
|
-
SELECT count(*) AS n FROM chunks c JOIN chunk_metadata m ON c.rowid = m.rowid
|
|
1665
|
+
SELECT count(*) AS n FROM chunks c JOIN chunk_metadata m ON c.rowid = m.rowid
|
|
1666
|
+
WHERE m.document_id = ? AND (m.provenance_state IS NULL OR m.profile_id = ?)
|
|
1667
|
+
`).get(documentId, this.currentProfileId).n;
|
|
1668
|
+
const linked = this.db.prepare(`
|
|
1669
|
+
SELECT count(DISTINCT ce.entity_id) AS n FROM chunk_entities ce
|
|
1670
|
+
JOIN chunk_metadata m ON ce.chunk_rowid = m.rowid WHERE m.document_id = ?
|
|
1248
1671
|
`).get(documentId).n;
|
|
1249
1672
|
if (cmCount > 0 && cmCount === embCount) {
|
|
1250
|
-
const linked = this.db.prepare(`
|
|
1251
|
-
SELECT count(DISTINCT ce.entity_id) AS n FROM chunk_entities ce
|
|
1252
|
-
JOIN chunk_metadata m ON ce.chunk_rowid = m.rowid WHERE m.document_id = ?
|
|
1253
|
-
`).get(documentId).n;
|
|
1254
1673
|
console.error(`⏭️ syncDocumentFromFile: ${documentId} unchanged (hash match, ${cmCount} chunks embedded) — skipped`);
|
|
1255
1674
|
return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked, skipped: true, reason: 'unchanged' };
|
|
1256
1675
|
}
|
|
1676
|
+
if (cmCount > 0 && embCount < cmCount) {
|
|
1677
|
+
// v3.6 (spec §5b M12): identical content with incomplete/stale vectors
|
|
1678
|
+
// keeps the document, chunks, rowids and entity links — only the
|
|
1679
|
+
// missing vectors are re-queued via the coordinator. Full re-chunking
|
|
1680
|
+
// here would churn rowids and links for no content change.
|
|
1681
|
+
console.error(`♻️ syncDocumentFromFile: ${documentId} unchanged but ${cmCount - embCount} vectors missing — re-queued (chunks preserved)`);
|
|
1682
|
+
this.coordinator?.kick();
|
|
1683
|
+
return { documentId, bytes, chunks: cmCount, embeddedChunks: embCount, linkedEntities: linked, skipped: true, reason: 'unchanged-revectorizing', embedding_status: this.gate.isDisabled ? 'disabled' : 'queued' };
|
|
1684
|
+
}
|
|
1257
1685
|
}
|
|
1258
1686
|
}
|
|
1259
1687
|
console.error(`🔄 syncDocumentFromFile: ${documentId} <- ${filePath} (${bytes} bytes)`);
|
|
1260
|
-
// 3.
|
|
1261
|
-
//
|
|
1688
|
+
// 3. Two contracts (spec §5b):
|
|
1689
|
+
// ready — pre-compute ALL embeddings BEFORE any DB mutation; if
|
|
1690
|
+
// inference throws mid-way the old document stays intact
|
|
1691
|
+
// (v3.5.0 atomicity, unchanged).
|
|
1692
|
+
// not-ready — intentional lazy sync: store document + chunks + FTS in
|
|
1693
|
+
// one transaction with NO vectors (embedding_status:
|
|
1694
|
+
// queued); the backfill coordinator recovers them.
|
|
1262
1695
|
const { maxTokens = 800, overlap = 160 } = options.chunkParams || {};
|
|
1263
1696
|
const segments = this.chunkText(content, maxTokens, overlap);
|
|
1697
|
+
const lazySync = !this.gate.isReady;
|
|
1264
1698
|
const embedded = [];
|
|
1265
|
-
|
|
1266
|
-
const
|
|
1267
|
-
|
|
1699
|
+
if (lazySync) {
|
|
1700
|
+
for (const seg of segments)
|
|
1701
|
+
embedded.push({ seg, embedding: null });
|
|
1702
|
+
}
|
|
1703
|
+
else {
|
|
1704
|
+
for (const seg of segments) {
|
|
1705
|
+
const embedding = await this.generateEmbedding(seg.text, 1024, false, 'bulk');
|
|
1706
|
+
embedded.push({ seg, embedding });
|
|
1707
|
+
}
|
|
1268
1708
|
}
|
|
1269
|
-
// 4. Atomic swap: delete old -> insert doc -> insert chunks + embeddings
|
|
1270
|
-
//
|
|
1709
|
+
// 4. Atomic swap: delete old -> insert doc -> insert chunks (+ embeddings
|
|
1710
|
+
// with verified provenance when ready), one synchronous transaction.
|
|
1271
1711
|
const applyTx = this.db.transaction(() => {
|
|
1272
1712
|
const db = this.db;
|
|
1273
1713
|
// 4a. cleanup old doc (inlined sync version of cleanupDocument).
|
|
@@ -1281,7 +1721,8 @@ export class RAGKnowledgeGraphManager {
|
|
|
1281
1721
|
// 4b. insert document.
|
|
1282
1722
|
db.prepare(`INSERT INTO documents (id, content, metadata) VALUES (?, ?, ?)`)
|
|
1283
1723
|
.run(documentId, content, JSON.stringify(metadata));
|
|
1284
|
-
// 4c. insert chunk_metadata (FTS5 chunks_fts auto-filled by trigger)
|
|
1724
|
+
// 4c. insert chunk_metadata (FTS5 chunks_fts auto-filled by trigger);
|
|
1725
|
+
// vectors + provenance only on the ready path (§6a-2).
|
|
1285
1726
|
for (const { seg, embedding } of embedded) {
|
|
1286
1727
|
const chunkId = `${documentId}_chunk_${seg.chunk_index}`;
|
|
1287
1728
|
const info = db.prepare(`
|
|
@@ -1289,11 +1730,18 @@ export class RAGKnowledgeGraphManager {
|
|
|
1289
1730
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1290
1731
|
`).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
|
|
1291
1732
|
const rowid = Number(info.lastInsertRowid);
|
|
1292
|
-
|
|
1733
|
+
if (embedding) {
|
|
1734
|
+
db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(Buffer.from(embedding.buffer));
|
|
1735
|
+
db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
|
|
1736
|
+
.run(createHash('sha256').update(seg.text).digest('hex'), this.currentProfileId, rowid);
|
|
1737
|
+
}
|
|
1293
1738
|
}
|
|
1294
1739
|
});
|
|
1295
1740
|
applyTx();
|
|
1296
|
-
|
|
1741
|
+
this.coordinator?.invalidateCoverage();
|
|
1742
|
+
if (lazySync)
|
|
1743
|
+
this.coordinator?.kick();
|
|
1744
|
+
const embeddedChunks = lazySync ? 0 : embedded.length;
|
|
1297
1745
|
// 5. Entity linking AFTER commit. Non-destructive + idempotent (INSERT OR
|
|
1298
1746
|
// IGNORE), so a linking failure cannot corrupt the doc/embeddings.
|
|
1299
1747
|
const linkedEntities = await this.autoLinkEntities(documentId);
|
|
@@ -1309,6 +1757,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
1309
1757
|
chunks: segments.length,
|
|
1310
1758
|
embeddedChunks,
|
|
1311
1759
|
linkedEntities,
|
|
1760
|
+
embedding_status: lazySync ? (this.gate.isDisabled ? 'disabled' : 'queued') : 'embedded',
|
|
1312
1761
|
...(explicitlyLinked !== undefined ? { explicitlyLinked } : {}),
|
|
1313
1762
|
};
|
|
1314
1763
|
if (linkedEntities === 0 && explicitlyLinked === undefined) {
|
|
@@ -1366,6 +1815,10 @@ export class RAGKnowledgeGraphManager {
|
|
|
1366
1815
|
});
|
|
1367
1816
|
}
|
|
1368
1817
|
console.error(`✅ Document chunked: ${chunks.length} chunks created`);
|
|
1818
|
+
// Indirect missing-row producer (spec §5): freshly chunked rows have no
|
|
1819
|
+
// vectors yet — let the coordinator recover them without a restart.
|
|
1820
|
+
this.coordinator?.invalidateCoverage();
|
|
1821
|
+
this.coordinator?.kick();
|
|
1369
1822
|
return { documentId, chunks: resultChunks };
|
|
1370
1823
|
}
|
|
1371
1824
|
async embedChunks(documentId) {
|
|
@@ -1382,18 +1835,20 @@ export class RAGKnowledgeGraphManager {
|
|
|
1382
1835
|
let embeddedCount = 0;
|
|
1383
1836
|
const errors = [];
|
|
1384
1837
|
for (const chunk of chunks) {
|
|
1385
|
-
// Generate embedding
|
|
1386
|
-
const embedding = await this.generateEmbedding(chunk.text);
|
|
1838
|
+
// Generate embedding (foreground-bulk priority)
|
|
1839
|
+
const embedding = await this.generateEmbedding(chunk.text, 1024, false, 'bulk');
|
|
1387
1840
|
const rowid = Number(chunk.rowid);
|
|
1388
|
-
// Store in vector table
|
|
1841
|
+
// Store in vector table (+ verified provenance, §6a-2 atomic)
|
|
1389
1842
|
try {
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1843
|
+
const tx = this.db.transaction(() => {
|
|
1844
|
+
this.db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(rowid)}`);
|
|
1845
|
+
this.db.prepare(`
|
|
1846
|
+
INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)
|
|
1847
|
+
`).run(Buffer.from(embedding.buffer));
|
|
1848
|
+
this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
|
|
1849
|
+
.run(createHash('sha256').update(chunk.text).digest('hex'), this.currentProfileId, rowid);
|
|
1850
|
+
});
|
|
1851
|
+
tx();
|
|
1397
1852
|
embeddedCount++;
|
|
1398
1853
|
}
|
|
1399
1854
|
catch (error) {
|
|
@@ -1403,6 +1858,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
1403
1858
|
}
|
|
1404
1859
|
}
|
|
1405
1860
|
console.error(`✅ Chunks embedded: ${embeddedCount}/${chunks.length}`);
|
|
1861
|
+
this.coordinator?.invalidateCoverage();
|
|
1406
1862
|
// Auto-link entities to document after embedding
|
|
1407
1863
|
const linkedCount = await this.autoLinkEntities(documentId);
|
|
1408
1864
|
return { documentId, embeddedChunks: embeddedCount, totalChunks: chunks.length, linkedEntities: linkedCount, ...(errors.length > 0 && { errors: errors.slice(0, 5) }) };
|
|
@@ -1811,6 +2267,9 @@ export class RAGKnowledgeGraphManager {
|
|
|
1811
2267
|
}
|
|
1812
2268
|
}
|
|
1813
2269
|
console.error(`✅ Import completed: ${imported.entities} entities, ${imported.relations} relations, ${imported.documents} documents imported`);
|
|
2270
|
+
// Indirect missing-row producer (spec §5): imported rows may lack vectors.
|
|
2271
|
+
this.coordinator?.invalidateCoverage();
|
|
2272
|
+
this.coordinator?.kick();
|
|
1814
2273
|
return { imported, skipped };
|
|
1815
2274
|
}
|
|
1816
2275
|
async hybridSearch(query, limit = 5, useGraph = true) {
|
|
@@ -1819,6 +2278,9 @@ export class RAGKnowledgeGraphManager {
|
|
|
1819
2278
|
if (!this.encoding)
|
|
1820
2279
|
throw new Error('Tokenizer not initialized');
|
|
1821
2280
|
console.error(`🔍 Enhanced hybrid search: "${query}"`);
|
|
2281
|
+
// Parity with searchNodes (beta 1R supplement): an unsearchable query gets
|
|
2282
|
+
// an explicit warning instead of a silent empty envelope.
|
|
2283
|
+
const ftsUnsearchable = compileFtsLiteralQuery(query) === null;
|
|
1822
2284
|
const queryVariants = this.buildCrossLingualVariants(query);
|
|
1823
2285
|
if (queryVariants.length > 1) {
|
|
1824
2286
|
console.error(`🌐 Cross-lingual variants: ${queryVariants.slice(1).join(' | ')}`);
|
|
@@ -1853,37 +2315,47 @@ export class RAGKnowledgeGraphManager {
|
|
|
1853
2315
|
`).all(Buffer.from(embedding.buffer), k);
|
|
1854
2316
|
};
|
|
1855
2317
|
// Search original query plus cross-lingual expansions and keep best match per chunk.
|
|
2318
|
+
// v3.6 eligibility gate (spec §3): vector usage requires model_ready AND
|
|
2319
|
+
// reconciliation settled — otherwise FTS5-only, no waiting.
|
|
1856
2320
|
const resultMap = new Map();
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
2321
|
+
let degradationReason;
|
|
2322
|
+
if (!(this.coordinator?.eligible ?? false)) {
|
|
2323
|
+
vectorDegraded = true;
|
|
2324
|
+
degradationReason = this.degradationReason();
|
|
2325
|
+
console.error(`ℹ️ vector search not eligible (${degradationReason ?? 'unknown'}) — FTS5-only`);
|
|
2326
|
+
}
|
|
2327
|
+
else {
|
|
2328
|
+
try {
|
|
2329
|
+
primaryQueryEmbedding = await this.generateEmbedding(queryVariants[0], 1024, true);
|
|
2330
|
+
for (const variant of queryVariants) {
|
|
2331
|
+
const embedding = await this.generateEmbedding(variant, 1024, true);
|
|
2332
|
+
const variantResults = searchChunks(embedding, limit * 3);
|
|
2333
|
+
for (const r of variantResults) {
|
|
2334
|
+
const existing = resultMap.get(r.chunk_id);
|
|
2335
|
+
if (!existing || r.distance < existing.distance) {
|
|
2336
|
+
resultMap.set(r.chunk_id, r);
|
|
2337
|
+
}
|
|
1866
2338
|
}
|
|
1867
2339
|
}
|
|
1868
2340
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2341
|
+
catch (embErr) {
|
|
2342
|
+
vectorDegraded = true;
|
|
2343
|
+
// 'inference_error' (not 'model_not_ready'): model_state may still read
|
|
2344
|
+
// 'ready' here — a contradictory reason pair confused callers (beta B6).
|
|
2345
|
+
degradationReason = this.degradationReason() ?? 'inference_error';
|
|
2346
|
+
console.error(`⚠️ Vector search unavailable — degrading to FTS5-only:`, embErr instanceof Error ? embErr.message : embErr);
|
|
2347
|
+
}
|
|
1873
2348
|
}
|
|
1874
2349
|
const vectorResults = Array.from(resultMap.values()).sort((a, b) => a.distance - b.distance);
|
|
1875
2350
|
// FTS5 full-text search as additional signal (Reciprocal Rank Fusion)
|
|
1876
2351
|
const ftsBoostMap = new Map();
|
|
1877
2352
|
try {
|
|
1878
2353
|
const ftsSearchQuery = (q) => {
|
|
1879
|
-
//
|
|
1880
|
-
|
|
1881
|
-
|
|
2354
|
+
// Shared compiler (spec §5) — same sanitize rules as pre-3.6, extracted
|
|
2355
|
+
// so entity FTS fallback uses identical MATCH-safety guarantees.
|
|
2356
|
+
const ftsExpr = compileFtsLiteralQuery(q);
|
|
2357
|
+
if (ftsExpr === null)
|
|
1882
2358
|
return [];
|
|
1883
|
-
const terms = sanitized.split(/\s+/).filter(t => t.length > 0);
|
|
1884
|
-
if (terms.length === 0)
|
|
1885
|
-
return [];
|
|
1886
|
-
const ftsExpr = terms.map(t => `"${t}"`).join(' OR ');
|
|
1887
2359
|
return this.db.prepare(`
|
|
1888
2360
|
SELECT cm.rowid, cm.chunk_id, bm25(chunks_fts) as fts_score
|
|
1889
2361
|
FROM chunks_fts
|
|
@@ -1953,7 +2425,19 @@ export class RAGKnowledgeGraphManager {
|
|
|
1953
2425
|
}
|
|
1954
2426
|
if (vectorResults.length === 0) {
|
|
1955
2427
|
console.error(`ℹ️ No vector or FTS5 matches found for "${query}"`);
|
|
1956
|
-
|
|
2428
|
+
// Empty results still carry state (spec §5c: envelope exists so callers
|
|
2429
|
+
// can distinguish "nothing matched" from "vector search was degraded").
|
|
2430
|
+
const covE = this.coordinator?.coverage();
|
|
2431
|
+
const chunkPctE = covE && covE.chunk.total > 0 ? Math.round((covE.chunk.embedded / covE.chunk.total) * 100) : 100;
|
|
2432
|
+
const graphPctE = covE && covE.entity.total > 0 ? Math.round((covE.entity.embedded / covE.entity.total) * 100) : 100;
|
|
2433
|
+
return {
|
|
2434
|
+
results: [],
|
|
2435
|
+
search_mode: vectorDegraded ? 'fts-only' : (chunkPctE < 100 ? 'hybrid-partial' : 'hybrid'),
|
|
2436
|
+
model_state: this.gate.status.state,
|
|
2437
|
+
coverage: { chunk_pct: chunkPctE, graph_coverage_pct: graphPctE },
|
|
2438
|
+
...(degradationReason ? { degradation_reason: degradationReason } : {}),
|
|
2439
|
+
...(ftsUnsearchable ? { warning: 'query has no searchable terms for FTS' } : {}),
|
|
2440
|
+
};
|
|
1957
2441
|
}
|
|
1958
2442
|
// Get entity information for graph enhancement via vector similarity
|
|
1959
2443
|
let connectedEntities = new Set();
|
|
@@ -2157,8 +2641,7 @@ export class RAGKnowledgeGraphManager {
|
|
|
2157
2641
|
fts_boost: ftsBoost > 0 ? ftsBoost : undefined,
|
|
2158
2642
|
full_context_available: true,
|
|
2159
2643
|
chunk_type: result.chunk_type,
|
|
2160
|
-
source_id: sourceId
|
|
2161
|
-
search_mode: vectorDegraded ? 'fts-only' : 'hybrid'
|
|
2644
|
+
source_id: sourceId
|
|
2162
2645
|
});
|
|
2163
2646
|
}
|
|
2164
2647
|
// Sort by relevance and return top results
|
|
@@ -2170,7 +2653,34 @@ export class RAGKnowledgeGraphManager {
|
|
|
2170
2653
|
const entityResults = finalResults.filter(r => r.chunk_type === 'entity').length;
|
|
2171
2654
|
const relResults = finalResults.filter(r => r.chunk_type === 'relationship').length;
|
|
2172
2655
|
console.error(`✅ Enhanced hybrid search completed: ${finalResults.length} results (${docResults} docs, ${entityResults} entities, ${relResults} relationships)`);
|
|
2173
|
-
|
|
2656
|
+
// v3.6 envelope (spec §5c, breaking): search_mode moved from per-item to
|
|
2657
|
+
// top-level so state is visible even on empty results; coverage tells the
|
|
2658
|
+
// caller how much of the corpus is actually vector-searchable.
|
|
2659
|
+
const cov = this.coordinator?.coverage();
|
|
2660
|
+
const chunkPct = cov && cov.chunk.total > 0 ? Math.round((cov.chunk.embedded / cov.chunk.total) * 100) : 100;
|
|
2661
|
+
const graphPct = cov && cov.entity.total > 0 ? Math.round((cov.entity.embedded / cov.entity.total) * 100) : 100;
|
|
2662
|
+
const search_mode = vectorDegraded ? 'fts-only' : (chunkPct < 100 ? 'hybrid-partial' : 'hybrid');
|
|
2663
|
+
return {
|
|
2664
|
+
results: finalResults,
|
|
2665
|
+
search_mode,
|
|
2666
|
+
model_state: this.gate.status.state,
|
|
2667
|
+
coverage: { chunk_pct: chunkPct, graph_coverage_pct: graphPct },
|
|
2668
|
+
...(degradationReason ? { degradation_reason: degradationReason } : {}),
|
|
2669
|
+
...(ftsUnsearchable ? { warning: 'query has no searchable terms for FTS' } : {}),
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
// v3.6 (spec §5c / 6R note 2): why is vector search degraded right now?
|
|
2673
|
+
degradationReason() {
|
|
2674
|
+
if (this.gate.isDisabled)
|
|
2675
|
+
return 'disabled';
|
|
2676
|
+
if (!this.gate.isReady)
|
|
2677
|
+
return 'model_not_ready';
|
|
2678
|
+
const rs = this.coordinator?.reconState;
|
|
2679
|
+
if (rs === 'failed')
|
|
2680
|
+
return 'reconciliation_failed';
|
|
2681
|
+
if (rs && rs !== 'complete' && rs !== 'n/a')
|
|
2682
|
+
return 'reconciling';
|
|
2683
|
+
return undefined;
|
|
2174
2684
|
}
|
|
2175
2685
|
// NEW: Get detailed context for a specific chunk
|
|
2176
2686
|
async getDetailedContext(chunkId, includeSurrounding = true) {
|
|
@@ -2261,6 +2771,10 @@ export class RAGKnowledgeGraphManager {
|
|
|
2261
2771
|
const chunkCount = this.db.prepare(`
|
|
2262
2772
|
SELECT COUNT(*) as count FROM chunk_metadata
|
|
2263
2773
|
`).get();
|
|
2774
|
+
// v3.6 (spec §8-2, additive): server self-report — the framework's /start
|
|
2775
|
+
// reads version, model/reconciliation state, and provenance coverage here.
|
|
2776
|
+
const gs = this.gate.status;
|
|
2777
|
+
const cov = this.coordinator?.coverage();
|
|
2264
2778
|
return {
|
|
2265
2779
|
entities: {
|
|
2266
2780
|
total: entityStats.reduce((sum, stat) => sum + stat.count, 0),
|
|
@@ -2271,7 +2785,23 @@ export class RAGKnowledgeGraphManager {
|
|
|
2271
2785
|
by_type: Object.fromEntries(relationshipStats.map(s => [s.relationType, s.count]))
|
|
2272
2786
|
},
|
|
2273
2787
|
documents: documentCount.count,
|
|
2274
|
-
chunks: chunkCount.count
|
|
2788
|
+
chunks: chunkCount.count,
|
|
2789
|
+
server: {
|
|
2790
|
+
version: PKG_VERSION,
|
|
2791
|
+
node: process.versions.node,
|
|
2792
|
+
embeddings_mode: this.embeddingsMode,
|
|
2793
|
+
model: `${EMBEDDING_MODEL}@${MODEL_REVISION}`,
|
|
2794
|
+
model_state: gs.state,
|
|
2795
|
+
ready_since: gs.readySince ?? null,
|
|
2796
|
+
last_error: gs.lastError ? sanitizeErrorMessage(gs.lastError) : null,
|
|
2797
|
+
retry_at: gs.retryAt ?? null,
|
|
2798
|
+
reconciliation_state: this.coordinator?.reconState ?? 'n/a',
|
|
2799
|
+
reconciliation_last_error: this.coordinator?.reconLastError ?? null,
|
|
2800
|
+
coverage: cov ? {
|
|
2801
|
+
chunk: { total: cov.chunk.total, embedded: cov.chunk.embedded, verified: cov.chunk.verified, legacy_assumed: cov.chunk.legacy_assumed, missing: cov.chunk.total - cov.chunk.embedded },
|
|
2802
|
+
entity: { total: cov.entity.total, embedded: cov.entity.embedded, verified: cov.entity.verified, legacy_assumed: cov.entity.legacy_assumed, missing: cov.entity.total - cov.entity.embedded },
|
|
2803
|
+
} : null,
|
|
2804
|
+
}
|
|
2275
2805
|
};
|
|
2276
2806
|
}
|
|
2277
2807
|
// === GRAPH ANALYTICS TOOLS (graphology) ===
|
|
@@ -2600,8 +3130,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2600
3130
|
await ragKgManager.deleteEntities(validatedArgs.entityNames);
|
|
2601
3131
|
return { content: [{ type: "text", text: "Entities deleted successfully" }] };
|
|
2602
3132
|
case "deleteObservations":
|
|
2603
|
-
|
|
2604
|
-
|
|
3133
|
+
// v3.6 (spec §5c, breaking): structured per-entity results replace the
|
|
3134
|
+
// bare success string — mixed embedded/queued/no-op states are visible.
|
|
3135
|
+
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.deleteObservations(validatedArgs.deletions), null, 2) }] };
|
|
2605
3136
|
case "deleteRelations":
|
|
2606
3137
|
await ragKgManager.deleteRelations(validatedArgs.relations);
|
|
2607
3138
|
return { content: [{ type: "text", text: "Relations deleted successfully" }] };
|
|
@@ -2672,35 +3203,95 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2672
3203
|
}
|
|
2673
3204
|
}
|
|
2674
3205
|
catch (error) {
|
|
3206
|
+
// v3.6 (spec §5c): machine-distinguishable failures. Embedding-gate errors
|
|
3207
|
+
// become structured retryable/terminal payloads; every error response now
|
|
3208
|
+
// sets isError so clients stop parsing "Error: ..." strings.
|
|
3209
|
+
if (error instanceof GateNotReadyError) {
|
|
3210
|
+
return { isError: true, content: [{ type: "text", text: JSON.stringify({
|
|
3211
|
+
code: error.code, state: error.state,
|
|
3212
|
+
...(error.retryAfterMs !== undefined ? { retry_after_ms: error.retryAfterMs } : {}),
|
|
3213
|
+
message: error.message
|
|
3214
|
+
}) }] };
|
|
3215
|
+
}
|
|
3216
|
+
if (error instanceof GateDisabledError) {
|
|
3217
|
+
return { isError: true, content: [{ type: "text", text: JSON.stringify({
|
|
3218
|
+
code: error.code, state: error.state, message: error.message
|
|
3219
|
+
}) }] };
|
|
3220
|
+
}
|
|
2675
3221
|
if (error instanceof Error) {
|
|
2676
3222
|
console.error(`❌ Tool execution error for ${name}:`, error.message);
|
|
2677
|
-
return { content: [{ type: "text", text: `Error: ${error.message}` }] };
|
|
3223
|
+
return { isError: true, content: [{ type: "text", text: `Error: ${error.message}` }] };
|
|
2678
3224
|
}
|
|
2679
3225
|
throw error;
|
|
2680
3226
|
}
|
|
2681
3227
|
});
|
|
2682
3228
|
async function main() {
|
|
2683
3229
|
try {
|
|
3230
|
+
assertNodeVersion();
|
|
2684
3231
|
await ragKgManager.initialize();
|
|
3232
|
+
printBanner({
|
|
3233
|
+
model: EMBEDDING_MODEL, revision: MODEL_REVISION, dtype: MODEL_DTYPE,
|
|
3234
|
+
cachePath: resolveModelCacheDir(process.env, process.platform, os.homedir()),
|
|
3235
|
+
dbPath: DB_FILE_PATH,
|
|
3236
|
+
});
|
|
3237
|
+
if (ragKgManager.embeddingsMode === 'eager') {
|
|
3238
|
+
// eager = wait for BOTH the first model load attempt and reconciliation to
|
|
3239
|
+
// settle (success or failure) before connecting — v3.5-equivalent boot
|
|
3240
|
+
// extended to legacy DBs (spec §9). Failures fall back to background retry.
|
|
3241
|
+
await Promise.allSettled([ragKgManager.gate.start(), ragKgManager.startReconciliation()]);
|
|
3242
|
+
}
|
|
2685
3243
|
const transport = new StdioServerTransport();
|
|
2686
3244
|
await server.connect(transport);
|
|
2687
3245
|
console.error("🚀 Enhanced RAG Knowledge Graph MCP Server running on stdio");
|
|
2688
|
-
|
|
3246
|
+
if (ragKgManager.embeddingsMode === 'lazy') {
|
|
3247
|
+
// Background: model load + provenance reconciliation run in parallel.
|
|
3248
|
+
// Failures surface via gate/coordinator state, never as rejections.
|
|
3249
|
+
void ragKgManager.gate.start().catch(() => { });
|
|
3250
|
+
void ragKgManager.startReconciliation().catch(() => { });
|
|
3251
|
+
}
|
|
3252
|
+
else if (ragKgManager.embeddingsMode === 'off') {
|
|
3253
|
+
// off mode still CLASSIFIES reconciliation state (deferred vs n/a) so
|
|
3254
|
+
// stats honor the mode matrix — no sanitation, no inference (beta B7).
|
|
3255
|
+
void ragKgManager.startReconciliation().catch(() => { });
|
|
3256
|
+
}
|
|
3257
|
+
// Graceful shutdown (spec §3 order, beta-2R-amended) — transport close
|
|
3258
|
+
// FIRST so stdin stops holding the event loop, then settle coordinator and
|
|
3259
|
+
// gate, then DB close. process.exit is forbidden EXCEPT the one spec'd
|
|
3260
|
+
// case: a model load/download still pending after the settle deadline
|
|
3261
|
+
// (un-abortable fetch would hold the loop forever) — see shutdownAll.
|
|
3262
|
+
let shuttingDown = false;
|
|
2689
3263
|
const shutdown = () => {
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
3264
|
+
if (shuttingDown)
|
|
3265
|
+
return;
|
|
3266
|
+
shuttingDown = true;
|
|
3267
|
+
void (async () => {
|
|
3268
|
+
try {
|
|
3269
|
+
await server.close();
|
|
3270
|
+
}
|
|
3271
|
+
catch { /* transport already gone */ }
|
|
3272
|
+
try {
|
|
3273
|
+
process.stdin.pause();
|
|
3274
|
+
process.stdin.unref?.();
|
|
3275
|
+
}
|
|
3276
|
+
catch { /* best-effort */ }
|
|
3277
|
+
await ragKgManager.shutdownAll();
|
|
3278
|
+
})();
|
|
2695
3279
|
};
|
|
2696
3280
|
process.on('SIGINT', shutdown);
|
|
2697
3281
|
process.on('SIGTERM', shutdown);
|
|
2698
|
-
process.on('exit',
|
|
3282
|
+
process.on('exit', () => { try {
|
|
3283
|
+
ragKgManager.cleanup();
|
|
3284
|
+
}
|
|
3285
|
+
catch { /* idempotent */ } });
|
|
3286
|
+
console.error('🛡️ shutdown handlers registered'); // deterministic handler-ready marker (5R test residual)
|
|
2699
3287
|
}
|
|
2700
3288
|
catch (error) {
|
|
2701
3289
|
console.error("Failed to initialize server:", error);
|
|
2702
|
-
|
|
2703
|
-
|
|
3290
|
+
try {
|
|
3291
|
+
ragKgManager.cleanup();
|
|
3292
|
+
}
|
|
3293
|
+
catch { /* already down */ }
|
|
3294
|
+
process.exitCode = 1;
|
|
2704
3295
|
}
|
|
2705
3296
|
}
|
|
2706
3297
|
// Boot the server unless explicitly suppressed. Tests import this module with
|
|
@@ -2711,7 +3302,10 @@ async function main() {
|
|
|
2711
3302
|
if (process.env.RAG_MEMORY_NO_AUTOSTART !== '1') {
|
|
2712
3303
|
main().catch((error) => {
|
|
2713
3304
|
console.error("Fatal error in main():", error);
|
|
2714
|
-
|
|
2715
|
-
|
|
3305
|
+
try {
|
|
3306
|
+
ragKgManager.cleanup();
|
|
3307
|
+
}
|
|
3308
|
+
catch { /* already down */ }
|
|
3309
|
+
process.exitCode = 1;
|
|
2716
3310
|
});
|
|
2717
3311
|
}
|