@betterdb/memory 0.2.0 → 0.4.1
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 +26 -12
- 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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { SessionSummary } from "../../memory/schema.js";
|
|
2
|
+
import type { ModelClient, ModelPreset } from "../model.js";
|
|
3
|
+
|
|
4
|
+
// On-device embeddings via @xenova/transformers — no API key, no running
|
|
5
|
+
// service. Weights (all-MiniLM-L6-v2, Apache-2.0, 384-dim) download once on
|
|
6
|
+
// first use and are cached under the transformers cache dir thereafter.
|
|
7
|
+
|
|
8
|
+
const MODEL_ID = "Xenova/all-MiniLM-L6-v2";
|
|
9
|
+
const EMBED_DIM = 384;
|
|
10
|
+
|
|
11
|
+
type FeatureExtractor = (
|
|
12
|
+
text: string,
|
|
13
|
+
options: { pooling: "mean"; normalize: boolean },
|
|
14
|
+
) => Promise<{ data: Float32Array }>;
|
|
15
|
+
|
|
16
|
+
interface TransformersModule {
|
|
17
|
+
pipeline(
|
|
18
|
+
task: "feature-extraction",
|
|
19
|
+
model: string,
|
|
20
|
+
): Promise<FeatureExtractor>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Lazy singleton: the model loads once and is reused across embed calls, and
|
|
24
|
+
// @xenova/transformers is only imported when local embeddings are actually used.
|
|
25
|
+
let extractorPromise: Promise<FeatureExtractor> | null = null;
|
|
26
|
+
|
|
27
|
+
function getExtractor(): Promise<FeatureExtractor> {
|
|
28
|
+
if (!extractorPromise) {
|
|
29
|
+
extractorPromise = import("@xenova/transformers").then((mod) =>
|
|
30
|
+
(mod as unknown as TransformersModule).pipeline(
|
|
31
|
+
"feature-extraction",
|
|
32
|
+
MODEL_ID,
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return extractorPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class LocalEmbedClient implements ModelClient {
|
|
40
|
+
readonly embedDim = EMBED_DIM;
|
|
41
|
+
readonly preset: ModelPreset = {
|
|
42
|
+
embedModel: MODEL_ID,
|
|
43
|
+
summarizeModel: "n/a",
|
|
44
|
+
embedDim: EMBED_DIM,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
async embed(text: string): Promise<number[]> {
|
|
48
|
+
const extract = await getExtractor();
|
|
49
|
+
const output = await extract(text, { pooling: "mean", normalize: true });
|
|
50
|
+
return Array.from(output.data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async summarize(_transcript: string): Promise<SessionSummary> {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"Local embeddings provider does not summarize — configure a summarize provider (Ollama, Anthropic, OpenAI, Groq, or Together)",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/client/valkey.ts
CHANGED
|
@@ -31,6 +31,15 @@ export class ValkeyClient {
|
|
|
31
31
|
this.client = client;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The underlying iovalkey connection. Exposed so the episodic-vector path
|
|
36
|
+
* (PluginMemoryStore) can share this single connection instead of opening a
|
|
37
|
+
* second one — its `.call()` satisfies MemoryStoreClient.
|
|
38
|
+
*/
|
|
39
|
+
get redis(): Redis {
|
|
40
|
+
return this.client;
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
// --- Index Management ---
|
|
35
44
|
|
|
36
45
|
async assertEmbedDim(expectedDim: number, providerLabel?: string): Promise<void> {
|
package/src/config.ts
CHANGED
|
@@ -46,16 +46,39 @@ export const config = {
|
|
|
46
46
|
},
|
|
47
47
|
memory: {
|
|
48
48
|
maxContextMemories: Number(env("BETTERDB_MAX_CONTEXT_MEMORIES") ?? 5),
|
|
49
|
-
decayRate: Number(env("BETTERDB_DECAY_RATE") ?? 0.95),
|
|
50
49
|
compressThreshold: Number(env("BETTERDB_COMPRESS_THRESHOLD") ?? 0.3),
|
|
51
50
|
distillMinSessions: Number(env("BETTERDB_DISTILL_MIN_SESSIONS") ?? 5),
|
|
52
51
|
contextFile: env("BETTERDB_CONTEXT_FILE") ?? ".betterdb_context.md",
|
|
53
52
|
agingIntervalHours: Number(env("BETTERDB_AGING_INTERVAL_HOURS") ?? 6),
|
|
54
53
|
},
|
|
54
|
+
recall: {
|
|
55
|
+
// Relative gate — model-agnostic (embed models compress cosine similarity
|
|
56
|
+
// into different bands, so absolute thresholds don't transfer). `floor`
|
|
57
|
+
// drops genuine noise and loosens the store's own distance gate; `margin`
|
|
58
|
+
// keeps hits within that similarity of the top match; `separation` is the
|
|
59
|
+
// top-vs-next gap above which a result is "high" confidence.
|
|
60
|
+
floor: Number(env("BETTERDB_RECALL_FLOOR") ?? 0.5),
|
|
61
|
+
margin: Number(env("BETTERDB_RECALL_MARGIN") ?? 0.05),
|
|
62
|
+
separation: Number(env("BETTERDB_RECALL_SEPARATION") ?? 0.04),
|
|
63
|
+
// Over-fetch pool sizes: rung-1 (project) and rung-2/3 (wider / cross).
|
|
64
|
+
poolK: Number(env("BETTERDB_RECALL_POOL_K") ?? 10),
|
|
65
|
+
poolKWide: Number(env("BETTERDB_RECALL_POOL_K_WIDE") ?? 20),
|
|
66
|
+
// Allow the ladder / search_context to fall back to cross-project scope.
|
|
67
|
+
allowCrossProject: env("BETTERDB_ALLOW_CROSS_PROJECT") !== "false",
|
|
68
|
+
// Composite recall scoring, owned by @betterdb/agent-memory: a weighted
|
|
69
|
+
// blend of semantic similarity, recency (half-life decay), and importance.
|
|
70
|
+
// Recency is the ONE time-decay in the system — it replaces the old, unused
|
|
71
|
+
// per-day `decayRate`. `halfLifeDays` is the age at which a memory's recency
|
|
72
|
+
// term halves; weights (defaults match the store's) blend the three terms.
|
|
73
|
+
halfLifeDays: Number(env("BETTERDB_RECALL_HALF_LIFE_DAYS") ?? 7),
|
|
74
|
+
weightSimilarity: Number(env("BETTERDB_RECALL_WEIGHT_SIMILARITY") ?? 0.6),
|
|
75
|
+
weightRecency: Number(env("BETTERDB_RECALL_WEIGHT_RECENCY") ?? 0.25),
|
|
76
|
+
weightImportance: Number(env("BETTERDB_RECALL_WEIGHT_IMPORTANCE") ?? 0.15),
|
|
77
|
+
},
|
|
55
78
|
allowRemoteFallback: env("BETTERDB_ALLOW_REMOTE_FALLBACK") !== "false",
|
|
56
79
|
providers: {
|
|
57
80
|
embedProvider: env("BETTERDB_EMBED_PROVIDER") as
|
|
58
|
-
| "ollama" | "openai" | "voyage" | "groq" | "together"
|
|
81
|
+
| "local" | "ollama" | "openai" | "voyage" | "groq" | "together"
|
|
59
82
|
| undefined,
|
|
60
83
|
summarizeProvider: env("BETTERDB_SUMMARIZE_PROVIDER") as
|
|
61
84
|
| "ollama" | "openai" | "anthropic" | "groq" | "together"
|
package/src/hooks/pre-tool.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readRawPayload, runHook } from "./_utils.js";
|
|
2
|
-
import {
|
|
2
|
+
import { getPluginMemoryStore } from "../client/memory-store.js";
|
|
3
|
+
import { getCwdProject } from "../memory/capture.js";
|
|
3
4
|
import { config, isConfigured } from "../config.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -23,21 +24,20 @@ runHook(async () => {
|
|
|
23
24
|
|
|
24
25
|
if (!filePath) return;
|
|
25
26
|
|
|
26
|
-
let
|
|
27
|
+
let store;
|
|
27
28
|
try {
|
|
28
|
-
|
|
29
|
+
store = await getPluginMemoryStore();
|
|
29
30
|
} catch {
|
|
30
31
|
return; // Valkey unavailable — skip silently
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
// Scan
|
|
34
|
-
|
|
34
|
+
// Scan the current project's recent memories for ones that reference this
|
|
35
|
+
// file. Scope to the project and cap at 50 so this stays cheap on every tool
|
|
36
|
+
// call instead of materializing the whole store.
|
|
37
|
+
const memories = await store.listMemories(getCwdProject(), undefined, 50);
|
|
35
38
|
const relevantNotes: string[] = [];
|
|
36
39
|
|
|
37
|
-
for (const
|
|
38
|
-
const memory = await valkeyClient.getMemory(id);
|
|
39
|
-
if (!memory) continue;
|
|
40
|
-
|
|
40
|
+
for (const memory of memories) {
|
|
41
41
|
if (memory.summary.filesChanged.some((f) => f.includes(filePath) || filePath.includes(f))) {
|
|
42
42
|
relevantNotes.push(
|
|
43
43
|
`- ${memory.summary.oneLineSummary} (${memory.timestamp.split("T")[0]})`,
|
|
@@ -56,5 +56,5 @@ runHook(async () => {
|
|
|
56
56
|
await Bun.write(config.memory.contextFile, existing + note);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
await
|
|
59
|
+
await store.close();
|
|
60
60
|
});
|
package/src/hooks/session-end.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readRawPayload, runHook } from "./_utils.js";
|
|
2
2
|
import { getValkeyClient } from "../client/valkey.js";
|
|
3
|
+
import { getPluginMemoryStore } from "../client/memory-store.js";
|
|
3
4
|
import { createModelClient } from "../client/model.js";
|
|
4
5
|
import {
|
|
5
6
|
SessionCapture,
|
|
@@ -110,7 +111,6 @@ runHook(async () => {
|
|
|
110
111
|
|
|
111
112
|
const summary = await modelClient.summarize(transcript);
|
|
112
113
|
const importance = computeInitialImportance(summary);
|
|
113
|
-
const embedding = await modelClient.embed(summary.oneLineSummary);
|
|
114
114
|
|
|
115
115
|
const memory: EpisodicMemory = {
|
|
116
116
|
memoryId: crypto.randomUUID(),
|
|
@@ -123,7 +123,9 @@ runHook(async () => {
|
|
|
123
123
|
lastAccessed: new Date().toISOString(),
|
|
124
124
|
};
|
|
125
125
|
|
|
126
|
-
await
|
|
126
|
+
const store = await getPluginMemoryStore((t) => modelClient.embed(t));
|
|
127
|
+
await store.storeMemory(memory);
|
|
128
|
+
await store.close();
|
|
127
129
|
await valkeyClient.quit();
|
|
128
130
|
await cleanup(eventFilePath);
|
|
129
131
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readRawPayload, runHook } from "./_utils.js";
|
|
2
|
-
import {
|
|
2
|
+
import { getPluginMemoryStore } from "../client/memory-store.js";
|
|
3
3
|
import { createModelClient } from "../client/model.js";
|
|
4
|
-
import { SessionCapture } from "../memory/capture.js";
|
|
5
|
-
import {
|
|
4
|
+
import { SessionCapture, getGitBranch } from "../memory/capture.js";
|
|
5
|
+
import { formatForInjection } from "../memory/retrieval.js";
|
|
6
|
+
import { escalatingRecall } from "../memory/recall.js";
|
|
6
7
|
import { config, isConfigured } from "../config.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -28,21 +29,32 @@ runHook(async () => {
|
|
|
28
29
|
process.chdir(cwd);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
const modelClient = await createModelClient();
|
|
33
|
+
|
|
34
|
+
let store;
|
|
32
35
|
try {
|
|
33
|
-
|
|
36
|
+
store = await getPluginMemoryStore((t) => modelClient.embed(t));
|
|
34
37
|
} catch {
|
|
35
38
|
return; // Valkey unreachable — skip silently
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
const modelClient = await createModelClient();
|
|
39
|
-
|
|
40
41
|
const capture = new SessionCapture();
|
|
41
42
|
const queryContext = await capture.getQueryContext();
|
|
42
43
|
|
|
43
|
-
const retriever = new MemoryRetriever(valkeyClient, modelClient);
|
|
44
44
|
const project = queryContext.split("\n")[0]?.replace("Project: ", "") ?? "unknown";
|
|
45
|
-
const
|
|
45
|
+
const branch = getGitBranch();
|
|
46
|
+
// Project+branch-scoped, threshold-gated recall (no cross-project auto-inject
|
|
47
|
+
// at startup — nothing to consent to yet). Only memories clearing the
|
|
48
|
+
// relevance bar are injected, so we stop padding context with irrelevant
|
|
49
|
+
// top-N filler.
|
|
50
|
+
const result = await escalatingRecall(store, queryContext, {
|
|
51
|
+
project,
|
|
52
|
+
...(branch !== "unknown" ? { branch } : {}),
|
|
53
|
+
crossProjectRequested: false,
|
|
54
|
+
});
|
|
55
|
+
const memories = result.hits
|
|
56
|
+
.slice(0, config.memory.maxContextMemories)
|
|
57
|
+
.map((h) => h.memory);
|
|
46
58
|
|
|
47
59
|
if (memories.length > 0) {
|
|
48
60
|
const formatted = formatForInjection(memories);
|
|
@@ -52,5 +64,5 @@ runHook(async () => {
|
|
|
52
64
|
process.stdout.write(formatted);
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
await
|
|
67
|
+
await store.close();
|
|
56
68
|
});
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from "node:fs";
|
|
14
14
|
import { join, resolve } from "node:path";
|
|
15
15
|
|
|
16
|
-
const VERSION = "0.1
|
|
16
|
+
const VERSION = "0.4.1";
|
|
17
17
|
const HOME = process.env["HOME"] ?? process.env["USERPROFILE"] ?? "";
|
|
18
18
|
const BETTERDB_DIR = join(HOME, ".betterdb");
|
|
19
19
|
const BIN_DIR = join(BETTERDB_DIR, "bin");
|
|
@@ -36,12 +36,19 @@ Usage:
|
|
|
36
36
|
betterdb-memory <command>
|
|
37
37
|
|
|
38
38
|
Commands:
|
|
39
|
-
install
|
|
40
|
-
uninstall
|
|
41
|
-
status
|
|
42
|
-
maintain
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
install Compile binaries, register hooks + MCP server
|
|
40
|
+
uninstall Remove hooks, MCP server, and compiled binaries
|
|
41
|
+
status Check health of Valkey and model providers
|
|
42
|
+
maintain Run aging/consolidation pipeline manually
|
|
43
|
+
forget Bulk-delete memories by scope (dry run; pass --apply)
|
|
44
|
+
Flags: --project <name> (default: cwd) | --all-projects
|
|
45
|
+
--branch <name> --tags <a,b> --apply
|
|
46
|
+
migrate Move legacy betterdb:memory:* memories into the MemoryStore
|
|
47
|
+
(dry run; pass --apply to perform)
|
|
48
|
+
ingest-claude-md Ingest a CLAUDE.md / MEMORY.md file into the store [path]
|
|
49
|
+
setup-index Create the episodic vector index (recovery after install)
|
|
50
|
+
docker-valkey Manage Docker Valkey container [start|stop|status|remove]
|
|
51
|
+
version Print version
|
|
45
52
|
|
|
46
53
|
Environment:
|
|
47
54
|
BETTERDB_VALKEY_URL Valkey connection (default: redis://localhost:6379)
|
|
@@ -64,6 +71,18 @@ switch (command) {
|
|
|
64
71
|
case "maintain":
|
|
65
72
|
await runMaintain();
|
|
66
73
|
break;
|
|
74
|
+
case "forget":
|
|
75
|
+
await runForget(process.argv.slice(3));
|
|
76
|
+
break;
|
|
77
|
+
case "migrate":
|
|
78
|
+
await runMigrate(process.argv.includes("--apply"));
|
|
79
|
+
break;
|
|
80
|
+
case "ingest-claude-md":
|
|
81
|
+
await runIngestClaudeMd(process.argv[3]);
|
|
82
|
+
break;
|
|
83
|
+
case "setup-index":
|
|
84
|
+
await runSetupIndex();
|
|
85
|
+
break;
|
|
67
86
|
case "docker-valkey": {
|
|
68
87
|
const action = process.argv[3] ?? "start";
|
|
69
88
|
const port = process.argv[4] ?? "6379";
|
|
@@ -215,10 +234,16 @@ async function runInstall() {
|
|
|
215
234
|
console.log("\nSetting up Valkey index...");
|
|
216
235
|
try {
|
|
217
236
|
const { getValkeyClient } = await import("./client/valkey.js");
|
|
218
|
-
const
|
|
237
|
+
const { getPluginMemoryStore } = await import("./client/memory-store.js");
|
|
238
|
+
const { createModelClient } = await import("./client/model.js");
|
|
219
239
|
const client = await getValkeyClient();
|
|
220
|
-
await
|
|
240
|
+
const modelClient = await createModelClient();
|
|
241
|
+
// Record the active provider/dimension so a later provider swap is caught.
|
|
242
|
+
await client.assertEmbedDim(modelClient.embedDim, modelClient.preset.embedModel);
|
|
243
|
+
const store = await getPluginMemoryStore((t) => modelClient.embed(t));
|
|
244
|
+
await store.ensureIndex();
|
|
221
245
|
console.log(" Valkey index ready");
|
|
246
|
+
await store.close();
|
|
222
247
|
await client.quit();
|
|
223
248
|
} catch (err) {
|
|
224
249
|
console.log(` WARNING: Index setup failed (${err instanceof Error ? err.message : String(err)})`);
|
|
@@ -331,9 +356,19 @@ async function runStatus() {
|
|
|
331
356
|
try {
|
|
332
357
|
const { config } = await import("./config.js");
|
|
333
358
|
const { getValkeyClient } = await import("./client/valkey.js");
|
|
359
|
+
const { getPluginMemoryStore } = await import("./client/memory-store.js");
|
|
334
360
|
const client = await getValkeyClient();
|
|
335
|
-
const
|
|
336
|
-
|
|
361
|
+
const store = await getPluginMemoryStore();
|
|
362
|
+
const stats = await store.stats();
|
|
363
|
+
console.log(`OK (${stats.itemCount} memories, ${config.valkey.url})`);
|
|
364
|
+
const w = stats.config.weights;
|
|
365
|
+
const halfLifeDays = Math.round(stats.config.halfLifeSeconds / 86400);
|
|
366
|
+
console.log(
|
|
367
|
+
` Recall scoring: half-life ${halfLifeDays}d · ` +
|
|
368
|
+
`weights sim/rec/imp ${w.similarity}/${w.recency}/${w.importance}` +
|
|
369
|
+
(stats.evictions > 0 ? ` · ${stats.evictions} evictions` : ""),
|
|
370
|
+
);
|
|
371
|
+
await store.close();
|
|
337
372
|
await client.quit();
|
|
338
373
|
} catch (err) {
|
|
339
374
|
console.log(`FAILED (${err instanceof Error ? err.message : String(err)})`);
|
|
@@ -418,30 +453,292 @@ async function runMaintain() {
|
|
|
418
453
|
console.log("BetterDB Memory for Claude Code — Maintenance\n");
|
|
419
454
|
|
|
420
455
|
const { getValkeyClient } = await import("./client/valkey.js");
|
|
456
|
+
const { getPluginMemoryStore } = await import("./client/memory-store.js");
|
|
421
457
|
const { createModelClient } = await import("./client/model.js");
|
|
422
458
|
const { AgingPipeline } = await import("./memory/aging.js");
|
|
423
459
|
|
|
424
460
|
const valkeyClient = await getValkeyClient();
|
|
425
461
|
const modelClient = await createModelClient();
|
|
426
|
-
const
|
|
462
|
+
const store = await getPluginMemoryStore((t) => modelClient.embed(t));
|
|
463
|
+
const pipeline = new AgingPipeline(valkeyClient, store, modelClient);
|
|
464
|
+
|
|
465
|
+
const memories = await store.listMemories();
|
|
466
|
+
console.log(`Total memories: ${memories.length}`);
|
|
467
|
+
|
|
468
|
+
await pipeline.runFullPipeline();
|
|
469
|
+
|
|
470
|
+
console.log("\nAging pipeline complete.");
|
|
471
|
+
await store.close();
|
|
472
|
+
await valkeyClient.quit();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// forget (bulk delete by scope: project / branch / tags)
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
async function runForget(argv: string[]) {
|
|
480
|
+
console.log("BetterDB Memory for Claude Code — Forget by scope\n");
|
|
481
|
+
|
|
482
|
+
const flag = (name: string): string | undefined => {
|
|
483
|
+
const i = argv.indexOf(`--${name}`);
|
|
484
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
485
|
+
};
|
|
486
|
+
const apply = argv.includes("--apply");
|
|
487
|
+
const allProjects = argv.includes("--all-projects");
|
|
488
|
+
const branch = flag("branch");
|
|
489
|
+
const tags = flag("tags")?.split(",").map((t) => t.trim()).filter(Boolean);
|
|
490
|
+
|
|
491
|
+
const { getValkeyClient } = await import("./client/valkey.js");
|
|
492
|
+
const { getPluginMemoryStore } = await import("./client/memory-store.js");
|
|
493
|
+
const { getCwdProject } = await import("./memory/capture.js");
|
|
494
|
+
|
|
495
|
+
const project = allProjects ? undefined : (flag("project") ?? getCwdProject());
|
|
496
|
+
|
|
497
|
+
// Refuse an unbounded delete: --all-projects must be narrowed by branch/tags.
|
|
498
|
+
if (project === undefined && branch === undefined && (!tags || tags.length === 0)) {
|
|
499
|
+
console.error("Refusing to delete every memory. Narrow --all-projects with --branch or --tags.");
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const scopeDesc = [
|
|
504
|
+
project !== undefined ? `project=${project}` : "all projects",
|
|
505
|
+
branch !== undefined ? `branch=${branch}` : null,
|
|
506
|
+
tags && tags.length > 0 ? `tags=${tags.join(",")}` : null,
|
|
507
|
+
].filter(Boolean).join(", ");
|
|
508
|
+
console.log(`Scope: ${scopeDesc}`);
|
|
509
|
+
|
|
510
|
+
const valkeyClient = await getValkeyClient();
|
|
511
|
+
const store = await getPluginMemoryStore();
|
|
512
|
+
|
|
513
|
+
const scope = {
|
|
514
|
+
...(project !== undefined ? { project } : {}),
|
|
515
|
+
...(branch !== undefined ? { branch } : {}),
|
|
516
|
+
...(tags && tags.length > 0 ? { tags } : {}),
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// Preview through the SAME native scope filter forgetByScope deletes with, so
|
|
520
|
+
// the dry-run count is exactly what --apply will remove (older memories
|
|
521
|
+
// without native tags are matched identically by both paths).
|
|
522
|
+
const candidates = await store.listByScope(scope);
|
|
523
|
+
|
|
524
|
+
console.log(`Matched ${candidates.length} memories.`);
|
|
525
|
+
for (const m of candidates.slice(0, 5)) {
|
|
526
|
+
console.log(` - [${m.branch}] ${m.summary.oneLineSummary.slice(0, 70)}`);
|
|
527
|
+
}
|
|
528
|
+
if (candidates.length > 5) console.log(` ... and ${candidates.length - 5} more`);
|
|
529
|
+
|
|
530
|
+
if (!apply) {
|
|
531
|
+
console.log("\nDry run — re-run with --apply to delete.");
|
|
532
|
+
await store.close();
|
|
533
|
+
await valkeyClient.quit();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const deleted = await store.forgetByScope(scope);
|
|
538
|
+
console.log(`\nDeleted ${deleted} memories.`);
|
|
539
|
+
|
|
540
|
+
await store.close();
|
|
541
|
+
await valkeyClient.quit();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// setup-index (recovery path: build the MemoryStore episodic vector index)
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
async function runSetupIndex() {
|
|
549
|
+
const { getValkeyClient } = await import("./client/valkey.js");
|
|
550
|
+
const { getPluginMemoryStore } = await import("./client/memory-store.js");
|
|
551
|
+
const { createModelClient } = await import("./client/model.js");
|
|
552
|
+
|
|
553
|
+
const client = await getValkeyClient();
|
|
554
|
+
const modelClient = await createModelClient();
|
|
555
|
+
// Record the active provider/dimension so a later provider swap is caught.
|
|
556
|
+
await client.assertEmbedDim(modelClient.embedDim, modelClient.preset.embedModel);
|
|
557
|
+
const store = await getPluginMemoryStore((t) => modelClient.embed(t));
|
|
558
|
+
await store.ensureIndex();
|
|
559
|
+
console.log("Index ready: betterdb:mem:idx");
|
|
560
|
+
|
|
561
|
+
await store.close();
|
|
562
|
+
await client.quit();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// migrate (legacy betterdb:memory:* -> MemoryStore betterdb:mem:*)
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
async function runMigrate(apply: boolean) {
|
|
570
|
+
console.log("BetterDB Memory for Claude Code — Migrate legacy memories\n");
|
|
571
|
+
|
|
572
|
+
const { getValkeyClient } = await import("./client/valkey.js");
|
|
573
|
+
const { getPluginMemoryStore } = await import("./client/memory-store.js");
|
|
574
|
+
const { createModelClient } = await import("./client/model.js");
|
|
575
|
+
|
|
576
|
+
const valkeyClient = await getValkeyClient();
|
|
577
|
+
const legacyIds = await valkeyClient.listMemoryIds();
|
|
578
|
+
console.log(`Found ${legacyIds.length} legacy memories under betterdb:memory:*`);
|
|
579
|
+
|
|
580
|
+
if (legacyIds.length === 0) {
|
|
581
|
+
console.log("Nothing to migrate.");
|
|
582
|
+
await valkeyClient.quit();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
427
585
|
|
|
428
|
-
|
|
429
|
-
|
|
586
|
+
if (!apply) {
|
|
587
|
+
console.log("\nDry run — re-run with --apply to migrate.");
|
|
588
|
+
console.log("Each legacy memory is re-embedded and written to betterdb:mem:*,");
|
|
589
|
+
console.log("and knowledge entries are re-pointed to the new memory ids.");
|
|
590
|
+
console.log("The legacy index is dropped only after the new count is verified;");
|
|
591
|
+
console.log("legacy hashes are left in place for you to delete once satisfied.");
|
|
592
|
+
await valkeyClient.quit();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
430
595
|
|
|
431
|
-
|
|
596
|
+
const modelClient = await createModelClient();
|
|
597
|
+
const store = await getPluginMemoryStore((t) => modelClient.embed(t));
|
|
598
|
+
await store.ensureIndex();
|
|
599
|
+
|
|
600
|
+
// Baseline so we can verify the store actually grew by the migrated count,
|
|
601
|
+
// not just that its total happens to exceed it (pre-existing memories).
|
|
602
|
+
const beforeCount = (await store.listMemories()).length;
|
|
603
|
+
|
|
604
|
+
let migrated = 0;
|
|
605
|
+
let failed = 0;
|
|
606
|
+
// MemoryStore.remember mints a fresh id, so track legacy -> new so we can
|
|
607
|
+
// re-point knowledge entries that reference the old episodic ids.
|
|
608
|
+
const idMap = new Map<string, string>();
|
|
432
609
|
const projects = new Set<string>();
|
|
433
|
-
for (const id of
|
|
610
|
+
for (const id of legacyIds) {
|
|
434
611
|
const memory = await valkeyClient.getMemory(id);
|
|
435
|
-
if (memory)
|
|
612
|
+
if (!memory) {
|
|
613
|
+
failed++;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const newId = await store.storeMemory(memory);
|
|
618
|
+
idMap.set(id, newId);
|
|
619
|
+
projects.add(memory.project);
|
|
620
|
+
migrated++;
|
|
621
|
+
if (migrated % 10 === 0) {
|
|
622
|
+
console.log(` Migrated ${migrated}/${legacyIds.length}...`);
|
|
623
|
+
}
|
|
624
|
+
} catch (err) {
|
|
625
|
+
console.error(` Failed to migrate ${id}:`, err instanceof Error ? err.message : String(err));
|
|
626
|
+
failed++;
|
|
627
|
+
}
|
|
436
628
|
}
|
|
437
629
|
|
|
630
|
+
// Re-point distilled knowledge so sourceMemoryIds keep referencing real
|
|
631
|
+
// episodic memories under the new ids. storeKnowledge upserts by
|
|
632
|
+
// project:topic, so re-storing overwrites in place.
|
|
633
|
+
let remappedKnowledge = 0;
|
|
438
634
|
for (const project of projects) {
|
|
439
|
-
|
|
440
|
-
|
|
635
|
+
for (const entry of await valkeyClient.listKnowledge(project)) {
|
|
636
|
+
const remapped = entry.sourceMemoryIds.map((sid) => idMap.get(sid) ?? sid);
|
|
637
|
+
if (remapped.some((sid, i) => sid !== entry.sourceMemoryIds[i])) {
|
|
638
|
+
await valkeyClient.storeKnowledge({ ...entry, sourceMemoryIds: remapped });
|
|
639
|
+
remappedKnowledge++;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (remappedKnowledge > 0) {
|
|
644
|
+
console.log(`Re-pointed ${remappedKnowledge} knowledge entries to new memory ids.`);
|
|
441
645
|
}
|
|
442
646
|
|
|
443
|
-
|
|
444
|
-
|
|
647
|
+
// Verify before dropping the legacy index: the store must have grown by the
|
|
648
|
+
// number we successfully migrated (not merely exceed it, which pre-existing
|
|
649
|
+
// memories would satisfy even if rows failed to copy).
|
|
650
|
+
const afterCount = (await store.listMemories()).length;
|
|
651
|
+
const grew = afterCount - beforeCount;
|
|
652
|
+
console.log(`\nMigrated: ${migrated}, failed: ${failed}, store grew by ${grew} (now ${afterCount}).`);
|
|
653
|
+
|
|
654
|
+
if (migrated > 0 && grew >= migrated) {
|
|
655
|
+
await valkeyClient.dropIndex();
|
|
656
|
+
console.log("Verified — dropped the legacy index (betterdb-memory-index).");
|
|
657
|
+
console.log("Legacy hashes (betterdb:memory:*) remain; delete them manually when ready.");
|
|
658
|
+
} else {
|
|
659
|
+
console.log("Count mismatch — left the legacy index in place. Re-run after investigating.");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await store.close();
|
|
663
|
+
await valkeyClient.quit();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// ingest-claude-md (ingest a CLAUDE.md / MEMORY.md file into the store)
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
async function runIngestClaudeMd(pathArg?: string) {
|
|
671
|
+
console.log("BetterDB Memory for Claude Code — Ingest markdown memory file\n");
|
|
672
|
+
|
|
673
|
+
const candidates = pathArg
|
|
674
|
+
? [pathArg]
|
|
675
|
+
: [
|
|
676
|
+
join(process.cwd(), "CLAUDE.md"),
|
|
677
|
+
join(process.cwd(), "MEMORY.md"),
|
|
678
|
+
join(HOME, ".claude", "CLAUDE.md"),
|
|
679
|
+
];
|
|
680
|
+
|
|
681
|
+
const filePath = candidates.find((p) => existsSync(p));
|
|
682
|
+
if (!filePath) {
|
|
683
|
+
console.error(`No memory file found. Looked in:\n ${candidates.join("\n ")}`);
|
|
684
|
+
process.exit(1);
|
|
685
|
+
}
|
|
686
|
+
console.log(`Reading ${filePath}`);
|
|
687
|
+
|
|
688
|
+
const content = readFileSync(filePath, "utf-8");
|
|
689
|
+
// Split into paragraph-sized chunks on blank lines so each becomes an
|
|
690
|
+
// independently recallable memory; cap length to keep embeddings sane.
|
|
691
|
+
const MAX_CHUNK = 480;
|
|
692
|
+
const chunks = content
|
|
693
|
+
.split(/\n\s*\n/)
|
|
694
|
+
.map((c) => c.trim())
|
|
695
|
+
.filter((c) => c.length > 0)
|
|
696
|
+
.map((c) => (c.length > MAX_CHUNK ? c.slice(0, MAX_CHUNK) : c));
|
|
697
|
+
|
|
698
|
+
if (chunks.length === 0) {
|
|
699
|
+
console.log("File is empty — nothing to ingest.");
|
|
700
|
+
process.exit(0);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const { getValkeyClient } = await import("./client/valkey.js");
|
|
704
|
+
const { getPluginMemoryStore } = await import("./client/memory-store.js");
|
|
705
|
+
const { createModelClient } = await import("./client/model.js");
|
|
706
|
+
const { getCwdProject } = await import("./memory/capture.js");
|
|
707
|
+
const { SessionSummarySchema } = await import("./memory/schema.js");
|
|
708
|
+
|
|
709
|
+
const valkeyClient = await getValkeyClient();
|
|
710
|
+
const modelClient = await createModelClient();
|
|
711
|
+
const store = await getPluginMemoryStore((t) => modelClient.embed(t));
|
|
712
|
+
await store.ensureIndex();
|
|
713
|
+
|
|
714
|
+
const project = getCwdProject();
|
|
715
|
+
const timestamp = new Date().toISOString();
|
|
716
|
+
let stored = 0;
|
|
717
|
+
|
|
718
|
+
for (const chunk of chunks) {
|
|
719
|
+
const summary = SessionSummarySchema.parse({
|
|
720
|
+
decisions: [],
|
|
721
|
+
patterns: [],
|
|
722
|
+
problemsSolved: [],
|
|
723
|
+
openThreads: [],
|
|
724
|
+
filesChanged: [],
|
|
725
|
+
oneLineSummary: chunk,
|
|
726
|
+
});
|
|
727
|
+
await store.storeMemory({
|
|
728
|
+
memoryId: crypto.randomUUID(),
|
|
729
|
+
project,
|
|
730
|
+
branch: "claude-md",
|
|
731
|
+
timestamp,
|
|
732
|
+
summary,
|
|
733
|
+
importanceScore: 0.6,
|
|
734
|
+
accessCount: 0,
|
|
735
|
+
lastAccessed: timestamp,
|
|
736
|
+
});
|
|
737
|
+
stored++;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
console.log(`\nIngested ${stored} chunks from ${filePath} into project "${project}".`);
|
|
741
|
+
await store.close();
|
|
445
742
|
await valkeyClient.quit();
|
|
446
743
|
}
|
|
447
744
|
|