@wrongstack/vector-memory 0.319.0 → 0.320.1
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/dist/index.d.ts +1 -1
- package/dist/index.js +161 -79
- package/dist/sage-event-mirror.d.ts +53 -3
- package/dist/sage-port-wrapper.d.ts +52 -16
- package/dist/store.d.ts +19 -0
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { SAGE_SYNC_MARKER_FILENAME, decideWhetherToSync, startFirstBootSageSync,
|
|
|
9
9
|
export { createVectorMemoryTools } from './tools.js';
|
|
10
10
|
export { asVectorRecallProvider, fuseWithVectorMemory, type SageFusionHit, type SageFusionOptions, } from './sage-fusion.js';
|
|
11
11
|
export { wrapMemoryPortWithVectorRecall, type VectorPortWrappingOptions, } from './sage-port-wrapper.js';
|
|
12
|
-
export { forgetStaleSageMirrors, subscribeVectorMemoryToSage, type VectorMemoryMirrorHandle, type VectorMemoryMirrorOptions, } from './sage-event-mirror.js';
|
|
12
|
+
export { DEFAULT_SWEEP_INTERVAL_MS, forgetStaleSageMirrors, SAGE_SWEEP_MARKER_FILENAME, subscribeVectorMemoryToSage, sweepStaleSageMirrors, type SweepStaleSageMirrorsOptions, type SweepStaleSageMirrorsResult, type VectorMemoryMirrorHandle, type VectorMemoryMirrorOptions, } from './sage-event-mirror.js';
|
|
13
13
|
export { runSearchRace, type SearchRaceChannelHit, type SearchRaceOptions, type SearchRaceResult, } from './search-race.js';
|
|
14
14
|
export type { SageSyncReport, VectorEntry, VectorEntryInput, VectorEntryWithVector, VectorKind, VectorMemoryStoreOptions, VectorScope, VectorSearchHit, VectorSearchOptions, VectorStoreStats, } from './types.js';
|
|
15
15
|
export { VectorMemoryError, VectorMemoryProviderUnavailableError, } from './errors.js';
|
package/dist/index.js
CHANGED
|
@@ -165,9 +165,7 @@ function initVectorSchema(db) {
|
|
|
165
165
|
db.exec("CREATE INDEX IF NOT EXISTS idx_entries_scope ON entries(scope)");
|
|
166
166
|
db.exec("CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind)");
|
|
167
167
|
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_hash ON entries(content_hash)");
|
|
168
|
-
db.exec(
|
|
169
|
-
"CREATE INDEX IF NOT EXISTS idx_entries_updated ON entries(updated_at DESC)"
|
|
170
|
-
);
|
|
168
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_entries_updated ON entries(updated_at DESC)");
|
|
171
169
|
db.exec(`
|
|
172
170
|
CREATE TABLE IF NOT EXISTS vectors (
|
|
173
171
|
entry_id TEXT NOT NULL,
|
|
@@ -229,15 +227,7 @@ function upsertEmbeddingCache(db, row) {
|
|
|
229
227
|
last_used_at = excluded.last_used_at,
|
|
230
228
|
use_count = embedding_cache.use_count + 1,
|
|
231
229
|
vector = excluded.vector`
|
|
232
|
-
).run(
|
|
233
|
-
row.contentHash,
|
|
234
|
-
row.providerId,
|
|
235
|
-
row.dimensions,
|
|
236
|
-
row.vector,
|
|
237
|
-
row.text,
|
|
238
|
-
row.now,
|
|
239
|
-
row.now
|
|
240
|
-
);
|
|
230
|
+
).run(row.contentHash, row.providerId, row.dimensions, row.vector, row.text, row.now, row.now);
|
|
241
231
|
}
|
|
242
232
|
function lookupEmbeddingCache(db, contentHash, providerId, dimensions, now) {
|
|
243
233
|
const row = db.prepare(
|
|
@@ -553,29 +543,59 @@ var VectorMemoryStore = class _VectorMemoryStore {
|
|
|
553
543
|
filters.push("e.kind = ?");
|
|
554
544
|
params.push(opts.kind);
|
|
555
545
|
}
|
|
556
|
-
const
|
|
557
|
-
`SELECT e.id
|
|
558
|
-
e.content_hash, e.created_at, e.updated_at,
|
|
559
|
-
v.vector AS vec_blob
|
|
546
|
+
const scanRows = this.db.prepare(
|
|
547
|
+
`SELECT e.id AS id, v.vector AS vec_blob
|
|
560
548
|
FROM entries e
|
|
561
549
|
JOIN vectors v ON v.entry_id = e.id
|
|
562
550
|
WHERE ${filters.join(" AND ")}`
|
|
563
551
|
).all(...params);
|
|
564
|
-
const
|
|
565
|
-
for (const row of
|
|
566
|
-
const
|
|
567
|
-
const vec = decodeVector(blob);
|
|
552
|
+
const top = [];
|
|
553
|
+
for (const row of scanRows) {
|
|
554
|
+
const vec = decodeVector(row.vec_blob);
|
|
568
555
|
const raw = cosineSimilarity(queryVec, vec);
|
|
569
556
|
const score = Math.max(0, Math.min(1, raw));
|
|
570
557
|
if (score < threshold) continue;
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
558
|
+
if (top.length >= limit && score <= (top[top.length - 1]?.score ?? 0)) continue;
|
|
559
|
+
let at = top.length;
|
|
560
|
+
while (at > 0 && (top[at - 1]?.score ?? 0) < score) at--;
|
|
561
|
+
top.splice(at, 0, { id: row.id, score, vector: vec });
|
|
562
|
+
if (top.length > limit) top.length = limit;
|
|
563
|
+
}
|
|
564
|
+
if (top.length === 0) return [];
|
|
565
|
+
const placeholders = top.map(() => "?").join(",");
|
|
566
|
+
const hydrated = this.db.prepare(
|
|
567
|
+
`SELECT id, text, summary, metadata, tags, scope, kind,
|
|
568
|
+
content_hash, created_at, updated_at
|
|
569
|
+
FROM entries WHERE id IN (${placeholders})`
|
|
570
|
+
).all(...top.map((t) => t.id));
|
|
571
|
+
const entryById = /* @__PURE__ */ new Map();
|
|
572
|
+
for (const row of hydrated) {
|
|
573
|
+
entryById.set(row.id, this.rowToEntry(row));
|
|
574
|
+
}
|
|
575
|
+
const scored = [];
|
|
576
|
+
for (const candidate of top) {
|
|
577
|
+
const entry = entryById.get(candidate.id);
|
|
578
|
+
if (!entry) continue;
|
|
579
|
+
const hit = { entry, score: candidate.score, providerId };
|
|
580
|
+
if (includeVectors) hit.vector = candidate.vector;
|
|
574
581
|
scored.push(hit);
|
|
575
582
|
}
|
|
576
|
-
scored
|
|
577
|
-
return scored.slice(0, limit);
|
|
583
|
+
return scored;
|
|
578
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Page through entries, newest first.
|
|
587
|
+
*
|
|
588
|
+
* Ordering is `(updated_at, id)` DESC — `updated_at` alone is not unique, so
|
|
589
|
+
* without the id tiebreak two entries written in the same millisecond can
|
|
590
|
+
* swap places between calls and a paging caller silently skips one.
|
|
591
|
+
*
|
|
592
|
+
* Pagination is keyset (`after`), not offset, because the only caller that
|
|
593
|
+
* pages is `forgetStaleSageMirrors`, which *deletes as it walks*. Under
|
|
594
|
+
* `OFFSET` every deletion shifts the remaining rows left and the next page
|
|
595
|
+
* skips exactly as many entries as were removed. Keyset is immune: it
|
|
596
|
+
* resumes from a position, and the rows a deletion removes are ones the
|
|
597
|
+
* sweep has already passed.
|
|
598
|
+
*/
|
|
579
599
|
list(opts = {}) {
|
|
580
600
|
this.assertOpen();
|
|
581
601
|
const where = [];
|
|
@@ -588,8 +608,12 @@ var VectorMemoryStore = class _VectorMemoryStore {
|
|
|
588
608
|
where.push("kind = ?");
|
|
589
609
|
params.push(opts.kind);
|
|
590
610
|
}
|
|
611
|
+
if (opts.after) {
|
|
612
|
+
where.push("(updated_at < ? OR (updated_at = ? AND id < ?))");
|
|
613
|
+
params.push(opts.after.updatedAt, opts.after.updatedAt, opts.after.id);
|
|
614
|
+
}
|
|
591
615
|
const sql = `SELECT * FROM entries ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
592
|
-
ORDER BY updated_at DESC LIMIT ?`;
|
|
616
|
+
ORDER BY updated_at DESC, id DESC LIMIT ?`;
|
|
593
617
|
params.push(opts.limit ?? 100);
|
|
594
618
|
const rows = this.db.prepare(sql).all(...params);
|
|
595
619
|
return rows.map((r) => this.rowToEntry(r));
|
|
@@ -934,7 +958,10 @@ function decideWhetherToSync(store, staleAfterMs, now = /* @__PURE__ */ new Date
|
|
|
934
958
|
const ageMs = Number.isNaN(startedAt) ? Number.POSITIVE_INFINITY : now.getTime() - startedAt;
|
|
935
959
|
const stale = ageMs > staleAfterMs;
|
|
936
960
|
if (existing.pid === void 0) {
|
|
937
|
-
return Number.isNaN(startedAt) || stale ? {
|
|
961
|
+
return Number.isNaN(startedAt) || stale ? {
|
|
962
|
+
run: true,
|
|
963
|
+
reason: Number.isNaN(startedAt) ? "running-marker-undated" : "running-marker-stale"
|
|
964
|
+
} : { run: false, reason: "running-unknown-pid" };
|
|
938
965
|
}
|
|
939
966
|
try {
|
|
940
967
|
if (pidAlive(existing.pid)) {
|
|
@@ -987,7 +1014,12 @@ function storeProvider(store) {
|
|
|
987
1014
|
return void 0;
|
|
988
1015
|
}
|
|
989
1016
|
function counts(report) {
|
|
990
|
-
return {
|
|
1017
|
+
return {
|
|
1018
|
+
scanned: report.scanned,
|
|
1019
|
+
indexed: report.indexed,
|
|
1020
|
+
skipped: report.skipped,
|
|
1021
|
+
failed: report.failed
|
|
1022
|
+
};
|
|
991
1023
|
}
|
|
992
1024
|
|
|
993
1025
|
// src/tools.ts
|
|
@@ -1062,7 +1094,12 @@ function vectorMemorySearchTool(store) {
|
|
|
1062
1094
|
type: "object",
|
|
1063
1095
|
properties: {
|
|
1064
1096
|
query: { type: "string", minLength: 1, description: "The natural-language query." },
|
|
1065
|
-
limit: {
|
|
1097
|
+
limit: {
|
|
1098
|
+
type: "number",
|
|
1099
|
+
minimum: 1,
|
|
1100
|
+
maximum: 100,
|
|
1101
|
+
description: "Max results (default 10)."
|
|
1102
|
+
},
|
|
1066
1103
|
threshold: {
|
|
1067
1104
|
type: "number",
|
|
1068
1105
|
minimum: 0,
|
|
@@ -1137,7 +1174,11 @@ function vectorMemoryForgetTool(store) {
|
|
|
1137
1174
|
inputSchema: {
|
|
1138
1175
|
type: "object",
|
|
1139
1176
|
properties: {
|
|
1140
|
-
id: {
|
|
1177
|
+
id: {
|
|
1178
|
+
type: "string",
|
|
1179
|
+
minLength: 1,
|
|
1180
|
+
description: "Entry id returned by `vector_memory_remember`."
|
|
1181
|
+
}
|
|
1141
1182
|
},
|
|
1142
1183
|
required: ["id"],
|
|
1143
1184
|
additionalProperties: false
|
|
@@ -1254,20 +1295,11 @@ function clamp01(value) {
|
|
|
1254
1295
|
|
|
1255
1296
|
// src/sage-port-wrapper.ts
|
|
1256
1297
|
import {
|
|
1298
|
+
augmentLexicalWithVectorRecall,
|
|
1299
|
+
isSageVisibleForSearch,
|
|
1257
1300
|
SAGE_RETRIEVAL_CAPABILITY,
|
|
1258
1301
|
SAGE_SURFACE_CAPABILITY
|
|
1259
1302
|
} from "@wrongstack/sage";
|
|
1260
|
-
function mergeVectorRecall(options, recall, weight, threshold) {
|
|
1261
|
-
if (options && typeof options === "object" && "vectorRecall" in options && options["vectorRecall"]) {
|
|
1262
|
-
return options;
|
|
1263
|
-
}
|
|
1264
|
-
return {
|
|
1265
|
-
...options ?? {},
|
|
1266
|
-
vectorRecall: recall,
|
|
1267
|
-
...weight !== void 0 ? { vectorRecallWeight: weight } : {},
|
|
1268
|
-
...threshold !== void 0 ? { vectorRecallMinScore: threshold } : {}
|
|
1269
|
-
};
|
|
1270
|
-
}
|
|
1271
1303
|
function asVectorRecallProviderAdapter(store) {
|
|
1272
1304
|
return {
|
|
1273
1305
|
async search(query, opts) {
|
|
@@ -1288,18 +1320,39 @@ function asVectorRecallProviderAdapter(store) {
|
|
|
1288
1320
|
}
|
|
1289
1321
|
function wrapMemoryPortWithVectorRecall(port, options) {
|
|
1290
1322
|
const recall = options.vectorRecall ?? asVectorRecallProviderAdapter(options.store);
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1323
|
+
const materializeFor = (searchOpts) => async (sageId) => {
|
|
1324
|
+
const surface = port.getCapability(SAGE_SURFACE_CAPABILITY);
|
|
1325
|
+
if (!surface?.getSage) return void 0;
|
|
1326
|
+
const memory = await surface.getSage(sageId);
|
|
1327
|
+
if (!memory) return void 0;
|
|
1328
|
+
return isSageVisibleForSearch(memory, searchOpts) ? memory : void 0;
|
|
1329
|
+
};
|
|
1330
|
+
const fusionOptions = (searchOpts) => ({
|
|
1331
|
+
vectorRecall: recall,
|
|
1332
|
+
materializeVectorOnly: materializeFor(searchOpts),
|
|
1333
|
+
...options.weight !== void 0 ? { vectorWeight: options.weight } : {},
|
|
1334
|
+
...options.threshold !== void 0 ? { threshold: options.threshold } : {},
|
|
1335
|
+
...options.vectorOnlyThreshold !== void 0 ? { vectorOnlyThreshold: options.vectorOnlyThreshold } : {},
|
|
1336
|
+
...options.maxMaterializations !== void 0 ? { maxMaterializations: options.maxMaterializations } : {},
|
|
1337
|
+
...searchOpts?.limit !== void 0 ? { limit: searchOpts.limit } : {}
|
|
1338
|
+
});
|
|
1339
|
+
const callerOwnsFusion = (searchOpts) => Boolean(searchOpts?.vectorRecall);
|
|
1340
|
+
const wrapSearchSage = (original) => async (query, searchOpts) => {
|
|
1341
|
+
const opts = searchOpts;
|
|
1342
|
+
const lexical = await original(query, searchOpts);
|
|
1343
|
+
if (callerOwnsFusion(opts)) return lexical;
|
|
1344
|
+
const fused = await augmentLexicalWithVectorRecall(query, lexical, fusionOptions(opts));
|
|
1345
|
+
return fused.map((hit) => hit.memory);
|
|
1346
|
+
};
|
|
1347
|
+
const wrapSearchWithBreakdown = (original) => async (query, searchOpts) => {
|
|
1348
|
+
const opts = searchOpts;
|
|
1349
|
+
const lexicalHits = await original(query, searchOpts);
|
|
1350
|
+
if (callerOwnsFusion(opts)) return lexicalHits;
|
|
1351
|
+
return augmentLexicalWithVectorRecall(
|
|
1295
1352
|
query,
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
weight,
|
|
1300
|
-
threshold
|
|
1301
|
-
)
|
|
1302
|
-
));
|
|
1353
|
+
lexicalHits.map((hit) => hit.memory),
|
|
1354
|
+
fusionOptions(opts)
|
|
1355
|
+
);
|
|
1303
1356
|
};
|
|
1304
1357
|
const wrapped = Object.create(
|
|
1305
1358
|
Object.getPrototypeOf(port),
|
|
@@ -1311,13 +1364,9 @@ function wrapMemoryPortWithVectorRecall(port, options) {
|
|
|
1311
1364
|
if (!original) return void 0;
|
|
1312
1365
|
return {
|
|
1313
1366
|
...original,
|
|
1314
|
-
searchSage:
|
|
1315
|
-
// The rich-breakdown variant uses the same options-merge
|
|
1316
|
-
// helper — pass the vector recall through so consumers that
|
|
1317
|
-
// want the per-channel score breakdown get the same fusion
|
|
1318
|
-
// behaviour as `searchSage`.
|
|
1367
|
+
searchSage: wrapSearchSage(original.searchSage),
|
|
1319
1368
|
...original.searchSageWithBreakdown ? {
|
|
1320
|
-
searchSageWithBreakdown:
|
|
1369
|
+
searchSageWithBreakdown: wrapSearchWithBreakdown(
|
|
1321
1370
|
original.searchSageWithBreakdown
|
|
1322
1371
|
)
|
|
1323
1372
|
} : {}
|
|
@@ -1328,9 +1377,9 @@ function wrapMemoryPortWithVectorRecall(port, options) {
|
|
|
1328
1377
|
if (!original) return void 0;
|
|
1329
1378
|
return {
|
|
1330
1379
|
...original,
|
|
1331
|
-
searchSage:
|
|
1380
|
+
searchSage: wrapSearchSage(original.searchSage),
|
|
1332
1381
|
...original.searchSageWithBreakdown ? {
|
|
1333
|
-
searchSageWithBreakdown:
|
|
1382
|
+
searchSageWithBreakdown: wrapSearchWithBreakdown(
|
|
1334
1383
|
original.searchSageWithBreakdown
|
|
1335
1384
|
)
|
|
1336
1385
|
} : {}
|
|
@@ -1342,6 +1391,8 @@ function wrapMemoryPortWithVectorRecall(port, options) {
|
|
|
1342
1391
|
}
|
|
1343
1392
|
|
|
1344
1393
|
// src/sage-event-mirror.ts
|
|
1394
|
+
import * as fs3 from "node:fs";
|
|
1395
|
+
import * as path3 from "node:path";
|
|
1345
1396
|
import { getSageSurface as getSageSurface2 } from "@wrongstack/sage";
|
|
1346
1397
|
function subscribeVectorMemoryToSage(opts) {
|
|
1347
1398
|
const { store, memoryStore } = opts;
|
|
@@ -1363,9 +1414,7 @@ function subscribeVectorMemoryToSage(opts) {
|
|
|
1363
1414
|
try {
|
|
1364
1415
|
return await surface.getSage(memoryId);
|
|
1365
1416
|
} catch (err) {
|
|
1366
|
-
log?.warn?.(
|
|
1367
|
-
`vector-memory mirror fetch failed for ${memoryId}: ${errMsg2(err)}`
|
|
1368
|
-
);
|
|
1417
|
+
log?.warn?.(`vector-memory mirror fetch failed for ${memoryId}: ${errMsg2(err)}`);
|
|
1369
1418
|
return null;
|
|
1370
1419
|
}
|
|
1371
1420
|
};
|
|
@@ -1392,9 +1441,7 @@ function subscribeVectorMemoryToSage(opts) {
|
|
|
1392
1441
|
}
|
|
1393
1442
|
});
|
|
1394
1443
|
} catch (err) {
|
|
1395
|
-
log?.warn?.(
|
|
1396
|
-
`vector-memory mirror remember failed for ${memoryId}: ${errMsg2(err)}`
|
|
1397
|
-
);
|
|
1444
|
+
log?.warn?.(`vector-memory mirror remember failed for ${memoryId}: ${errMsg2(err)}`);
|
|
1398
1445
|
}
|
|
1399
1446
|
};
|
|
1400
1447
|
const forgetMirror = async (memoryId) => {
|
|
@@ -1402,9 +1449,7 @@ function subscribeVectorMemoryToSage(opts) {
|
|
|
1402
1449
|
const existing = store.findBySageId(memoryId);
|
|
1403
1450
|
if (existing) await store.forget(existing.id);
|
|
1404
1451
|
} catch (err) {
|
|
1405
|
-
log?.warn?.(
|
|
1406
|
-
`vector-memory mirror forget failed for ${memoryId}: ${errMsg2(err)}`
|
|
1407
|
-
);
|
|
1452
|
+
log?.warn?.(`vector-memory mirror forget failed for ${memoryId}: ${errMsg2(err)}`);
|
|
1408
1453
|
}
|
|
1409
1454
|
};
|
|
1410
1455
|
const offAccepted = events.onPattern("memory.accepted", (_event, payload) => {
|
|
@@ -1438,14 +1483,18 @@ function subscribeVectorMemoryToSage(opts) {
|
|
|
1438
1483
|
}
|
|
1439
1484
|
};
|
|
1440
1485
|
}
|
|
1441
|
-
async function forgetStaleSageMirrors(store, memoryStore, logger) {
|
|
1486
|
+
async function forgetStaleSageMirrors(store, memoryStore, logger, options) {
|
|
1442
1487
|
const surface = getSageSurface2(memoryStore);
|
|
1443
1488
|
if (!surface) return { scanned: 0, removed: 0 };
|
|
1444
1489
|
let scanned = 0;
|
|
1445
1490
|
let removed = 0;
|
|
1446
|
-
|
|
1447
|
-
|
|
1491
|
+
const PAGE = Math.max(1, options?.pageSize ?? 500);
|
|
1492
|
+
let after;
|
|
1493
|
+
for (; ; ) {
|
|
1494
|
+
const page = store.list(after ? { limit: PAGE, after } : { limit: PAGE });
|
|
1448
1495
|
if (page.length === 0) break;
|
|
1496
|
+
const last = page[page.length - 1];
|
|
1497
|
+
after = { updatedAt: last.updatedAt, id: last.id };
|
|
1449
1498
|
for (const entry of page) {
|
|
1450
1499
|
scanned++;
|
|
1451
1500
|
const sageId = entry.metadata?.sageId;
|
|
@@ -1456,18 +1505,46 @@ async function forgetStaleSageMirrors(store, memoryStore, logger) {
|
|
|
1456
1505
|
await store.forget(entry.id);
|
|
1457
1506
|
removed++;
|
|
1458
1507
|
} catch (err) {
|
|
1459
|
-
logger?.warn?.(
|
|
1460
|
-
`vector-memory stale-mirror sweep failed for ${sageId}: ${errMsg2(err)}`
|
|
1461
|
-
);
|
|
1508
|
+
logger?.warn?.(`vector-memory stale-mirror sweep failed for ${sageId}: ${errMsg2(err)}`);
|
|
1462
1509
|
}
|
|
1463
1510
|
}
|
|
1464
|
-
if (page.length <
|
|
1511
|
+
if (page.length < PAGE) break;
|
|
1465
1512
|
}
|
|
1466
1513
|
return { scanned, removed };
|
|
1467
1514
|
}
|
|
1468
1515
|
function errMsg2(err) {
|
|
1469
1516
|
return err instanceof Error ? err.message : String(err);
|
|
1470
1517
|
}
|
|
1518
|
+
var SAGE_SWEEP_MARKER_FILENAME = "sage-mirror-sweep.json";
|
|
1519
|
+
var DEFAULT_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
1520
|
+
async function sweepStaleSageMirrors(opts) {
|
|
1521
|
+
const markerPath2 = path3.join(opts.store.directory, SAGE_SWEEP_MARKER_FILENAME);
|
|
1522
|
+
const interval = opts.minIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
1523
|
+
if (!opts.force) {
|
|
1524
|
+
try {
|
|
1525
|
+
const raw = JSON.parse(fs3.readFileSync(markerPath2, "utf8"));
|
|
1526
|
+
const at = typeof raw.at === "string" ? Date.parse(raw.at) : Number.NaN;
|
|
1527
|
+
if (Number.isFinite(at) && Date.now() - at < interval) {
|
|
1528
|
+
return { swept: false, reason: "throttled" };
|
|
1529
|
+
}
|
|
1530
|
+
} catch {
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
try {
|
|
1534
|
+
fs3.writeFileSync(markerPath2, JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() }), "utf8");
|
|
1535
|
+
} catch {
|
|
1536
|
+
}
|
|
1537
|
+
try {
|
|
1538
|
+
const result = await forgetStaleSageMirrors(opts.store, opts.memoryStore, opts.logger);
|
|
1539
|
+
opts.logger?.debug?.(
|
|
1540
|
+
`vector-memory stale-mirror sweep: scanned=${result.scanned} removed=${result.removed}`
|
|
1541
|
+
);
|
|
1542
|
+
return { swept: true, ...result };
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
opts.logger?.warn?.(`vector-memory stale-mirror sweep failed: ${errMsg2(err)}`);
|
|
1545
|
+
return { swept: false, reason: errMsg2(err) };
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1471
1548
|
|
|
1472
1549
|
// src/search-race.ts
|
|
1473
1550
|
function previewText(text, maxLen) {
|
|
@@ -1499,8 +1576,8 @@ async function runSearchRace(query, lexical, vectorStore, options = {}) {
|
|
|
1499
1576
|
overlap.push({
|
|
1500
1577
|
id,
|
|
1501
1578
|
lexicalScore: score,
|
|
1502
|
-
vectorScore:
|
|
1503
|
-
// patched below
|
|
1579
|
+
vectorScore: null,
|
|
1580
|
+
// patched below when a vector hit carries this id
|
|
1504
1581
|
preview: previewText(mem.text, 140)
|
|
1505
1582
|
});
|
|
1506
1583
|
}
|
|
@@ -1523,7 +1600,7 @@ async function runSearchRace(query, lexical, vectorStore, options = {}) {
|
|
|
1523
1600
|
}
|
|
1524
1601
|
for (let i = overlap.length - 1; i >= 0; i--) {
|
|
1525
1602
|
const row = overlap[i];
|
|
1526
|
-
if (row.vectorScore ===
|
|
1603
|
+
if (row.vectorScore === null) {
|
|
1527
1604
|
lexicalOnly.push({
|
|
1528
1605
|
id: row.id,
|
|
1529
1606
|
lexicalScore: row.lexicalScore,
|
|
@@ -1540,6 +1617,8 @@ async function runSearchRace(query, lexical, vectorStore, options = {}) {
|
|
|
1540
1617
|
query,
|
|
1541
1618
|
lexicalOnly,
|
|
1542
1619
|
vectorOnly,
|
|
1620
|
+
// The sweep above removed every row still carrying a null vectorScore,
|
|
1621
|
+
// so every remaining row holds a real number here.
|
|
1543
1622
|
overlap,
|
|
1544
1623
|
metrics: {
|
|
1545
1624
|
lexicalCount,
|
|
@@ -1552,9 +1631,11 @@ async function runSearchRace(query, lexical, vectorStore, options = {}) {
|
|
|
1552
1631
|
};
|
|
1553
1632
|
}
|
|
1554
1633
|
export {
|
|
1634
|
+
DEFAULT_SWEEP_INTERVAL_MS,
|
|
1555
1635
|
DEFAULT_VECTOR_DIMENSIONS,
|
|
1556
1636
|
DEFAULT_VECTOR_DTYPE,
|
|
1557
1637
|
DEFAULT_VECTOR_MODEL_ID,
|
|
1638
|
+
SAGE_SWEEP_MARKER_FILENAME,
|
|
1558
1639
|
SAGE_SYNC_MARKER_FILENAME,
|
|
1559
1640
|
TransformersEmbeddingProvider,
|
|
1560
1641
|
VECTOR_DIMENSIONS_KEY,
|
|
@@ -1577,6 +1658,7 @@ export {
|
|
|
1577
1658
|
runSearchRace,
|
|
1578
1659
|
startFirstBootSageSync,
|
|
1579
1660
|
subscribeVectorMemoryToSage,
|
|
1661
|
+
sweepStaleSageMirrors,
|
|
1580
1662
|
upsertEmbeddingCache,
|
|
1581
1663
|
wrapMemoryPortWithVectorRecall
|
|
1582
1664
|
};
|
|
@@ -34,14 +34,64 @@ export declare function subscribeVectorMemoryToSage(opts: VectorMemoryMirrorOpti
|
|
|
34
34
|
* Walks the store, looks up each `metadata.sageId` in the SAGE surface,
|
|
35
35
|
* and forgets entries whose SAGE id no longer resolves.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `memory.deleted
|
|
37
|
+
* This is the safety net for bulk operations — hygiene's archive/purge
|
|
38
|
+
* passes, `memory.cleared` — which emit a single top-level event rather than
|
|
39
|
+
* a per-memory `memory.deleted`, so the live mirror never sees them. Without
|
|
40
|
+
* a periodic sweep those rows stay in the vector store forever.
|
|
41
|
+
*
|
|
42
|
+
* A stale row is not a *correctness* hole: a semantic-only hit is resolved
|
|
43
|
+
* through `SageSurface.getSage` and re-checked with `isSageVisibleForSearch`,
|
|
44
|
+
* which rejects an archived or deleted memory. It is a *cost* — every stale
|
|
45
|
+
* row is scanned on every cosine pass and can consume one of the fusion's
|
|
46
|
+
* bounded `maxMaterializations` slots before being dropped.
|
|
47
|
+
*
|
|
48
|
+
* Hosts run this from the session-end teardown, throttled alongside SAGE
|
|
49
|
+
* hygiene (see `setupVectorMemory` / `startWebUI`).
|
|
40
50
|
*/
|
|
41
51
|
export declare function forgetStaleSageMirrors(store: VectorMemoryStore, memoryStore: MemoryPort, logger?: {
|
|
42
52
|
warn?(msg: string, ctx?: unknown): void | undefined;
|
|
53
|
+
},
|
|
54
|
+
/** Rows per keyset page. Exposed so tests can exercise multi-page walks. */
|
|
55
|
+
options?: {
|
|
56
|
+
pageSize?: number | undefined;
|
|
43
57
|
}): Promise<{
|
|
44
58
|
scanned: number;
|
|
45
59
|
removed: number;
|
|
46
60
|
}>;
|
|
61
|
+
/** Sidecar recording the last stale-mirror sweep, next to the vector db. */
|
|
62
|
+
export declare const SAGE_SWEEP_MARKER_FILENAME = "sage-mirror-sweep.json";
|
|
63
|
+
/** Default minimum gap between sweeps. Matches SAGE's auto-hygiene throttle. */
|
|
64
|
+
export declare const DEFAULT_SWEEP_INTERVAL_MS: number;
|
|
65
|
+
export interface SweepStaleSageMirrorsOptions {
|
|
66
|
+
store: VectorMemoryStore;
|
|
67
|
+
memoryStore: MemoryPort;
|
|
68
|
+
logger?: {
|
|
69
|
+
debug?(msg: string): void | undefined;
|
|
70
|
+
warn?(msg: string): void | undefined;
|
|
71
|
+
} | undefined;
|
|
72
|
+
/** Skip when the last sweep was more recent than this. Default 1 hour. */
|
|
73
|
+
minIntervalMs?: number | undefined;
|
|
74
|
+
/** Run regardless of the throttle (operator-forced re-sync). */
|
|
75
|
+
force?: boolean | undefined;
|
|
76
|
+
}
|
|
77
|
+
export interface SweepStaleSageMirrorsResult {
|
|
78
|
+
swept: boolean;
|
|
79
|
+
reason?: string;
|
|
80
|
+
scanned?: number;
|
|
81
|
+
removed?: number;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Throttled wrapper around {@link forgetStaleSageMirrors} for host wiring.
|
|
85
|
+
*
|
|
86
|
+
* The sweep is O(corpus) with one `getSage` per mirrored row, so it must not
|
|
87
|
+
* run on every boot of every surface — a project with the CLI and the WebUI
|
|
88
|
+
* open would otherwise sweep twice per session start. The throttle is a
|
|
89
|
+
* timestamp file beside the vector database rather than a process-local
|
|
90
|
+
* variable, precisely so that those two independent processes share it.
|
|
91
|
+
*
|
|
92
|
+
* Fail-open in every direction: an unreadable or corrupt marker is treated as
|
|
93
|
+
* "never swept", and a failed sweep is logged and swallowed. Callers
|
|
94
|
+
* fire-and-forget this during boot.
|
|
95
|
+
*/
|
|
96
|
+
export declare function sweepStaleSageMirrors(opts: SweepStaleSageMirrorsOptions): Promise<SweepStaleSageMirrorsResult>;
|
|
47
97
|
//# sourceMappingURL=sage-event-mirror.d.ts.map
|
|
@@ -1,21 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wrap an existing SAGE `MemoryPort` so that every `searchSage` /
|
|
3
|
-
* `
|
|
4
|
-
*
|
|
5
|
-
* underlying port's identity for callers that compare ports, but routes
|
|
6
|
-
* the read-side capability methods through a vector-augmented
|
|
7
|
-
* `searchSage`.
|
|
3
|
+
* `searchSageWithBreakdown` call fuses the port's lexical candidate set with
|
|
4
|
+
* a semantic recall from the local vector store.
|
|
8
5
|
*
|
|
9
6
|
* Why a wrapper and not a direct constructor change:
|
|
10
7
|
* - non-invasive: no migration needed for existing host construction
|
|
11
8
|
* - opt-in: hosts that don't want vector augmentation just don't wrap
|
|
12
9
|
* - testable: easy to mock the wrapper in unit tests
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* ## Why the fusion runs HERE and not inside the store
|
|
12
|
+
*
|
|
13
|
+
* The historical implementation merged a `vectorRecall` provider into the
|
|
14
|
+
* search *options* and let `SqliteSageStore.searchSage` do the fusion. That
|
|
15
|
+
* works only when the store is in-process. In production it is not: hosts
|
|
16
|
+
* build the port with `createProjectSageMemoryPort`, which returns a
|
|
17
|
+
* `ProjectSageMemoryPort` speaking line-delimited JSON to the per-project
|
|
18
|
+
* SAGE daemon (`encodeSageProjectServerMessage` = `JSON.stringify`).
|
|
19
|
+
* `JSON.stringify({ vectorRecall: { search: fn } })` yields
|
|
20
|
+
* `{"vectorRecall":{}}` — functions do not survive the wire — so the daemon
|
|
21
|
+
* saw a truthy-but-empty provider, threw `search is not a function` inside
|
|
22
|
+
* the fusion's fail-open `try`, and silently returned the lexical list.
|
|
23
|
+
* The entire semantic channel was dead in every production surface while
|
|
24
|
+
* every diagnostic reported it as wired.
|
|
25
|
+
*
|
|
26
|
+
* The vector store also *cannot* simply move into the daemon: it owns an
|
|
27
|
+
* ONNX embedding provider and `@wrongstack/vector-memory` already depends on
|
|
28
|
+
* `@wrongstack/sage`, so wiring it the other way is a dependency cycle.
|
|
29
|
+
*
|
|
30
|
+
* So the fusion runs on the host side of the boundary:
|
|
31
|
+
* 1. call the port's `searchSage` (remote or in-process) for the lexical list
|
|
32
|
+
* 2. query the local vector store for the semantic list
|
|
33
|
+
* 3. fuse with RRF via `augmentLexicalWithVectorRecall`
|
|
34
|
+
* 4. resolve vector-only hits by id through the port's surface capability,
|
|
35
|
+
* re-applying every visibility rule the lexical channel enforces in SQL
|
|
36
|
+
* (`isSageVisibleForSearch`)
|
|
37
|
+
*
|
|
38
|
+
* Step 4 is one round-trip per admitted vector-only hit, which is why the
|
|
39
|
+
* fusion is called with a `maxMaterializations` bound.
|
|
40
|
+
*
|
|
41
|
+
* The wrapper only augments read-side capabilities
|
|
42
|
+
* (`SAGE_RETRIEVAL_CAPABILITY` / `SAGE_SURFACE_CAPABILITY`). Other
|
|
43
|
+
* capabilities (write-side, hygiene, audit) pass through unchanged so the
|
|
44
|
+
* wrapper never widens the trust boundary.
|
|
19
45
|
*/
|
|
20
46
|
import type { MemoryPort } from '@wrongstack/core/types';
|
|
21
47
|
import { type VectorRecallProvider } from '@wrongstack/sage';
|
|
@@ -25,18 +51,28 @@ export interface VectorPortWrappingOptions {
|
|
|
25
51
|
store: VectorMemoryStore;
|
|
26
52
|
/**
|
|
27
53
|
* Optional pre-built provider. When omitted, the wrapper builds one via
|
|
28
|
-
* `
|
|
54
|
+
* `asVectorRecallProviderAdapter(store)`.
|
|
29
55
|
*/
|
|
30
56
|
vectorRecall?: VectorRecallProvider | undefined;
|
|
31
57
|
/**
|
|
32
|
-
* Cosine threshold forwarded to the vector backend.
|
|
33
|
-
* (keep all hits, let RRF decide).
|
|
58
|
+
* Cosine threshold forwarded to the vector backend. Undefined = no
|
|
59
|
+
* threshold (keep all hits, let RRF decide).
|
|
34
60
|
*/
|
|
35
61
|
threshold?: number | undefined;
|
|
36
62
|
/**
|
|
37
63
|
* Weight of the vector channel in the RRF blend. Default 0.3.
|
|
38
64
|
*/
|
|
39
65
|
weight?: number | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Cosine floor a semantic-only hit must clear before it is resolved and
|
|
68
|
+
* admitted. Falls back to the fusion's own default (0.62).
|
|
69
|
+
*/
|
|
70
|
+
vectorOnlyThreshold?: number | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Cap on by-id resolutions of semantic-only hits per search. Each one is a
|
|
73
|
+
* round-trip when the port is remote. Falls back to the fusion default.
|
|
74
|
+
*/
|
|
75
|
+
maxMaterializations?: number | undefined;
|
|
40
76
|
}
|
|
41
77
|
/**
|
|
42
78
|
* Adapt a `VectorMemoryStore` to the SAGE `VectorRecallProvider` contract.
|
|
@@ -45,9 +81,9 @@ export interface VectorPortWrappingOptions {
|
|
|
45
81
|
*/
|
|
46
82
|
export declare function asVectorRecallProviderAdapter(store: VectorMemoryStore): VectorRecallProvider;
|
|
47
83
|
/**
|
|
48
|
-
* Return a new `MemoryPort`
|
|
49
|
-
*
|
|
50
|
-
*
|
|
84
|
+
* Return a new `MemoryPort` whose `searchSage` / `searchSageWithBreakdown`
|
|
85
|
+
* fuse lexical and semantic recall. All other capabilities pass through
|
|
86
|
+
* unchanged.
|
|
51
87
|
*/
|
|
52
88
|
export declare function wrapMemoryPortWithVectorRecall(port: MemoryPort, options: VectorPortWrappingOptions): MemoryPort;
|
|
53
89
|
//# sourceMappingURL=sage-port-wrapper.d.ts.map
|
package/dist/store.d.ts
CHANGED
|
@@ -70,10 +70,29 @@ export declare class VectorMemoryStore {
|
|
|
70
70
|
/** Hard-delete an entry by id. Wrapped in `withFileLock` for cross-process safety. */
|
|
71
71
|
forget(id: string): Promise<boolean>;
|
|
72
72
|
search(query: string, opts?: VectorSearchOptions): Promise<VectorSearchHit[]>;
|
|
73
|
+
/**
|
|
74
|
+
* Page through entries, newest first.
|
|
75
|
+
*
|
|
76
|
+
* Ordering is `(updated_at, id)` DESC — `updated_at` alone is not unique, so
|
|
77
|
+
* without the id tiebreak two entries written in the same millisecond can
|
|
78
|
+
* swap places between calls and a paging caller silently skips one.
|
|
79
|
+
*
|
|
80
|
+
* Pagination is keyset (`after`), not offset, because the only caller that
|
|
81
|
+
* pages is `forgetStaleSageMirrors`, which *deletes as it walks*. Under
|
|
82
|
+
* `OFFSET` every deletion shifts the remaining rows left and the next page
|
|
83
|
+
* skips exactly as many entries as were removed. Keyset is immune: it
|
|
84
|
+
* resumes from a position, and the rows a deletion removes are ones the
|
|
85
|
+
* sweep has already passed.
|
|
86
|
+
*/
|
|
73
87
|
list(opts?: {
|
|
74
88
|
limit?: number;
|
|
75
89
|
scope?: VectorScope;
|
|
76
90
|
kind?: VectorKind;
|
|
91
|
+
/** Resume after this entry — pass the last row of the previous page. */
|
|
92
|
+
after?: {
|
|
93
|
+
updatedAt: string;
|
|
94
|
+
id: string;
|
|
95
|
+
} | undefined;
|
|
77
96
|
}): VectorEntry[];
|
|
78
97
|
reindexAll(): Promise<{
|
|
79
98
|
processed: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wrongstack/vector-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.320.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "WrongStack Vector Memory — an additional vector-search memory store powered by @huggingface/transformers (local ONNX embeddings), alongside the SAGE lexical memory system.",
|
|
6
6
|
"repository": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"README.md"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@wrongstack/core": "0.
|
|
31
|
-
"@wrongstack/persistence": "0.
|
|
32
|
-
"@wrongstack/sage": "0.
|
|
30
|
+
"@wrongstack/core": "0.320.1",
|
|
31
|
+
"@wrongstack/persistence": "0.320.1",
|
|
32
|
+
"@wrongstack/sage": "0.320.1"
|
|
33
33
|
},
|
|
34
34
|
"optionalDependencies": {
|
|
35
35
|
"@huggingface/transformers": "^4.2.0"
|