stackmem 1.0.26 → 1.0.27

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 CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  isCodeFile,
25
25
  hasTokenOverlap,
26
26
  isQualityMessage,
27
+ parseUtcDate,
27
28
  redactSecrets,
28
29
  scoreMemoryRelevance,
29
30
  tokenize,
@@ -71,7 +72,7 @@ const TYPE_LABELS: Record<MemoryType, string> = {
71
72
 
72
73
  function usage(): never {
73
74
  console.error(`Usage:
74
- cm start [project]
75
+ cm start [project] [--hook]
75
76
  cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
76
77
  cm fix [project] <problem> <solution>
77
78
  cm analyze [project] <path>
@@ -79,17 +80,22 @@ function usage(): never {
79
80
  cm resolve [project] <memory-id>
80
81
  cm pin [project] <memory-id>
81
82
  cm unpin [project] <memory-id>
83
+ cm review [--project <name>]
84
+ cm edit [project] <memory-id>
82
85
  cm delete [project] <memory-id>
83
86
  cm inspect [project] <memory-id>
84
87
  cm compress [project]
85
88
  cm init <project> <path> [--config]
86
89
  cm uninstall [project] [path]
87
90
  cm link
91
+ cm open [--project <name>]
92
+ cm purge [--project <name>] [--all]
88
93
  cm sync
89
- cm context [--project <name>] <task>
94
+ cm context [--project <name>] <task> [--hook]
90
95
  cm why [--project <name>] <file>
91
- cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
96
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
92
97
  cm doctor [--fix]
98
+ cm status [--project <name>]
93
99
  cm undo [project]
94
100
  cm export [filename]
95
101
  cm import [project] <filename>
@@ -111,11 +117,13 @@ function cmdHelp() {
111
117
  [project] is optional everywhere below if a .stackmem file exists in
112
118
  the current directory (written by 'cm init'). Otherwise pass it explicitly.
113
119
 
114
- cm start [project]
120
+ cm start [project] [--hook]
115
121
  Load all unresolved memories for a project, sorted by decay
116
122
  score and capped at a 2000 token context budget, grouped by
117
123
  type (decisions, constraints, discoveries, rejections), with
118
- linked memory ids shown under each entry.
124
+ linked memory ids shown under each entry. --hook switches to
125
+ plain-text output (no ids, scores, or separator lines) for use
126
+ as a Claude Code SessionStart hook command — see cm init.
119
127
 
120
128
  cm save [project] <type> <content> [--force]
121
129
  Save a new memory. <type> is one of: decision, rejection,
@@ -147,6 +155,18 @@ function cmdHelp() {
147
155
  cm unpin [project] <memory-id>
148
156
  Un-pin a memory, returning it to normal decay-based ranking.
149
157
 
158
+ cm review [--project <name>]
159
+ Walk through suggested memories one at a time (hook-captured
160
+ memories start as suggested — see cm save/cm init). For each:
161
+ (k)eep promotes it to permanent, (e)dit opens $EDITOR (or an
162
+ inline prompt) for new content then keeps it, (d)rop deletes
163
+ it, (s)kip leaves it suggested for next time, (q)uit stops and
164
+ reports progress so far. Prints a kept/dropped/skipped summary.
165
+
166
+ cm edit [project] <memory-id>
167
+ Open a memory's content in $EDITOR (or an inline prompt if
168
+ $EDITOR isn't set), save the result.
169
+
150
170
  cm delete [project] <memory-id>
151
171
  Delete a memory and any memory_links referencing it.
152
172
 
@@ -194,6 +214,20 @@ function cmdHelp() {
194
214
  single-use and expires after 10 minutes; polling stops when it
195
215
  does. Links are per-device, so run this once on each machine.
196
216
 
217
+ cm open [--project <name>]
218
+ Open the hosted vault's page for this project in the default
219
+ browser (open on Mac, xdg-open on Linux, start on Windows).
220
+
221
+ cm purge [--project <name>] [--all]
222
+ Permanently delete memories, fixes, and memory_links — with a
223
+ typed confirmation first, never deletes on a flag alone.
224
+ --project (or the one in .stackmem): deletes that project's
225
+ data only, after you type the exact project name to confirm.
226
+ --all: deletes everything for this device's device_id across
227
+ every project, after you type DELETE to confirm. Prints the
228
+ count of deleted records, or "Confirmation did not match" if
229
+ you abort.
230
+
197
231
  cm sync
198
232
  Regenerate .cursorrules and .windsurfrules from the project's
199
233
  current unresolved memories (decay-ranked, capped at a 2000
@@ -201,11 +235,13 @@ function cmdHelp() {
201
235
  overwriting both files on disk only (never git add/commit).
202
236
  Project is read from .stackmem.
203
237
 
204
- cm context [--project <name>] <task>
238
+ cm context [--project <name>] <task> [--hook]
205
239
  Return only the top 5 memories most relevant to a specific
206
240
  task, ranked by a blend of task relevance and decay score,
207
241
  as a markdown block capped at a 2000 token budget. Use
208
242
  --project to target a project other than the one in .stackmem.
243
+ <task> is optional with --hook (ranks by decay alone) — used
244
+ as a Claude Code PreCompact hook command; see cm init.
209
245
 
210
246
  cm why [--project <name>] <file>
211
247
  Show memories tied to a file: matched by Item 6's file_paths
@@ -215,7 +251,7 @@ function cmdHelp() {
215
251
  nothing matches. Use --project to target a project other than
216
252
  the one in .stackmem.
217
253
 
218
- cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
254
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
219
255
  Capture what happened in a session and extract memories: each
220
256
  error becomes an unsolved execution_log entry, each approach
221
257
  containing "failed", "didn't work", "reverted", or "switched
@@ -225,6 +261,11 @@ function cmdHelp() {
225
261
  Accepts the same fields as JSON on stdin instead of flags
226
262
  (project, summary, errors_encountered, files_touched,
227
263
  approaches_tried) when stdin is piped rather than a terminal.
264
+ --hook is accepted for use as a Claude Code Stop hook command
265
+ (see cm init) but is currently a no-op: Claude Code pipes its
266
+ own hook payload on stdin, not this command's expected shape,
267
+ so a Stop-triggered run captures 0 memories until a future
268
+ phase parses the session transcript directly.
228
269
 
229
270
  cm doctor [--fix]
230
271
  Check every integration point in the current directory — backend
@@ -237,6 +278,12 @@ function cmdHelp() {
237
278
  — those print guidance instead. Reports "Fixed: <item>" or
238
279
  "Could not auto-fix: <item>" for each failing check.
239
280
 
281
+ cm status [--project <name>]
282
+ One-screen project summary: memory/pinned/suggested counts,
283
+ recorded fixes, the active token budget (and whether it's the
284
+ default or from .stackmem.json), time since the last capture,
285
+ and hook/MCP/backend/editor registration status.
286
+
240
287
  cm undo [project]
241
288
  Delete the most recently saved unresolved memory (and any
242
289
  memory_links referencing it) for a project.
@@ -265,11 +312,35 @@ function fail(message: string): never {
265
312
 
266
313
  const STACKMEM_FILE = ".stackmem";
267
314
 
315
+ const MONOREPO_PARENT_LOOKUP_DEPTH = 3;
316
+
317
+ // Checks cwd, then up to 3 parent directories, for a .stackmem file — a
318
+ // monorepo subpackage (e.g. packages/api) won't have its own .stackmem,
319
+ // but the repo root will. When found in an ancestor, the project name
320
+ // gets the intervening path segments appended, e.g. "myapp/packages/api"
321
+ // — so a subpackage still reads/writes under its own namespace within
322
+ // the shared project, rather than silently resolving to just "myapp".
268
323
  function readProjectFromFile(): string | null {
269
- const stackmemPath = nodePath.join(process.cwd(), STACKMEM_FILE);
270
- if (!nodeFs.existsSync(stackmemPath)) return null;
271
- const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
272
- return content.length > 0 ? content : null;
324
+ let dir = process.cwd();
325
+ const subdirSegments: string[] = [];
326
+
327
+ for (let depth = 0; depth <= MONOREPO_PARENT_LOOKUP_DEPTH; depth++) {
328
+ const stackmemPath = nodePath.join(dir, STACKMEM_FILE);
329
+ if (nodeFs.existsSync(stackmemPath)) {
330
+ const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
331
+ if (content.length === 0) return null;
332
+ return subdirSegments.length > 0
333
+ ? `${content}/${subdirSegments.reverse().join("/")}`
334
+ : content;
335
+ }
336
+
337
+ const parent = nodePath.dirname(dir);
338
+ if (parent === dir) break; // reached the filesystem root
339
+ subdirSegments.push(nodePath.basename(dir));
340
+ dir = parent;
341
+ }
342
+
343
+ return null;
273
344
  }
274
345
 
275
346
  // Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
@@ -352,24 +423,52 @@ const BRANCH_NAME_BOOST = 1.2;
352
423
  const RECENT_FILES_BOOST = 1.3;
353
424
  const PR_TITLE_BOOST = 1.2;
354
425
 
355
- // null means "signal unavailable" (git/gh failed, detached HEAD, not
356
- // enough history, gh not installed, no open PR, etc.) — every one of
357
- // these is a soft skip, never an error, per the task spec.
426
+ // null means "signal unavailable" (git/gh failed, not enough history, gh
427
+ // not installed, no open PR, etc.) — every one of these is a soft skip,
428
+ // never an error, per the task spec.
358
429
  function getBranchNameTokens(): Set<string> | null {
430
+ let branch: string;
359
431
  try {
360
- const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
432
+ // symbolic-ref (not rev-parse --abbrev-ref) specifically because it
433
+ // fails on a detached HEAD rather than returning the literal string
434
+ // "HEAD" — that failure is the signal this needs to distinguish a
435
+ // real detached-HEAD state from "not a git repo at all" below.
436
+ branch = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
361
437
  encoding: "utf-8",
362
438
  stdio: ["ignore", "pipe", "ignore"],
363
439
  }).trim();
364
- if (!branch || branch === "HEAD") return null; // detached HEAD
365
- const tokens = tokenize(branch);
366
- return tokens.size > 0 ? tokens : null;
367
440
  } catch {
368
- return null;
441
+ try {
442
+ execFileSync("git", ["rev-parse", "HEAD"], { stdio: "pipe" });
443
+ // HEAD resolves to a real commit but symbolic-ref failed — genuinely
444
+ // detached, not a missing repo. Still a valid state to rank in, not
445
+ // a reason to skip the signal entirely.
446
+ branch = "detached";
447
+ } catch {
448
+ return null;
449
+ }
369
450
  }
451
+
452
+ const tokens = tokenize(branch);
453
+ return tokens.size > 0 ? tokens : null;
370
454
  }
371
455
 
372
456
  function getRecentlyTouchedFiles(): Set<string> | null {
457
+ try {
458
+ const isShallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
459
+ encoding: "utf-8",
460
+ stdio: ["ignore", "pipe", "ignore"],
461
+ }).trim();
462
+ // A shallow clone can't look back RECENT_COMMITS_FOR_FILE_SIGNAL
463
+ // commits — degrade gracefully rather than attempting (and either
464
+ // failing, or silently returning an incomplete picture of "recent").
465
+ if (isShallow === "true") return null;
466
+ } catch {
467
+ // --is-shallow-repository itself failing (very old git, or no repo at
468
+ // all) isn't a reason to skip on its own — the lookback below will
469
+ // fail (and get caught) or succeed on its own merits either way.
470
+ }
471
+
373
472
  try {
374
473
  const output = execFileSync(
375
474
  "git",
@@ -413,20 +512,62 @@ function fileListOverlaps(filePaths: unknown, recentFiles: Set<string>): boolean
413
512
  });
414
513
  }
415
514
 
416
- async function cmdStart(project: string) {
515
+ // Plain-text, no ids/scores/box-drawing — meant to be injected directly
516
+ // into a Claude Code SessionStart hook's context, not read by a human at
517
+ // a terminal (that's the default, non---hook rendering below).
518
+ function printStartHookOutput(
519
+ project: string,
520
+ pinnedMemories: any[],
521
+ unpinnedMemories: any[],
522
+ budgetedFixes: { problem: string; solution: string | null }[]
523
+ ): void {
524
+ console.log(`# Project memory: ${project}`);
525
+
526
+ if (pinnedMemories.length > 0) {
527
+ console.log("");
528
+ console.log("## Pinned");
529
+ for (const memory of pinnedMemories) {
530
+ console.log(`- ${memory.content}`);
531
+ }
532
+ }
533
+
534
+ for (const type of MEMORY_TYPES) {
535
+ const group = unpinnedMemories.filter((m) => m.type === type);
536
+ if (group.length === 0) continue;
537
+
538
+ console.log("");
539
+ console.log(`## ${TYPE_LABELS[type]}`);
540
+ for (const memory of group) {
541
+ console.log(`- ${memory.content}`);
542
+ }
543
+ }
544
+
545
+ if (budgetedFixes.length > 0) {
546
+ console.log("");
547
+ console.log("## Past Fixes");
548
+ for (const fix of budgetedFixes) {
549
+ console.log(`- ${fix.problem} -> ${fix.solution ?? "(pending)"}`);
550
+ }
551
+ }
552
+ }
553
+
554
+ async function cmdStart(project: string, hookMode = false) {
417
555
  const { data, error } = await supabase
418
556
  .from("memories")
419
557
  .select("*")
420
558
  .eq("project", project)
421
- .eq("resolved", false);
559
+ .eq("resolved", false)
560
+ .eq("suggested", false);
422
561
 
423
562
  if (error) fail(`Failed to load memories: ${error.message}`);
424
563
 
425
564
  const memories = data ?? [];
426
565
  if (memories.length === 0) {
427
- console.log(`No memories found for "${project}".`);
428
- console.log("");
429
- console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
566
+ if (!hookMode) {
567
+ console.log(`No memories found for "${project}".`);
568
+ console.log("");
569
+ console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
570
+ }
430
571
  return;
431
572
  }
432
573
 
@@ -444,7 +585,7 @@ async function cmdStart(project: string) {
444
585
  const scoredMemories = memories
445
586
  .map((memory) => {
446
587
  let decayScore = computeDecayScore(
447
- new Date(memory.created_at),
588
+ parseUtcDate(memory.created_at),
448
589
  memory.access_count ?? 0,
449
590
  memory.pinned ?? false,
450
591
  config.decayLambda
@@ -510,59 +651,63 @@ async function cmdStart(project: string) {
510
651
  linkedIdsByMemory.get(link.target_id)!.add(link.source_id);
511
652
  }
512
653
 
513
- console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
514
-
515
654
  const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
516
655
  const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
517
656
 
518
- if (pinnedMemories.length > 0) {
519
- console.log(`Pinned (${pinnedMemories.length})`);
520
- console.log("-".repeat(11));
521
- for (const memory of pinnedMemories) {
522
- console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
523
- const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
524
- if (linked.length > 0) {
525
- console.log(` linked: ${linked.join(", ")}`);
657
+ if (hookMode) {
658
+ printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes);
659
+ } else {
660
+ console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
661
+
662
+ if (pinnedMemories.length > 0) {
663
+ console.log(`Pinned (${pinnedMemories.length})`);
664
+ console.log("-".repeat(11));
665
+ for (const memory of pinnedMemories) {
666
+ console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
667
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
668
+ if (linked.length > 0) {
669
+ console.log(` linked: ${linked.join(", ")}`);
670
+ }
526
671
  }
672
+ console.log("");
527
673
  }
528
- console.log("");
529
- }
530
674
 
531
- for (const type of MEMORY_TYPES) {
532
- const group = unpinnedMemories.filter((m) => m.type === type);
533
- if (group.length === 0) continue;
675
+ for (const type of MEMORY_TYPES) {
676
+ const group = unpinnedMemories.filter((m) => m.type === type);
677
+ if (group.length === 0) continue;
534
678
 
535
- console.log(`${TYPE_LABELS[type]} (${group.length})`);
536
- console.log("-".repeat(TYPE_LABELS[type].length + 4));
537
- for (const memory of group) {
538
- console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
539
- const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
540
- if (linked.length > 0) {
541
- console.log(` linked: ${linked.join(", ")}`);
679
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
680
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
681
+ for (const memory of group) {
682
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
683
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
684
+ if (linked.length > 0) {
685
+ console.log(` linked: ${linked.join(", ")}`);
686
+ }
542
687
  }
688
+ console.log("");
543
689
  }
544
- console.log("");
545
- }
546
690
 
547
- if (budgetedFixes.length > 0) {
548
- console.log(`Past Fixes (${budgetedFixes.length})`);
549
- console.log("-".repeat(14));
550
- for (const fix of budgetedFixes) {
551
- console.log(` Problem: ${fix.problem}`);
552
- console.log(` Solution: ${fix.solution ?? "(pending)"}`);
553
- console.log("");
691
+ if (budgetedFixes.length > 0) {
692
+ console.log(`Past Fixes (${budgetedFixes.length})`);
693
+ console.log("-".repeat(14));
694
+ for (const fix of budgetedFixes) {
695
+ console.log(` Problem: ${fix.problem}`);
696
+ console.log(` Solution: ${fix.solution ?? "(pending)"}`);
697
+ console.log("");
698
+ }
554
699
  }
555
- }
556
700
 
557
- console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
558
- if (omittedCount > 0) {
559
- console.log(`${omittedCount} memories omitted (budget exceeded)`);
560
- }
701
+ console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
702
+ if (omittedCount > 0) {
703
+ console.log(`${omittedCount} memories omitted (budget exceeded)`);
704
+ }
561
705
 
562
- const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
563
- if (!hasTechStackMemory) {
564
- console.log("");
565
- console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
706
+ const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
707
+ if (!hasTechStackMemory) {
708
+ console.log("");
709
+ console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
710
+ }
566
711
  }
567
712
 
568
713
  const incrementResults = await Promise.all(
@@ -681,6 +826,11 @@ async function cmdSave(
681
826
  device_id: getDeviceId(),
682
827
  source,
683
828
  confidence,
829
+ // Hook-captured memories are heuristically detected and could be
830
+ // noisy or wrong — they start as suggested, pending `cm review`, and
831
+ // are excluded from cm start/context/why until promoted. Manual and
832
+ // mcp saves are already user-vetted, so they're permanent immediately.
833
+ suggested: source === "hook",
684
834
  };
685
835
  if (commitSha) insertPayload.commit_sha = commitSha;
686
836
  if (filePaths && filePaths.length > 0) insertPayload.file_paths = filePaths;
@@ -767,7 +917,8 @@ async function compressMemories(
767
917
  .from("memories")
768
918
  .select("*")
769
919
  .eq("project", project)
770
- .eq("resolved", false);
920
+ .eq("resolved", false)
921
+ .eq("suggested", false);
771
922
 
772
923
  if (error) fail(`Failed to load memories for compression: ${error.message}`);
773
924
 
@@ -971,10 +1122,173 @@ async function cmdDelete(project: string, memoryId: string) {
971
1122
  console.log(`Deleted memory [${memoryId}]`);
972
1123
  }
973
1124
 
1125
+ // --- shared: relative time + $EDITOR prompt (cm review, cm edit, cm status)
1126
+
1127
+ function formatRelativeTime(date: Date): string {
1128
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1129
+ if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"} ago`;
1130
+ const minutes = Math.floor(seconds / 60);
1131
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
1132
+ const hours = Math.floor(minutes / 60);
1133
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
1134
+ const days = Math.floor(hours / 24);
1135
+ return `${days} day${days === 1 ? "" : "s"} ago`;
1136
+ }
1137
+
1138
+ // Opens $EDITOR on a scratch file pre-filled with the current content, or
1139
+ // falls back to an inline readline prompt when $EDITOR isn't set. Returns
1140
+ // the original content unchanged if the result is blank (an empty save
1141
+ // buffer almost certainly means "I didn't mean to clear this", not "make
1142
+ // this memory empty").
1143
+ async function editContentInteractively(currentContent: string): Promise<string> {
1144
+ const editor = process.env.EDITOR;
1145
+
1146
+ if (editor) {
1147
+ const tmpFile = nodePath.join(nodeOs.tmpdir(), `stackmem-edit-${randomInt(1_000_000_000)}.txt`);
1148
+ nodeFs.writeFileSync(tmpFile, currentContent, "utf-8");
1149
+ try {
1150
+ execFileSync(editor, [tmpFile], { stdio: "inherit" });
1151
+ const edited = nodeFs.readFileSync(tmpFile, "utf-8").trim();
1152
+ return edited.length > 0 ? edited : currentContent;
1153
+ } finally {
1154
+ try {
1155
+ nodeFs.unlinkSync(tmpFile);
1156
+ } catch {
1157
+ // Best-effort cleanup — a leftover temp file isn't worth failing on.
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ console.log(`Current: ${currentContent}`);
1163
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1164
+ const answer = (await rl.question("New content (leave blank to keep unchanged): ")).trim();
1165
+ rl.close();
1166
+ return answer.length > 0 ? answer : currentContent;
1167
+ }
1168
+
1169
+ // --- cm review -------------------------------------------------------------
1170
+
1171
+ async function cmdReview(project: string): Promise<void> {
1172
+ const { data, error } = await supabase
1173
+ .from("memories")
1174
+ .select("*")
1175
+ .eq("project", project)
1176
+ .eq("resolved", false)
1177
+ .eq("suggested", true)
1178
+ .order("created_at", { ascending: true });
1179
+
1180
+ if (error) fail(`Failed to load suggested memories: ${error.message}`);
1181
+
1182
+ const memories = data ?? [];
1183
+ if (memories.length === 0) {
1184
+ console.log(`No memories to review for "${project}".`);
1185
+ console.log("Your inbox is empty.");
1186
+ return;
1187
+ }
1188
+
1189
+ console.log(`Reviewing ${memories.length} suggested memories for "${project}"`);
1190
+ console.log("━".repeat(40));
1191
+
1192
+ // Node's readline can't reliably answer multiple sequential
1193
+ // rl.question() calls over piped (non-TTY) stdin — all buffered input
1194
+ // arrives before the second question is even issued, and it never
1195
+ // resolves. A real interactive terminal doesn't hit this (keystrokes
1196
+ // arrive one at a time), so only the non-TTY path needs the
1197
+ // read-everything-up-front workaround.
1198
+ const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
1199
+ const pipedAnswers: string[] = rl
1200
+ ? []
1201
+ : (
1202
+ await new Promise<string>((resolve) => {
1203
+ let buf = "";
1204
+ process.stdin.setEncoding("utf-8");
1205
+ process.stdin.on("data", (chunk) => (buf += chunk));
1206
+ process.stdin.on("end", () => resolve(buf));
1207
+ })
1208
+ ).split("\n");
1209
+
1210
+ async function nextAnswer(prompt: string): Promise<string> {
1211
+ if (rl) return (await rl.question(prompt)).trim().toLowerCase();
1212
+ console.log(prompt);
1213
+ return (pipedAnswers.shift() ?? "q").trim().toLowerCase();
1214
+ }
1215
+
1216
+ let kept = 0;
1217
+ let dropped = 0;
1218
+ let skipped = 0;
1219
+
1220
+ for (let i = 0; i < memories.length; i++) {
1221
+ const memory = memories[i];
1222
+ console.log(`[${i + 1}/${memories.length}] ${memory.type}: ${memory.content}`);
1223
+ console.log(` Captured: ${formatRelativeTime(parseUtcDate(memory.created_at))}`);
1224
+ console.log("");
1225
+
1226
+ const answer = await nextAnswer("(k)eep (e)dit (d)rop (s)kip (q)uit > ");
1227
+ console.log("");
1228
+
1229
+ if (answer === "k") {
1230
+ const { error: updateError } = await supabase
1231
+ .from("memories")
1232
+ .update({ suggested: false })
1233
+ .eq("id", memory.id);
1234
+ if (!updateError) kept++;
1235
+ } else if (answer === "e") {
1236
+ const newContent = redactSecrets(await editContentInteractively(memory.content));
1237
+ const { error: updateError } = await supabase
1238
+ .from("memories")
1239
+ .update({ content: newContent, suggested: false })
1240
+ .eq("id", memory.id);
1241
+ if (!updateError) kept++;
1242
+ } else if (answer === "d") {
1243
+ await supabase
1244
+ .from("memory_links")
1245
+ .delete()
1246
+ .or(`source_id.eq.${memory.id},target_id.eq.${memory.id}`);
1247
+ const { error: deleteError } = await supabase.from("memories").delete().eq("id", memory.id);
1248
+ if (!deleteError) dropped++;
1249
+ } else if (answer === "q") {
1250
+ break;
1251
+ } else {
1252
+ // "s", blank, or anything unrecognized — leave it suggested and
1253
+ // move on rather than erroring on a mistyped key.
1254
+ skipped++;
1255
+ }
1256
+ }
1257
+
1258
+ rl?.close();
1259
+ console.log(`Review complete. Kept ${kept}, dropped ${dropped}, skipped ${skipped}.`);
1260
+ }
1261
+
1262
+ // --- cm edit ---------------------------------------------------------------
1263
+
1264
+ async function cmdEdit(project: string, memoryId: string): Promise<void> {
1265
+ const { data: memory, error } = await supabase
1266
+ .from("memories")
1267
+ .select("id, content")
1268
+ .eq("project", project)
1269
+ .eq("id", memoryId)
1270
+ .single();
1271
+
1272
+ if (error || !memory) {
1273
+ console.log(`No memory found with ID [${memoryId}]`);
1274
+ return;
1275
+ }
1276
+
1277
+ const newContent = redactSecrets(await editContentInteractively(memory.content));
1278
+ const { error: updateError } = await supabase
1279
+ .from("memories")
1280
+ .update({ content: newContent })
1281
+ .eq("id", memoryId);
1282
+
1283
+ if (updateError) fail(`Failed to update memory: ${updateError.message}`);
1284
+
1285
+ console.log(`Updated memory [${memoryId}].`);
1286
+ }
1287
+
974
1288
  // --- cm inspect ------------------------------------------------------------
975
1289
 
976
1290
  function formatDate(dateString: string): string {
977
- return new Date(dateString).toLocaleDateString("en-US", {
1291
+ return parseUtcDate(dateString).toLocaleDateString("en-US", {
978
1292
  month: "short",
979
1293
  day: "numeric",
980
1294
  year: "numeric",
@@ -993,7 +1307,7 @@ async function cmdInspect(project: string, memoryId: string) {
993
1307
 
994
1308
  const config = readStackmemConfig();
995
1309
  const decayScore = computeDecayScore(
996
- new Date(memory.created_at),
1310
+ parseUtcDate(memory.created_at),
997
1311
  memory.access_count ?? 0,
998
1312
  memory.pinned ?? false,
999
1313
  config.decayLambda
@@ -1717,6 +2031,122 @@ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()): boolean
1717
2031
  return true;
1718
2032
  }
1719
2033
 
2034
+ // --- MCP registration for other editors ---------------------------------
2035
+
2036
+ // Each entry detects its editor by an existing config directory (never
2037
+ // erroring if absent — "skip silently" per the task) and merges stackmem
2038
+ // into whatever's already there, same spirit as registerMcpServer above.
2039
+ // homeDir is a parameter (not a hardcoded nodeOs.homedir() call) so tests
2040
+ // can point at a scratch directory instead of the real one.
2041
+ interface EditorMcpTarget {
2042
+ name: string;
2043
+ configPath: (homeDir: string) => string | null;
2044
+ register: (configPath: string, distIndexPath: string) => void;
2045
+ }
2046
+
2047
+ function mergeMcpServersJson(configPath: string, distIndexPath: string): void {
2048
+ let config: { mcpServers?: Record<string, unknown> } = {};
2049
+ if (nodeFs.existsSync(configPath)) {
2050
+ try {
2051
+ config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2052
+ } catch {
2053
+ config = {};
2054
+ }
2055
+ }
2056
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
2057
+ config.mcpServers = {};
2058
+ }
2059
+ config.mcpServers.stackmem = { command: "node", args: [distIndexPath] };
2060
+
2061
+ nodeFs.mkdirSync(nodePath.dirname(configPath), { recursive: true });
2062
+ nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
2063
+ }
2064
+
2065
+ // VS Code installs the Copilot Chat extension under a version-suffixed
2066
+ // directory name (e.g. "github.copilot-chat-0.26.4") — this resolves the
2067
+ // "github.copilot-chat*" glob the task describes against whatever's
2068
+ // actually installed, rather than assuming a fixed folder name.
2069
+ function findCopilotChatExtensionDir(homeDir: string): string | null {
2070
+ const extensionsDir = nodePath.join(homeDir, ".vscode", "extensions");
2071
+ if (!nodeFs.existsSync(extensionsDir)) return null;
2072
+
2073
+ try {
2074
+ const match = nodeFs
2075
+ .readdirSync(extensionsDir)
2076
+ .find((entry) => entry.startsWith("github.copilot-chat"));
2077
+ return match ? nodePath.join(extensionsDir, match) : null;
2078
+ } catch {
2079
+ return null;
2080
+ }
2081
+ }
2082
+
2083
+ const EDITOR_MCP_TARGETS: EditorMcpTarget[] = [
2084
+ {
2085
+ name: "Cursor",
2086
+ configPath: (homeDir) => {
2087
+ const dir = nodePath.join(homeDir, ".cursor");
2088
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp.json") : null;
2089
+ },
2090
+ register: mergeMcpServersJson,
2091
+ },
2092
+ {
2093
+ name: "Windsurf",
2094
+ configPath: (homeDir) => {
2095
+ const dir = nodePath.join(homeDir, ".codeium", "windsurf");
2096
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp_config.json") : null;
2097
+ },
2098
+ register: mergeMcpServersJson,
2099
+ },
2100
+ {
2101
+ name: "VS Code (Copilot)",
2102
+ configPath: (homeDir) => {
2103
+ const extensionDir = findCopilotChatExtensionDir(homeDir);
2104
+ return extensionDir ? nodePath.join(extensionDir, "mcp.json") : null;
2105
+ },
2106
+ register: mergeMcpServersJson,
2107
+ },
2108
+ {
2109
+ name: "Zed",
2110
+ configPath: (homeDir) => {
2111
+ const dir = nodePath.join(homeDir, ".config", "zed");
2112
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "settings.json") : null;
2113
+ },
2114
+ register: (configPath, distIndexPath) => {
2115
+ let config: { context_servers?: Record<string, unknown> } = {};
2116
+ if (nodeFs.existsSync(configPath)) {
2117
+ try {
2118
+ config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2119
+ } catch {
2120
+ config = {};
2121
+ }
2122
+ }
2123
+ if (!config.context_servers || typeof config.context_servers !== "object") {
2124
+ config.context_servers = {};
2125
+ }
2126
+ config.context_servers.stackmem = { command: "node", args: [distIndexPath] };
2127
+ nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
2128
+ },
2129
+ },
2130
+ ];
2131
+
2132
+ // Registers stackmem's MCP server with every detected editor, printing a
2133
+ // line per success. An editor whose config directory doesn't exist is
2134
+ // skipped with no output and never an error.
2135
+ function registerEditorMcpServers(distIndexPath: string, homeDir = nodeOs.homedir()): void {
2136
+ for (const target of EDITOR_MCP_TARGETS) {
2137
+ const configPath = target.configPath(homeDir);
2138
+ if (!configPath) continue;
2139
+
2140
+ try {
2141
+ target.register(configPath, distIndexPath);
2142
+ console.log(`✓ MCP registered for ${target.name}`);
2143
+ } catch {
2144
+ // Best-effort — a permissions error or malformed existing config on
2145
+ // one editor shouldn't abort `cm init` or the other editors' writes.
2146
+ }
2147
+ }
2148
+ }
2149
+
1720
2150
  const DEFAULT_GITIGNORE_CONTENT = `node_modules/
1721
2151
  dist/
1722
2152
  .env
@@ -1728,6 +2158,7 @@ coverage/
1728
2158
  *.db
1729
2159
  *cursorrules
1730
2160
  *windsurfrules
2161
+ .cursor/rules/stackmem.md
1731
2162
  `;
1732
2163
 
1733
2164
  // Returns true if the file was created, false if it already existed
@@ -1767,6 +2198,109 @@ cm save ${project} <type> "<content>"
1767
2198
  console.log("CLAUDE.md written.");
1768
2199
  }
1769
2200
 
2201
+ // Same instructional purpose as writeClaudeMd — point the agent at
2202
+ // `cm start` rather than embedding a memory snapshot directly, since a
2203
+ // baked-in snapshot goes stale the moment the next commit lands. AGENTS.md
2204
+ // is the convention used by OpenAI Codex CLI, Gemini CLI, and others;
2205
+ // GEMINI.md is Gemini CLI's own equivalent — both get identical content.
2206
+ function writeAgentsMemoryFile(project: string, resolvedPath: string, filename: string): void {
2207
+ const filePath = nodePath.join(resolvedPath, filename);
2208
+
2209
+ if (nodeFs.existsSync(filePath)) {
2210
+ console.log(`${filename} already exists — skipping.`);
2211
+ return;
2212
+ }
2213
+
2214
+ const content = `# ${project} — Agent Memory
2215
+
2216
+ This project uses stackmem for persistent agent memory.
2217
+ Run \`cm start ${project}\` at the start of every session
2218
+ to load relevant context.
2219
+
2220
+ ## Key decisions, constraints and discoveries
2221
+
2222
+ Decisions, constraints, discoveries, and rejections live in the stackmem
2223
+ backend, not duplicated in this file — run \`cm start ${project}\` to load
2224
+ the current list.
2225
+
2226
+ When you make a decision, discover a constraint, reject an approach, or
2227
+ make a discovery during this session, save it with:
2228
+ cm save ${project} <type> "<content>"
2229
+ `;
2230
+
2231
+ nodeFs.writeFileSync(filePath, content, "utf-8");
2232
+ console.log(`${filename} written.`);
2233
+ }
2234
+
2235
+ // --- Claude Code hooks (.claude/settings.json) ------------------------
2236
+
2237
+ interface ClaudeHookHandler {
2238
+ type: string;
2239
+ command: string;
2240
+ }
2241
+
2242
+ interface ClaudeHookGroup {
2243
+ matcher?: string;
2244
+ hooks: ClaudeHookHandler[];
2245
+ }
2246
+
2247
+ interface ClaudeSettingsFile {
2248
+ hooks?: Record<string, ClaudeHookGroup[]>;
2249
+ [key: string]: unknown;
2250
+ }
2251
+
2252
+ // --project (not positional) for the PreCompact command below: cmdContext's
2253
+ // CLI convention takes --project like cm search/cm context already do
2254
+ // everywhere else — cm start and cm session-end both take a positional
2255
+ // project, so those two keep the user's literal command shape.
2256
+ const STACKMEM_HOOK_EVENTS: { event: string; buildCommand: (project: string) => string }[] = [
2257
+ { event: "SessionStart", buildCommand: (project) => `cm start ${project} --hook` },
2258
+ { event: "Stop", buildCommand: (project) => `cm session-end ${project} --hook` },
2259
+ { event: "PreCompact", buildCommand: (project) => `cm context --project ${project} --hook` },
2260
+ ];
2261
+
2262
+ // Writes (or merges into) <resolvedPath>/.claude/settings.json. Merges
2263
+ // rather than overwrites — an existing settings.json may have unrelated
2264
+ // hooks or settings that must survive this — and is idempotent: re-running
2265
+ // `cm init` checks for the exact command string already present in an
2266
+ // event's hook groups before adding another one.
2267
+ function writeClaudeCodeHooks(project: string, resolvedPath: string): void {
2268
+ const settingsDir = nodePath.join(resolvedPath, ".claude");
2269
+ const settingsPath = nodePath.join(settingsDir, "settings.json");
2270
+
2271
+ let settings: ClaudeSettingsFile = {};
2272
+ if (nodeFs.existsSync(settingsPath)) {
2273
+ try {
2274
+ settings = JSON.parse(nodeFs.readFileSync(settingsPath, "utf-8"));
2275
+ } catch {
2276
+ settings = {};
2277
+ }
2278
+ }
2279
+
2280
+ if (!settings.hooks || typeof settings.hooks !== "object") {
2281
+ settings.hooks = {};
2282
+ }
2283
+
2284
+ for (const { event, buildCommand } of STACKMEM_HOOK_EVENTS) {
2285
+ const command = buildCommand(project);
2286
+
2287
+ if (!Array.isArray(settings.hooks[event])) {
2288
+ settings.hooks[event] = [];
2289
+ }
2290
+
2291
+ const alreadyRegistered = settings.hooks[event].some(
2292
+ (group) => Array.isArray(group?.hooks) && group.hooks.some((h) => h?.command === command)
2293
+ );
2294
+
2295
+ if (!alreadyRegistered) {
2296
+ settings.hooks[event].push({ hooks: [{ type: "command", command }] });
2297
+ }
2298
+ }
2299
+
2300
+ nodeFs.mkdirSync(settingsDir, { recursive: true });
2301
+ nodeFs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
2302
+ }
2303
+
1770
2304
  // Section order for the generated rules-file markdown — matches the order
1771
2305
  // memories are presented in everywhere else (cm context's CONTEXT_TYPE_ORDER),
1772
2306
  // not MEMORY_TYPES' declaration order.
@@ -1781,7 +2315,8 @@ async function generateRulesContent(project: string): Promise<string> {
1781
2315
  .from("memories")
1782
2316
  .select("*")
1783
2317
  .eq("project", project)
1784
- .eq("resolved", false);
2318
+ .eq("resolved", false)
2319
+ .eq("suggested", false);
1785
2320
 
1786
2321
  if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
1787
2322
 
@@ -1790,7 +2325,7 @@ async function generateRulesContent(project: string): Promise<string> {
1790
2325
  .map((memory) => ({
1791
2326
  ...memory,
1792
2327
  decay_score: computeDecayScore(
1793
- new Date(memory.created_at),
2328
+ parseUtcDate(memory.created_at),
1794
2329
  memory.access_count ?? 0,
1795
2330
  memory.pinned ?? false,
1796
2331
  config.decayLambda
@@ -1867,6 +2402,19 @@ function writeCursorRules(resolvedPath: string, content: string): boolean {
1867
2402
  return true;
1868
2403
  }
1869
2404
 
2405
+ // Cursor's newer rules format (.cursor/rules/*.md) — .cursorrules is
2406
+ // legacy but still read by older Cursor versions, so both are written
2407
+ // during the transition rather than one replacing the other. Unlike
2408
+ // writeCursorRules/writeWindsurfRules (write-once at init, skip if
2409
+ // present), this one unconditionally overwrites on every call — both at
2410
+ // init and on every `cm sync` — since it's gitignored content meant to
2411
+ // always reflect the current memory state, same as the legacy files.
2412
+ function writeCursorRulesMdFile(resolvedPath: string, content: string): void {
2413
+ const rulesDir = nodePath.join(resolvedPath, ".cursor", "rules");
2414
+ nodeFs.mkdirSync(rulesDir, { recursive: true });
2415
+ nodeFs.writeFileSync(nodePath.join(rulesDir, "stackmem.md"), content, "utf-8");
2416
+ }
2417
+
1870
2418
  // Returns true if the file was written, false if it already existed
1871
2419
  // (and was skipped).
1872
2420
  function writeWindsurfRules(resolvedPath: string, content: string): boolean {
@@ -2071,7 +2619,11 @@ async function cmdInit(project: string, targetPath: string, writeConfig = false)
2071
2619
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
2072
2620
 
2073
2621
  registerMcpServer(DIST_INDEX_PATH);
2622
+ registerEditorMcpServers(DIST_INDEX_PATH);
2074
2623
  writeClaudeMd(project, resolvedPath);
2624
+ writeAgentsMemoryFile(project, resolvedPath, "AGENTS.md");
2625
+ writeAgentsMemoryFile(project, resolvedPath, "GEMINI.md");
2626
+ writeClaudeCodeHooks(project, resolvedPath);
2075
2627
 
2076
2628
  const seededCount = await seedMemories(project, resolvedPath);
2077
2629
  console.log(`Seeded ${seededCount} initial memories from project scan.`);
@@ -2079,6 +2631,7 @@ async function cmdInit(project: string, targetPath: string, writeConfig = false)
2079
2631
  const rulesContent = await generateRulesContent(project);
2080
2632
  const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
2081
2633
  const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
2634
+ writeCursorRulesMdFile(resolvedPath, rulesContent);
2082
2635
 
2083
2636
  const backendOk = await verifyBackendConnection(project);
2084
2637
 
@@ -2093,13 +2646,17 @@ async function cmdInit(project: string, targetPath: string, writeConfig = false)
2093
2646
  }
2094
2647
  console.log("✓ Git hook installed");
2095
2648
  console.log("✓ MCP server registered");
2649
+ console.log("✓ Claude Code hooks registered (.claude/settings.json)");
2096
2650
  console.log("✓ CLAUDE.md written");
2651
+ console.log("✓ AGENTS.md written");
2652
+ console.log("✓ GEMINI.md written");
2097
2653
  console.log(
2098
2654
  `✓ .cursorrules ${cursorRulesWritten ? "written" : "already exists"}`
2099
2655
  );
2100
2656
  console.log(
2101
2657
  `✓ .windsurfrules ${windsurfRulesWritten ? "written" : "already exists"}`
2102
2658
  );
2659
+ console.log("✓ .cursor/rules/stackmem.md written");
2103
2660
  console.log(`✓ Seeded ${seededCount} memories from project scan`);
2104
2661
  if (backendOk) {
2105
2662
  console.log("✓ Backend connection verified");
@@ -2125,8 +2682,9 @@ async function cmdSync(project: string) {
2125
2682
 
2126
2683
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".cursorrules"), rulesContent, "utf-8");
2127
2684
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
2685
+ writeCursorRulesMdFile(resolvedPath, rulesContent);
2128
2686
 
2129
- console.log("Synced memories to .cursorrules and .windsurfrules");
2687
+ console.log("Synced memories to .cursorrules, .windsurfrules, and .cursor/rules/stackmem.md");
2130
2688
  }
2131
2689
 
2132
2690
  // --- cm link ---------------------------------------------------------------
@@ -2211,23 +2769,148 @@ async function cmdLink() {
2211
2769
  console.log("Link code expired. Run cm link again.");
2212
2770
  }
2213
2771
 
2772
+ // --- cm open -----------------------------------------------------------
2773
+
2774
+ // Separated from cmdOpen so the platform-selection logic is unit-testable
2775
+ // without actually launching a browser — exercising this for real on
2776
+ // every platform isn't something a test (or a verification pass) should
2777
+ // do as a side effect.
2778
+ export function getOpenCommand(platform: string, url: string): { command: string; args: string[] } {
2779
+ if (platform === "darwin") return { command: "open", args: [url] };
2780
+ if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
2781
+ return { command: "xdg-open", args: [url] };
2782
+ }
2783
+
2784
+ async function cmdOpen(project: string): Promise<void> {
2785
+ const url = `${VAULT_URL}/project/${project}`;
2786
+ const { command, args } = getOpenCommand(process.platform, url);
2787
+
2788
+ try {
2789
+ execFileSync(command, args, { stdio: "ignore" });
2790
+ console.log(`Opened ${url}`);
2791
+ } catch (error) {
2792
+ fail(
2793
+ `Failed to open browser (${error instanceof Error ? error.message : String(error)}). Visit ${url} manually.`
2794
+ );
2795
+ }
2796
+ }
2797
+
2798
+ // --- cm purge ------------------------------------------------------------
2799
+
2800
+ async function cmdPurgeProject(project: string): Promise<void> {
2801
+ const { count: total, error: countError } = await supabase
2802
+ .from("memories")
2803
+ .select("id", { count: "exact", head: true })
2804
+ .eq("project", project);
2805
+ if (countError) fail(`Failed to count memories: ${countError.message}`);
2806
+
2807
+ console.log(`This will permanently delete all ${total ?? 0} memories for "${project}".`);
2808
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2809
+ const answer = (await rl.question("Type the project name to confirm: ")).trim();
2810
+ rl.close();
2811
+
2812
+ if (answer !== project) {
2813
+ console.log("Confirmation did not match — aborted. Nothing was deleted.");
2814
+ return;
2815
+ }
2816
+
2817
+ const { data: memRows, error: memFetchError } = await supabase
2818
+ .from("memories")
2819
+ .select("id")
2820
+ .eq("project", project);
2821
+ if (memFetchError) fail(`Failed to load memories: ${memFetchError.message}`);
2822
+
2823
+ const ids = (memRows ?? []).map((m) => m.id);
2824
+ let linksDeleted = 0;
2825
+ if (ids.length > 0) {
2826
+ const idList = ids.join(",");
2827
+ const { count, error: linksError } = await supabase
2828
+ .from("memory_links")
2829
+ .delete({ count: "exact" })
2830
+ .or(`source_id.in.(${idList}),target_id.in.(${idList})`);
2831
+ if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
2832
+ linksDeleted = count ?? 0;
2833
+ }
2834
+
2835
+ const { count: memoriesDeleted, error: memError } = await supabase
2836
+ .from("memories")
2837
+ .delete({ count: "exact" })
2838
+ .eq("project", project);
2839
+ if (memError) fail(`Failed to delete memories: ${memError.message}`);
2840
+
2841
+ const { count: fixesDeleted, error: fixError } = await supabase
2842
+ .from("execution_log")
2843
+ .delete({ count: "exact" })
2844
+ .eq("project", project);
2845
+ if (fixError) fail(`Failed to delete fixes: ${fixError.message}`);
2846
+
2847
+ console.log(
2848
+ `Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted} links for "${project}".`
2849
+ );
2850
+ }
2851
+
2852
+ // RLS already scopes every table read/write to this device's device_id
2853
+ // via the x-device-id header (see src/storage.ts) — a filter-less delete
2854
+ // here is already confined to this device, never another one. PostgREST
2855
+ // still requires an explicit filter clause to allow a DELETE at all, so
2856
+ // `.not("id", "is", null)` (every row has a non-null id) is used purely
2857
+ // to satisfy that, not to narrow the scope further.
2858
+ async function cmdPurgeAll(): Promise<void> {
2859
+ console.log("This will permanently delete ALL memories across ALL projects for this device.");
2860
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2861
+ const answer = (await rl.question("Type DELETE to confirm: ")).trim();
2862
+ rl.close();
2863
+
2864
+ if (answer !== "DELETE") {
2865
+ console.log("Confirmation did not match — aborted. Nothing was deleted.");
2866
+ return;
2867
+ }
2868
+
2869
+ const { count: linksDeleted, error: linksError } = await supabase
2870
+ .from("memory_links")
2871
+ .delete({ count: "exact" })
2872
+ .not("id", "is", null);
2873
+ if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
2874
+
2875
+ const { count: memoriesDeleted, error: memError } = await supabase
2876
+ .from("memories")
2877
+ .delete({ count: "exact" })
2878
+ .not("id", "is", null);
2879
+ if (memError) fail(`Failed to delete memories: ${memError.message}`);
2880
+
2881
+ const { count: fixesDeleted, error: fixError } = await supabase
2882
+ .from("execution_log")
2883
+ .delete({ count: "exact" })
2884
+ .not("id", "is", null);
2885
+ if (fixError) fail(`Failed to delete fixes: ${fixError.message}`);
2886
+
2887
+ console.log(
2888
+ `Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted ?? 0} links across all projects.`
2889
+ );
2890
+ }
2891
+
2214
2892
  // --- cm context ------------------------------------------------------------
2215
2893
 
2216
2894
  const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
2217
2895
  const CONTEXT_TOP_N = 5;
2218
2896
 
2219
- async function cmdContext(project: string, task: string) {
2897
+ // task is null for the PreCompact hook — there's no user-typed task at a
2898
+ // compaction boundary, just "save the most relevant context" generically
2899
+ // — so ranking falls back to decay score alone instead of diluting it
2900
+ // with a relevance score computed against an empty string.
2901
+ async function cmdContext(project: string, task: string | null, hookMode = false) {
2220
2902
  const { data, error } = await supabase
2221
2903
  .from("memories")
2222
2904
  .select("*")
2223
2905
  .eq("project", project)
2224
- .eq("resolved", false);
2906
+ .eq("resolved", false)
2907
+ .eq("suggested", false);
2225
2908
 
2226
2909
  if (error) fail(`Failed to load memories: ${error.message}`);
2227
2910
 
2228
2911
  const memories = data ?? [];
2229
2912
  if (memories.length === 0) {
2230
- console.log(`No memories found for "${project}".`);
2913
+ if (!hookMode) console.log(`No memories found for "${project}".`);
2231
2914
  return;
2232
2915
  }
2233
2916
 
@@ -2237,16 +2920,14 @@ async function cmdContext(project: string, task: string) {
2237
2920
  const scoredMemories = memories
2238
2921
  .map((memory) => {
2239
2922
  const decayScore = computeDecayScore(
2240
- new Date(memory.created_at),
2923
+ parseUtcDate(memory.created_at),
2241
2924
  memory.access_count ?? 0,
2242
2925
  memory.pinned ?? false,
2243
2926
  config.decayLambda
2244
2927
  );
2245
- const relevanceScore = scoreMemoryRelevance(memory.content, task);
2246
- return {
2247
- ...memory,
2248
- combined_score: relevanceScore * 0.6 + decayScore * 0.4,
2249
- };
2928
+ const combinedScore =
2929
+ task == null ? decayScore : scoreMemoryRelevance(memory.content, task) * 0.6 + decayScore * 0.4;
2930
+ return { ...memory, combined_score: combinedScore };
2250
2931
  })
2251
2932
  .sort((a, b) => b.combined_score - a.combined_score);
2252
2933
 
@@ -2257,7 +2938,9 @@ async function cmdContext(project: string, task: string) {
2257
2938
  0
2258
2939
  );
2259
2940
 
2260
- const lines: string[] = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
2941
+ const lines: string[] = [`## Project Context: ${project}`];
2942
+ if (task != null) lines.push(`### Task: ${task}`);
2943
+ lines.push("");
2261
2944
 
2262
2945
  for (const type of CONTEXT_TYPE_ORDER) {
2263
2946
  const group = budgetedMemories.filter((memory) => memory.type === type);
@@ -2270,7 +2953,7 @@ async function cmdContext(project: string, task: string) {
2270
2953
  lines.push("");
2271
2954
  }
2272
2955
 
2273
- lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
2956
+ if (!hookMode) lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
2274
2957
 
2275
2958
  console.log(lines.join("\n"));
2276
2959
  }
@@ -2294,7 +2977,8 @@ async function cmdWhy(project: string, file: string) {
2294
2977
  .from("memories")
2295
2978
  .select("*")
2296
2979
  .eq("project", project)
2297
- .eq("resolved", false);
2980
+ .eq("resolved", false)
2981
+ .eq("suggested", false);
2298
2982
 
2299
2983
  if (error) fail(`Failed to load memories: ${error.message}`);
2300
2984
 
@@ -2323,7 +3007,7 @@ async function cmdWhy(project: string, file: string) {
2323
3007
  .map((memory) => ({
2324
3008
  ...memory,
2325
3009
  decay_score: computeDecayScore(
2326
- new Date(memory.created_at),
3010
+ parseUtcDate(memory.created_at),
2327
3011
  memory.access_count ?? 0,
2328
3012
  memory.pinned ?? false,
2329
3013
  config.decayLambda
@@ -2394,6 +3078,10 @@ function parseSessionEndFlags(args: string[]): SessionEndInput {
2394
3078
  } else if (flag === "--approaches") {
2395
3079
  approaches = splitCommaList(value);
2396
3080
  i++;
3081
+ } else if (flag === "--hook") {
3082
+ // Recognized explicitly (not just silently skipped as an unknown
3083
+ // flag) — see the comment on cmdSessionEnd for why it's currently
3084
+ // a no-op here specifically.
2397
3085
  }
2398
3086
  }
2399
3087
 
@@ -2416,6 +3104,16 @@ async function readStdinJson(): Promise<Record<string, unknown> | null> {
2416
3104
  }
2417
3105
  }
2418
3106
 
3107
+ // --hook is accepted (see parseSessionEndFlags) but currently a no-op:
3108
+ // Claude Code's Stop hook pipes ITS OWN JSON payload on stdin (session
3109
+ // id, transcript path, cwd — see the Claude Code hooks docs), not the
3110
+ // {summary, errors_encountered, files_touched, approaches_tried} shape
3111
+ // this command expects. readStdinJson() parses that payload successfully
3112
+ // (it's valid JSON) but finds none of the fields it looks for, so every
3113
+ // array below comes back empty and this reports 0 memories saved — a
3114
+ // real, known limitation, not a bug. Extracting genuine learnings from
3115
+ // the Stop hook would mean parsing the session transcript itself, which
3116
+ // is out of scope here (Phase 2, per the user).
2419
3117
  async function cmdSessionEnd(project: string, input: Omit<SessionEndInput, "project">) {
2420
3118
  let memoriesSaved = 0;
2421
3119
  let errorsLogged = 0;
@@ -2529,6 +3227,136 @@ function isHookExecutable(hookPath: string): boolean {
2529
3227
  }
2530
3228
  }
2531
3229
 
3230
+ // --- cm status ---------------------------------------------------------
3231
+
3232
+ function hasMcpServersStackmemKey(configPath: string): boolean {
3233
+ if (!nodeFs.existsSync(configPath)) return false;
3234
+ try {
3235
+ const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
3236
+ return Boolean(config?.mcpServers?.stackmem);
3237
+ } catch {
3238
+ return false;
3239
+ }
3240
+ }
3241
+
3242
+ function hasContextServersStackmemKey(configPath: string): boolean {
3243
+ if (!nodeFs.existsSync(configPath)) return false;
3244
+ try {
3245
+ const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
3246
+ return Boolean(config?.context_servers?.stackmem);
3247
+ } catch {
3248
+ return false;
3249
+ }
3250
+ }
3251
+
3252
+ const EDITOR_STATUS_CHECKS: { name: string; isRegistered: (homeDir: string) => boolean }[] = [
3253
+ { name: "Claude Code", isRegistered: (homeDir) => isMcpRegistered(nodePath.join(homeDir, ".claude.json")) },
3254
+ { name: "Cursor", isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".cursor", "mcp.json")) },
3255
+ {
3256
+ name: "Windsurf",
3257
+ isRegistered: (homeDir) =>
3258
+ hasMcpServersStackmemKey(nodePath.join(homeDir, ".codeium", "windsurf", "mcp_config.json")),
3259
+ },
3260
+ {
3261
+ name: "VS Code",
3262
+ isRegistered: (homeDir) => {
3263
+ const dir = findCopilotChatExtensionDir(homeDir);
3264
+ return dir ? hasMcpServersStackmemKey(nodePath.join(dir, "mcp.json")) : false;
3265
+ },
3266
+ },
3267
+ {
3268
+ name: "Zed",
3269
+ isRegistered: (homeDir) => hasContextServersStackmemKey(nodePath.join(homeDir, ".config", "zed", "settings.json")),
3270
+ },
3271
+ ];
3272
+
3273
+ async function cmdStatus(project: string): Promise<void> {
3274
+ const { count: totalCount, error: totalError } = await supabase
3275
+ .from("memories")
3276
+ .select("id", { count: "exact", head: true })
3277
+ .eq("project", project)
3278
+ .eq("resolved", false);
3279
+ if (totalError) fail(`Failed to load memory count: ${totalError.message}`);
3280
+
3281
+ const { count: pinnedCount, error: pinnedError } = await supabase
3282
+ .from("memories")
3283
+ .select("id", { count: "exact", head: true })
3284
+ .eq("project", project)
3285
+ .eq("resolved", false)
3286
+ .eq("pinned", true);
3287
+ if (pinnedError) fail(`Failed to load pinned count: ${pinnedError.message}`);
3288
+
3289
+ const { count: suggestedCount, error: suggestedError } = await supabase
3290
+ .from("memories")
3291
+ .select("id", { count: "exact", head: true })
3292
+ .eq("project", project)
3293
+ .eq("resolved", false)
3294
+ .eq("suggested", true);
3295
+ if (suggestedError) fail(`Failed to load suggested count: ${suggestedError.message}`);
3296
+
3297
+ const { count: fixesCount, error: fixesError } = await supabase
3298
+ .from("execution_log")
3299
+ .select("id", { count: "exact", head: true })
3300
+ .eq("project", project)
3301
+ .eq("resolved", false);
3302
+ if (fixesError) fail(`Failed to load fixes count: ${fixesError.message}`);
3303
+
3304
+ const { data: lastCaptureRows, error: lastCaptureError } = await supabase
3305
+ .from("memories")
3306
+ .select("content, created_at")
3307
+ .eq("project", project)
3308
+ .order("created_at", { ascending: false })
3309
+ .limit(1);
3310
+ if (lastCaptureError) fail(`Failed to load last capture: ${lastCaptureError.message}`);
3311
+
3312
+ const config = readStackmemConfig();
3313
+ const configPath = nodePath.join(process.cwd(), STACKMEM_CONFIG_FILENAME);
3314
+ let usesCustomBudget = false;
3315
+ if (nodeFs.existsSync(configPath)) {
3316
+ try {
3317
+ const raw = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
3318
+ usesCustomBudget = typeof raw.tokenBudget === "number";
3319
+ } catch {
3320
+ // Malformed config — readStackmemConfig already falls back to the
3321
+ // default in this case too, so "default" is the accurate label.
3322
+ }
3323
+ }
3324
+
3325
+ const hookOk = isHookExecutable(nodePath.join(process.cwd(), ".git", "hooks", "post-commit"));
3326
+ const mcpOk = isMcpRegistered(getClaudeConfigPath());
3327
+ const backendOk = await verifyBackendConnection(project);
3328
+
3329
+ const homeDir = nodeOs.homedir();
3330
+ const registeredEditors = EDITOR_STATUS_CHECKS.filter((e) => e.isRegistered(homeDir)).map((e) => e.name);
3331
+
3332
+ console.log(`stackmem status for "${project}"`);
3333
+ console.log("━".repeat(27));
3334
+ console.log(
3335
+ `Memories: ${totalCount ?? 0} total (${pinnedCount ?? 0} pinned, ${suggestedCount ?? 0} suggested)`
3336
+ );
3337
+ console.log(`Fixes: ${fixesCount ?? 0} recorded`);
3338
+ console.log(`Token budget: ${config.tokenBudget} (${usesCustomBudget ? "from .stackmem.json" : "default"})`);
3339
+
3340
+ const lastCapture = (lastCaptureRows ?? [])[0];
3341
+ if (lastCapture) {
3342
+ const ago = formatRelativeTime(parseUtcDate(lastCapture.created_at));
3343
+ const snippet =
3344
+ lastCapture.content.length > 50 ? `${lastCapture.content.slice(0, 50)}…` : lastCapture.content;
3345
+ console.log(`Last capture: ${ago} (${snippet})`);
3346
+ } else {
3347
+ console.log("Last capture: none yet");
3348
+ }
3349
+
3350
+ console.log(`Hook: ${hookOk ? "✓ installed" : "✗ not installed"}`);
3351
+ console.log(`MCP: ${mcpOk ? "✓ registered" : "✗ not registered"}`);
3352
+ console.log(`Backend: ${backendOk ? "✓ connected" : "✗ not connected"}`);
3353
+ console.log(
3354
+ `Editors: ${
3355
+ registeredEditors.length > 0 ? registeredEditors.map((name) => `${name} ✓`).join(" ") : "(none registered)"
3356
+ }`
3357
+ );
3358
+ }
3359
+
2532
3360
  async function cmdDoctor(fix: boolean) {
2533
3361
  let issues = 0;
2534
3362
  const project = readProjectFromFile();
@@ -2843,10 +3671,14 @@ async function main() {
2843
3671
 
2844
3672
  switch (command) {
2845
3673
  case "start": {
2846
- const split = splitOptionalProject(args, 0);
3674
+ const hookIndex = args.indexOf("--hook");
3675
+ const hookMode = hookIndex !== -1;
3676
+ const positional = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
3677
+
3678
+ const split = splitOptionalProject(positional, 0);
2847
3679
  if (!split) usage();
2848
3680
  const project = requireProject(split.project);
2849
- await cmdStart(project);
3681
+ await cmdStart(project, hookMode);
2850
3682
  break;
2851
3683
  }
2852
3684
  case "save": {
@@ -2962,6 +3794,21 @@ async function main() {
2962
3794
  await cmdUnpin(project, memoryId);
2963
3795
  break;
2964
3796
  }
3797
+ case "review": {
3798
+ const split = splitProjectFromVariadic(args);
3799
+ if (split.rest.length > 0) usage();
3800
+ const project = requireProject(split.project);
3801
+ await cmdReview(project);
3802
+ break;
3803
+ }
3804
+ case "edit": {
3805
+ const split = splitOptionalProject(args, 1);
3806
+ if (!split) usage();
3807
+ const project = requireProject(split.project);
3808
+ const [memoryId] = split.rest;
3809
+ await cmdEdit(project, memoryId);
3810
+ break;
3811
+ }
2965
3812
  case "delete": {
2966
3813
  const split = splitOptionalProject(args, 1);
2967
3814
  if (!split) usage();
@@ -3002,6 +3849,29 @@ async function main() {
3002
3849
  await cmdLink();
3003
3850
  break;
3004
3851
  }
3852
+ case "open": {
3853
+ const split = splitProjectFromVariadic(args);
3854
+ if (split.rest.length > 0) usage();
3855
+ const project = requireProject(split.project);
3856
+ await cmdOpen(project);
3857
+ break;
3858
+ }
3859
+ case "purge": {
3860
+ const allIndex = args.indexOf("--all");
3861
+ const isAll = allIndex !== -1;
3862
+ const withoutAllFlag = isAll ? [...args.slice(0, allIndex), ...args.slice(allIndex + 1)] : args;
3863
+
3864
+ const split = splitProjectFromVariadic(withoutAllFlag);
3865
+ if (split.rest.length > 0) usage();
3866
+
3867
+ if (isAll) {
3868
+ await cmdPurgeAll();
3869
+ } else {
3870
+ const project = requireProject(split.project);
3871
+ await cmdPurgeProject(project);
3872
+ }
3873
+ break;
3874
+ }
3005
3875
  case "sync": {
3006
3876
  if (args.length > 0) usage();
3007
3877
  const project = requireProject(null);
@@ -3009,10 +3879,17 @@ async function main() {
3009
3879
  break;
3010
3880
  }
3011
3881
  case "context": {
3012
- const split = splitProjectFromVariadic(args);
3882
+ const hookIndex = args.indexOf("--hook");
3883
+ const hookMode = hookIndex !== -1;
3884
+ const withoutHookFlag = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
3885
+
3886
+ const split = splitProjectFromVariadic(withoutHookFlag);
3013
3887
  const project = requireProject(split.project);
3014
- if (split.rest.length === 0) usage();
3015
- await cmdContext(project, split.rest.join(" "));
3888
+ // task is optional only in --hook mode (the PreCompact hook has no
3889
+ // user-typed task to pass) — interactive use still requires one.
3890
+ if (split.rest.length === 0 && !hookMode) usage();
3891
+ const task = split.rest.length > 0 ? split.rest.join(" ") : null;
3892
+ await cmdContext(project, task, hookMode);
3016
3893
  break;
3017
3894
  }
3018
3895
  case "why": {
@@ -3031,6 +3908,13 @@ async function main() {
3031
3908
  await cmdDoctor(fix);
3032
3909
  break;
3033
3910
  }
3911
+ case "status": {
3912
+ const split = splitProjectFromVariadic(args);
3913
+ if (split.rest.length > 0) usage();
3914
+ const project = requireProject(split.project);
3915
+ await cmdStatus(project);
3916
+ break;
3917
+ }
3034
3918
  case "undo": {
3035
3919
  const split = splitOptionalProject(args, 0);
3036
3920
  if (!split) usage();