@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 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 rows = this.db.prepare(
557
- `SELECT e.id, e.text, e.summary, e.metadata, e.tags, e.scope, e.kind,
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 scored = [];
565
- for (const row of rows) {
566
- const blob = row.vec_blob;
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
- const entry = this.rowToEntry(row);
572
- const hit = { entry, score, providerId };
573
- if (includeVectors) hit.vector = vec;
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.sort((a, b) => b.score - a.score);
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 ? { run: true, reason: Number.isNaN(startedAt) ? "running-marker-undated" : "running-marker-stale" } : { run: false, reason: "running-unknown-pid" };
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 { scanned: report.scanned, indexed: report.indexed, skipped: report.skipped, failed: report.failed };
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: { type: "number", minimum: 1, maximum: 100, description: "Max results (default 10)." },
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: { type: "string", minLength: 1, description: "Entry id returned by `vector_memory_remember`." }
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 weight = options.weight;
1292
- const threshold = options.threshold;
1293
- const wrapSearch = (original) => {
1294
- return ((query, searchOpts) => original(
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
- mergeVectorRecall(
1297
- searchOpts,
1298
- recall,
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: wrapSearch(original.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: wrapSearch(
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: wrapSearch(original.searchSage),
1380
+ searchSage: wrapSearchSage(original.searchSage),
1332
1381
  ...original.searchSageWithBreakdown ? {
1333
- searchSageWithBreakdown: wrapSearch(
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
- for (let offset = 0; ; offset += 1e3) {
1447
- const page = store.list({ limit: 1e3 });
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 < 1e3) break;
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: 0,
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 === 0) {
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
- * Useful after bulk operations (`memory.cleared`, `hygiene.purge_deleted`)
38
- * that emit a single top-level event but may not emit per-memory
39
- * `memory.deleted` events.
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
- * `unifiedSearch` / `retrieveForAudience` call automatically injects
4
- * the supplied `VectorRecallProvider`. The wrapper preserves the
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
- * The wrapper only augments paths that go through the read-side
15
- * capability (`getCapability(SAGE_RETRIEVAL_CAPABILITY)` /
16
- * `getCapability(SAGE_SURFACE_CAPABILITY)`). Other capabilities
17
- * (write-side, hygiene, audit) pass through unchanged so the wrapper
18
- * never widens the trust boundary.
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
- * `asVectorRecallProvider(store)`.
54
+ * `asVectorRecallProviderAdapter(store)`.
29
55
  */
30
56
  vectorRecall?: VectorRecallProvider | undefined;
31
57
  /**
32
- * Cosine threshold forwarded to the vector backend. 0 = no threshold
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` that routes `searchSage` calls through the
49
- * supplied `VectorRecallProvider`. All other capabilities are passed
50
- * through unchanged.
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.319.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.319.0",
31
- "@wrongstack/persistence": "0.319.0",
32
- "@wrongstack/sage": "0.319.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"