@b4run/memory 0.8.28

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/browse-budget.d.ts +40 -0
  4. package/dist/browse-budget.d.ts.map +1 -0
  5. package/dist/browse-budget.js +57 -0
  6. package/dist/browse-cursor.d.ts +22 -0
  7. package/dist/browse-cursor.d.ts.map +1 -0
  8. package/dist/browse-cursor.js +146 -0
  9. package/dist/browse-filter.d.ts +13 -0
  10. package/dist/browse-filter.d.ts.map +1 -0
  11. package/dist/browse-filter.js +16 -0
  12. package/dist/browse-order.d.ts +28 -0
  13. package/dist/browse-order.d.ts.map +1 -0
  14. package/dist/browse-order.js +37 -0
  15. package/dist/browse-range.d.ts +16 -0
  16. package/dist/browse-range.d.ts.map +1 -0
  17. package/dist/browse-range.js +52 -0
  18. package/dist/browse-validate.d.ts +31 -0
  19. package/dist/browse-validate.d.ts.map +1 -0
  20. package/dist/browse-validate.js +263 -0
  21. package/dist/browse.d.ts +15 -0
  22. package/dist/browse.d.ts.map +1 -0
  23. package/dist/browse.js +5 -0
  24. package/dist/distill.d.ts +81 -0
  25. package/dist/distill.d.ts.map +1 -0
  26. package/dist/distill.js +380 -0
  27. package/dist/hybrid.d.ts +26 -0
  28. package/dist/hybrid.d.ts.map +1 -0
  29. package/dist/hybrid.js +86 -0
  30. package/dist/index.d.ts +17 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +13 -0
  33. package/dist/namespace.d.ts +18 -0
  34. package/dist/namespace.d.ts.map +1 -0
  35. package/dist/namespace.js +68 -0
  36. package/dist/reconcile.d.ts +51 -0
  37. package/dist/reconcile.d.ts.map +1 -0
  38. package/dist/reconcile.js +110 -0
  39. package/dist/score.d.ts +54 -0
  40. package/dist/score.d.ts.map +1 -0
  41. package/dist/score.js +66 -0
  42. package/dist/sqlite-browse-sql.d.ts +30 -0
  43. package/dist/sqlite-browse-sql.d.ts.map +1 -0
  44. package/dist/sqlite-browse-sql.js +178 -0
  45. package/dist/sqlite-store.d.ts +10 -0
  46. package/dist/sqlite-store.d.ts.map +1 -0
  47. package/dist/sqlite-store.js +521 -0
  48. package/dist/tokenize.d.ts +3 -0
  49. package/dist/tokenize.d.ts.map +1 -0
  50. package/dist/tokenize.js +14 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -0
  52. package/dist/types.d.ts +201 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +1 -0
  55. package/dist/vector.d.ts +22 -0
  56. package/dist/vector.d.ts.map +1 -0
  57. package/dist/vector.js +42 -0
  58. package/package.json +67 -0
