claude-memory-hub 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.
@@ -0,0 +1,892 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/db/schema.ts
5
+ import { Database } from "bun:sqlite";
6
+ import { existsSync, mkdirSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ function getDbPath() {
10
+ const dir = join(homedir(), ".claude-memory-hub");
11
+ if (!existsSync(dir)) {
12
+ mkdirSync(dir, { recursive: true, mode: 448 });
13
+ }
14
+ return join(dir, "memory.db");
15
+ }
16
+ var SCHEMA_VERSION = 1;
17
+ var CREATE_TABLES = `
18
+ -- Migration version tracking
19
+ CREATE TABLE IF NOT EXISTS schema_versions (
20
+ version INTEGER PRIMARY KEY,
21
+ applied_at INTEGER NOT NULL
22
+ );
23
+
24
+ -- L2: Session lifecycle
25
+ CREATE TABLE IF NOT EXISTS sessions (
26
+ id TEXT PRIMARY KEY,
27
+ project TEXT NOT NULL,
28
+ started_at INTEGER NOT NULL,
29
+ ended_at INTEGER,
30
+ user_prompt TEXT,
31
+ status TEXT NOT NULL DEFAULT 'active'
32
+ CHECK(status IN ('active', 'completed', 'failed'))
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
36
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
37
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
38
+
39
+ -- L2: Entity capture from tool events (no XML \u2014 direct hook metadata)
40
+ CREATE TABLE IF NOT EXISTS entities (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
43
+ project TEXT NOT NULL,
44
+ tool_name TEXT NOT NULL,
45
+ entity_type TEXT NOT NULL
46
+ CHECK(entity_type IN ('file_read','file_modified','file_created','error','decision')),
47
+ entity_value TEXT NOT NULL,
48
+ context TEXT,
49
+ importance INTEGER NOT NULL DEFAULT 1
50
+ CHECK(importance BETWEEN 1 AND 5),
51
+ created_at INTEGER NOT NULL,
52
+ prompt_number INTEGER NOT NULL DEFAULT 0
53
+ );
54
+
55
+ CREATE INDEX IF NOT EXISTS idx_entities_session ON entities(session_id);
56
+ CREATE INDEX IF NOT EXISTS idx_entities_project ON entities(project);
57
+ CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(entity_type);
58
+ CREATE INDEX IF NOT EXISTS idx_entities_value ON entities(entity_value);
59
+ CREATE INDEX IF NOT EXISTS idx_entities_created ON entities(created_at DESC);
60
+
61
+ -- L2: Manual session notes (from MCP memory_store tool or summarizer)
62
+ CREATE TABLE IF NOT EXISTS session_notes (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
65
+ content TEXT NOT NULL,
66
+ created_at INTEGER NOT NULL
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_notes_session ON session_notes(session_id);
70
+
71
+ -- L3: Cross-session persistent summaries
72
+ CREATE TABLE IF NOT EXISTS long_term_summaries (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ session_id TEXT NOT NULL UNIQUE,
75
+ project TEXT NOT NULL,
76
+ summary TEXT NOT NULL,
77
+ files_touched TEXT NOT NULL DEFAULT '[]', -- JSON array
78
+ decisions TEXT NOT NULL DEFAULT '[]', -- JSON array
79
+ errors_fixed TEXT NOT NULL DEFAULT '[]', -- JSON array
80
+ token_savings INTEGER NOT NULL DEFAULT 0,
81
+ created_at INTEGER NOT NULL
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_lts_project ON long_term_summaries(project);
85
+ CREATE INDEX IF NOT EXISTS idx_lts_created ON long_term_summaries(created_at DESC);
86
+
87
+ -- L3: FTS5 virtual table for semantic search across summaries
88
+ -- porter stemming + unicode61 handles English technical content well
89
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_memories USING fts5(
90
+ session_id UNINDEXED,
91
+ project,
92
+ summary,
93
+ files_touched,
94
+ decisions,
95
+ content = 'long_term_summaries',
96
+ content_rowid = 'id',
97
+ tokenize = 'porter unicode61'
98
+ );
99
+
100
+ -- Keep FTS5 index in sync with content table via triggers
101
+ CREATE TRIGGER IF NOT EXISTS fts_memories_insert
102
+ AFTER INSERT ON long_term_summaries BEGIN
103
+ INSERT INTO fts_memories(rowid, session_id, project, summary, files_touched, decisions)
104
+ VALUES (new.id, new.session_id, new.project, new.summary, new.files_touched, new.decisions);
105
+ END;
106
+
107
+ CREATE TRIGGER IF NOT EXISTS fts_memories_update
108
+ AFTER UPDATE ON long_term_summaries BEGIN
109
+ INSERT INTO fts_memories(fts_memories, rowid, session_id, project, summary, files_touched, decisions)
110
+ VALUES ('delete', old.id, old.session_id, old.project, old.summary, old.files_touched, old.decisions);
111
+ INSERT INTO fts_memories(rowid, session_id, project, summary, files_touched, decisions)
112
+ VALUES (new.id, new.session_id, new.project, new.summary, new.files_touched, new.decisions);
113
+ END;
114
+
115
+ CREATE TRIGGER IF NOT EXISTS fts_memories_delete
116
+ AFTER DELETE ON long_term_summaries BEGIN
117
+ INSERT INTO fts_memories(fts_memories, rowid, session_id, project, summary, files_touched, decisions)
118
+ VALUES ('delete', old.id, old.session_id, old.project, old.summary, old.files_touched, old.decisions);
119
+ END;
120
+ `;
121
+ function initDatabase(db) {
122
+ db.run("PRAGMA journal_mode = WAL");
123
+ db.run("PRAGMA synchronous = NORMAL");
124
+ db.run("PRAGMA foreign_keys = ON");
125
+ db.run("PRAGMA busy_timeout = 5000");
126
+ db.run("PRAGMA cache_size = -8000");
127
+ db.run(CREATE_TABLES);
128
+ const existing = db.query("SELECT version FROM schema_versions WHERE version = ?").get(SCHEMA_VERSION);
129
+ if (!existing) {
130
+ db.run("INSERT INTO schema_versions(version, applied_at) VALUES (?, ?)", [SCHEMA_VERSION, Date.now()]);
131
+ }
132
+ }
133
+ var _db = null;
134
+ function getDatabase() {
135
+ if (!_db) {
136
+ const path = getDbPath();
137
+ _db = new Database(path);
138
+ initDatabase(_db);
139
+ }
140
+ return _db;
141
+ }
142
+
143
+ // src/db/session-store.ts
144
+ class SessionStore {
145
+ db;
146
+ constructor(db) {
147
+ this.db = db ?? getDatabase();
148
+ }
149
+ upsertSession(session) {
150
+ this.db.run(`INSERT INTO sessions(id, project, started_at, ended_at, user_prompt, status)
151
+ VALUES (?, ?, ?, ?, ?, ?)
152
+ ON CONFLICT(id) DO UPDATE SET
153
+ ended_at = excluded.ended_at,
154
+ user_prompt = COALESCE(excluded.user_prompt, user_prompt),
155
+ status = excluded.status`, [
156
+ session.id,
157
+ session.project,
158
+ session.started_at,
159
+ session.ended_at ?? null,
160
+ session.user_prompt ?? null,
161
+ session.status
162
+ ]);
163
+ }
164
+ getSession(id) {
165
+ return this.db.query("SELECT * FROM sessions WHERE id = ?").get(id) ?? null;
166
+ }
167
+ completeSession(id) {
168
+ this.db.run("UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ?", [Date.now(), id]);
169
+ }
170
+ insertEntity(entity) {
171
+ const result = this.db.run(`INSERT INTO entities(session_id, project, tool_name, entity_type, entity_value, context, importance, created_at, prompt_number)
172
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
173
+ entity.session_id,
174
+ entity.project,
175
+ entity.tool_name,
176
+ entity.entity_type,
177
+ entity.entity_value,
178
+ entity.context ?? null,
179
+ entity.importance,
180
+ entity.created_at,
181
+ entity.prompt_number
182
+ ]);
183
+ return Number(result.lastInsertRowid);
184
+ }
185
+ getSessionEntities(session_id) {
186
+ return this.db.query("SELECT * FROM entities WHERE session_id = ? ORDER BY created_at ASC").all(session_id);
187
+ }
188
+ getSessionErrors(session_id) {
189
+ return this.db.query("SELECT * FROM entities WHERE session_id = ? AND entity_type = 'error' ORDER BY importance DESC").all(session_id);
190
+ }
191
+ getSessionDecisions(session_id) {
192
+ return this.db.query("SELECT * FROM entities WHERE session_id = ? AND entity_type = 'decision' ORDER BY importance DESC").all(session_id);
193
+ }
194
+ getSessionFiles(session_id) {
195
+ return this.db.query(`SELECT DISTINCT entity_value FROM entities
196
+ WHERE session_id = ? AND entity_type IN ('file_read','file_modified','file_created')
197
+ ORDER BY importance DESC, created_at DESC`).all(session_id).map((r) => r.entity_value);
198
+ }
199
+ insertNote(note) {
200
+ this.db.run("INSERT INTO session_notes(session_id, content, created_at) VALUES (?, ?, ?)", [note.session_id, note.content, note.created_at]);
201
+ }
202
+ getSessionNotes(session_id) {
203
+ return this.db.query("SELECT * FROM session_notes WHERE session_id = ? ORDER BY created_at ASC").all(session_id);
204
+ }
205
+ }
206
+
207
+ // src/db/long-term-store.ts
208
+ class LongTermStore {
209
+ db;
210
+ constructor(db) {
211
+ this.db = db ?? getDatabase();
212
+ }
213
+ upsertSummary(summary) {
214
+ this.db.run(`INSERT INTO long_term_summaries(session_id, project, summary, files_touched, decisions, errors_fixed, token_savings, created_at)
215
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
216
+ ON CONFLICT(session_id) DO UPDATE SET
217
+ summary = excluded.summary,
218
+ files_touched = excluded.files_touched,
219
+ decisions = excluded.decisions,
220
+ errors_fixed = excluded.errors_fixed,
221
+ token_savings = excluded.token_savings`, [
222
+ summary.session_id,
223
+ summary.project,
224
+ summary.summary,
225
+ summary.files_touched,
226
+ summary.decisions,
227
+ summary.errors_fixed,
228
+ summary.token_savings,
229
+ summary.created_at
230
+ ]);
231
+ }
232
+ getSummary(session_id) {
233
+ return this.db.query("SELECT * FROM long_term_summaries WHERE session_id = ?").get(session_id) ?? null;
234
+ }
235
+ getRecentSummaries(project, limit = 5) {
236
+ return this.db.query("SELECT * FROM long_term_summaries WHERE project = ? ORDER BY created_at DESC LIMIT ?").all(project, limit);
237
+ }
238
+ search(query, limit = 5) {
239
+ if (!query.trim())
240
+ return [];
241
+ const safeQuery = sanitizeFtsQuery(query);
242
+ if (!safeQuery)
243
+ return [];
244
+ try {
245
+ return this.db.query(`SELECT lts.session_id, lts.project, lts.summary,
246
+ lts.files_touched, lts.decisions, lts.errors_fixed,
247
+ lts.created_at, rank
248
+ FROM fts_memories
249
+ JOIN long_term_summaries lts ON lts.id = fts_memories.rowid
250
+ WHERE fts_memories MATCH ?
251
+ ORDER BY rank
252
+ LIMIT ?`).all(safeQuery, limit);
253
+ } catch {
254
+ return this.fallbackSearch(query, limit);
255
+ }
256
+ }
257
+ fallbackSearch(query, limit) {
258
+ const pattern = `%${query.replace(/[%_]/g, "\\$&")}%`;
259
+ return this.db.query(`SELECT session_id, project, summary, files_touched, decisions, errors_fixed, created_at
260
+ FROM long_term_summaries
261
+ WHERE summary LIKE ? OR files_touched LIKE ? OR decisions LIKE ?
262
+ ORDER BY created_at DESC LIMIT ?`).all(pattern, pattern, pattern, limit);
263
+ }
264
+ findByFile(filePath, limit = 10) {
265
+ const escaped = filePath.replace(/[%_]/g, "\\$&");
266
+ return this.db.query(`SELECT session_id, project, summary, files_touched, decisions, errors_fixed, created_at
267
+ FROM long_term_summaries
268
+ WHERE files_touched LIKE ?
269
+ ORDER BY created_at DESC LIMIT ?`).all(`%${escaped}%`, limit);
270
+ }
271
+ }
272
+ function sanitizeFtsQuery(query) {
273
+ const words = query.trim().split(/\s+/).filter(Boolean).map((w) => w.replace(/["*^()]/g, "")).filter((w) => w.length > 1);
274
+ if (words.length === 0)
275
+ return "";
276
+ const head = words.slice(0, -1).map((w) => `"${w}"`);
277
+ const last = words[words.length - 1];
278
+ return [...head, `"${last}"*`].join(" ");
279
+ }
280
+
281
+ // src/summarizer/summarizer-prompts.ts
282
+ function buildRuleBasedSummary(session, files, errors, decisions, notes = []) {
283
+ const parts = [];
284
+ if (session.user_prompt) {
285
+ parts.push(`Task: ${session.user_prompt.slice(0, 200)}.`);
286
+ }
287
+ if (files.length > 0) {
288
+ const listed = files.slice(0, 10).join(", ");
289
+ parts.push(`Files (${files.length}): ${listed}${files.length > 10 ? ` (+${files.length - 10} more)` : ""}.`);
290
+ }
291
+ if (decisions.length > 0) {
292
+ const listed = decisions.slice(0, 3).map((d) => d.entity_value.slice(0, 100)).join("; ");
293
+ parts.push(`Decisions: ${listed}.`);
294
+ }
295
+ if (errors.length > 0) {
296
+ const first = errors[0];
297
+ const ctx = first.context ? ` (${first.context.slice(0, 60)})` : "";
298
+ parts.push(`Errors (${errors.length}): ${first.entity_value.slice(0, 100)}${ctx}.`);
299
+ if (errors.length > 1) {
300
+ parts.push(`Also: ${errors[1].entity_value.slice(0, 80)}.`);
301
+ }
302
+ }
303
+ if (notes.length > 0) {
304
+ parts.push(`Notes: ${notes.slice(-2).join("; ").slice(0, 200)}.`);
305
+ }
306
+ return parts.join(" ") || `Session in project ${session.project}.`;
307
+ }
308
+
309
+ // src/summarizer/session-summarizer.ts
310
+ class SessionSummarizer {
311
+ sessionStore;
312
+ ltStore;
313
+ constructor() {
314
+ this.sessionStore = new SessionStore;
315
+ this.ltStore = new LongTermStore;
316
+ }
317
+ async summarize(session_id, project) {
318
+ const session = this.sessionStore.getSession(session_id);
319
+ if (!session)
320
+ return;
321
+ if (this.ltStore.getSummary(session_id))
322
+ return;
323
+ const files = this.sessionStore.getSessionFiles(session_id);
324
+ const errors = this.sessionStore.getSessionErrors(session_id);
325
+ const decisions = this.sessionStore.getSessionDecisions(session_id);
326
+ const notes = this.sessionStore.getSessionNotes(session_id).map((n) => n.content);
327
+ if (files.length === 0 && errors.length === 0 && notes.length === 0)
328
+ return;
329
+ const summaryText = buildRuleBasedSummary(session, files, errors, decisions, notes);
330
+ const ltSummary = {
331
+ session_id,
332
+ project,
333
+ summary: summaryText,
334
+ files_touched: JSON.stringify(files.slice(0, 50)),
335
+ decisions: JSON.stringify(decisions.slice(0, 20).map((d) => d.entity_value)),
336
+ errors_fixed: JSON.stringify(errors.slice(0, 10).map((e) => e.entity_value.slice(0, 100))),
337
+ token_savings: estimateTokenSavings(files.length, errors.length, notes.length),
338
+ created_at: Date.now()
339
+ };
340
+ this.ltStore.upsertSummary(ltSummary);
341
+ }
342
+ }
343
+ function estimateTokenSavings(fileCount, errorCount, noteCount) {
344
+ return fileCount * 500 + errorCount * 100 + noteCount * 50;
345
+ }
346
+
347
+ // src/compact/compact-interceptor.ts
348
+ async function handlePreCompact(hook, project) {
349
+ const store = new SessionStore;
350
+ const session = store.getSession(hook.session_id);
351
+ if (!session)
352
+ return "";
353
+ const entities = store.getSessionEntities(hook.session_id);
354
+ if (entities.length === 0)
355
+ return "";
356
+ const now = Date.now();
357
+ const scored = entities.map((e) => ({
358
+ ...e,
359
+ score: e.importance * recencyWeight(e.created_at, now)
360
+ })).sort((a, b) => b.score - a.score);
361
+ const lines = [
362
+ "## Memory Hub Priority Items",
363
+ "",
364
+ "The following items are CRITICAL \u2014 ensure they appear in the summary:",
365
+ ""
366
+ ];
367
+ const modifiedFiles = scored.filter((e) => e.entity_type === "file_modified" || e.entity_type === "file_created").slice(0, 15);
368
+ if (modifiedFiles.length > 0) {
369
+ lines.push("### Files Modified (MUST include)");
370
+ for (const f of modifiedFiles) {
371
+ lines.push(`- ${f.entity_value}`);
372
+ }
373
+ lines.push("");
374
+ }
375
+ const decisions = scored.filter((e) => e.entity_type === "decision").slice(0, 5);
376
+ if (decisions.length > 0) {
377
+ lines.push("### Decisions Made (MUST include)");
378
+ for (const d of decisions) {
379
+ lines.push(`- ${d.entity_value}`);
380
+ }
381
+ lines.push("");
382
+ }
383
+ const errors = scored.filter((e) => e.entity_type === "error").slice(0, 5);
384
+ if (errors.length > 0) {
385
+ lines.push("### Errors Encountered (include if space allows)");
386
+ for (const e of errors) {
387
+ lines.push(`- ${e.entity_value.slice(0, 150)}`);
388
+ if (e.context)
389
+ lines.push(` Context: ${e.context.slice(0, 100)}`);
390
+ }
391
+ lines.push("");
392
+ }
393
+ const notes = store.getSessionNotes(hook.session_id);
394
+ if (notes.length > 0) {
395
+ lines.push("### Session Notes (MUST include)");
396
+ for (const n of notes.slice(-3)) {
397
+ lines.push(`- ${n.content.slice(0, 200)}`);
398
+ }
399
+ lines.push("");
400
+ }
401
+ return lines.join(`
402
+ `);
403
+ }
404
+ async function handlePostCompact(hook, project) {
405
+ const store = new SessionStore;
406
+ const ltStore = new LongTermStore;
407
+ const files = store.getSessionFiles(hook.session_id);
408
+ const decisions = store.getSessionDecisions(hook.session_id);
409
+ const errors = store.getSessionErrors(hook.session_id);
410
+ ltStore.upsertSummary({
411
+ session_id: hook.session_id,
412
+ project,
413
+ summary: hook.compact_summary,
414
+ files_touched: JSON.stringify(files.slice(0, 50)),
415
+ decisions: JSON.stringify(decisions.slice(0, 20).map((d) => d.entity_value)),
416
+ errors_fixed: JSON.stringify(errors.slice(0, 10).map((e) => e.entity_value.slice(0, 100))),
417
+ token_savings: estimateTokenSavings2(hook.compact_summary.length, files.length),
418
+ created_at: Date.now()
419
+ });
420
+ const summarizer = new SessionSummarizer;
421
+ await summarizer.summarize(hook.session_id, project).catch(() => {});
422
+ }
423
+ function recencyWeight(createdAt, now) {
424
+ const hoursAgo = (now - createdAt) / (1000 * 60 * 60);
425
+ return Math.max(0.1, 1 / (1 + hoursAgo));
426
+ }
427
+ function estimateTokenSavings2(summaryLength, fileCount) {
428
+ return fileCount * 500 + summaryLength / 4;
429
+ }
430
+
431
+ // src/capture/context-enricher.ts
432
+ var CONTEXT_MAX_LENGTH = 500;
433
+ function enrichEntityContext(entity, hook) {
434
+ const response = hook.tool_response;
435
+ if (!response)
436
+ return entity;
437
+ switch (entity.entity_type) {
438
+ case "file_read":
439
+ return enrichFileReadContext(entity, hook);
440
+ case "file_modified":
441
+ case "file_created":
442
+ return enrichFileWriteContext(entity, hook);
443
+ case "error":
444
+ return enrichErrorContext(entity, hook);
445
+ default:
446
+ return entity;
447
+ }
448
+ }
449
+ function enrichFileReadContext(entity, hook) {
450
+ const output = getResponseText(hook);
451
+ if (!output)
452
+ return entity;
453
+ const firstLines = output.split(`
454
+ `).slice(0, 5).join(`
455
+ `);
456
+ const patterns = extractCodePatterns(output);
457
+ const contextParts = [];
458
+ if (firstLines.trim()) {
459
+ contextParts.push(`Head: ${firstLines.slice(0, 200)}`);
460
+ }
461
+ if (patterns.length > 0) {
462
+ contextParts.push(`Contains: ${patterns.slice(0, 5).join(", ")}`);
463
+ }
464
+ return contextParts.length > 0 ? { ...entity, context: contextParts.join(" | ").slice(0, CONTEXT_MAX_LENGTH) } : entity;
465
+ }
466
+ function enrichFileWriteContext(entity, hook) {
467
+ const input = hook.tool_input;
468
+ const contextParts = [];
469
+ if (hook.tool_name === "Edit" || hook.tool_name === "MultiEdit") {
470
+ const oldStr = stringField(input, "old_string");
471
+ const newStr = stringField(input, "new_string");
472
+ if (oldStr && newStr) {
473
+ contextParts.push(`Changed: "${oldStr.slice(0, 80)}" \u2192 "${newStr.slice(0, 80)}"`);
474
+ }
475
+ }
476
+ if (hook.tool_name === "Write") {
477
+ const content = stringField(input, "content");
478
+ if (content) {
479
+ const lines = content.split(`
480
+ `).length;
481
+ const first = content.split(`
482
+ `).slice(0, 3).join(`
483
+ `);
484
+ contextParts.push(`Wrote ${lines} lines: ${first.slice(0, 150)}`);
485
+ }
486
+ }
487
+ return contextParts.length > 0 ? { ...entity, context: contextParts.join(" | ").slice(0, CONTEXT_MAX_LENGTH) } : entity;
488
+ }
489
+ function enrichErrorContext(entity, hook) {
490
+ const stderr = stringField(hook.tool_response, "stderr");
491
+ const stdout = stringField(hook.tool_response, "stdout");
492
+ const cmd = stringField(hook.tool_input, "command");
493
+ const contextParts = [];
494
+ if (cmd)
495
+ contextParts.push(`Cmd: ${cmd.slice(0, 120)}`);
496
+ if (stderr)
497
+ contextParts.push(`Stderr: ${stderr.slice(0, 200)}`);
498
+ else if (stdout)
499
+ contextParts.push(`Output: ${stdout.slice(0, 200)}`);
500
+ return contextParts.length > 0 ? { ...entity, context: contextParts.join(" | ").slice(0, CONTEXT_MAX_LENGTH) } : entity;
501
+ }
502
+ function getResponseText(hook) {
503
+ const r = hook.tool_response;
504
+ return stringField(r, "output") ?? stringField(r, "stdout") ?? undefined;
505
+ }
506
+ function stringField(obj, key) {
507
+ if (!obj)
508
+ return;
509
+ const v = obj[key];
510
+ return typeof v === "string" && v.length > 0 ? v : undefined;
511
+ }
512
+ function extractCodePatterns(content) {
513
+ const patterns = [];
514
+ const lines = content.split(`
515
+ `).slice(0, 50);
516
+ for (const line of lines) {
517
+ const defMatch = line.match(/(?:export\s+)?(?:function|class|interface|type|const|enum)\s+(\w+)/);
518
+ if (defMatch?.[1]) {
519
+ patterns.push(defMatch[1]);
520
+ continue;
521
+ }
522
+ const importMatch = line.match(/(?:import|from)\s+['"]([^'"]+)['"]/);
523
+ if (importMatch?.[1]) {
524
+ patterns.push(`import:${importMatch[1]}`);
525
+ }
526
+ }
527
+ return [...new Set(patterns)];
528
+ }
529
+
530
+ // src/capture/entity-extractor.ts
531
+ function extractEntities(hook, promptNumber = 0) {
532
+ const { tool_name, tool_input, tool_response, session_id } = hook;
533
+ const project = deriveProject(hook);
534
+ const now = Date.now();
535
+ const raw = [];
536
+ switch (tool_name) {
537
+ case "Read": {
538
+ const path = stringField2(tool_input, "file_path");
539
+ if (path) {
540
+ raw.push(makeEntity(session_id, project, tool_name, "file_read", path, 1, now, promptNumber));
541
+ }
542
+ break;
543
+ }
544
+ case "Write": {
545
+ const path = stringField2(tool_input, "file_path");
546
+ if (path) {
547
+ raw.push(makeEntity(session_id, project, tool_name, "file_created", path, 4, now, promptNumber));
548
+ }
549
+ break;
550
+ }
551
+ case "Edit":
552
+ case "MultiEdit": {
553
+ const path = stringField2(tool_input, "file_path");
554
+ if (path) {
555
+ raw.push(makeEntity(session_id, project, tool_name, "file_modified", path, 4, now, promptNumber));
556
+ }
557
+ break;
558
+ }
559
+ case "Bash": {
560
+ const cmd = stringField2(tool_input, "command") ?? "";
561
+ const exitCode = tool_response?.exit_code;
562
+ const stdout = stringField2(tool_response, "stdout") ?? "";
563
+ const stderr = stringField2(tool_response, "stderr") ?? "";
564
+ if (typeof exitCode === "number" && exitCode !== 0) {
565
+ const errorCtx = [stderr, stdout].filter(Boolean).join(`
566
+ `).slice(0, 300);
567
+ raw.push(makeEntity(session_id, project, tool_name, "error", `[exit ${exitCode}] ${cmd.slice(0, 120)}`, exitCode > 0 ? 3 : 5, now, promptNumber, errorCtx || undefined));
568
+ }
569
+ const writtenFile = extractFileFromBashCmd(cmd);
570
+ if (writtenFile && (exitCode === 0 || exitCode === undefined)) {
571
+ raw.push(makeEntity(session_id, project, tool_name, "file_modified", writtenFile, 2, now, promptNumber));
572
+ }
573
+ break;
574
+ }
575
+ case "TodoWrite": {
576
+ const todos = tool_input["todos"];
577
+ if (Array.isArray(todos) && todos.length > 0) {
578
+ const summary = todos.slice(0, 3).map((t) => typeof t === "object" && t !== null ? String(t["content"] ?? "") : "").filter(Boolean).join("; ");
579
+ if (summary) {
580
+ raw.push(makeEntity(session_id, project, tool_name, "decision", summary, 3, now, promptNumber));
581
+ }
582
+ }
583
+ break;
584
+ }
585
+ default:
586
+ break;
587
+ }
588
+ return raw.map((e) => enrichEntityContext(e, hook));
589
+ }
590
+ function makeEntity(session_id, project, tool_name, entity_type, entity_value, importance, created_at, prompt_number, context) {
591
+ const base = { session_id, project, tool_name, entity_type, entity_value, importance, created_at, prompt_number };
592
+ return context !== undefined ? { ...base, context } : base;
593
+ }
594
+ function stringField2(obj, key) {
595
+ if (!obj)
596
+ return;
597
+ const v = obj[key];
598
+ return typeof v === "string" && v.length > 0 ? v : undefined;
599
+ }
600
+ function deriveProject(hook) {
601
+ return "unknown";
602
+ }
603
+ function extractFileFromBashCmd(cmd) {
604
+ const patterns = [
605
+ /(?:cp|mv)\s+\S+\s+(\S+\.[\w]+)/,
606
+ /(?:touch|truncate)\s+(\S+\.[\w]+)/,
607
+ />\s*(\S+\.[\w]+)/
608
+ ];
609
+ for (const re of patterns) {
610
+ const m = cmd.match(re);
611
+ if (m?.[1] && !m[1].startsWith("-"))
612
+ return m[1] ?? undefined;
613
+ }
614
+ return;
615
+ }
616
+
617
+ // src/context/resource-tracker.ts
618
+ var SCHEMA_ADDITIONS = `
619
+ CREATE TABLE IF NOT EXISTS resource_usage (
620
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
621
+ session_id TEXT NOT NULL,
622
+ project TEXT NOT NULL,
623
+ resource_type TEXT NOT NULL
624
+ CHECK(resource_type IN ('skill','agent','claude_md','memory','mcp_tool')),
625
+ resource_name TEXT NOT NULL,
626
+ use_count INTEGER NOT NULL DEFAULT 1,
627
+ token_cost INTEGER NOT NULL DEFAULT 0,
628
+ created_at INTEGER NOT NULL
629
+ );
630
+
631
+ CREATE INDEX IF NOT EXISTS idx_resource_session ON resource_usage(session_id);
632
+ CREATE INDEX IF NOT EXISTS idx_resource_project ON resource_usage(project);
633
+ CREATE INDEX IF NOT EXISTS idx_resource_name ON resource_usage(resource_name);
634
+ CREATE INDEX IF NOT EXISTS idx_resource_type ON resource_usage(resource_type);
635
+ `;
636
+
637
+ class ResourceTracker {
638
+ db;
639
+ initialized = false;
640
+ constructor(db) {
641
+ this.db = db ?? getDatabase();
642
+ }
643
+ ensureSchema() {
644
+ if (this.initialized)
645
+ return;
646
+ this.db.run(SCHEMA_ADDITIONS);
647
+ this.initialized = true;
648
+ }
649
+ trackUsage(session_id, project, resource_type, resource_name, token_cost = 0) {
650
+ this.ensureSchema();
651
+ const existing = this.db.query("SELECT id, use_count FROM resource_usage WHERE session_id = ? AND resource_name = ?").get(session_id, resource_name);
652
+ if (existing) {
653
+ this.db.run("UPDATE resource_usage SET use_count = use_count + 1 WHERE id = ?", [existing.id]);
654
+ } else {
655
+ this.db.run(`INSERT INTO resource_usage(session_id, project, resource_type, resource_name, use_count, token_cost, created_at)
656
+ VALUES (?, ?, ?, ?, 1, ?, ?)`, [session_id, project, resource_type, resource_name, token_cost, Date.now()]);
657
+ }
658
+ }
659
+ getMostUsedResources(project, resource_type, limit = 10) {
660
+ this.ensureSchema();
661
+ if (resource_type) {
662
+ return this.db.query(`SELECT resource_name, SUM(use_count) as total_uses, AVG(token_cost) as avg_tokens
663
+ FROM resource_usage WHERE project = ? AND resource_type = ?
664
+ GROUP BY resource_name ORDER BY total_uses DESC LIMIT ?`).all(project, resource_type, limit);
665
+ }
666
+ return this.db.query(`SELECT resource_name, SUM(use_count) as total_uses, AVG(token_cost) as avg_tokens
667
+ FROM resource_usage WHERE project = ?
668
+ GROUP BY resource_name ORDER BY total_uses DESC LIMIT ?`).all(project, limit);
669
+ }
670
+ getRecentlyUsedResources(project, lastNSessions = 5) {
671
+ this.ensureSchema();
672
+ return this.db.query(`SELECT resource_type, resource_name, COUNT(DISTINCT session_id) as frequency
673
+ FROM resource_usage
674
+ WHERE project = ?
675
+ AND session_id IN (
676
+ SELECT DISTINCT session_id FROM resource_usage
677
+ WHERE project = ?
678
+ ORDER BY created_at DESC
679
+ LIMIT ?
680
+ )
681
+ GROUP BY resource_type, resource_name
682
+ ORDER BY frequency DESC, resource_type`).all(project, project, lastNSessions);
683
+ }
684
+ estimateTokenBudget(project) {
685
+ this.ensureSchema();
686
+ const rows = this.db.query(`SELECT resource_type, SUM(token_cost) as total_tokens, COUNT(DISTINCT resource_name) as count
687
+ FROM resource_usage
688
+ WHERE project = ?
689
+ GROUP BY resource_type`).all(project);
690
+ const by_type = {};
691
+ let total = 0;
692
+ let count = 0;
693
+ for (const row of rows) {
694
+ by_type[row.resource_type] = row.total_tokens;
695
+ total += row.total_tokens;
696
+ count += row.count;
697
+ }
698
+ return { total_token_cost: total, resource_count: count, by_type };
699
+ }
700
+ }
701
+
702
+ // src/context/smart-resource-loader.ts
703
+ var DEFAULT_TOKEN_BUDGET = 30000;
704
+
705
+ class SmartResourceLoader {
706
+ tracker;
707
+ ltStore;
708
+ constructor() {
709
+ this.tracker = new ResourceTracker;
710
+ this.ltStore = new LongTermStore;
711
+ }
712
+ buildContextPlan(project, userPrompt, tokenBudget = DEFAULT_TOKEN_BUDGET) {
713
+ const recommendations = [];
714
+ const recent = this.tracker.getRecentlyUsedResources(project, 5);
715
+ for (const r of recent) {
716
+ const avgTokens = this.getAvgTokenCost(project, r.resource_name);
717
+ recommendations.push({
718
+ resource_type: r.resource_type,
719
+ resource_name: r.resource_name,
720
+ score: Math.min(100, r.frequency * 20),
721
+ token_cost: avgTokens,
722
+ reason: `used in ${r.frequency}/5 recent sessions`
723
+ });
724
+ }
725
+ if (userPrompt.trim()) {
726
+ const searchResults = this.ltStore.search(userPrompt, 3);
727
+ for (const result of searchResults) {
728
+ const files = safeJson(result.files_touched, []);
729
+ for (const file of files.slice(0, 5)) {
730
+ const existing = recommendations.find((r) => r.resource_name === file && r.resource_type === "claude_md");
731
+ if (!existing) {
732
+ recommendations.push({
733
+ resource_type: "claude_md",
734
+ resource_name: file,
735
+ score: 40,
736
+ token_cost: 500,
737
+ reason: `touched in similar past session`
738
+ });
739
+ }
740
+ }
741
+ }
742
+ }
743
+ recommendations.sort((a, b) => b.score - a.score);
744
+ let usedTokens = 0;
745
+ const included = [];
746
+ const skipped = [];
747
+ for (const r of recommendations) {
748
+ if (usedTokens + r.token_cost <= tokenBudget) {
749
+ included.push(r);
750
+ usedTokens += r.token_cost;
751
+ } else {
752
+ skipped.push(r);
753
+ }
754
+ }
755
+ return {
756
+ recommendations: included,
757
+ total_tokens: usedTokens,
758
+ budget_remaining: tokenBudget - usedTokens,
759
+ skipped
760
+ };
761
+ }
762
+ formatContextAdvice(plan) {
763
+ if (plan.skipped.length === 0)
764
+ return "";
765
+ const lines = [
766
+ "<!-- memory-hub: context budget advice -->",
767
+ `Context budget: ${plan.total_tokens}/${plan.total_tokens + plan.budget_remaining} tokens used by recommended resources.`
768
+ ];
769
+ if (plan.skipped.length > 0) {
770
+ lines.push(`${plan.skipped.length} resource(s) deferred for token efficiency:`);
771
+ for (const s of plan.skipped.slice(0, 5)) {
772
+ lines.push(` - ${s.resource_type}:${s.resource_name} (~${s.token_cost} tokens, ${s.reason})`);
773
+ }
774
+ lines.push("Use SkillTool or ToolSearch to load these on demand if needed.");
775
+ }
776
+ lines.push("<!-- end memory-hub advice -->");
777
+ return lines.join(`
778
+ `);
779
+ }
780
+ getAvgTokenCost(project, resourceName) {
781
+ const results = this.tracker.getMostUsedResources(project);
782
+ const match = results.find((r) => r.resource_name === resourceName);
783
+ return match?.avg_tokens ?? 500;
784
+ }
785
+ }
786
+ function safeJson(text, fallback) {
787
+ try {
788
+ return JSON.parse(text);
789
+ } catch {
790
+ return fallback;
791
+ }
792
+ }
793
+
794
+ // src/capture/hook-handler.ts
795
+ import { basename } from "path";
796
+ async function handlePostToolUse(hook, project) {
797
+ const store = new SessionStore;
798
+ store.upsertSession({
799
+ id: hook.session_id,
800
+ project,
801
+ started_at: Date.now(),
802
+ status: "active"
803
+ });
804
+ const entities = extractEntities(hook);
805
+ for (const entity of entities) {
806
+ store.insertEntity({ ...entity, project });
807
+ }
808
+ const tracker = new ResourceTracker;
809
+ if (hook.tool_name === "Skill") {
810
+ const skillName = stringField3(hook.tool_input, "skill");
811
+ if (skillName)
812
+ tracker.trackUsage(hook.session_id, project, "skill", skillName);
813
+ }
814
+ if (hook.tool_name === "Agent") {
815
+ const agentType = stringField3(hook.tool_input, "subagent_type");
816
+ if (agentType)
817
+ tracker.trackUsage(hook.session_id, project, "agent", agentType);
818
+ }
819
+ if (hook.tool_name.startsWith("mcp__")) {
820
+ tracker.trackUsage(hook.session_id, project, "mcp_tool", hook.tool_name);
821
+ }
822
+ }
823
+ async function handleUserPromptSubmit(hook, project) {
824
+ const store = new SessionStore;
825
+ const ltStore = new LongTermStore;
826
+ store.upsertSession({
827
+ id: hook.session_id,
828
+ project,
829
+ started_at: Date.now(),
830
+ user_prompt: hook.prompt.slice(0, 500),
831
+ status: "active"
832
+ });
833
+ const results = ltStore.search(hook.prompt, 3);
834
+ const loader = new SmartResourceLoader;
835
+ const plan = loader.buildContextPlan(project, hook.prompt);
836
+ const advice = loader.formatContextAdvice(plan);
837
+ const lines = [];
838
+ if (results.length > 0) {
839
+ lines.push("<!-- memory-hub: past session context -->");
840
+ for (const r of results) {
841
+ const date = new Date(r.created_at).toLocaleDateString();
842
+ const files = safeJson2(r.files_touched, []);
843
+ lines.push(`**Past session (${date}, ${r.project}):** ${r.summary.slice(0, 300)}`);
844
+ if (files.length > 0)
845
+ lines.push(`Files: ${files.slice(0, 5).join(", ")}`);
846
+ }
847
+ lines.push("<!-- end past sessions -->");
848
+ }
849
+ if (advice)
850
+ lines.push(advice);
851
+ return { additionalContext: lines.join(`
852
+ `) };
853
+ }
854
+ async function handleSessionEnd(hook, project) {
855
+ const store = new SessionStore;
856
+ store.completeSession(hook.session_id);
857
+ }
858
+ function stringField3(obj, key) {
859
+ if (!obj)
860
+ return;
861
+ const v = obj[key];
862
+ return typeof v === "string" && v.length > 0 ? v : undefined;
863
+ }
864
+ function safeJson2(text, fallback) {
865
+ try {
866
+ return JSON.parse(text);
867
+ } catch {
868
+ return fallback;
869
+ }
870
+ }
871
+ function projectFromCwd(cwd) {
872
+ if (!cwd)
873
+ return "unknown";
874
+ return basename(cwd);
875
+ }
876
+
877
+ // src/hooks-entry/pre-compact.ts
878
+ async function main() {
879
+ const raw = await Bun.stdin.text();
880
+ if (!raw.trim())
881
+ return;
882
+ let hook;
883
+ try {
884
+ hook = JSON.parse(raw);
885
+ } catch {
886
+ return;
887
+ }
888
+ const instructions = await handlePreCompact(hook, projectFromCwd(process.env["CLAUDE_CWD"] ?? process.cwd()));
889
+ if (instructions.trim())
890
+ process.stdout.write(instructions);
891
+ }
892
+ main().catch(() => {}).finally(() => process.exit(0));