@wrongstack/vector-memory 0.319.1 → 1.0.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/dist/index.d.ts +1 -1
- package/dist/index.js +146 -56
- package/dist/sage-event-mirror.d.ts +53 -3
- package/dist/sage-fusion.d.ts +18 -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
|
@@ -249,6 +249,11 @@ function encodeVector(vec) {
|
|
|
249
249
|
return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
|
|
250
250
|
}
|
|
251
251
|
function decodeVector(buf) {
|
|
252
|
+
if (buf.byteLength % 4 !== 0) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`decodeVector: invalid vector byteLength ${buf.byteLength} (must be a multiple of 4)`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
252
257
|
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
253
258
|
const copy = new Float32Array(buf.byteLength / 4);
|
|
254
259
|
for (let i = 0; i < copy.length; i++) {
|
|
@@ -264,6 +269,12 @@ import * as path from "node:path";
|
|
|
264
269
|
import { withFileLock } from "@wrongstack/core/utils";
|
|
265
270
|
import { loadRuntimeDatabaseSync } from "@wrongstack/persistence";
|
|
266
271
|
import { cosineSimilarity, HashingEmbeddingProvider } from "@wrongstack/sage";
|
|
272
|
+
var DEFAULT_SEARCH_LIMIT = 10;
|
|
273
|
+
function normalizeLimit(limit) {
|
|
274
|
+
if (limit === void 0 || !Number.isFinite(limit)) return DEFAULT_SEARCH_LIMIT;
|
|
275
|
+
const floored = Math.floor(limit);
|
|
276
|
+
return floored < 1 ? DEFAULT_SEARCH_LIMIT : floored;
|
|
277
|
+
}
|
|
267
278
|
var DEFAULT_DIRECTORY = ".wrongstack/vector-memory";
|
|
268
279
|
var DEFAULT_FILENAME = "vector-memory.db";
|
|
269
280
|
var DEFAULT_LOCK_TIMEOUT_MS = 5e3;
|
|
@@ -525,7 +536,7 @@ var VectorMemoryStore = class _VectorMemoryStore {
|
|
|
525
536
|
}
|
|
526
537
|
async search(query, opts = {}) {
|
|
527
538
|
this.assertOpen();
|
|
528
|
-
const limit = opts.limit
|
|
539
|
+
const limit = normalizeLimit(opts.limit);
|
|
529
540
|
const threshold = opts.threshold ?? 0;
|
|
530
541
|
const includeVectors = opts.includeVectors === true;
|
|
531
542
|
if (typeof query !== "string" || query.trim().length === 0) return [];
|
|
@@ -543,29 +554,59 @@ var VectorMemoryStore = class _VectorMemoryStore {
|
|
|
543
554
|
filters.push("e.kind = ?");
|
|
544
555
|
params.push(opts.kind);
|
|
545
556
|
}
|
|
546
|
-
const
|
|
547
|
-
`SELECT e.id
|
|
548
|
-
e.content_hash, e.created_at, e.updated_at,
|
|
549
|
-
v.vector AS vec_blob
|
|
557
|
+
const scanRows = this.db.prepare(
|
|
558
|
+
`SELECT e.id AS id, v.vector AS vec_blob
|
|
550
559
|
FROM entries e
|
|
551
560
|
JOIN vectors v ON v.entry_id = e.id
|
|
552
561
|
WHERE ${filters.join(" AND ")}`
|
|
553
562
|
).all(...params);
|
|
554
|
-
const
|
|
555
|
-
for (const row of
|
|
556
|
-
const
|
|
557
|
-
const vec = decodeVector(blob);
|
|
563
|
+
const top = [];
|
|
564
|
+
for (const row of scanRows) {
|
|
565
|
+
const vec = decodeVector(row.vec_blob);
|
|
558
566
|
const raw = cosineSimilarity(queryVec, vec);
|
|
559
567
|
const score = Math.max(0, Math.min(1, raw));
|
|
560
568
|
if (score < threshold) continue;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
569
|
+
if (top.length >= limit && score <= (top[top.length - 1]?.score ?? 0)) continue;
|
|
570
|
+
let at = top.length;
|
|
571
|
+
while (at > 0 && (top[at - 1]?.score ?? 0) < score) at--;
|
|
572
|
+
top.splice(at, 0, { id: row.id, score, vector: vec });
|
|
573
|
+
if (top.length > limit) top.length = limit;
|
|
574
|
+
}
|
|
575
|
+
if (top.length === 0) return [];
|
|
576
|
+
const placeholders = top.map(() => "?").join(",");
|
|
577
|
+
const hydrated = this.db.prepare(
|
|
578
|
+
`SELECT id, text, summary, metadata, tags, scope, kind,
|
|
579
|
+
content_hash, created_at, updated_at
|
|
580
|
+
FROM entries WHERE id IN (${placeholders})`
|
|
581
|
+
).all(...top.map((t) => t.id));
|
|
582
|
+
const entryById = /* @__PURE__ */ new Map();
|
|
583
|
+
for (const row of hydrated) {
|
|
584
|
+
entryById.set(row.id, this.rowToEntry(row));
|
|
585
|
+
}
|
|
586
|
+
const scored = [];
|
|
587
|
+
for (const candidate of top) {
|
|
588
|
+
const entry = entryById.get(candidate.id);
|
|
589
|
+
if (!entry) continue;
|
|
590
|
+
const hit = { entry, score: candidate.score, providerId };
|
|
591
|
+
if (includeVectors) hit.vector = candidate.vector;
|
|
564
592
|
scored.push(hit);
|
|
565
593
|
}
|
|
566
|
-
scored
|
|
567
|
-
return scored.slice(0, limit);
|
|
594
|
+
return scored;
|
|
568
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* Page through entries, newest first.
|
|
598
|
+
*
|
|
599
|
+
* Ordering is `(updated_at, id)` DESC — `updated_at` alone is not unique, so
|
|
600
|
+
* without the id tiebreak two entries written in the same millisecond can
|
|
601
|
+
* swap places between calls and a paging caller silently skips one.
|
|
602
|
+
*
|
|
603
|
+
* Pagination is keyset (`after`), not offset, because the only caller that
|
|
604
|
+
* pages is `forgetStaleSageMirrors`, which *deletes as it walks*. Under
|
|
605
|
+
* `OFFSET` every deletion shifts the remaining rows left and the next page
|
|
606
|
+
* skips exactly as many entries as were removed. Keyset is immune: it
|
|
607
|
+
* resumes from a position, and the rows a deletion removes are ones the
|
|
608
|
+
* sweep has already passed.
|
|
609
|
+
*/
|
|
569
610
|
list(opts = {}) {
|
|
570
611
|
this.assertOpen();
|
|
571
612
|
const where = [];
|
|
@@ -578,8 +619,12 @@ var VectorMemoryStore = class _VectorMemoryStore {
|
|
|
578
619
|
where.push("kind = ?");
|
|
579
620
|
params.push(opts.kind);
|
|
580
621
|
}
|
|
622
|
+
if (opts.after) {
|
|
623
|
+
where.push("(updated_at < ? OR (updated_at = ? AND id < ?))");
|
|
624
|
+
params.push(opts.after.updatedAt, opts.after.updatedAt, opts.after.id);
|
|
625
|
+
}
|
|
581
626
|
const sql = `SELECT * FROM entries ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
582
|
-
ORDER BY updated_at DESC LIMIT ?`;
|
|
627
|
+
ORDER BY updated_at DESC, id DESC LIMIT ?`;
|
|
583
628
|
params.push(opts.limit ?? 100);
|
|
584
629
|
const rows = this.db.prepare(sql).all(...params);
|
|
585
630
|
return rows.map((r) => this.rowToEntry(r));
|
|
@@ -1180,12 +1225,6 @@ async function fuseWithVectorMemory(query, lexical, options = {}) {
|
|
|
1180
1225
|
}
|
|
1181
1226
|
const sageById = /* @__PURE__ */ new Map();
|
|
1182
1227
|
for (const memory of lexical) sageById.set(memory.id, memory);
|
|
1183
|
-
for (const hit of vectorHits) {
|
|
1184
|
-
const sageId = hit.entry.metadata?.["sageId"];
|
|
1185
|
-
if (typeof sageId === "string" && !sageById.has(sageId)) {
|
|
1186
|
-
continue;
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
1228
|
const lexicalRanked = lexical.map((memory, index) => ({
|
|
1190
1229
|
memory,
|
|
1191
1230
|
rankScore: lexicalRankScore(index, lexical.length),
|
|
@@ -1203,7 +1242,7 @@ async function fuseWithVectorMemory(query, lexical, options = {}) {
|
|
|
1203
1242
|
const fused = /* @__PURE__ */ new Map();
|
|
1204
1243
|
for (let i = 0; i < lexicalRanked.length; i++) {
|
|
1205
1244
|
const c = lexicalRanked[i];
|
|
1206
|
-
const rrf =
|
|
1245
|
+
const rrf = (1 - weight) * (1 / (k + i + 1));
|
|
1207
1246
|
fused.set(c.memory.id, {
|
|
1208
1247
|
memory: c.memory,
|
|
1209
1248
|
vectorScore: null,
|
|
@@ -1261,20 +1300,11 @@ function clamp01(value) {
|
|
|
1261
1300
|
|
|
1262
1301
|
// src/sage-port-wrapper.ts
|
|
1263
1302
|
import {
|
|
1303
|
+
augmentLexicalWithVectorRecall,
|
|
1304
|
+
isSageVisibleForSearch,
|
|
1264
1305
|
SAGE_RETRIEVAL_CAPABILITY,
|
|
1265
1306
|
SAGE_SURFACE_CAPABILITY
|
|
1266
1307
|
} from "@wrongstack/sage";
|
|
1267
|
-
function mergeVectorRecall(options, recall, weight, threshold) {
|
|
1268
|
-
if (options && typeof options === "object" && "vectorRecall" in options && options["vectorRecall"]) {
|
|
1269
|
-
return options;
|
|
1270
|
-
}
|
|
1271
|
-
return {
|
|
1272
|
-
...options ?? {},
|
|
1273
|
-
vectorRecall: recall,
|
|
1274
|
-
...weight !== void 0 ? { vectorRecallWeight: weight } : {},
|
|
1275
|
-
...threshold !== void 0 ? { vectorRecallMinScore: threshold } : {}
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
1308
|
function asVectorRecallProviderAdapter(store) {
|
|
1279
1309
|
return {
|
|
1280
1310
|
async search(query, opts) {
|
|
@@ -1295,18 +1325,39 @@ function asVectorRecallProviderAdapter(store) {
|
|
|
1295
1325
|
}
|
|
1296
1326
|
function wrapMemoryPortWithVectorRecall(port, options) {
|
|
1297
1327
|
const recall = options.vectorRecall ?? asVectorRecallProviderAdapter(options.store);
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1328
|
+
const materializeFor = (searchOpts) => async (sageId) => {
|
|
1329
|
+
const surface = port.getCapability(SAGE_SURFACE_CAPABILITY);
|
|
1330
|
+
if (!surface?.getSage) return void 0;
|
|
1331
|
+
const memory = await surface.getSage(sageId);
|
|
1332
|
+
if (!memory) return void 0;
|
|
1333
|
+
return isSageVisibleForSearch(memory, searchOpts) ? memory : void 0;
|
|
1334
|
+
};
|
|
1335
|
+
const fusionOptions = (searchOpts) => ({
|
|
1336
|
+
vectorRecall: recall,
|
|
1337
|
+
materializeVectorOnly: materializeFor(searchOpts),
|
|
1338
|
+
...options.weight !== void 0 ? { vectorWeight: options.weight } : {},
|
|
1339
|
+
...options.threshold !== void 0 ? { threshold: options.threshold } : {},
|
|
1340
|
+
...options.vectorOnlyThreshold !== void 0 ? { vectorOnlyThreshold: options.vectorOnlyThreshold } : {},
|
|
1341
|
+
...options.maxMaterializations !== void 0 ? { maxMaterializations: options.maxMaterializations } : {},
|
|
1342
|
+
...searchOpts?.limit !== void 0 ? { limit: searchOpts.limit } : {}
|
|
1343
|
+
});
|
|
1344
|
+
const callerOwnsFusion = (searchOpts) => Boolean(searchOpts?.vectorRecall);
|
|
1345
|
+
const wrapSearchSage = (original) => async (query, searchOpts) => {
|
|
1346
|
+
const opts = searchOpts;
|
|
1347
|
+
const lexical = await original(query, searchOpts);
|
|
1348
|
+
if (callerOwnsFusion(opts)) return lexical;
|
|
1349
|
+
const fused = await augmentLexicalWithVectorRecall(query, lexical, fusionOptions(opts));
|
|
1350
|
+
return fused.map((hit) => hit.memory);
|
|
1351
|
+
};
|
|
1352
|
+
const wrapSearchWithBreakdown = (original) => async (query, searchOpts) => {
|
|
1353
|
+
const opts = searchOpts;
|
|
1354
|
+
const lexicalHits = await original(query, searchOpts);
|
|
1355
|
+
if (callerOwnsFusion(opts)) return lexicalHits;
|
|
1356
|
+
return augmentLexicalWithVectorRecall(
|
|
1302
1357
|
query,
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
weight,
|
|
1307
|
-
threshold
|
|
1308
|
-
)
|
|
1309
|
-
));
|
|
1358
|
+
lexicalHits.map((hit) => hit.memory),
|
|
1359
|
+
fusionOptions(opts)
|
|
1360
|
+
);
|
|
1310
1361
|
};
|
|
1311
1362
|
const wrapped = Object.create(
|
|
1312
1363
|
Object.getPrototypeOf(port),
|
|
@@ -1318,13 +1369,11 @@ function wrapMemoryPortWithVectorRecall(port, options) {
|
|
|
1318
1369
|
if (!original) return void 0;
|
|
1319
1370
|
return {
|
|
1320
1371
|
...original,
|
|
1321
|
-
searchSage:
|
|
1322
|
-
// The rich-breakdown variant uses the same options-merge
|
|
1323
|
-
// helper — pass the vector recall through so consumers that
|
|
1324
|
-
// want the per-channel score breakdown get the same fusion
|
|
1325
|
-
// behaviour as `searchSage`.
|
|
1372
|
+
searchSage: wrapSearchSage(original.searchSage),
|
|
1326
1373
|
...original.searchSageWithBreakdown ? {
|
|
1327
|
-
searchSageWithBreakdown:
|
|
1374
|
+
searchSageWithBreakdown: wrapSearchWithBreakdown(
|
|
1375
|
+
original.searchSageWithBreakdown
|
|
1376
|
+
)
|
|
1328
1377
|
} : {}
|
|
1329
1378
|
};
|
|
1330
1379
|
}
|
|
@@ -1333,9 +1382,11 @@ function wrapMemoryPortWithVectorRecall(port, options) {
|
|
|
1333
1382
|
if (!original) return void 0;
|
|
1334
1383
|
return {
|
|
1335
1384
|
...original,
|
|
1336
|
-
searchSage:
|
|
1385
|
+
searchSage: wrapSearchSage(original.searchSage),
|
|
1337
1386
|
...original.searchSageWithBreakdown ? {
|
|
1338
|
-
searchSageWithBreakdown:
|
|
1387
|
+
searchSageWithBreakdown: wrapSearchWithBreakdown(
|
|
1388
|
+
original.searchSageWithBreakdown
|
|
1389
|
+
)
|
|
1339
1390
|
} : {}
|
|
1340
1391
|
};
|
|
1341
1392
|
}
|
|
@@ -1345,6 +1396,8 @@ function wrapMemoryPortWithVectorRecall(port, options) {
|
|
|
1345
1396
|
}
|
|
1346
1397
|
|
|
1347
1398
|
// src/sage-event-mirror.ts
|
|
1399
|
+
import * as fs3 from "node:fs";
|
|
1400
|
+
import * as path3 from "node:path";
|
|
1348
1401
|
import { getSageSurface as getSageSurface2 } from "@wrongstack/sage";
|
|
1349
1402
|
function subscribeVectorMemoryToSage(opts) {
|
|
1350
1403
|
const { store, memoryStore } = opts;
|
|
@@ -1435,14 +1488,18 @@ function subscribeVectorMemoryToSage(opts) {
|
|
|
1435
1488
|
}
|
|
1436
1489
|
};
|
|
1437
1490
|
}
|
|
1438
|
-
async function forgetStaleSageMirrors(store, memoryStore, logger) {
|
|
1491
|
+
async function forgetStaleSageMirrors(store, memoryStore, logger, options) {
|
|
1439
1492
|
const surface = getSageSurface2(memoryStore);
|
|
1440
1493
|
if (!surface) return { scanned: 0, removed: 0 };
|
|
1441
1494
|
let scanned = 0;
|
|
1442
1495
|
let removed = 0;
|
|
1443
|
-
|
|
1444
|
-
|
|
1496
|
+
const PAGE = Math.max(1, options?.pageSize ?? 500);
|
|
1497
|
+
let after;
|
|
1498
|
+
for (; ; ) {
|
|
1499
|
+
const page = store.list(after ? { limit: PAGE, after } : { limit: PAGE });
|
|
1445
1500
|
if (page.length === 0) break;
|
|
1501
|
+
const last = page[page.length - 1];
|
|
1502
|
+
after = { updatedAt: last.updatedAt, id: last.id };
|
|
1446
1503
|
for (const entry of page) {
|
|
1447
1504
|
scanned++;
|
|
1448
1505
|
const sageId = entry.metadata?.sageId;
|
|
@@ -1456,13 +1513,43 @@ async function forgetStaleSageMirrors(store, memoryStore, logger) {
|
|
|
1456
1513
|
logger?.warn?.(`vector-memory stale-mirror sweep failed for ${sageId}: ${errMsg2(err)}`);
|
|
1457
1514
|
}
|
|
1458
1515
|
}
|
|
1459
|
-
if (page.length <
|
|
1516
|
+
if (page.length < PAGE) break;
|
|
1460
1517
|
}
|
|
1461
1518
|
return { scanned, removed };
|
|
1462
1519
|
}
|
|
1463
1520
|
function errMsg2(err) {
|
|
1464
1521
|
return err instanceof Error ? err.message : String(err);
|
|
1465
1522
|
}
|
|
1523
|
+
var SAGE_SWEEP_MARKER_FILENAME = "sage-mirror-sweep.json";
|
|
1524
|
+
var DEFAULT_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
1525
|
+
async function sweepStaleSageMirrors(opts) {
|
|
1526
|
+
const markerPath2 = path3.join(opts.store.directory, SAGE_SWEEP_MARKER_FILENAME);
|
|
1527
|
+
const interval = opts.minIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
1528
|
+
if (!opts.force) {
|
|
1529
|
+
try {
|
|
1530
|
+
const raw = JSON.parse(fs3.readFileSync(markerPath2, "utf8"));
|
|
1531
|
+
const at = typeof raw.at === "string" ? Date.parse(raw.at) : Number.NaN;
|
|
1532
|
+
if (Number.isFinite(at) && Date.now() - at < interval) {
|
|
1533
|
+
return { swept: false, reason: "throttled" };
|
|
1534
|
+
}
|
|
1535
|
+
} catch {
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
fs3.writeFileSync(markerPath2, JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() }), "utf8");
|
|
1540
|
+
} catch {
|
|
1541
|
+
}
|
|
1542
|
+
try {
|
|
1543
|
+
const result = await forgetStaleSageMirrors(opts.store, opts.memoryStore, opts.logger);
|
|
1544
|
+
opts.logger?.debug?.(
|
|
1545
|
+
`vector-memory stale-mirror sweep: scanned=${result.scanned} removed=${result.removed}`
|
|
1546
|
+
);
|
|
1547
|
+
return { swept: true, ...result };
|
|
1548
|
+
} catch (err) {
|
|
1549
|
+
opts.logger?.warn?.(`vector-memory stale-mirror sweep failed: ${errMsg2(err)}`);
|
|
1550
|
+
return { swept: false, reason: errMsg2(err) };
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1466
1553
|
|
|
1467
1554
|
// src/search-race.ts
|
|
1468
1555
|
function previewText(text, maxLen) {
|
|
@@ -1549,9 +1636,11 @@ async function runSearchRace(query, lexical, vectorStore, options = {}) {
|
|
|
1549
1636
|
};
|
|
1550
1637
|
}
|
|
1551
1638
|
export {
|
|
1639
|
+
DEFAULT_SWEEP_INTERVAL_MS,
|
|
1552
1640
|
DEFAULT_VECTOR_DIMENSIONS,
|
|
1553
1641
|
DEFAULT_VECTOR_DTYPE,
|
|
1554
1642
|
DEFAULT_VECTOR_MODEL_ID,
|
|
1643
|
+
SAGE_SWEEP_MARKER_FILENAME,
|
|
1555
1644
|
SAGE_SYNC_MARKER_FILENAME,
|
|
1556
1645
|
TransformersEmbeddingProvider,
|
|
1557
1646
|
VECTOR_DIMENSIONS_KEY,
|
|
@@ -1574,6 +1663,7 @@ export {
|
|
|
1574
1663
|
runSearchRace,
|
|
1575
1664
|
startFirstBootSageSync,
|
|
1576
1665
|
subscribeVectorMemoryToSage,
|
|
1666
|
+
sweepStaleSageMirrors,
|
|
1577
1667
|
upsertEmbeddingCache,
|
|
1578
1668
|
wrapMemoryPortWithVectorRecall
|
|
1579
1669
|
};
|
|
@@ -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
|
package/dist/sage-fusion.d.ts
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The fusion is a Reciprocal Rank Fusion (RRF)-style blend rather than a
|
|
8
8
|
* raw cosine-blend, so it stays robust when one side returns nothing
|
|
9
|
-
* (e.g. embedding provider unavailable).
|
|
10
|
-
*
|
|
9
|
+
* (e.g. embedding provider unavailable).
|
|
10
|
+
*
|
|
11
|
+
* IMPORTANT — the vector channel can only RE-RANK the lexical list, never
|
|
12
|
+
* extend it. A vector hit is kept only when its `metadata.sageId` resolves to
|
|
13
|
+
* a memory already present in `lexical`, because the caller's lexical list is
|
|
14
|
+
* the authoritative source of `Sage` objects and this function will not
|
|
15
|
+
* fabricate one (pinned by tests/sage-fusion.test.ts). Therefore:
|
|
16
|
+
* - `lexical` may be `[]` → the result is `[]`, whatever the vector channel
|
|
17
|
+
* found. This is NOT a fallback to pure vector ranking.
|
|
11
18
|
* - `vector` may be `[]` → fusion falls back to pure lexical ranking.
|
|
12
19
|
* - either may be `undefined` → fusion silently drops that channel.
|
|
13
20
|
*
|
|
@@ -40,6 +47,10 @@ export interface SageFusionOptions {
|
|
|
40
47
|
/**
|
|
41
48
|
* Cosine threshold below which a vector-only hit is dropped (no lexical
|
|
42
49
|
* counterpart to lift it). Default 0 — keep all, let RRF decide.
|
|
50
|
+
*
|
|
51
|
+
* Currently inert: vector-only hits cannot occur (see the module docstring),
|
|
52
|
+
* so nothing ever reaches this threshold. Kept for API stability and for
|
|
53
|
+
* the day the vector channel is allowed to extend the candidate set.
|
|
43
54
|
*/
|
|
44
55
|
vectorOnlyThreshold?: number | undefined;
|
|
45
56
|
}
|
|
@@ -51,7 +62,11 @@ export interface SageFusionHit {
|
|
|
51
62
|
lexicalScore: number | null;
|
|
52
63
|
/** 0..1, monotonically higher = better. */
|
|
53
64
|
finalScore: number;
|
|
54
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Where the candidate came from. `'vector'` is currently unreachable —
|
|
67
|
+
* every candidate originates in the lexical list (see the module
|
|
68
|
+
* docstring); the vector channel only upgrades one to `'both'`.
|
|
69
|
+
*/
|
|
55
70
|
source: 'lexical' | 'vector' | 'both';
|
|
56
71
|
}
|
|
57
72
|
declare function lexicalRankScore(index: number, total: number): number;
|
|
@@ -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": "1.0.0",
|
|
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": "1.0.0",
|
|
31
|
+
"@wrongstack/persistence": "1.0.0",
|
|
32
|
+
"@wrongstack/sage": "1.0.0"
|
|
33
33
|
},
|
|
34
34
|
"optionalDependencies": {
|
|
35
35
|
"@huggingface/transformers": "^4.2.0"
|