@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.
- package/README.md +101 -10
- package/package.json +3 -1
- package/scripts/aging-worker.ts +4 -1
- package/scripts/docker-valkey.sh +101 -0
- package/scripts/register-hooks.ts +94 -0
- package/scripts/setup-index.ts +10 -3
- package/scripts/unregister-hooks.ts +79 -0
- package/src/client/memory-store.ts +406 -0
- package/src/client/model.ts +10 -10
- package/src/client/providers/local.ts +58 -0
- package/src/client/valkey.ts +9 -0
- package/src/config.ts +38 -6
- package/src/hooks/post-tool.ts +2 -0
- package/src/hooks/pre-tool.ts +12 -11
- package/src/hooks/session-end.ts +14 -4
- package/src/hooks/session-start.ts +33 -8
- package/src/index.ts +379 -21
- package/src/mcp/server.ts +82 -42
- package/src/memory/aging.ts +78 -196
- package/src/memory/recall.ts +169 -0
- package/src/memory/retrieval.ts +73 -70
|
@@ -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
|
+
}
|
package/src/memory/retrieval.ts
CHANGED
|
@@ -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 {
|
|
2
|
+
import type { RecallResult } from "./recall.js";
|
|
6
3
|
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+
}
|