@betterdb/memory 0.1.2 → 0.4.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.
@@ -0,0 +1,169 @@
1
+ import { config } from "../config.js";
2
+ import type { PluginMemoryStore, ScoredMemory } from "../client/memory-store.js";
3
+
4
+ // Over-fetch → gate → narrow, mirroring the LongMemEval harness. Dense recall
5
+ // is already ~95%; the gain is a real candidate set plus an honest gate, so
6
+ // "found nothing" means "nothing cleared the gate" rather than an empty KNN.
7
+ //
8
+ // The gate is RELATIVE, not an absolute similarity threshold. Embed models
9
+ // compress cosine similarity into different, narrow bands (mxbai-embed-large
10
+ // packs everything into ~0.7–0.88; all-MiniLM differs), so a fixed tau doesn't
11
+ // transfer across models. Instead: loosen the store's own distance gate to a
12
+ // generous floor, drop genuine noise below that floor, then keep only the hits
13
+ // within `margin` of the top match. Confidence comes from the top-vs-next gap,
14
+ // which is scale-independent.
15
+
16
+ export interface RecallResult {
17
+ hits: ScoredMemory[];
18
+ scope: "project" | "all";
19
+ /** 1: project+branch (or project) · 2: project · 3: cross-project · 0: nothing. */
20
+ rung: 0 | 1 | 2 | 3;
21
+ confidence: "high" | "low" | "none";
22
+ /**
23
+ * True on a miss when the caller asked to widen (`crossProjectRequested`) but
24
+ * `BETTERDB_ALLOW_CROSS_PROJECT` is off, so the cross-project rung never ran.
25
+ * Lets the formatter say "cross-project is disabled" instead of falsely
26
+ * offering a scope="all" retry the config would also refuse.
27
+ */
28
+ crossProjectBlocked: boolean;
29
+ }
30
+
31
+ /** Scoping for {@link escalatingRecall}. */
32
+ export interface RecallQuery {
33
+ project: string;
34
+ /** Git branch (native thread scope). Rung 1 narrows to it when present. */
35
+ branch?: string;
36
+ /** Content-type filter (e.g. `["decision"]`) applied at every rung. */
37
+ tags?: string[];
38
+ /**
39
+ * Whether the caller wants to widen past the project (user consent / an
40
+ * explicit scope="all"). The cross-project rung *also* requires
41
+ * `BETTERDB_ALLOW_CROSS_PROJECT`; when requested but globally disabled the
42
+ * result is flagged {@link RecallResult.crossProjectBlocked}.
43
+ */
44
+ crossProjectRequested: boolean;
45
+ }
46
+
47
+ interface Gated {
48
+ hits: ScoredMemory[];
49
+ confidence: "high" | "low" | "none";
50
+ }
51
+
52
+ /** Keep hits within `margin` of the top match above `floor`; grade by gap. */
53
+ function gate(pool: ScoredMemory[]): Gated {
54
+ const { floor, margin, separation } = config.recall;
55
+ const eligible = pool
56
+ .filter((h) => h.relevance >= floor)
57
+ .sort((a, b) => b.relevance - a.relevance);
58
+ if (eligible.length === 0) return { hits: [], confidence: "none" };
59
+
60
+ const top = eligible[0]!.relevance;
61
+ const hits = eligible.filter((h) => h.relevance >= top - margin);
62
+ const second = eligible[1]?.relevance ?? -Infinity;
63
+ // A clear peak above the rest → confident; a bunched cluster (e.g. many
64
+ // near-duplicate file-history entries) → low, honestly.
65
+ const confidence =
66
+ eligible.length === 1 || top - second >= separation ? "high" : "low";
67
+ return { hits, confidence };
68
+ }
69
+
70
+ /** Distance gate to hand the store so its strict default (0.25) doesn't
71
+ * pre-filter everything: similarity `floor` ↔ distance `2·(1 − floor)`. */
72
+ function storeThreshold(): number {
73
+ return 2 * (1 - config.recall.floor);
74
+ }
75
+
76
+ /**
77
+ * Escalating recall, narrow → wide:
78
+ * rung 1 — project + `branch` (when given), pool `poolK`. Same project and
79
+ * branch is the most relevant scope; without a branch this is just
80
+ * project scope.
81
+ * rung 2 — project, any branch, wider pool `poolKWide`.
82
+ * rung 3 — cross-project probe. Only when the caller requested widening AND
83
+ * `BETTERDB_ALLOW_CROSS_PROJECT` is on, since another project's
84
+ * memory is often noise or privacy-sensitive.
85
+ * Every rung is a speculative over-fetch, so all recalls set `reinforce:false`:
86
+ * the store reinforces its whole returned pool, but the gate then drops most of
87
+ * it, so reinforcing pre-gate would bump access counts on candidates the user
88
+ * never sees (and on entire pools of a miss), skewing composite ranking.
89
+ * A `tags` filter, when present, applies at every rung. Stops at the first rung
90
+ * that yields gated hits.
91
+ */
92
+ export async function escalatingRecall(
93
+ store: PluginMemoryStore,
94
+ query: string,
95
+ q: RecallQuery,
96
+ ): Promise<RecallResult> {
97
+ const { poolK, poolKWide } = config.recall;
98
+ const threshold = storeThreshold();
99
+ const { project, branch, tags, crossProjectRequested } = q;
100
+ const crossProjectEnabled =
101
+ crossProjectRequested && config.recall.allowCrossProject;
102
+
103
+ // rung 1 — project + branch (most specific).
104
+ let pool = await store.recall(query, {
105
+ project,
106
+ ...(branch !== undefined ? { branch } : {}),
107
+ tags,
108
+ k: poolK,
109
+ threshold,
110
+ reinforce: false,
111
+ });
112
+ let g = gate(pool);
113
+ if (g.hits.length > 0) {
114
+ return {
115
+ hits: g.hits,
116
+ scope: "project",
117
+ rung: 1,
118
+ confidence: g.confidence,
119
+ crossProjectBlocked: false,
120
+ };
121
+ }
122
+
123
+ // rung 2 — project, any branch, wider pool.
124
+ pool = await store.recall(query, {
125
+ project,
126
+ tags,
127
+ k: poolKWide,
128
+ threshold,
129
+ reinforce: false,
130
+ });
131
+ g = gate(pool);
132
+ if (g.hits.length > 0) {
133
+ return {
134
+ hits: g.hits,
135
+ scope: "project",
136
+ rung: 2,
137
+ confidence: g.confidence,
138
+ crossProjectBlocked: false,
139
+ };
140
+ }
141
+
142
+ // rung 3 — cross-project probe.
143
+ if (crossProjectEnabled) {
144
+ pool = await store.recall(query, {
145
+ tags,
146
+ k: poolKWide,
147
+ threshold,
148
+ reinforce: false,
149
+ });
150
+ g = gate(pool);
151
+ if (g.hits.length > 0) {
152
+ return {
153
+ hits: g.hits,
154
+ scope: "all",
155
+ rung: 3,
156
+ confidence: g.confidence,
157
+ crossProjectBlocked: false,
158
+ };
159
+ }
160
+ }
161
+
162
+ return {
163
+ hits: [],
164
+ scope: crossProjectEnabled ? "all" : "project",
165
+ rung: 0,
166
+ confidence: "none",
167
+ crossProjectBlocked: crossProjectRequested && !config.recall.allowCrossProject,
168
+ };
169
+ }
@@ -1,75 +1,9 @@
1
- import { config } from "../config.js";
2
- import type { ModelClient } from "../client/model.js";
3
- import type { ValkeyClient } from "../client/valkey.js";
4
1
  import type { EpisodicMemory } from "./schema.js";
