@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 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 ?? 10;
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 rows = this.db.prepare(
547
- `SELECT e.id, e.text, e.summary, e.metadata, e.tags, e.scope, e.kind,
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 scored = [];
555
- for (const row of rows) {
556
- const blob = row.vec_blob;
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
- const entry = this.rowToEntry(row);
562
- const hit = { entry, score, providerId };
563
- if (includeVectors) hit.vector = vec;
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.sort((a, b) => b.score - a.score);
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 = weight * 0 + (1 - weight) * (1 / (k + i + 1));
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 weight = options.weight;
1299
- const threshold = options.threshold;
1300
- const wrapSearch = (original) => {
1301
- return ((query, searchOpts) => original(
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
- mergeVectorRecall(
1304
- searchOpts,
1305
- recall,
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: wrapSearch(original.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: wrapSearch(original.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: wrapSearch(original.searchSage),
1385
+ searchSage: wrapSearchSage(original.searchSage),
1337
1386
  ...original.searchSageWithBreakdown ? {
1338
- searchSageWithBreakdown: wrapSearch(original.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
- for (let offset = 0; ; offset += 1e3) {
1444
- const page = store.list({ limit: 1e3 });
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 < 1e3) break;
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
- * 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
@@ -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). All inputs are optional:
10
- * - `lexical` may be `[]` → fusion falls back to pure vector ranking.
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
- /** Where the candidate came from. */
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
- * `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.1",
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.319.1",
31
- "@wrongstack/persistence": "0.319.1",
32
- "@wrongstack/sage": "0.319.1"
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"