@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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/browse-budget.d.ts +40 -0
- package/dist/browse-budget.d.ts.map +1 -0
- package/dist/browse-budget.js +57 -0
- package/dist/browse-cursor.d.ts +22 -0
- package/dist/browse-cursor.d.ts.map +1 -0
- package/dist/browse-cursor.js +146 -0
- package/dist/browse-filter.d.ts +13 -0
- package/dist/browse-filter.d.ts.map +1 -0
- package/dist/browse-filter.js +16 -0
- package/dist/browse-order.d.ts +28 -0
- package/dist/browse-order.d.ts.map +1 -0
- package/dist/browse-order.js +37 -0
- package/dist/browse-range.d.ts +16 -0
- package/dist/browse-range.d.ts.map +1 -0
- package/dist/browse-range.js +52 -0
- package/dist/browse-validate.d.ts +31 -0
- package/dist/browse-validate.d.ts.map +1 -0
- package/dist/browse-validate.js +263 -0
- package/dist/browse.d.ts +15 -0
- package/dist/browse.d.ts.map +1 -0
- package/dist/browse.js +5 -0
- package/dist/distill.d.ts +81 -0
- package/dist/distill.d.ts.map +1 -0
- package/dist/distill.js +380 -0
- package/dist/hybrid.d.ts +26 -0
- package/dist/hybrid.d.ts.map +1 -0
- package/dist/hybrid.js +86 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/namespace.d.ts +18 -0
- package/dist/namespace.d.ts.map +1 -0
- package/dist/namespace.js +68 -0
- package/dist/reconcile.d.ts +51 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +110 -0
- package/dist/score.d.ts +54 -0
- package/dist/score.d.ts.map +1 -0
- package/dist/score.js +66 -0
- package/dist/sqlite-browse-sql.d.ts +30 -0
- package/dist/sqlite-browse-sql.d.ts.map +1 -0
- package/dist/sqlite-browse-sql.js +178 -0
- package/dist/sqlite-store.d.ts +10 -0
- package/dist/sqlite-store.d.ts.map +1 -0
- package/dist/sqlite-store.js +521 -0
- package/dist/tokenize.d.ts +3 -0
- package/dist/tokenize.d.ts.map +1 -0
- package/dist/tokenize.js +14 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +201 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/vector.d.ts +22 -0
- package/dist/vector.d.ts.map +1 -0
- package/dist/vector.js +42 -0
- 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
|
+
}
|
package/dist/score.d.ts
ADDED
|
@@ -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"}
|