stackmem 1.0.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/src/cli.ts ADDED
@@ -0,0 +1,814 @@
1
+ #!/usr/bin/env tsx
2
+ import nodeFs from "node:fs";
3
+ import nodePath from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { analyzeCodebase } from "./ast-index.js";
7
+ import { getDeviceId } from "./device.js";
8
+ import {
9
+ applyContextBudget,
10
+ buildSummaryContent,
11
+ clusterMemories,
12
+ computeDecayScore,
13
+ computeMemoryLinks,
14
+ decideSaveAction,
15
+ estimateTokens,
16
+ scoreMemoryRelevance,
17
+ } from "./scoring.js";
18
+ import { supabase } from "./storage.js";
19
+
20
+ const CONTEXT_BUDGET_TOKENS = 2000;
21
+ const COMPRESSION_THRESHOLD = 20;
22
+ const CLI_PATH = fileURLToPath(import.meta.url);
23
+ // The hook must always shell out to the compiled CLI, never `tsx src/cli.ts`
24
+ // directly — tsx's esbuild transform fails when invoked from inside a git
25
+ // hook's stripped-down shell environment.
26
+ const DIST_CLI_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "cli.js");
27
+
28
+ const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
29
+ type MemoryType = (typeof MEMORY_TYPES)[number];
30
+
31
+ const TYPE_LABELS: Record<MemoryType, string> = {
32
+ decision: "Decisions",
33
+ constraint: "Constraints",
34
+ discovery: "Discoveries",
35
+ rejection: "Rejections",
36
+ };
37
+
38
+ function usage(): never {
39
+ console.error(`Usage:
40
+ cm start <project>
41
+ cm save <project> <type> <content> [--force] (type: decision|rejection|constraint|discovery)
42
+ cm fix <project> <problem> <solution>
43
+ cm analyze <project> <path>
44
+ cm search <project> <query>
45
+ cm resolve <project> <memory-id>
46
+ cm delete <project> <memory-id>
47
+ cm compress <project>
48
+ cm init <project> <path>
49
+ cm context <project> <task>
50
+ cm help`);
51
+ process.exit(1);
52
+ }
53
+
54
+ function cmdHelp() {
55
+ console.log(`cm — coding memory CLI
56
+
57
+ cm start <project>
58
+ Load all unresolved memories for a project, sorted by decay
59
+ score and capped at a 2000 token context budget, grouped by
60
+ type (decisions, constraints, discoveries, rejections), with
61
+ linked memory ids shown under each entry.
62
+
63
+ cm save <project> <type> <content> [--force]
64
+ Save a new memory. <type> is one of: decision, rejection,
65
+ constraint, discovery. Automatically links to related existing
66
+ memories. If the new memory contradicts an existing one, prompts
67
+ to keep the old memory or replace it — pass --force to skip the
68
+ prompt and always replace.
69
+
70
+ cm fix <project> <problem> <solution>
71
+ Record a problem and its solution for future reference.
72
+
73
+ cm analyze <project> <path>
74
+ Build an AST index (files, functions, classes) for the codebase
75
+ at <path>.
76
+
77
+ cm search <project> <query>
78
+ Search saved memories and past fixes for a project matching
79
+ <query>.
80
+
81
+ cm resolve <project> <memory-id>
82
+ Mark a memory as resolved.
83
+
84
+ cm delete <project> <memory-id>
85
+ Delete a memory and any memory_links referencing it.
86
+
87
+ cm compress <project>
88
+ Cluster related unresolved memories (needs 20+) and collapse
89
+ clusters of 3 or more into a single summary memory.
90
+
91
+ cm init <project> <path>
92
+ Install a git post-commit hook in the repo at <path> that
93
+ records the commit message and any implicit memories (removed
94
+ imports, new env vars, new auth-related files) after every
95
+ commit.
96
+
97
+ cm context <project> <task>
98
+ Return only the top 5 memories most relevant to a specific
99
+ task, ranked by a blend of task relevance and decay score,
100
+ as a markdown block capped at a 2000 token budget.
101
+
102
+ cm help
103
+ Show this help message.`);
104
+ }
105
+
106
+ function fail(message: string): never {
107
+ console.error(`Error: ${message}`);
108
+ process.exit(1);
109
+ }
110
+
111
+ // --- cm start ----------------------------------------------------------
112
+
113
+ async function cmdStart(project: string) {
114
+ const { data, error } = await supabase
115
+ .from("memories")
116
+ .select("*")
117
+ .eq("project", project)
118
+ .eq("resolved", false);
119
+
120
+ if (error) fail(`Failed to load memories: ${error.message}`);
121
+
122
+ const memories = data ?? [];
123
+ if (memories.length === 0) {
124
+ console.log(`No memories found for "${project}".`);
125
+ return;
126
+ }
127
+
128
+ const scoredMemories = memories
129
+ .map((memory) => ({
130
+ ...memory,
131
+ decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
132
+ }))
133
+ .sort((a, b) => b.decay_score - a.decay_score);
134
+
135
+ const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
136
+ const omittedCount = scoredMemories.length - budgetedMemories.length;
137
+ const tokensUsed = budgetedMemories.reduce(
138
+ (sum, memory) => sum + estimateTokens(memory.content),
139
+ 0
140
+ );
141
+
142
+ const memoryIds = budgetedMemories.map((m) => m.id);
143
+ const linkedIdsByMemory = new Map<string, Set<string>>();
144
+
145
+ const idList = memoryIds.join(",");
146
+ const { data: links, error: linksError } = await supabase
147
+ .from("memory_links")
148
+ .select("source_id, target_id")
149
+ .or(`source_id.in.(${idList}),target_id.in.(${idList})`);
150
+
151
+ if (linksError) fail(`Failed to load memory links: ${linksError.message}`);
152
+
153
+ for (const link of links ?? []) {
154
+ if (!linkedIdsByMemory.has(link.source_id)) linkedIdsByMemory.set(link.source_id, new Set());
155
+ if (!linkedIdsByMemory.has(link.target_id)) linkedIdsByMemory.set(link.target_id, new Set());
156
+ linkedIdsByMemory.get(link.source_id)!.add(link.target_id);
157
+ linkedIdsByMemory.get(link.target_id)!.add(link.source_id);
158
+ }
159
+
160
+ console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
161
+
162
+ for (const type of MEMORY_TYPES) {
163
+ const group = budgetedMemories.filter((m) => m.type === type);
164
+ if (group.length === 0) continue;
165
+
166
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
167
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
168
+ for (const memory of group) {
169
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
170
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
171
+ if (linked.length > 0) {
172
+ console.log(` linked: ${linked.join(", ")}`);
173
+ }
174
+ }
175
+ console.log("");
176
+ }
177
+
178
+ console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
179
+ if (omittedCount > 0) {
180
+ console.log(`${omittedCount} memories omitted (budget exceeded)`);
181
+ }
182
+
183
+ const incrementResults = await Promise.all(
184
+ budgetedMemories.map((memory) =>
185
+ supabase
186
+ .from("memories")
187
+ .update({ access_count: (memory.access_count ?? 0) + 1 })
188
+ .eq("id", memory.id)
189
+ )
190
+ );
191
+ const incrementError = incrementResults.find((result) => result.error)?.error;
192
+ if (incrementError) fail(`Failed to update access counts: ${incrementError.message}`);
193
+ }
194
+
195
+ // --- cm save -------------------------------------------------------------
196
+
197
+ // Prompts on stdin before superseding — cm save runs interactively, unlike
198
+ // the MCP save_memory tool, which must supersede automatically since it has
199
+ // no user attached to ask.
200
+ async function confirmSupersede(oldId: string, newContent: string): Promise<boolean> {
201
+ const { data: oldMemory, error } = await supabase
202
+ .from("memories")
203
+ .select("content")
204
+ .eq("id", oldId)
205
+ .single();
206
+
207
+ if (error || !oldMemory) fail(`Failed to load superseded memory: ${error?.message ?? "not found"}`);
208
+
209
+ console.log(`Conflict detected:\n Old: ${oldMemory.content}\n New: ${newContent}\n`);
210
+
211
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
212
+ const answer = (await rl.question("Keep old or replace? (k/r): ")).trim().toLowerCase();
213
+ rl.close();
214
+
215
+ if (answer === "k") {
216
+ console.log("Kept original memory.");
217
+ return false;
218
+ }
219
+ if (answer === "r") {
220
+ return true;
221
+ }
222
+ console.log("Invalid input — kept original memory.");
223
+ return false;
224
+ }
225
+
226
+ async function cmdSave(project: string, type: string, content: string, force = false) {
227
+ if (!MEMORY_TYPES.includes(type as MemoryType)) {
228
+ fail(`Invalid type "${type}". Must be one of: ${MEMORY_TYPES.join(", ")}`);
229
+ }
230
+
231
+
232
+ const { data: existingRows, error: existingError } = await supabase
233
+ .from("memories")
234
+ .select("id, content")
235
+ .eq("project", project)
236
+ .eq("resolved", false);
237
+
238
+ if (existingError) fail(`Failed to load memories: ${existingError.message}`);
239
+
240
+ const existingMemories = existingRows ?? [];
241
+ const decision = decideSaveAction(content, existingMemories);
242
+
243
+ if (decision.action === "duplicate") {
244
+ console.log("Duplicate memory detected — skipping.");
245
+ return;
246
+ }
247
+
248
+ if (decision.action === "superseded" && !force) {
249
+ const shouldSupersede = await confirmSupersede(decision.oldId, content);
250
+ if (!shouldSupersede) return;
251
+ }
252
+
253
+ if (decision.action === "superseded") {
254
+ const { error: resolveError } = await supabase
255
+ .from("memories")
256
+ .update({ resolved: true })
257
+ .eq("id", decision.oldId);
258
+
259
+ if (resolveError) fail(`Failed to resolve superseded memory: ${resolveError.message}`);
260
+ }
261
+
262
+ const { data: inserted, error } = await supabase
263
+ .from("memories")
264
+ .insert({ project, type, content, device_id: getDeviceId() })
265
+ .select("id")
266
+ .single();
267
+
268
+ if (error || !inserted) fail(`Failed to save memory: ${error?.message ?? "no row returned"}`);
269
+
270
+ const newMemoryId = inserted.id;
271
+ const links = computeMemoryLinks(newMemoryId, content, existingMemories);
272
+
273
+ if (links.length > 0) {
274
+ const { error: linkError } = await supabase.from("memory_links").insert(links);
275
+ if (linkError) fail(`Failed to save memory links: ${linkError.message}`);
276
+ }
277
+
278
+ if (decision.action === "superseded") {
279
+ console.log(`Replaced with new memory [${newMemoryId}].`);
280
+ }
281
+
282
+ console.log(`Saved ${type} [${newMemoryId}] for "${project}".`);
283
+ if (links.length > 0) {
284
+ console.log(`Linked to ${links.length} existing memory(ies): ${links.map((l) => l.target_id).join(", ")}`);
285
+ }
286
+
287
+ const { count: unresolvedCount, error: countError } = await supabase
288
+ .from("memories")
289
+ .select("id", { count: "exact", head: true })
290
+ .eq("project", project)
291
+ .eq("resolved", false);
292
+
293
+ if (countError) fail(`Failed to count memories: ${countError.message}`);
294
+
295
+ if ((unresolvedCount ?? 0) >= COMPRESSION_THRESHOLD) {
296
+ await compressMemories(project);
297
+ }
298
+ }
299
+
300
+ // --- memory compression ----------------------------------------------------
301
+
302
+ async function compressMemories(
303
+ project: string
304
+ ): Promise<{ totalUnresolved: number; clustersCompressed: number }> {
305
+ const { data, error } = await supabase
306
+ .from("memories")
307
+ .select("*")
308
+ .eq("project", project)
309
+ .eq("resolved", false);
310
+
311
+ if (error) fail(`Failed to load memories for compression: ${error.message}`);
312
+
313
+ const memories = data ?? [];
314
+ if (memories.length < COMPRESSION_THRESHOLD) {
315
+ return { totalUnresolved: memories.length, clustersCompressed: 0 };
316
+ }
317
+
318
+ const clusters = clusterMemories(memories);
319
+ let clustersCompressed = 0;
320
+
321
+ for (const cluster of clusters) {
322
+ if (cluster.length < 3) continue;
323
+
324
+ const summaryContent = buildSummaryContent(cluster);
325
+ const { error: insertError } = await supabase
326
+ .from("memories")
327
+ .insert({ project, type: "discovery", content: summaryContent, device_id: getDeviceId() });
328
+
329
+ if (insertError) fail(`Failed to insert summary memory: ${insertError.message}`);
330
+
331
+ const idsToResolve = cluster.map((memory) => memory.id);
332
+ const { error: resolveError } = await supabase
333
+ .from("memories")
334
+ .update({ resolved: true })
335
+ .in("id", idsToResolve);
336
+
337
+ if (resolveError) fail(`Failed to resolve compressed memories: ${resolveError.message}`);
338
+
339
+ console.log(`Compressed ${cluster.length} memories into 1 summary`);
340
+ clustersCompressed++;
341
+ }
342
+
343
+ console.log(`Compression complete. ${clustersCompressed} clusters compressed.`);
344
+ return { totalUnresolved: memories.length, clustersCompressed };
345
+ }
346
+
347
+ // --- cm compress -------------------------------------------------------
348
+
349
+ async function cmdCompress(project: string) {
350
+ const result = await compressMemories(project);
351
+ if (result.totalUnresolved < COMPRESSION_THRESHOLD) {
352
+ console.log(
353
+ `Not enough memories to compress (need ${COMPRESSION_THRESHOLD}+, have ${result.totalUnresolved}).`
354
+ );
355
+ }
356
+ }
357
+
358
+ // --- cm fix --------------------------------------------------------------
359
+
360
+ async function cmdFix(project: string, problem: string, solution: string) {
361
+ const { error } = await supabase
362
+ .from("execution_log")
363
+ .insert({ project, problem, solution, resolved: false, device_id: getDeviceId() });
364
+
365
+ if (error) fail(`Failed to record fix: ${error.message}`);
366
+
367
+ console.log(`Fix recorded for "${project}".`);
368
+ }
369
+
370
+ // --- cm analyze ------------------------------------------------------------
371
+
372
+ async function cmdAnalyze(project: string, path: string) {
373
+ void project;
374
+ const summary = analyzeCodebase(path);
375
+ console.log(`Indexed ${summary.files_indexed} file(s):`);
376
+ console.log(` functions: ${summary.functions}`);
377
+ console.log(` classes: ${summary.classes}`);
378
+ }
379
+
380
+ // --- cm search -------------------------------------------------------------
381
+
382
+ async function cmdSearch(project: string, query: string) {
383
+ const { data: memories, error: memoriesError } = await supabase
384
+ .from("memories")
385
+ .select("*")
386
+ .eq("project", project)
387
+ .ilike("content", `%${query}%`);
388
+
389
+ if (memoriesError) fail(`Failed to search memories: ${memoriesError.message}`);
390
+
391
+ const { data: fixes, error: fixesError } = await supabase
392
+ .from("execution_log")
393
+ .select("*")
394
+ .eq("project", project)
395
+ .or(`problem.ilike.%${query}%,solution.ilike.%${query}%`);
396
+
397
+ if (fixesError) fail(`Failed to search past fixes: ${fixesError.message}`);
398
+
399
+ const memoryResults = memories ?? [];
400
+ const fixResults = fixes ?? [];
401
+
402
+ if (memoryResults.length === 0 && fixResults.length === 0) {
403
+ console.log("No results found");
404
+ return;
405
+ }
406
+
407
+ console.log(`Memories (${memoryResults.length})`);
408
+ console.log("-".repeat(11));
409
+ if (memoryResults.length === 0) {
410
+ console.log("No results found");
411
+ } else {
412
+ for (const memory of memoryResults) {
413
+ console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
414
+ }
415
+ }
416
+ console.log("");
417
+
418
+ console.log(`Past Fixes (${fixResults.length})`);
419
+ console.log("-".repeat(14));
420
+ if (fixResults.length === 0) {
421
+ console.log("No results found");
422
+ } else {
423
+ for (const fix of fixResults) {
424
+ console.log(` Problem: ${fix.problem}`);
425
+ console.log(` Solution: ${fix.solution}`);
426
+ console.log("");
427
+ }
428
+ }
429
+ }
430
+
431
+ // --- cm resolve ------------------------------------------------------------
432
+
433
+ async function cmdResolve(project: string, memoryId: string) {
434
+ const { error } = await supabase
435
+ .from("memories")
436
+ .update({ resolved: true })
437
+ .eq("project", project)
438
+ .eq("id", memoryId);
439
+
440
+ if (error) fail(`Failed to resolve memory: ${error.message}`);
441
+
442
+ console.log(`Resolved memory [${memoryId}]`);
443
+ }
444
+
445
+ // --- cm delete -------------------------------------------------------------
446
+
447
+ async function cmdDelete(project: string, memoryId: string) {
448
+ const { error: linksError } = await supabase
449
+ .from("memory_links")
450
+ .delete()
451
+ .or(`source_id.eq.${memoryId},target_id.eq.${memoryId}`);
452
+
453
+ if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
454
+
455
+ const { error } = await supabase
456
+ .from("memories")
457
+ .delete()
458
+ .eq("project", project)
459
+ .eq("id", memoryId);
460
+
461
+ if (error) fail(`Failed to delete memory: ${error.message}`);
462
+
463
+ console.log(`Deleted memory [${memoryId}]`);
464
+ }
465
+
466
+ // --- cm init -------------------------------------------------------------
467
+
468
+ // The hook logic lives in its own .mjs file (not directly in
469
+ // .git/hooks/post-commit) so its module type is unambiguous — an
470
+ // extensionless file run by git would otherwise have its CommonJS/ESM
471
+ // interpretation depend on the target repo's own package.json.
472
+ function buildHookLogicScript(project: string, cliPath: string): string {
473
+ return `#!/usr/bin/env node
474
+ import { execFileSync } from "node:child_process";
475
+
476
+ const PROJECT = ${JSON.stringify(project)};
477
+ const CLI_PATH = ${JSON.stringify(cliPath)};
478
+
479
+ function git(args) {
480
+ return execFileSync("git", args, { encoding: "utf-8" }).trim();
481
+ }
482
+
483
+ function cmSave(type, content) {
484
+ execFileSync("node", [CLI_PATH, "save", PROJECT, type, content], {
485
+ stdio: "inherit",
486
+ });
487
+ }
488
+
489
+ function basename(filePath) {
490
+ return filePath.split("/").pop() ?? filePath;
491
+ }
492
+
493
+ function isConfigFile(filePath) {
494
+ const base = basename(filePath);
495
+ return (
496
+ /\\.config\\.(ts|js)$/.test(base) ||
497
+ base.startsWith("docker-compose") ||
498
+ base === "Dockerfile" ||
499
+ base === "nginx.conf" ||
500
+ base.startsWith(".eslintrc") ||
501
+ base.startsWith(".prettierrc")
502
+ );
503
+ }
504
+
505
+ function isSchemaFile(filePath) {
506
+ return (
507
+ /migration/i.test(filePath) ||
508
+ filePath.includes("schema.prisma") ||
509
+ filePath.includes("schema.sql")
510
+ );
511
+ }
512
+
513
+ const savedItems = [];
514
+
515
+ const commitMessage = git(["log", "-1", "--pretty=%B"]);
516
+ cmSave("decision", commitMessage);
517
+ savedItems.push(\`decision: \${commitMessage}\`);
518
+
519
+ const changedFiles = git(["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"])
520
+ .split("\\n")
521
+ .filter(Boolean);
522
+
523
+ let diff = "";
524
+ try {
525
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
526
+ } catch {
527
+ diff = "";
528
+ }
529
+
530
+ const lines = diff.split("\\n");
531
+ let currentFile = null;
532
+ let isNewFile = false;
533
+ let inDependenciesSection = false;
534
+
535
+ for (const line of lines) {
536
+ if (line.startsWith("diff --git")) {
537
+ isNewFile = false;
538
+ inDependenciesSection = false;
539
+ }
540
+ if (line.startsWith("new file mode")) {
541
+ isNewFile = true;
542
+ }
543
+
544
+ if (line.startsWith("+++ ")) {
545
+ let filePath = line.slice(4);
546
+ if (filePath.startsWith("b/")) filePath = filePath.slice(2);
547
+ currentFile = filePath;
548
+
549
+ if (isNewFile) {
550
+ if (/(auth|login|session|token)/i.test(currentFile)) {
551
+ const content = \`new auth-related file: \${currentFile}\`;
552
+ cmSave("discovery", content);
553
+ savedItems.push(\`discovery: \${content}\`);
554
+ }
555
+ if (isConfigFile(currentFile)) {
556
+ const content = \`new config: \${currentFile}\`;
557
+ cmSave("constraint", content);
558
+ savedItems.push(\`constraint: \${content}\`);
559
+ }
560
+ if (isSchemaFile(currentFile)) {
561
+ const content = \`schema change: \${currentFile}\`;
562
+ cmSave("discovery", content);
563
+ savedItems.push(\`discovery: \${content}\`);
564
+ }
565
+ }
566
+ continue;
567
+ }
568
+
569
+ const isPackageJson = currentFile != null && basename(currentFile) === "package.json";
570
+ if (isPackageJson) {
571
+ const rawLine =
572
+ line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")
573
+ ? line.slice(1)
574
+ : line;
575
+ if (/"(dependencies|devDependencies)"\\s*:\\s*\\{/.test(rawLine)) {
576
+ inDependenciesSection = true;
577
+ } else if (inDependenciesSection && /^\\s*\\}/.test(rawLine)) {
578
+ inDependenciesSection = false;
579
+ }
580
+ }
581
+
582
+ if (line.startsWith("-") && !line.startsWith("---")) {
583
+ const codeLine = line.slice(1).trim();
584
+
585
+ if (codeLine.startsWith("import") || codeLine.startsWith("require")) {
586
+ const pathMatch = codeLine.match(/['"]([^'"]+)['"]/);
587
+ const importPath = pathMatch ? pathMatch[1] : codeLine;
588
+ const content = \`removed dependency: \${importPath}\`;
589
+ cmSave("rejection", content);
590
+ savedItems.push(\`rejection: \${content}\`);
591
+ }
592
+
593
+ if (isPackageJson && inDependenciesSection) {
594
+ const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
595
+ if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
596
+ const content = \`removed dependency: \${pkgMatch[1]}\`;
597
+ cmSave("rejection", content);
598
+ savedItems.push(\`rejection: \${content}\`);
599
+ }
600
+ }
601
+ continue;
602
+ }
603
+
604
+ if (line.startsWith("+") && !line.startsWith("+++")) {
605
+ const codeLine = line.slice(1);
606
+
607
+ if (currentFile) {
608
+ const base = basename(currentFile);
609
+ if (base === ".env" || base === ".env.example") {
610
+ const envMatch = codeLine.match(/([A-Z_]{3,})=/);
611
+ if (envMatch) {
612
+ const content = \`new env var required: \${envMatch[1]}\`;
613
+ cmSave("constraint", content);
614
+ savedItems.push(\`constraint: \${content}\`);
615
+ }
616
+ }
617
+ }
618
+
619
+ if (isPackageJson && inDependenciesSection) {
620
+ const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
621
+ if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
622
+ const content = \`added dependency: \${pkgMatch[1]}\`;
623
+ cmSave("decision", content);
624
+ savedItems.push(\`decision: \${content}\`);
625
+ }
626
+ }
627
+
628
+ if (currentFile && isSchemaFile(currentFile) && /CREATE TABLE|ALTER TABLE|DROP TABLE/i.test(codeLine)) {
629
+ const truncated = codeLine.trim().slice(0, 80);
630
+ const content = \`database change: \${truncated}\`;
631
+ cmSave("discovery", content);
632
+ savedItems.push(\`discovery: \${content}\`);
633
+ }
634
+
635
+ if (/TODO:|FIXME:|HACK:|XXX:/.test(codeLine)) {
636
+ const truncated = codeLine.trim().slice(0, 100);
637
+ const content = \`open issue from commit: \${truncated}\`;
638
+ cmSave("discovery", content);
639
+ savedItems.push(\`discovery: \${content}\`);
640
+ }
641
+ }
642
+ }
643
+
644
+ console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
645
+ for (const item of savedItems) {
646
+ console.log(\` - \${item}\`);
647
+ }
648
+ `;
649
+ }
650
+
651
+ async function cmdInit(project: string, targetPath: string) {
652
+ const resolvedPath = nodePath.resolve(targetPath);
653
+ const gitDir = nodePath.join(resolvedPath, ".git");
654
+
655
+ if (!nodeFs.existsSync(gitDir)) {
656
+ console.error(`No git repo found at ${targetPath}`);
657
+ process.exit(1);
658
+ }
659
+
660
+ const codingMemoryDir = nodePath.join(resolvedPath, ".coding-memory");
661
+ nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
662
+
663
+ const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
664
+ nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(project, DIST_CLI_PATH), "utf-8");
665
+
666
+ const hooksDir = nodePath.join(gitDir, "hooks");
667
+ nodeFs.mkdirSync(hooksDir, { recursive: true });
668
+
669
+ const hookPath = nodePath.join(hooksDir, "post-commit");
670
+ nodeFs.writeFileSync(hookPath, `#!/bin/sh\nexec node "${hookLogicPath}" "$@"\n`, "utf-8");
671
+ nodeFs.chmodSync(hookPath, 0o755);
672
+
673
+ console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
674
+ }
675
+
676
+ // --- cm context ------------------------------------------------------------
677
+
678
+ const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
679
+ const CONTEXT_TOP_N = 5;
680
+
681
+ async function cmdContext(project: string, task: string) {
682
+ const { data, error } = await supabase
683
+ .from("memories")
684
+ .select("*")
685
+ .eq("project", project)
686
+ .eq("resolved", false);
687
+
688
+ if (error) fail(`Failed to load memories: ${error.message}`);
689
+
690
+ const memories = data ?? [];
691
+ if (memories.length === 0) {
692
+ console.log(`No memories found for "${project}".`);
693
+ return;
694
+ }
695
+
696
+ const scoredMemories = memories
697
+ .map((memory) => {
698
+ const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
699
+ const relevanceScore = scoreMemoryRelevance(memory.content, task);
700
+ return {
701
+ ...memory,
702
+ combined_score: relevanceScore * 0.6 + decayScore * 0.4,
703
+ };
704
+ })
705
+ .sort((a, b) => b.combined_score - a.combined_score);
706
+
707
+ const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
708
+ const budgetedMemories = applyContextBudget(topMemories, CONTEXT_BUDGET_TOKENS);
709
+ const tokensUsed = budgetedMemories.reduce(
710
+ (sum, memory) => sum + estimateTokens(memory.content),
711
+ 0
712
+ );
713
+
714
+ const lines: string[] = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
715
+
716
+ for (const type of CONTEXT_TYPE_ORDER) {
717
+ const group = budgetedMemories.filter((memory) => memory.type === type);
718
+ if (group.length === 0) continue;
719
+
720
+ lines.push(`**${TYPE_LABELS[type]}**`);
721
+ for (const memory of group) {
722
+ lines.push(`- ${memory.content}`);
723
+ }
724
+ lines.push("");
725
+ }
726
+
727
+ lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
728
+
729
+ console.log(lines.join("\n"));
730
+ }
731
+
732
+ // --- main --------------------------------------------------------------
733
+
734
+ async function main() {
735
+ const [, , command, ...args] = process.argv;
736
+
737
+ switch (command) {
738
+ case "start": {
739
+ const [project] = args;
740
+ if (!project) usage();
741
+ await cmdStart(project);
742
+ break;
743
+ }
744
+ case "save": {
745
+ const forceIndex = args.indexOf("--force");
746
+ const force = forceIndex !== -1;
747
+ const positional = force
748
+ ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
749
+ : args;
750
+ const [project, type, ...rest] = positional;
751
+ if (!project || !type || rest.length === 0) usage();
752
+ await cmdSave(project, type, rest.join(" "), force);
753
+ break;
754
+ }
755
+ case "fix": {
756
+ const [project, problem, solution] = args;
757
+ if (!project || !problem || !solution) usage();
758
+ await cmdFix(project, problem, solution);
759
+ break;
760
+ }
761
+ case "analyze": {
762
+ const [project, path] = args;
763
+ if (!project || !path) usage();
764
+ await cmdAnalyze(project, path);
765
+ break;
766
+ }
767
+ case "search": {
768
+ const [project, ...rest] = args;
769
+ if (!project || rest.length === 0) usage();
770
+ await cmdSearch(project, rest.join(" "));
771
+ break;
772
+ }
773
+ case "resolve": {
774
+ const [project, memoryId] = args;
775
+ if (!project || !memoryId) usage();
776
+ await cmdResolve(project, memoryId);
777
+ break;
778
+ }
779
+ case "delete": {
780
+ const [project, memoryId] = args;
781
+ if (!project || !memoryId) usage();
782
+ await cmdDelete(project, memoryId);
783
+ break;
784
+ }
785
+ case "compress": {
786
+ const [project] = args;
787
+ if (!project) usage();
788
+ await cmdCompress(project);
789
+ break;
790
+ }
791
+ case "init": {
792
+ const [project, targetPath] = args;
793
+ if (!project || !targetPath) usage();
794
+ await cmdInit(project, targetPath);
795
+ break;
796
+ }
797
+ case "context": {
798
+ const [project, ...rest] = args;
799
+ if (!project || rest.length === 0) usage();
800
+ await cmdContext(project, rest.join(" "));
801
+ break;
802
+ }
803
+ case "help": {
804
+ cmdHelp();
805
+ break;
806
+ }
807
+ default:
808
+ usage();
809
+ }
810
+ }
811
+
812
+ main().catch((error) => {
813
+ fail(error instanceof Error ? error.message : String(error));
814
+ });