@betterdb/memory 0.2.0 → 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 +46 -6
- package/package.json +3 -1
- package/scripts/aging-worker.ts +4 -1
- package/scripts/setup-index.ts +10 -3
- 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 +25 -2
- package/src/hooks/pre-tool.ts +10 -10
- package/src/hooks/session-end.ts +4 -2
- package/src/hooks/session-start.ts +22 -10
- package/src/index.ts +318 -21
- package/src/mcp/server.ts +62 -42
- package/src/memory/aging.ts +78 -196
- package/src/memory/recall.ts +169 -0
- package/src/memory/retrieval.ts +73 -70
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
|
+
}
|