5
- import { AgingPipeline } from "./aging.js";
2
+ import type { RecallResult } from "./recall.js";
6
3
 
7
- // --- Memory Retriever ---
8
-
9
- export class MemoryRetriever {
10
- private valkeyClient: ValkeyClient;
11
- private modelClient: ModelClient;
12
- private agingPipeline: AgingPipeline;
13
-
14
- constructor(valkeyClient: ValkeyClient, modelClient: ModelClient) {
15
- this.valkeyClient = valkeyClient;
16
- this.modelClient = modelClient;
17
- this.agingPipeline = new AgingPipeline(valkeyClient, modelClient);
18
- }
19
-
20
- async retrieve(
21
- queryContext: string,
22
- project: string,
23
- ): Promise<EpisodicMemory[]> {
24
- await this.maybeRunAging(project);
25
-
26
- const embedding = await this.modelClient.embed(queryContext);
27
-
28
- const topK = config.memory.maxContextMemories * 2;
29
- const candidates = await this.valkeyClient.searchMemories(
30
- embedding,
31
- project,
32
- topK,
33
- );
34
-
35
- const now = Date.now();
36
- const scored = candidates
37
- .filter((m) => m.importanceScore >= 0.1)
38
- .map((m) => {
39
- const daysSince =
40
- (now - new Date(m.lastAccessed).getTime()) / (1000 * 60 * 60 * 24);
41
- const recencyFactor = Math.pow(
42
- config.memory.decayRate,
43
- Math.max(daysSince, 0),
44
- );
45
- return {
46
- memory: m,
47
- score: m.importanceScore * recencyFactor,
48
- };
49
- })
50
- .sort((a, b) => b.score - a.score)
51
- .slice(0, config.memory.maxContextMemories);
52
-
53
- // Fire-and-forget access increments
54
- for (const { memory } of scored) {
55
- this.valkeyClient.incrementAccess(memory.memoryId).catch(() => {});
56
- }
57
-
58
- return scored.map((s) => s.memory);
59
- }
60
-
61
- async maybeRunAging(project: string): Promise<void> {
62
- const lastRun = await this.valkeyClient.getLastAgingRun();
63
- const hoursAgo = lastRun
64
- ? (Date.now() - lastRun.getTime()) / (1000 * 60 * 60)
65
- : Infinity;
66
-
67
- if (hoursAgo >= config.memory.agingIntervalHours) {
68
- await this.agingPipeline.runDecay(project);
69
- await this.valkeyClient.setLastAgingRun(new Date());
70
- }
71
- }
72
- }
4
+ // Recall (KNN + composite recency/importance scoring + access reinforcement)
5
+ // now lives in @betterdb/agent-memory's MemoryStore, reached via
6
+ // PluginMemoryStore.recall. This module keeps only the formatters.
73
7
 