@@ -0,0 +1,51 @@
1
+ import type { MemoryKind, MemoryRecord, MemoryStore } from "./types.js";
2
+ export type WritePolicy = {
3
+ readonly mode: "reconcile";
4
+ } | {
5
+ readonly mode: "append";
6
+ };
7
+ /** Per-kind write discipline. Semantic facts reconcile (identity match →
8
+ * update/supersede); episodic events append (a later episode never
9
+ * contradicts an earlier one); reflection insights append too — distillation
10
+ * ACCUMULATES insights, and a later insight never contradicts an earlier one
11
+ * (superseding stale insights is a future concern). Procedural alone is typed
12
+ * but not yet wired — throwing beats baking in accidental semantics.
13
+ * Mirrored inline in packages/core/src/capabilities/built-in/memory.ts
14
+ * remember (core can't import this package) — keep in sync. */
15
+ export declare function writePolicyFor(kind: MemoryKind): WritePolicy;
16
+ export type WriteOp = {
17
+ op: "add";
18
+ } | {
19
+ op: "update";
20
+ targetId: string;
21
+ } | {
22
+ op: "supersede";
23
+ targetId: string;
24
+ };
25
+ /** Deterministic write classification (no LLM): ADD if no identity match; UPDATE if identity+data equal; SUPERSEDE if identity matches but data differs. */
26
+ export declare function classifyWrite(incoming: MemoryRecord, candidates: readonly MemoryRecord[], identityKeys: readonly string[]): WriteOp;
27
+ export interface ApproveResult {
28
+ readonly approved: MemoryRecord;
29
+ readonly action: "activated" | "superseded" | "deduped";
30
+ readonly superseded: readonly MemoryRecord[];
31
+ readonly identityKeys: readonly string[];
32
+ }
33
+ /**
34
+ * Approve a candidate WITH supersede reconciliation (fixes the two-actives bug):
35
+ * same identity + different data → the old active row is superseded; same
36
+ * identity + identical data → the candidate is dropped (dedupe); no identity
37
+ * match → plain activation. Used by `b4 memory approve` and the inspector —
38
+ * the capability's auto-write path keeps its own inline logic by design.
39
+ * Append-kind candidates (per writePolicyFor, e.g. episodic) bypass
40
+ * reconciliation entirely — approval is a plain activation, no identity scan.
41
+ *
42
+ * NOT transactional: MemoryStore has no CAS primitive, so the read-classify-
43
+ * write sequence can race a concurrent same-identity auto-write. Worst case is
44
+ * the pre-existing two-actives state, which self-heals on the next auto-write
45
+ * or approval of that identity.
46
+ */
47
+ export declare function approveWithReconcile(store: MemoryStore, id: string, opts: {
48
+ readonly identityKeys: readonly string[];
49
+ readonly now: string;
50
+ }): Promise<ApproveResult>;
51
+ //# sourceMappingURL=reconcile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../src/reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAEvE,MAAM,MAAM,WAAW,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAA;AAEtF;;;;;;;gEAOgE;AAChE,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,WAAW,CAY5D;AAED,MAAM,MAAM,OAAO,GACf;IAAE,EAAE,EAAE,KAAK,CAAA;CAAE,GACb;IAAE,EAAE,EAAE,QAAQ,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,EAAE,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAA;AAIzC,4JAA4J;AAC5J,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,SAAS,YAAY,EAAE,EACnC,YAAY,EAAE,SAAS,MAAM,EAAE,GAC9B,OAAO,CAMT;AAQD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,GAAG,SAAS,CAAA;IACvD,QAAQ,CAAC,UAAU,EAAE,SAAS,YAAY,EAAE,CAAA;IAC5C,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CACzC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,WAAW,EAClB,EAAE,EAAE,MAAM,EACV,IAAI,EAAE;IAAE,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACvE,OAAO,CAAC,aAAa,CAAC,CAqDxB"}
@@ -0,0 +1,110 @@
1
+ /** Per-kind write discipline. Semantic facts reconcile (identity match →
2
+ * update/supersede); episodic events append (a later episode never
3
+ * contradicts an earlier one); reflection insights append too — distillation
4
+ * ACCUMULATES insights, and a later insight never contradicts an earlier one
5
+ * (superseding stale insights is a future concern). Procedural alone is typed
6
+ * but not yet wired — throwing beats baking in accidental semantics.
7
+ * Mirrored inline in packages/core/src/capabilities/built-in/memory.ts
8
+ * remember (core can't import this package) — keep in sync. */
9
+ export function writePolicyFor(kind) {
10
+ switch (kind) {
11
+ case "semantic":
12
+ return { mode: "reconcile" };
13
+ case "episodic":
14
+ case "reflection":
15
+ return { mode: "append" };
16
+ default:
17
+ throw new Error(`memory kind '${kind}' is not yet wired (semantic, episodic and reflection are)`);
18
+ }
19
+ }
20
+ function identityOf(data, keys) {
21
+ return keys.map((k) => JSON.stringify(data[k] ?? null)).join(" ");
22
+ }
23
+ /** Deterministic write classification (no LLM): ADD if no identity match; UPDATE if identity+data equal; SUPERSEDE if identity matches but data differs. */
24
+ export function classifyWrite(incoming, candidates, identityKeys) {
25
+ const incomingId = identityOf(incoming.data, identityKeys);
26
+ const match = candidates.find((c) => identityOf(c.data, identityKeys) === incomingId);
27
+ if (!match)
28
+ return { op: "add" };
29
+ const same = JSON.stringify(match.data) === JSON.stringify(incoming.data);
30
+ return same ? { op: "update", targetId: match.id } : { op: "supersede", targetId: match.id };
31
+ }
32
+ /** Upper bound on active rows scanned for an identity match during approval.
33
+ * Namespaces beyond this size would silently skip reconciliation for the
34
+ * overflow rows — accepted trade-off: real namespaces are orders of magnitude
35
+ * smaller, and an unbounded scan risks pathological reads. */
36
+ const ACTIVE_SCAN_LIMIT = 10_000;
37
+ /**
38
+ * Approve a candidate WITH supersede reconciliation (fixes the two-actives bug):
39
+ * same identity + different data → the old active row is superseded; same
40
+ * identity + identical data → the candidate is dropped (dedupe); no identity
41
+ * match → plain activation. Used by `b4 memory approve` and the inspector —
42
+ * the capability's auto-write path keeps its own inline logic by design.
43
+ * Append-kind candidates (per writePolicyFor, e.g. episodic) bypass
44
+ * reconciliation entirely — approval is a plain activation, no identity scan.
45
+ *
46
+ * NOT transactional: MemoryStore has no CAS primitive, so the read-classify-
47
+ * write sequence can race a concurrent same-identity auto-write. Worst case is
48
+ * the pre-existing two-actives state, which self-heals on the next auto-write
49
+ * or approval of that identity.
50
+ */
51
+ export async function approveWithReconcile(store, id, opts) {
52
+ const candidate = await store.get(id);
53
+ if (!candidate)
54
+ throw new Error(`memory ${id} not found`);
55
+ if (candidate.status !== "candidate")
56
+ throw new Error(`memory ${id} is '${candidate.status}', not a candidate`);
57
+ if (writePolicyFor(candidate.kind).mode === "append") {
58
+ // Append-only kinds: approval is a plain activation — no identity scan.
59
+ await store.update(id, { status: "active", updatedAt: opts.now });
60
+ const approved = await store.get(id);
61
+ if (!approved)
62
+ throw new Error(`approved memory ${id} vanished`);
63
+ return { approved, action: "activated", superseded: [], identityKeys: opts.identityKeys };
64
+ }
65
+ const actives = await store.search({
66
+ namespace: candidate.namespace,
67
+ status: "active",
68
+ kind: candidate.kind,
69
+ limit: ACTIVE_SCAN_LIMIT,
70
+ });
71
+ const op = classifyWrite(candidate, actives, opts.identityKeys);
72
+ if (op.op === "update") {
73
+ const existing = actives.find((r) => r.id === op.targetId);
74
+ if (!existing)
75
+ throw new Error(`reconcile target ${op.targetId} vanished`);
76
+ // Deliberately does NOT refresh the surviving record (unlike the auto
77
+ // path's idempotent-update): approving a duplicate shouldn't reorder recency.
78
+ await store.delete(id);
79
+ return {
80
+ approved: existing,
81
+ action: "deduped",
82
+ superseded: [],
83
+ identityKeys: opts.identityKeys,
84
+ };
85
+ }
86
+ if (op.op === "supersede") {
87
+ const target = actives.find((r) => r.id === op.targetId);
88
+ if (!target)
89
+ throw new Error(`reconcile target ${op.targetId} vanished`);
90
+ // Activate first, then demote — same order as the capability's auto path
91
+ // (put active record → store.supersede). store.supersede also appends the
92
+ // demoted id to this record's `supersedes` via a Set (both sqlite and
93
+ // pgvector), so the explicit link here cannot double-append.
94
+ await store.update(id, {
95
+ status: "active",
96
+ updatedAt: opts.now,
97
+ supersedes: [...(candidate.supersedes ?? []), target.id],
98
+ });
99
+ await store.supersede(target.id, id);
100
+ const approved = await store.get(id);
101
+ if (!approved)
102
+ throw new Error(`approved memory ${id} vanished`);
103
+ return { approved, action: "superseded", superseded: [target], identityKeys: opts.identityKeys };
104
+ }
105
+ await store.update(id, { status: "active", updatedAt: opts.now });
106
+ const approved = await store.get(id);
107
+ if (!approved)
108
+ throw new Error(`approved memory ${id} vanished`);
109
+ return { approved, action: "activated", superseded: [], identityKeys: opts.identityKeys };
110
+ }
@@ -0,0 +1,54 @@
1
+ export interface RecallWeights {
2
+ readonly relevance?: number;
3
+ readonly recency?: number;
4
+ readonly confidence?: number;
5
+ }
6
+ export interface RecallRankingOptions {
7
+ readonly weights?: RecallWeights;
8
+ readonly recencyHalfLifeMs?: number;
9
+ readonly candidatePool?: number;
10
+ }
11
+ export declare const DEFAULT_RECALL_WEIGHTS: {
12
+ readonly relevance: 0.6;
13
+ readonly recency: 0.3;
14
+ readonly confidence: 0.1;
15
+ };
16
+ export declare const DEFAULT_RECENCY_HALF_LIFE_MS: number;
17
+ export declare const DEFAULT_CANDIDATE_POOL = 256;
18
+ /**
19
+ * BM25-smoothed inverse document frequency. Always positive — a token present
20
+ * in every memory (df = corpusSize) still carries a small weight rather than
21
+ * zeroing out or going negative (as unsmoothed idf would).
22
+ */
23
+ export declare function idf(df: number, corpusSize: number): number;
24
+ /**
25
+ * Exponential recency decay in (0, 1]. age = referenceMs − updatedMs, clamped ≥ 0;
26
+ * invalid/absent timestamps degrade to age 0 (decay 1). Non-positive/non-finite
27
+ * halfLife falls back to the default.
28
+ */
29
+ export declare function recencyDecay(updatedAt: string, referenceNow: string, halfLifeMs?: number): number;
30
+ /**
31
+ * Composite recall score: wRel·relevance + wRec·recency + wConf·confidence.
32
+ *
33
+ * relevance = Σ idf(matched query tokens) / Σ idf(all query tokens) — the
34
+ * fraction of the query's information this memory matches (0..1). Query
35
+ * tokens matching nothing inflate only the shared denominator, so relative
36
+ * ordering is unaffected.
37
+ *
38
+ * recency = 2^(−age / halfLife), age measured from `referenceNow` back to
39
+ * `updatedAt`, clamped ≥ 0. Invalid timestamps degrade to age 0. A
40
+ * non-positive or non-finite halfLifeMs falls back to the default.
41
+ *
42
+ * `queryTokens` are expected to be deduplicated, as produced by `tokenize()`.
43
+ */
44
+ export declare function scoreMemory(args: {
45
+ readonly memoryTokens: ReadonlySet<string>;
46
+ readonly queryTokens: readonly string[];
47
+ readonly dfByToken: ReadonlyMap<string, number>;
48
+ readonly corpusSize: number;
49
+ readonly updatedAt: string;
50
+ readonly confidence: number;
51
+ readonly referenceNow: string;
52
+ readonly options?: RecallRankingOptions;
53
+ }): number;
54
+ //# sourceMappingURL=score.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"score.d.ts","sourceRoot":"","sources":["../src/score.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAA;IAChC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAA;IACnC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAChC;AAED,eAAO,MAAM,sBAAsB;aACjC,SAAS,EAAE,GAAG;aACd,OAAO,EAAE,GAAG;aACZ,UAAU,EAAE,GAAG;CACP,CAAA;AAEV,eAAO,MAAM,4BAA4B,QAA2B,CAAA;AACpE,eAAO,MAAM,sBAAsB,MAAM,CAAA;AAEzC;;;;GAIG;AACH,wBAAgB,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAI1D;AAMD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CASjG;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE;IAChC,QAAQ,CAAC,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IAC1C,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;IACvC,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,oBAAoB,CAAA;CACxC,GAAG,MAAM,CAiBT"}
package/dist/score.js ADDED
@@ -0,0 +1,66 @@
1
+ // Pure recall scoring — no I/O, no clock, no randomness. Deterministic by
2
+ // construction so aimock fixtures and eval replays stay stable. See
3
+ // docs/superpowers/specs/2026-07-05-smarter-recall-design.md.
4
+ export const DEFAULT_RECALL_WEIGHTS = {
5
+ relevance: 0.6,
6
+ recency: 0.3,
7
+ confidence: 0.1,
8
+ };
9
+ export const DEFAULT_RECENCY_HALF_LIFE_MS = 14 * 24 * 60 * 60 * 1000;
10
+ export const DEFAULT_CANDIDATE_POOL = 256;
11
+ /**
12
+ * BM25-smoothed inverse document frequency. Always positive — a token present
13
+ * in every memory (df = corpusSize) still carries a small weight rather than
14
+ * zeroing out or going negative (as unsmoothed idf would).
15
+ */
16
+ export function idf(df, corpusSize) {
17
+ const d = Math.max(0, df);
18
+ const n = Math.max(d, corpusSize);
19
+ return Math.log(1 + (n - d + 0.5) / (d + 0.5));
20
+ }
21
+ function clamp01(v) {
22
+ return Number.isFinite(v) ? (v < 0 ? 0 : v > 1 ? 1 : v) : 0;
23
+ }
24
+ /**
25
+ * Exponential recency decay in (0, 1]. age = referenceMs − updatedMs, clamped ≥ 0;
26
+ * invalid/absent timestamps degrade to age 0 (decay 1). Non-positive/non-finite
27
+ * halfLife falls back to the default.
28
+ */
29
+ export function recencyDecay(updatedAt, referenceNow, halfLifeMs) {
30
+ const hl = typeof halfLifeMs === "number" && Number.isFinite(halfLifeMs) && halfLifeMs > 0
31
+ ? halfLifeMs
32
+ : DEFAULT_RECENCY_HALF_LIFE_MS;
33
+ const ref = Date.parse(referenceNow);
34
+ const upd = Date.parse(updatedAt);
35
+ const ageMs = Number.isNaN(ref) || Number.isNaN(upd) ? 0 : Math.max(0, ref - upd);
36
+ return 2 ** (-ageMs / hl);
37
+ }
38
+ /**
39
+ * Composite recall score: wRel·relevance + wRec·recency + wConf·confidence.
40
+ *
41
+ * relevance = Σ idf(matched query tokens) / Σ idf(all query tokens) — the
42
+ * fraction of the query's information this memory matches (0..1). Query
43
+ * tokens matching nothing inflate only the shared denominator, so relative
44
+ * ordering is unaffected.
45
+ *
46
+ * recency = 2^(−age / halfLife), age measured from `referenceNow` back to
47
+ * `updatedAt`, clamped ≥ 0. Invalid timestamps degrade to age 0. A
48
+ * non-positive or non-finite halfLifeMs falls back to the default.
49
+ *
50
+ * `queryTokens` are expected to be deduplicated, as produced by `tokenize()`.
51
+ */
52
+ export function scoreMemory(args) {
53
+ const weights = { ...DEFAULT_RECALL_WEIGHTS, ...args.options?.weights };
54
+ let matchedIdf = 0;
55
+ let totalIdf = 0;
56
+ for (const t of args.queryTokens) {
57
+ const w = idf(args.dfByToken.get(t) ?? 0, args.corpusSize);
58
+ totalIdf += w;
59
+ if (args.memoryTokens.has(t))
60
+ matchedIdf += w;
61
+ }
62
+ const relevance = totalIdf > 0 ? matchedIdf / totalIdf : 0;
63
+ const recency = recencyDecay(args.updatedAt, args.referenceNow, args.options?.recencyHalfLifeMs);
64
+ const confidence = clamp01(args.confidence);
65
+ return weights.relevance * relevance + weights.recency * recency + weights.confidence * confidence;
66
+ }
@@ -0,0 +1,30 @@
1
+ import type { SQLInputValue } from "node:sqlite";
2
+ import type { BrowseCursorPayload } from "./browse-cursor.js";
3
+ import type { ResolvedBrowseSort } from "./browse-order.js";
4
+ import type { BrowseFilter } from "./types.js";
5
+ /**
6
+ * Append one normalized filter to a SQLite WHERE list. Column names come from this
7
+ * switch and nowhere else; every value is bound.
8
+ *
9
+ * Text matching uses literal substring primitives (`instr`, `substr`) rather than
10
+ * LIKE, so `%`, `_` and `\` in a user's search term need no escaping and can never
11
+ * change the predicate. Case-insensitivity is `lower()`, which folds ASCII only in
12
+ * SQLite without ICU: `lower('CAFÉ')` is `'cafÉ'` here and `'café'` in Postgres, so a
13
+ * non-ASCII needle matches on one backend and misses on the other. The conformance
14
+ * suite asserts ONE reading for both stores and therefore cannot pin that divergence —
15
+ * every fixture there is ASCII, and this comment is the whole documentation of it.
16
+ */
17
+ export declare function appendSqliteBrowseFilter(filter: BrowseFilter, where: string[], params: SQLInputValue[]): void;
18
+ /**
19
+ * Everything strictly after `cursor` in `order`, as a WHERE fragment.
20
+ *
21
+ * Row-value comparisons cannot express mixed asc/desc, so this is the expanded
22
+ * OR-chain — plus a REDUNDANT leading range guard. The guard is logically implied by
23
+ * the chain and is still mandatory: it is what lets the planner seek the leading index
24
+ * column instead of scanning the whole index (0.54 ms vs 22.8 ms at 1M rows).
25
+ *
26
+ * Parameters are pushed in the exact textual order they appear, so the caller can
27
+ * concatenate this fragment after its filter clauses without renumbering.
28
+ */
29
+ export declare function sqliteKeysetWhere(order: readonly ResolvedBrowseSort[], cursor: BrowseCursorPayload, params: SQLInputValue[]): string;
30
+ //# sourceMappingURL=sqlite-browse-sql.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-browse-sql.d.ts","sourceRoot":"","sources":["../src/sqlite-browse-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAC7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAG3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE9C;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,aAAa,EAAE,GACtB,IAAI,CA8HN;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,SAAS,kBAAkB,EAAE,EACpC,MAAM,EAAE,mBAAmB,EAC3B,MAAM,EAAE,aAAa,EAAE,GACtB,MAAM,CAiCR"}
@@ -0,0 +1,178 @@
1
+ import { namespacePrefixUpperBound, utcDayAfter, utcDayStart } from "./browse-range.js";
2
+ import { BrowseQueryError } from "./browse-validate.js";
3
+ /**
4
+ * Append one normalized filter to a SQLite WHERE list. Column names come from this
5
+ * switch and nowhere else; every value is bound.
6
+ *
7
+ * Text matching uses literal substring primitives (`instr`, `substr`) rather than
8
+ * LIKE, so `%`, `_` and `\` in a user's search term need no escaping and can never
9
+ * change the predicate. Case-insensitivity is `lower()`, which folds ASCII only in
10
+ * SQLite without ICU: `lower('CAFÉ')` is `'cafÉ'` here and `'café'` in Postgres, so a
11
+ * non-ASCII needle matches on one backend and misses on the other. The conformance
12
+ * suite asserts ONE reading for both stores and therefore cannot pin that divergence —
13
+ * every fixture there is ASCII, and this comment is the whole documentation of it.
14
+ */
15
+ export function appendSqliteBrowseFilter(filter, where, params) {
16
+ switch (filter.field) {
17
+ case "status":
18
+ case "kind": {
19
+ const column = filter.field;
20
+ const placeholders = filter.values.map(() => "?").join(",");
21
+ where.push(filter.op === "in"
22
+ ? `${column} IN (${placeholders})`
23
+ : `${column} NOT IN (${placeholders})`);
24
+ params.push(...filter.values);
25
+ return;
26
+ }
27
+ case "content": {
28
+ switch (filter.op) {
29
+ case "contains":
30
+ where.push("instr(lower(content), lower(?)) > 0");
31
+ params.push(filter.value);
32
+ return;
33
+ case "notContains":
34
+ where.push("instr(lower(content), lower(?)) = 0");
35
+ params.push(filter.value);
36
+ return;
37
+ case "startsWith":
38
+ where.push("instr(lower(content), lower(?)) = 1");
39
+ params.push(filter.value);
40
+ return;
41
+ case "endsWith":
42
+ // Negative substr() takes the LAST n characters; a needle longer than the
43
+ // content yields the whole content, which cannot equal it. Correct by
44
+ // construction, no length guard needed.
45
+ where.push("substr(lower(content), -length(?)) = lower(?)");
46
+ params.push(filter.value, filter.value);
47
+ return;
48
+ case "equals":
49
+ where.push("lower(content) = lower(?)");
50
+ params.push(filter.value);
51
+ return;
52
+ case "notEquals":
53
+ where.push("lower(content) <> lower(?)");
54
+ params.push(filter.value);
55
+ return;
56
+ default: {
57
+ // Every op has its own case above, so this binding is `never` today: an op
58
+ // added to the union stops the BUILD here rather than reaching a caller as
59
+ // `<>` — wrong rows, no signal, and two dialects to forget.
60
+ const unmapped = filter;
61
+ throw new BrowseQueryError(`unhandled content filter op: ${JSON.stringify(unmapped.op)}`);
62
+ }
63
+ }
64
+ }
65
+ case "namespace": {
66
+ if (filter.op === "equals") {
67
+ where.push("namespace = ?");
68
+ params.push(filter.value);
69
+ return;
70
+ }
71
+ // Byte-exact prefix as a half-open RANGE: metacharacters stay literal and the
72
+ // comparison stays case-sensitive. Sargable, with the same ORDER BY trade the
73
+ // store's own `namespacePrefix` documents.
74
+ const upper = namespacePrefixUpperBound(filter.value);
75
+ where.push(upper === undefined ? "namespace >= ?" : "namespace >= ? AND namespace < ?");
76
+ params.push(filter.value);
77
+ if (upper !== undefined)
78
+ params.push(upper);
79
+ return;
80
+ }
81
+ case "confidence": {
82
+ if (filter.op === "between") {
83
+ // Inclusive on both ends, matching the grid's local `between`.
84
+ where.push("confidence >= ? AND confidence <= ?");
85
+ params.push(filter.min, filter.max);
86
+ return;
87
+ }
88
+ const operators = { eq: "=", neq: "<>", gt: ">", gte: ">=", lt: "<", lte: "<=" };
89
+ where.push(`confidence ${operators[filter.op]} ?`);
90
+ params.push(filter.value);
91
+ return;
92
+ }
93
+ case "updatedAt": {
94
+ // Bounds are the fixed-width `YYYY-MM-DDT00:00:00.000Z` form and the comparison
95
+ // is lexicographic, so a row buckets to the right UTC day only while its stored
96
+ // updated_at is that same form. Nothing enforces that — MemoryRecord types
97
+ // updatedAt as a bare string — so `2026-08-02T20:00:00-05:00` lands in the day it
98
+ // READS as, not the day it is. Both schemas flag the unnormalized column for
99
+ // ORDER BY; here it decides membership, not just order.
100
+ switch (filter.op) {
101
+ case "onDay":
102
+ where.push("updated_at >= ? AND updated_at < ?");
103
+ params.push(utcDayStart(filter.day), utcDayAfter(filter.day));
104
+ return;
105
+ case "beforeDay":
106
+ where.push("updated_at < ?");
107
+ params.push(utcDayStart(filter.day));
108
+ return;
109
+ case "afterDay":
110
+ where.push("updated_at >= ?");
111
+ params.push(utcDayAfter(filter.day));
112
+ return;
113
+ case "betweenDays":
114
+ // Inclusive of BOTH days.
115
+ where.push("updated_at >= ? AND updated_at < ?");
116
+ params.push(utcDayStart(filter.fromDay), utcDayAfter(filter.untilDay));
117
+ return;
118
+ default: {
119
+ const unmapped = filter;
120
+ throw new BrowseQueryError(`unhandled updatedAt filter op: ${JSON.stringify(unmapped.op)}`);
121
+ }
122
+ }
123
+ }
124
+ default: {
125
+ // Every field has its own case above, so this binding is `never`: a field added
126
+ // to the union stops the BUILD here rather than reaching a caller unfiltered.
127
+ // Still throws at runtime — the untyped HTTP caller reaches this too, and
128
+ // BrowseQueryError rather than Error because that boundary maps a rejection by
129
+ // NAME: a plain Error 500s where this must 400.
130
+ const unmapped = filter;
131
+ throw new BrowseQueryError(`unhandled browse filter field: ${unmapped.field}`);
132
+ }
133
+ }
134
+ }
135
+ /**
136
+ * Everything strictly after `cursor` in `order`, as a WHERE fragment.
137
+ *
138
+ * Row-value comparisons cannot express mixed asc/desc, so this is the expanded
139
+ * OR-chain — plus a REDUNDANT leading range guard. The guard is logically implied by
140
+ * the chain and is still mandatory: it is what lets the planner seek the leading index
141
+ * column instead of scanning the whole index (0.54 ms vs 22.8 ms at 1M rows).
142
+ *
143
+ * Parameters are pushed in the exact textual order they appear, so the caller can
144
+ * concatenate this fragment after its filter clauses without renumbering.
145
+ */
146
+ export function sqliteKeysetWhere(order, cursor, params) {
147
+ const first = order[0];
148
+ // A plain Error 500s at the boundary that maps this module's throws to 400 by name.
149
+ if (!first)
150
+ throw new BrowseQueryError("keyset requires at least one ordered key");
151
+ const guard = `${first.column} ${first.dir === "desc" ? "<=" : ">="} ?`;
152
+ params.push(cursor.key[0]);
153
+ const terms = [];
154
+ for (let i = 0; i < order.length; i += 1) {
155
+ const parts = [];
156
+ for (let j = 0; j < i; j += 1) {
157
+ // Narrowed rather than `order[j]?.column`: out of range this throws, where
158
+ // optional chaining would interpolate the literal identifier `undefined`.
159
+ const entry = order[j];
160
+ parts.push(`${entry.column} = ?`);
161
+ params.push(cursor.key[j]);
162
+ }
163
+ const entry = order[i];
164
+ parts.push(`${entry.column} ${entry.dir === "desc" ? "<" : ">"} ?`);
165
+ params.push(cursor.key[i]);
166
+ terms.push(parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`);
167
+ }
168
+ const tail = [];
169
+ for (let j = 0; j < order.length; j += 1) {
170
+ const entry = order[j];
171
+ tail.push(`${entry.column} = ?`);
172
+ params.push(cursor.key[j]);
173
+ }
174
+ tail.push("id > ?");
175
+ params.push(cursor.id);
176
+ terms.push(`(${tail.join(" AND ")})`);
177
+ return `${guard} AND (${terms.join(" OR ")})`;
178
+ }
@@ -0,0 +1,10 @@
1
+ import { type RecallRankingOptions } from "./score.js";
2
+ import type { MemoryStore, VectorRankingOptions } from "./types.js";
3
+ export declare function sqliteMemoryStore(opts: {
4
+ path: string;
5
+ /** Recall ranking tuning; all fields defaulted. See score.ts. */
6
+ recall?: RecallRankingOptions;
7
+ /** Store-level hybrid tuning; used when a query omits `vector`. All fields defaulted. */
8
+ vector?: VectorRankingOptions;
9
+ }): MemoryStore;
10
+ //# sourceMappingURL=sqlite-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-store.d.ts","sourceRoot":"","sources":["../src/sqlite-store.ts"],"names":[],"mappings":"AAeA,OAAO,EAA0B,KAAK,oBAAoB,EAAsB,MAAM,YAAY,CAAA;AAGlG,OAAO,KAAK,EAA6B,WAAW,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AA+I9F,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,IAAI,EAAE,MAAM,CAAA;IACZ,iEAAiE;IACjE,MAAM,CAAC,EAAE,oBAAoB,CAAA;IAC7B,yFAAyF;IACzF,MAAM,CAAC,EAAE,oBAAoB,CAAA;CAC9B,GAAG,WAAW,CAged"}