@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.
@@ -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
+ });