@betterdb/memory 0.1.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/LICENSE +21 -0
- package/README.md +70 -0
- package/package.json +60 -0
- package/scripts/aging-worker.ts +24 -0
- package/scripts/check-providers.ts +103 -0
- package/scripts/install-hooks.sh +103 -0
- package/scripts/migrate-embeddings.ts +69 -0
- package/scripts/setup-index.ts +14 -0
- package/scripts/validate-pack.sh +67 -0
- package/src/client/model.ts +281 -0
- package/src/client/providers/_prompt.ts +35 -0
- package/src/client/providers/anthropic.ts +70 -0
- package/src/client/providers/groq.ts +102 -0
- package/src/client/providers/ollama.ts +53 -0
- package/src/client/providers/openai.ts +125 -0
- package/src/client/providers/together.ts +94 -0
- package/src/client/providers/voyage.ts +46 -0
- package/src/client/valkey.ts +448 -0
- package/src/config.ts +67 -0
- package/src/hooks/_utils.ts +53 -0
- package/src/hooks/post-tool.ts +46 -0
- package/src/hooks/pre-tool.ts +59 -0
- package/src/hooks/session-end.ts +194 -0
- package/src/hooks/session-start.ts +43 -0
- package/src/index.ts +435 -0
- package/src/mcp/server.ts +201 -0
- package/src/memory/aging.ts +321 -0
- package/src/memory/capture.ts +122 -0
- package/src/memory/retrieval.ts +114 -0
- package/src/memory/schema.ts +111 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readRawPayload, runHook } from "./_utils.js";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
3
|
+
import type { SessionEvent } from "../memory/schema.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PostToolUse hook: Records tool call results to a temp JSONL file.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code hooks contract:
|
|
9
|
+
* - Fires after a tool call succeeds
|
|
10
|
+
* - Receives JSON on stdin with tool_name, tool_input, tool_result
|
|
11
|
+
* - Exit 0 for success
|
|
12
|
+
*
|
|
13
|
+
* The JSONL file is read by session-end.ts to build the session transcript.
|
|
14
|
+
*/
|
|
15
|
+
runHook(async () => {
|
|
16
|
+
const payload = await readRawPayload();
|
|
17
|
+
const sessionId = payload["session_id"] as string;
|
|
18
|
+
const toolName = (payload["tool_name"] as string) ?? "unknown";
|
|
19
|
+
const toolInput = payload["tool_input"] as Record<string, unknown> | undefined;
|
|
20
|
+
const toolResult = payload["tool_result"];
|
|
21
|
+
|
|
22
|
+
const filePath =
|
|
23
|
+
(toolInput?.["file_path"] as string) ??
|
|
24
|
+
(toolInput?.["path"] as string) ??
|
|
25
|
+
undefined;
|
|
26
|
+
|
|
27
|
+
// Build a concise content string
|
|
28
|
+
const inputSummary = toolInput
|
|
29
|
+
? JSON.stringify(toolInput).slice(0, 500)
|
|
30
|
+
: "";
|
|
31
|
+
const resultSummary =
|
|
32
|
+
typeof toolResult === "string"
|
|
33
|
+
? toolResult.slice(0, 500)
|
|
34
|
+
: JSON.stringify(toolResult ?? "").slice(0, 500);
|
|
35
|
+
|
|
36
|
+
const event: SessionEvent = {
|
|
37
|
+
sessionId,
|
|
38
|
+
timestamp: new Date().toISOString(),
|
|
39
|
+
eventType: "tool_call",
|
|
40
|
+
content: `${toolName}: ${inputSummary} → ${resultSummary}`,
|
|
41
|
+
filePath,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const eventFilePath = `/tmp/betterdb-${sessionId}.jsonl`;
|
|
45
|
+
await appendFile(eventFilePath, JSON.stringify(event) + "\n");
|
|
46
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readRawPayload, runHook } from "./_utils.js";
|
|
2
|
+
import { getValkeyClient } from "../client/valkey.js";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PreToolUse hook: Checks for file history and appends notes to context.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code hooks contract:
|
|
9
|
+
* - Fires before a tool call executes
|
|
10
|
+
* - Receives JSON on stdin with tool_name, tool_input
|
|
11
|
+
* - Exit 0 to allow, exit 2 to block
|
|
12
|
+
*/
|
|
13
|
+
runHook(async () => {
|
|
14
|
+
const payload = await readRawPayload();
|
|
15
|
+
const toolInput = payload["tool_input"] as Record<string, unknown> | undefined;
|
|
16
|
+
|
|
17
|
+
// Extract file path from tool input
|
|
18
|
+
const filePath =
|
|
19
|
+
(toolInput?.["file_path"] as string) ??
|
|
20
|
+
(toolInput?.["path"] as string) ??
|
|
21
|
+
null;
|
|
22
|
+
|
|
23
|
+
if (!filePath) return;
|
|
24
|
+
|
|
25
|
+
let valkeyClient;
|
|
26
|
+
try {
|
|
27
|
+
valkeyClient = await getValkeyClient();
|
|
28
|
+
} catch {
|
|
29
|
+
return; // Valkey unavailable — skip silently
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Scan for memories that reference this file
|
|
33
|
+
const memoryIds = await valkeyClient.listMemoryIds();
|
|
34
|
+
const relevantNotes: string[] = [];
|
|
35
|
+
|
|
36
|
+
for (const id of memoryIds.slice(0, 50)) {
|
|
37
|
+
const memory = await valkeyClient.getMemory(id);
|
|
38
|
+
if (!memory) continue;
|
|
39
|
+
|
|
40
|
+
if (memory.summary.filesChanged.some((f) => f.includes(filePath) || filePath.includes(f))) {
|
|
41
|
+
relevantNotes.push(
|
|
42
|
+
`- ${memory.summary.oneLineSummary} (${memory.timestamp.split("T")[0]})`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (relevantNotes.length > 0) {
|
|
48
|
+
const contextFile = Bun.file(config.memory.contextFile);
|
|
49
|
+
let existing = "";
|
|
50
|
+
if (await contextFile.exists()) {
|
|
51
|
+
existing = await contextFile.text();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const note = `\n\n## File History: ${filePath}\n${relevantNotes.slice(0, 3).join("\n")}`;
|
|
55
|
+
await Bun.write(config.memory.contextFile, existing + note);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await valkeyClient.quit();
|
|
59
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readRawPayload, runHook } from "./_utils.js";
|
|
2
|
+
import { getValkeyClient } from "../client/valkey.js";
|
|
3
|
+
import { createModelClient } from "../client/model.js";
|
|
4
|
+
import {
|
|
5
|
+
SessionCapture,
|
|
6
|
+
computeInitialImportance,
|
|
7
|
+
getGitBranch,
|
|
8
|
+
getCwdProject,
|
|
9
|
+
} from "../memory/capture.js";
|
|
10
|
+
import { SessionEventSchema, type EpisodicMemory } from "../memory/schema.js";
|
|
11
|
+
import { config } from "../config.js";
|
|
12
|
+
import { unlink } from "node:fs/promises";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stop hook (session-end): Captures the session transcript and stores a memory.
|
|
16
|
+
*
|
|
17
|
+
* Claude Code hooks contract:
|
|
18
|
+
* - Fires when Claude finishes responding (Stop event)
|
|
19
|
+
* - Receives JSON on stdin with session_id, transcript_path, cwd
|
|
20
|
+
* - Exit 0 for success
|
|
21
|
+
*
|
|
22
|
+
* Capture strategy:
|
|
23
|
+
* 1. Prefer transcript_path (complete conversation with user messages)
|
|
24
|
+
* 2. Fall back to JSONL event file (tool calls only)
|
|
25
|
+
* 3. If model client is unavailable, queue for later processing
|
|
26
|
+
*/
|
|
27
|
+
runHook(async () => {
|
|
28
|
+
const payload = await readRawPayload();
|
|
29
|
+
const sessionId = payload["session_id"] as string;
|
|
30
|
+
const cwd = (payload["cwd"] as string) ?? process.cwd();
|
|
31
|
+
const transcriptPath = payload["transcript_path"] as string | undefined;
|
|
32
|
+
|
|
33
|
+
if (cwd) {
|
|
34
|
+
process.chdir(cwd);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const eventFilePath = `/tmp/betterdb-${sessionId}.jsonl`;
|
|
38
|
+
let transcript = "";
|
|
39
|
+
|
|
40
|
+
// Prefer transcript_path — contains the full conversation including user messages
|
|
41
|
+
if (transcriptPath) {
|
|
42
|
+
transcript = await parseTranscriptPath(transcriptPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fall back to JSONL event file (tool calls captured by PostToolUse hook)
|
|
46
|
+
if (!transcript) {
|
|
47
|
+
const eventFile = Bun.file(eventFilePath);
|
|
48
|
+
if (await eventFile.exists()) {
|
|
49
|
+
const raw = await eventFile.text();
|
|
50
|
+
const capture = new SessionCapture();
|
|
51
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
52
|
+
try {
|
|
53
|
+
const event = SessionEventSchema.parse(JSON.parse(line));
|
|
54
|
+
capture.addEvent(event);
|
|
55
|
+
} catch {
|
|
56
|
+
// Skip malformed lines
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
transcript = capture.buildTranscript();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Nothing to store
|
|
64
|
+
if (!transcript || transcript.length < 20) {
|
|
65
|
+
await cleanup(eventFilePath);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Cap transcript to ~8K chars to avoid overwhelming the summarizer
|
|
70
|
+
// Keep first 4K (session start) + last 4K (session end) for long sessions
|
|
71
|
+
const MAX_TRANSCRIPT = 8000;
|
|
72
|
+
if (transcript.length > MAX_TRANSCRIPT) {
|
|
73
|
+
const half = MAX_TRANSCRIPT / 2;
|
|
74
|
+
transcript =
|
|
75
|
+
transcript.slice(0, half) +
|
|
76
|
+
"\n\n[... middle of session truncated ...]\n\n" +
|
|
77
|
+
transcript.slice(-half);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const valkeyClient = await getValkeyClient();
|
|
81
|
+
const project = getCwdProject();
|
|
82
|
+
const branch = getGitBranch();
|
|
83
|
+
|
|
84
|
+
// Try to summarize; queue on failure
|
|
85
|
+
let modelClient;
|
|
86
|
+
try {
|
|
87
|
+
modelClient = await createModelClient();
|
|
88
|
+
} catch {
|
|
89
|
+
console.error(
|
|
90
|
+
"[betterdb] Ollama unavailable — transcript queued for later processing",
|
|
91
|
+
);
|
|
92
|
+
await valkeyClient.pushIngestQueue(transcript, {
|
|
93
|
+
project,
|
|
94
|
+
branch,
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
sessionId,
|
|
97
|
+
});
|
|
98
|
+
await valkeyClient.quit();
|
|
99
|
+
await cleanup(eventFilePath);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const summary = await modelClient.summarize(transcript);
|
|
104
|
+
const importance = computeInitialImportance(summary);
|
|
105
|
+
const embedding = await modelClient.embed(summary.oneLineSummary);
|
|
106
|
+
|
|
107
|
+
const memory: EpisodicMemory = {
|
|
108
|
+
memoryId: crypto.randomUUID(),
|
|
109
|
+
project,
|
|
110
|
+
branch,
|
|
111
|
+
timestamp: new Date().toISOString(),
|
|
112
|
+
summary,
|
|
113
|
+
importanceScore: importance,
|
|
114
|
+
accessCount: 0,
|
|
115
|
+
lastAccessed: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
await valkeyClient.storeMemory(memory, embedding);
|
|
119
|
+
await valkeyClient.quit();
|
|
120
|
+
await cleanup(eventFilePath);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse Claude Code's transcript JSONL into a clean text transcript.
|
|
125
|
+
* The JSONL contains objects with type: "user" | "assistant" and message content.
|
|
126
|
+
* We extract user/assistant turns to build a readable conversation.
|
|
127
|
+
*/
|
|
128
|
+
async function parseTranscriptPath(path: string): Promise<string> {
|
|
129
|
+
const file = Bun.file(path);
|
|
130
|
+
if (!(await file.exists())) return "";
|
|
131
|
+
|
|
132
|
+
const raw = await file.text();
|
|
133
|
+
const lines: string[] = [];
|
|
134
|
+
|
|
135
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
136
|
+
try {
|
|
137
|
+
const entry = JSON.parse(line);
|
|
138
|
+
if (entry.type === "user" && entry.message?.content) {
|
|
139
|
+
const content =
|
|
140
|
+
typeof entry.message.content === "string"
|
|
141
|
+
? entry.message.content
|
|
142
|
+
: Array.isArray(entry.message.content)
|
|
143
|
+
? entry.message.content
|
|
144
|
+
.filter((b: { type: string }) => b.type === "text")
|
|
145
|
+
.map((b: { text: string }) => b.text)
|
|
146
|
+
.join("\n")
|
|
147
|
+
: "";
|
|
148
|
+
// Skip system-generated messages (commands, caveats)
|
|
149
|
+
if (content && !content.includes("<local-command") && !content.includes("<command-name>")) {
|
|
150
|
+
lines.push(`User: ${content}`);
|
|
151
|
+
}
|
|
152
|
+
} else if (entry.type === "assistant" && entry.message?.content) {
|
|
153
|
+
const content =
|
|
154
|
+
typeof entry.message.content === "string"
|
|
155
|
+
? entry.message.content
|
|
156
|
+
: Array.isArray(entry.message.content)
|
|
157
|
+
? entry.message.content
|
|
158
|
+
.filter((b: { type: string }) => b.type === "text")
|
|
159
|
+
.map((b: { text: string }) => b.text)
|
|
160
|
+
.join("\n")
|
|
161
|
+
: "";
|
|
162
|
+
if (content) {
|
|
163
|
+
lines.push(`Assistant: ${content.slice(0, 2000)}`);
|
|
164
|
+
}
|
|
165
|
+
} else if (entry.type === "tool_use" || entry.type === "tool_result") {
|
|
166
|
+
// Include tool names for context but keep it brief
|
|
167
|
+
const toolName = entry.tool_name ?? entry.name ?? "";
|
|
168
|
+
if (toolName) {
|
|
169
|
+
lines.push(`Tool: ${toolName}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Skip malformed lines
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return lines.join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function cleanup(eventFilePath: string): Promise<void> {
|
|
181
|
+
try {
|
|
182
|
+
await unlink(eventFilePath);
|
|
183
|
+
} catch {
|
|
184
|
+
// File may not exist
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const contextFile = Bun.file(config.memory.contextFile);
|
|
188
|
+
if (await contextFile.exists()) {
|
|
189
|
+
await unlink(config.memory.contextFile);
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// Ignore cleanup errors
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readRawPayload, runHook } from "./_utils.js";
|
|
2
|
+
import { getValkeyClient } from "../client/valkey.js";
|
|
3
|
+
import { createModelClient } from "../client/model.js";
|
|
4
|
+
import { SessionCapture } from "../memory/capture.js";
|
|
5
|
+
import { MemoryRetriever, formatForInjection } from "../memory/retrieval.js";
|
|
6
|
+
import { config } from "../config.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SessionStart hook: Retrieves relevant memories and injects context.
|
|
10
|
+
*
|
|
11
|
+
* Claude Code hooks contract:
|
|
12
|
+
* - Receives JSON on stdin with session_id, cwd, transcript_path
|
|
13
|
+
* - stdout text is added to Claude's context
|
|
14
|
+
* - Exit 0 for success
|
|
15
|
+
*/
|
|
16
|
+
runHook(async () => {
|
|
17
|
+
const payload = await readRawPayload();
|
|
18
|
+
const cwd = (payload["cwd"] as string) ?? process.cwd();
|
|
19
|
+
|
|
20
|
+
if (cwd) {
|
|
21
|
+
process.chdir(cwd);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const valkeyClient = await getValkeyClient();
|
|
25
|
+
const modelClient = await createModelClient();
|
|
26
|
+
|
|
27
|
+
const capture = new SessionCapture();
|
|
28
|
+
const queryContext = await capture.getQueryContext();
|
|
29
|
+
|
|
30
|
+
const retriever = new MemoryRetriever(valkeyClient, modelClient);
|
|
31
|
+
const project = queryContext.split("\n")[0]?.replace("Project: ", "") ?? "unknown";
|
|
32
|
+
const memories = await retriever.retrieve(queryContext, project);
|
|
33
|
+
|
|
34
|
+
if (memories.length > 0) {
|
|
35
|
+
const formatted = formatForInjection(memories);
|
|
36
|
+
// Write context file for reference
|
|
37
|
+
await Bun.write(config.memory.contextFile, formatted);
|
|
38
|
+
// Output to stdout — Claude Code injects this into context
|
|
39
|
+
process.stdout.write(formatted);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await valkeyClient.quit();
|
|
43
|
+
});
|