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