74
8
  // --- Format for Injection ---
75
9
 
@@ -89,6 +23,9 @@ export function formatForInjection(memories: EpisodicMemory[]): string {
89
23
  for (const d of m.summary.decisions) {
90
24
  sections.push(` - Decision: ${d}`);
91
25
  }
26
+ for (const pat of m.summary.patterns) {
27
+ sections.push(` - Pattern: ${pat}`);
28
+ }
92
29
  for (const p of m.summary.problemsSolved) {
93
30
  sections.push(` - Solved: ${p.problem} → ${p.resolution}`);
94
31
  }
@@ -112,3 +49,69 @@ export function formatForInjection(memories: EpisodicMemory[]): string {
112
49
 
113
50
  return sections.join("\n");
114
51
  }
52
+
53
+ // --- Format search_context result (reader contract) ---
54
+
55
+ function detailLines(m: EpisodicMemory): string[] {
56
+ const lines: string[] = [];
57
+ for (const d of m.summary.decisions) lines.push(` - Decision: ${d}`);
58
+ for (const pat of m.summary.patterns) lines.push(` - Pattern: ${pat}`);
59
+ for (const p of m.summary.problemsSolved) {
60
+ lines.push(` - Solved: ${p.problem} → ${p.resolution}`);
61
+ }
62
+ for (const t of m.summary.openThreads) lines.push(` - Open: ${t}`);
63
+ return lines;
64
+ }
65
+
66
+ /**
67
+ * Format an escalating-recall result for the search_context tool. The output
68
+ * is self-instructing: on a miss it tells the model to be honest and not
69
+ * fabricate (mirroring the LongMemEval reader prompt); on a hit it tells the
70
+ * model to answer only from the excerpts. `topK` caps how many hits are shown.
71
+ */
72
+ export function formatSearchResult(
73
+ query: string,
74
+ result: RecallResult,
75
+ topK: number,
76
+ ): string {
77
+ if (result.hits.length === 0) {
78
+ const searched =
79
+ result.scope === "all"
80
+ ? "this project AND all other projects"
81
+ : "this project";
82
+ // Cross-project was asked for but is disabled by config — don't offer a
83
+ // scope="all" retry the config would also refuse; say so plainly instead.
84
+ const offer = result.crossProjectBlocked
85
+ ? ` Cross-project search is disabled by configuration (BETTERDB_ALLOW_CROSS_PROJECT=false), so widening is not available.`
86
+ : result.scope === "project"
87
+ ? ` You may offer to search across ALL projects — call search_context again with scope="all".`
88
+ : "";
89
+ return [
90
+ `# Memory search: "${query}"`,
91
+ `Searched: ${searched}.`,
92
+ `NO memories cleared the relevance threshold.`,
93
+ `Tell the user you found nothing in memory about this. Do NOT fabricate an ` +
94
+ `answer, and do NOT substitute a codebase search as if it were recall.${offer}`,
95
+ ].join("\n");
96
+ }
97
+
98
+ const shown = result.hits.slice(0, topK);
99
+ const lines: string[] = [
100
+ `# Memory search: "${query}"`,
101
+ `Scope: ${result.scope} · confidence: ${result.confidence} · ${shown.length} match(es)`,
102
+ ``,
103
+ ];
104
+ shown.forEach((h, i) => {
105
+ const date = h.memory.timestamp.split("T")[0];
106
+ lines.push(
107
+ `[${i + 1}] (rel ${h.relevance.toFixed(2)}, ${date}) ${h.memory.summary.oneLineSummary}`,
108
+ );
109
+ lines.push(...detailLines(h.memory));
110
+ });
111
+ lines.push(``);
112
+ lines.push(
113
+ `Answer the user ONLY from these excerpts. If they do not contain the answer, ` +
114
+ `say so plainly — do not invent.`,
115
+ );
116
+ return lines.join("\n");
117
+ }