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/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { analyzeCodebase } from "./ast-index.js";
10
10
  import { VAULT_URL } from "./config.js";
11
11
  import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
12
12
  import { getDeviceId } from "./device.js";
13
- import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, hasTokenOverlap, isQualityMessage, redactSecrets, scoreMemoryRelevance, tokenize, } from "./scoring.js";
13
+ import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, hasTokenOverlap, isQualityMessage, parseUtcDate, redactSecrets, scoreMemoryRelevance, tokenize, } from "./scoring.js";
14
14
  import { supabase } from "./storage.js";
15
15
  // Checked as early as ESM import hoisting allows — before any command
16
16
  // dispatch, config loading, or network call — so an unsupported Node
@@ -45,7 +45,7 @@ const TYPE_LABELS = {
45
45
  };
46
46
  function usage() {
47
47
  console.error(`Usage:
48
- cm start [project]
48
+ cm start [project] [--hook]
49
49
  cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
50
50
  cm fix [project] <problem> <solution>
51
51
  cm analyze [project] <path>
@@ -53,17 +53,22 @@ function usage() {
53
53
  cm resolve [project] <memory-id>
54
54
  cm pin [project] <memory-id>
55
55
  cm unpin [project] <memory-id>
56
+ cm review [--project <name>]
57
+ cm edit [project] <memory-id>
56
58
  cm delete [project] <memory-id>
57
59
  cm inspect [project] <memory-id>
58
60
  cm compress [project]
59
61
  cm init <project> <path> [--config]
60
62
  cm uninstall [project] [path]
61
63
  cm link
64
+ cm open [--project <name>]
65
+ cm purge [--project <name>] [--all]
62
66
  cm sync
63
- cm context [--project <name>] <task>
67
+ cm context [--project <name>] <task> [--hook]
64
68
  cm why [--project <name>] <file>
65
- cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
69
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
66
70
  cm doctor [--fix]
71
+ cm status [--project <name>]
67
72
  cm undo [project]
68
73
  cm export [filename]
69
74
  cm import [project] <filename>
@@ -84,11 +89,13 @@ function cmdHelp() {
84
89
  [project] is optional everywhere below if a .stackmem file exists in
85
90
  the current directory (written by 'cm init'). Otherwise pass it explicitly.
86
91
 
87
- cm start [project]
92
+ cm start [project] [--hook]
88
93
  Load all unresolved memories for a project, sorted by decay
89
94
  score and capped at a 2000 token context budget, grouped by
90
95
  type (decisions, constraints, discoveries, rejections), with
91
- linked memory ids shown under each entry.
96
+ linked memory ids shown under each entry. --hook switches to
97
+ plain-text output (no ids, scores, or separator lines) for use
98
+ as a Claude Code SessionStart hook command — see cm init.
92
99
 
93
100
  cm save [project] <type> <content> [--force]
94
101
  Save a new memory. <type> is one of: decision, rejection,
@@ -120,6 +127,18 @@ function cmdHelp() {
120
127
  cm unpin [project] <memory-id>
121
128
  Un-pin a memory, returning it to normal decay-based ranking.
122
129
 
130
+ cm review [--project <name>]
131
+ Walk through suggested memories one at a time (hook-captured
132
+ memories start as suggested — see cm save/cm init). For each:
133
+ (k)eep promotes it to permanent, (e)dit opens $EDITOR (or an
134
+ inline prompt) for new content then keeps it, (d)rop deletes
135
+ it, (s)kip leaves it suggested for next time, (q)uit stops and
136
+ reports progress so far. Prints a kept/dropped/skipped summary.
137
+
138
+ cm edit [project] <memory-id>
139
+ Open a memory's content in $EDITOR (or an inline prompt if
140
+ $EDITOR isn't set), save the result.
141
+
123
142
  cm delete [project] <memory-id>
124
143
  Delete a memory and any memory_links referencing it.
125
144
 
@@ -167,6 +186,20 @@ function cmdHelp() {
167
186
  single-use and expires after 10 minutes; polling stops when it
168
187
  does. Links are per-device, so run this once on each machine.
169
188
 
189
+ cm open [--project <name>]
190
+ Open the hosted vault's page for this project in the default
191
+ browser (open on Mac, xdg-open on Linux, start on Windows).
192
+
193
+ cm purge [--project <name>] [--all]
194
+ Permanently delete memories, fixes, and memory_links — with a
195
+ typed confirmation first, never deletes on a flag alone.
196
+ --project (or the one in .stackmem): deletes that project's
197
+ data only, after you type the exact project name to confirm.
198
+ --all: deletes everything for this device's device_id across
199
+ every project, after you type DELETE to confirm. Prints the
200
+ count of deleted records, or "Confirmation did not match" if
201
+ you abort.
202
+
170
203
  cm sync
171
204
  Regenerate .cursorrules and .windsurfrules from the project's
172
205
  current unresolved memories (decay-ranked, capped at a 2000
@@ -174,11 +207,13 @@ function cmdHelp() {
174
207
  overwriting both files on disk only (never git add/commit).
175
208
  Project is read from .stackmem.
176
209
 
177
- cm context [--project <name>] <task>
210
+ cm context [--project <name>] <task> [--hook]
178
211
  Return only the top 5 memories most relevant to a specific
179
212
  task, ranked by a blend of task relevance and decay score,
180
213
  as a markdown block capped at a 2000 token budget. Use
181
214
  --project to target a project other than the one in .stackmem.
215
+ <task> is optional with --hook (ranks by decay alone) — used
216
+ as a Claude Code PreCompact hook command; see cm init.
182
217
 
183
218
  cm why [--project <name>] <file>
184
219
  Show memories tied to a file: matched by Item 6's file_paths
@@ -188,7 +223,7 @@ function cmdHelp() {
188
223
  nothing matches. Use --project to target a project other than
189
224
  the one in .stackmem.
190
225
 
191
- cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
226
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
192
227
  Capture what happened in a session and extract memories: each
193
228
  error becomes an unsolved execution_log entry, each approach
194
229
  containing "failed", "didn't work", "reverted", or "switched
@@ -198,6 +233,11 @@ function cmdHelp() {
198
233
  Accepts the same fields as JSON on stdin instead of flags
199
234
  (project, summary, errors_encountered, files_touched,
200
235
  approaches_tried) when stdin is piped rather than a terminal.
236
+ --hook is accepted for use as a Claude Code Stop hook command
237
+ (see cm init) but is currently a no-op: Claude Code pipes its
238
+ own hook payload on stdin, not this command's expected shape,
239
+ so a Stop-triggered run captures 0 memories until a future
240
+ phase parses the session transcript directly.
201
241
 
202
242
  cm doctor [--fix]
203
243
  Check every integration point in the current directory — backend
@@ -210,6 +250,12 @@ function cmdHelp() {
210
250
  — those print guidance instead. Reports "Fixed: <item>" or
211
251
  "Could not auto-fix: <item>" for each failing check.
212
252
 
253
+ cm status [--project <name>]
254
+ One-screen project summary: memory/pinned/suggested counts,
255
+ recorded fixes, the active token budget (and whether it's the
256
+ default or from .stackmem.json), time since the last capture,
257
+ and hook/MCP/backend/editor registration status.
258
+
213
259
  cm undo [project]
214
260
  Delete the most recently saved unresolved memory (and any
215
261
  memory_links referencing it) for a project.
@@ -234,12 +280,33 @@ function fail(message) {
234
280
  }
235
281
  // --- optional project resolution ------------------------------------------
236
282
  const STACKMEM_FILE = ".stackmem";
283
+ const MONOREPO_PARENT_LOOKUP_DEPTH = 3;
284
+ // Checks cwd, then up to 3 parent directories, for a .stackmem file — a
285
+ // monorepo subpackage (e.g. packages/api) won't have its own .stackmem,
286
+ // but the repo root will. When found in an ancestor, the project name
287
+ // gets the intervening path segments appended, e.g. "myapp/packages/api"
288
+ // — so a subpackage still reads/writes under its own namespace within
289
+ // the shared project, rather than silently resolving to just "myapp".
237
290
  function readProjectFromFile() {
238
- const stackmemPath = nodePath.join(process.cwd(), STACKMEM_FILE);
239
- if (!nodeFs.existsSync(stackmemPath))
240
- return null;
241
- const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
242
- return content.length > 0 ? content : null;
291
+ let dir = process.cwd();
292
+ const subdirSegments = [];
293
+ for (let depth = 0; depth <= MONOREPO_PARENT_LOOKUP_DEPTH; depth++) {
294
+ const stackmemPath = nodePath.join(dir, STACKMEM_FILE);
295
+ if (nodeFs.existsSync(stackmemPath)) {
296
+ const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
297
+ if (content.length === 0)
298
+ return null;
299
+ return subdirSegments.length > 0
300
+ ? `${content}/${subdirSegments.reverse().join("/")}`
301
+ : content;
302
+ }
303
+ const parent = nodePath.dirname(dir);
304
+ if (parent === dir)
305
+ break; // reached the filesystem root
306
+ subdirSegments.push(nodePath.basename(dir));
307
+ dir = parent;
308
+ }
309
+ return null;
243
310
  }
244
311
  // Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
245
312
  // muscle memory from `cm init <project> .` — "." (or "..", or anything
@@ -309,25 +376,53 @@ const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
309
376
  const BRANCH_NAME_BOOST = 1.2;
310
377
  const RECENT_FILES_BOOST = 1.3;
311
378
  const PR_TITLE_BOOST = 1.2;
312
- // null means "signal unavailable" (git/gh failed, detached HEAD, not
313
- // enough history, gh not installed, no open PR, etc.) — every one of
314
- // these is a soft skip, never an error, per the task spec.
379
+ // null means "signal unavailable" (git/gh failed, not enough history, gh
380
+ // not installed, no open PR, etc.) — every one of these is a soft skip,
381
+ // never an error, per the task spec.
315
382
  function getBranchNameTokens() {
383
+ let branch;
316
384
  try {
317
- const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
385
+ // symbolic-ref (not rev-parse --abbrev-ref) specifically because it
386
+ // fails on a detached HEAD rather than returning the literal string
387
+ // "HEAD" — that failure is the signal this needs to distinguish a
388
+ // real detached-HEAD state from "not a git repo at all" below.
389
+ branch = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
318
390
  encoding: "utf-8",
319
391
  stdio: ["ignore", "pipe", "ignore"],
320
392
  }).trim();
321
- if (!branch || branch === "HEAD")
322
- return null; // detached HEAD
323
- const tokens = tokenize(branch);
324
- return tokens.size > 0 ? tokens : null;
325
393
  }
326
394
  catch {
327
- return null;
395
+ try {
396
+ execFileSync("git", ["rev-parse", "HEAD"], { stdio: "pipe" });
397
+ // HEAD resolves to a real commit but symbolic-ref failed — genuinely
398
+ // detached, not a missing repo. Still a valid state to rank in, not
399
+ // a reason to skip the signal entirely.
400
+ branch = "detached";
401
+ }
402
+ catch {
403
+ return null;
404
+ }
328
405
  }
406
+ const tokens = tokenize(branch);
407
+ return tokens.size > 0 ? tokens : null;
329
408
  }
330
409
  function getRecentlyTouchedFiles() {
410
+ try {
411
+ const isShallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
412
+ encoding: "utf-8",
413
+ stdio: ["ignore", "pipe", "ignore"],
414
+ }).trim();
415
+ // A shallow clone can't look back RECENT_COMMITS_FOR_FILE_SIGNAL
416
+ // commits — degrade gracefully rather than attempting (and either
417
+ // failing, or silently returning an incomplete picture of "recent").
418
+ if (isShallow === "true")
419
+ return null;
420
+ }
421
+ catch {
422
+ // --is-shallow-repository itself failing (very old git, or no repo at
423
+ // all) isn't a reason to skip on its own — the lookback below will
424
+ // fail (and get caught) or succeed on its own merits either way.
425
+ }
331
426
  try {
332
427
  const output = execFileSync("git", ["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
333
428
  const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
@@ -371,19 +466,52 @@ function fileListOverlaps(filePaths, recentFiles) {
371
466
  return false;
372
467
  });
373
468
  }
374
- async function cmdStart(project) {
469
+ // Plain-text, no ids/scores/box-drawing — meant to be injected directly
470
+ // into a Claude Code SessionStart hook's context, not read by a human at
471
+ // a terminal (that's the default, non---hook rendering below).
472
+ function printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes) {
473
+ console.log(`# Project memory: ${project}`);
474
+ if (pinnedMemories.length > 0) {
475
+ console.log("");
476
+ console.log("## Pinned");
477
+ for (const memory of pinnedMemories) {
478
+ console.log(`- ${memory.content}`);
479
+ }
480
+ }
481
+ for (const type of MEMORY_TYPES) {
482
+ const group = unpinnedMemories.filter((m) => m.type === type);
483
+ if (group.length === 0)
484
+ continue;
485
+ console.log("");
486
+ console.log(`## ${TYPE_LABELS[type]}`);
487
+ for (const memory of group) {
488
+ console.log(`- ${memory.content}`);
489
+ }
490
+ }
491
+ if (budgetedFixes.length > 0) {
492
+ console.log("");
493
+ console.log("## Past Fixes");
494
+ for (const fix of budgetedFixes) {
495
+ console.log(`- ${fix.problem} -> ${fix.solution ?? "(pending)"}`);
496
+ }
497
+ }
498
+ }
499
+ async function cmdStart(project, hookMode = false) {
375
500
  const { data, error } = await supabase
376
501
  .from("memories")
377
502
  .select("*")
378
503
  .eq("project", project)
379
- .eq("resolved", false);
504
+ .eq("resolved", false)
505
+ .eq("suggested", false);
380
506
  if (error)
381
507
  fail(`Failed to load memories: ${error.message}`);
382
508
  const memories = data ?? [];
383
509
  if (memories.length === 0) {
384
- console.log(`No memories found for "${project}".`);
385
- console.log("");
386
- console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
510
+ if (!hookMode) {
511
+ console.log(`No memories found for "${project}".`);
512
+ console.log("");
513
+ console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
514
+ }
387
515
  return;
388
516
  }
389
517
  // Task-aware re-ranking: boost memories relevant to what's actively
@@ -397,7 +525,7 @@ async function cmdStart(project) {
397
525
  const tokenBudget = config.tokenBudget;
398
526
  const scoredMemories = memories
399
527
  .map((memory) => {
400
- let decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
528
+ let decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
401
529
  const contentTokens = tokenize(memory.content);
402
530
  if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
403
531
  decayScore *= BRANCH_NAME_BOOST;
@@ -450,54 +578,59 @@ async function cmdStart(project) {
450
578
  linkedIdsByMemory.get(link.source_id).add(link.target_id);
451
579
  linkedIdsByMemory.get(link.target_id).add(link.source_id);
452
580
  }
453
- console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
454
581
  const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
455
582
  const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
456
- if (pinnedMemories.length > 0) {
457
- console.log(`Pinned (${pinnedMemories.length})`);
458
- console.log("-".repeat(11));
459
- for (const memory of pinnedMemories) {
460
- console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
461
- const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
462
- if (linked.length > 0) {
463
- console.log(` linked: ${linked.join(", ")}`);
583
+ if (hookMode) {
584
+ printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes);
585
+ }
586
+ else {
587
+ console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
588
+ if (pinnedMemories.length > 0) {
589
+ console.log(`Pinned (${pinnedMemories.length})`);
590
+ console.log("-".repeat(11));
591
+ for (const memory of pinnedMemories) {
592
+ console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
593
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
594
+ if (linked.length > 0) {
595
+ console.log(` linked: ${linked.join(", ")}`);
596
+ }
464
597
  }
598
+ console.log("");
465
599
  }
466
- console.log("");
467
- }
468
- for (const type of MEMORY_TYPES) {
469
- const group = unpinnedMemories.filter((m) => m.type === type);
470
- if (group.length === 0)
471
- continue;
472
- console.log(`${TYPE_LABELS[type]} (${group.length})`);
473
- console.log("-".repeat(TYPE_LABELS[type].length + 4));
474
- for (const memory of group) {
475
- console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
476
- const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
477
- if (linked.length > 0) {
478
- console.log(` linked: ${linked.join(", ")}`);
600
+ for (const type of MEMORY_TYPES) {
601
+ const group = unpinnedMemories.filter((m) => m.type === type);
602
+ if (group.length === 0)
603
+ continue;
604
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
605
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
606
+ for (const memory of group) {
607
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
608
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
609
+ if (linked.length > 0) {
610
+ console.log(` linked: ${linked.join(", ")}`);
611
+ }
479
612
  }
613
+ console.log("");
480
614
  }
481
- console.log("");
482
- }
483
- if (budgetedFixes.length > 0) {
484
- console.log(`Past Fixes (${budgetedFixes.length})`);
485
- console.log("-".repeat(14));
486
- for (const fix of budgetedFixes) {
487
- console.log(` Problem: ${fix.problem}`);
488
- console.log(` Solution: ${fix.solution ?? "(pending)"}`);
615
+ if (budgetedFixes.length > 0) {
616
+ console.log(`Past Fixes (${budgetedFixes.length})`);
617
+ console.log("-".repeat(14));
618
+ for (const fix of budgetedFixes) {
619
+ console.log(` Problem: ${fix.problem}`);
620
+ console.log(` Solution: ${fix.solution ?? "(pending)"}`);
621
+ console.log("");
622
+ }
623
+ }
624
+ console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
625
+ if (omittedCount > 0) {
626
+ console.log(`${omittedCount} memories omitted (budget exceeded)`);
627
+ }
628
+ const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
629
+ if (!hasTechStackMemory) {
489
630
  console.log("");
631
+ console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
490
632
  }
491
633
  }
492
- console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
493
- if (omittedCount > 0) {
494
- console.log(`${omittedCount} memories omitted (budget exceeded)`);
495
- }
496
- const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
497
- if (!hasTechStackMemory) {
498
- console.log("");
499
- console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
500
- }
501
634
  const incrementResults = await Promise.all(budgetedMemories.map((memory) => supabase
502
635
  .from("memories")
503
636
  .update({ access_count: (memory.access_count ?? 0) + 1 })
@@ -577,6 +710,11 @@ async function cmdSave(project, type, content, force = false, provenance = {}) {
577
710
  device_id: getDeviceId(),
578
711
  source,
579
712
  confidence,
713
+ // Hook-captured memories are heuristically detected and could be
714
+ // noisy or wrong — they start as suggested, pending `cm review`, and
715
+ // are excluded from cm start/context/why until promoted. Manual and
716
+ // mcp saves are already user-vetted, so they're permanent immediately.
717
+ suggested: source === "hook",
580
718
  };
581
719
  if (commitSha)
582
720
  insertPayload.commit_sha = commitSha;
@@ -652,7 +790,8 @@ async function compressMemories(project) {
652
790
  .from("memories")
653
791
  .select("*")
654
792
  .eq("project", project)
655
- .eq("resolved", false);
793
+ .eq("resolved", false)
794
+ .eq("suggested", false);
656
795
  if (error)
657
796
  fail(`Failed to load memories for compression: ${error.message}`);
658
797
  const memories = data ?? [];
@@ -818,9 +957,162 @@ async function cmdDelete(project, memoryId) {
818
957
  }
819
958
  console.log(`Deleted memory [${memoryId}]`);
820
959
  }
960
+ // --- shared: relative time + $EDITOR prompt (cm review, cm edit, cm status)
961
+ function formatRelativeTime(date) {
962
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
963
+ if (seconds < 60)
964
+ return `${seconds} second${seconds === 1 ? "" : "s"} ago`;
965
+ const minutes = Math.floor(seconds / 60);
966
+ if (minutes < 60)
967
+ return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
968
+ const hours = Math.floor(minutes / 60);
969
+ if (hours < 24)
970
+ return `${hours} hour${hours === 1 ? "" : "s"} ago`;
971
+ const days = Math.floor(hours / 24);
972
+ return `${days} day${days === 1 ? "" : "s"} ago`;
973
+ }
974
+ // Opens $EDITOR on a scratch file pre-filled with the current content, or
975
+ // falls back to an inline readline prompt when $EDITOR isn't set. Returns
976
+ // the original content unchanged if the result is blank (an empty save
977
+ // buffer almost certainly means "I didn't mean to clear this", not "make
978
+ // this memory empty").
979
+ async function editContentInteractively(currentContent) {
980
+ const editor = process.env.EDITOR;
981
+ if (editor) {
982
+ const tmpFile = nodePath.join(nodeOs.tmpdir(), `stackmem-edit-${randomInt(1_000_000_000)}.txt`);
983
+ nodeFs.writeFileSync(tmpFile, currentContent, "utf-8");
984
+ try {
985
+ execFileSync(editor, [tmpFile], { stdio: "inherit" });
986
+ const edited = nodeFs.readFileSync(tmpFile, "utf-8").trim();
987
+ return edited.length > 0 ? edited : currentContent;
988
+ }
989
+ finally {
990
+ try {
991
+ nodeFs.unlinkSync(tmpFile);
992
+ }
993
+ catch {
994
+ // Best-effort cleanup — a leftover temp file isn't worth failing on.
995
+ }
996
+ }
997
+ }
998
+ console.log(`Current: ${currentContent}`);
999
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1000
+ const answer = (await rl.question("New content (leave blank to keep unchanged): ")).trim();
1001
+ rl.close();
1002
+ return answer.length > 0 ? answer : currentContent;
1003
+ }
1004
+ // --- cm review -------------------------------------------------------------
1005
+ async function cmdReview(project) {
1006
+ const { data, error } = await supabase
1007
+ .from("memories")
1008
+ .select("*")
1009
+ .eq("project", project)
1010
+ .eq("resolved", false)
1011
+ .eq("suggested", true)
1012
+ .order("created_at", { ascending: true });
1013
+ if (error)
1014
+ fail(`Failed to load suggested memories: ${error.message}`);
1015
+ const memories = data ?? [];
1016
+ if (memories.length === 0) {
1017
+ console.log(`No memories to review for "${project}".`);
1018
+ console.log("Your inbox is empty.");
1019
+ return;
1020
+ }
1021
+ console.log(`Reviewing ${memories.length} suggested memories for "${project}"`);
1022
+ console.log("━".repeat(40));
1023
+ // Node's readline can't reliably answer multiple sequential
1024
+ // rl.question() calls over piped (non-TTY) stdin — all buffered input
1025
+ // arrives before the second question is even issued, and it never
1026
+ // resolves. A real interactive terminal doesn't hit this (keystrokes
1027
+ // arrive one at a time), so only the non-TTY path needs the
1028
+ // read-everything-up-front workaround.
1029
+ const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
1030
+ const pipedAnswers = rl
1031
+ ? []
1032
+ : (await new Promise((resolve) => {
1033
+ let buf = "";
1034
+ process.stdin.setEncoding("utf-8");
1035
+ process.stdin.on("data", (chunk) => (buf += chunk));
1036
+ process.stdin.on("end", () => resolve(buf));
1037
+ })).split("\n");
1038
+ async function nextAnswer(prompt) {
1039
+ if (rl)
1040
+ return (await rl.question(prompt)).trim().toLowerCase();
1041
+ console.log(prompt);
1042
+ return (pipedAnswers.shift() ?? "q").trim().toLowerCase();
1043
+ }
1044
+ let kept = 0;
1045
+ let dropped = 0;
1046
+ let skipped = 0;
1047
+ for (let i = 0; i < memories.length; i++) {
1048
+ const memory = memories[i];
1049
+ console.log(`[${i + 1}/${memories.length}] ${memory.type}: ${memory.content}`);
1050
+ console.log(` Captured: ${formatRelativeTime(parseUtcDate(memory.created_at))}`);
1051
+ console.log("");
1052
+ const answer = await nextAnswer("(k)eep (e)dit (d)rop (s)kip (q)uit > ");
1053
+ console.log("");
1054
+ if (answer === "k") {
1055
+ const { error: updateError } = await supabase
1056
+ .from("memories")
1057
+ .update({ suggested: false })
1058
+ .eq("id", memory.id);
1059
+ if (!updateError)
1060
+ kept++;
1061
+ }
1062
+ else if (answer === "e") {
1063
+ const newContent = redactSecrets(await editContentInteractively(memory.content));
1064
+ const { error: updateError } = await supabase
1065
+ .from("memories")
1066
+ .update({ content: newContent, suggested: false })
1067
+ .eq("id", memory.id);
1068
+ if (!updateError)
1069
+ kept++;
1070
+ }
1071
+ else if (answer === "d") {
1072
+ await supabase
1073
+ .from("memory_links")
1074
+ .delete()
1075
+ .or(`source_id.eq.${memory.id},target_id.eq.${memory.id}`);
1076
+ const { error: deleteError } = await supabase.from("memories").delete().eq("id", memory.id);
1077
+ if (!deleteError)
1078
+ dropped++;
1079
+ }
1080
+ else if (answer === "q") {
1081
+ break;
1082
+ }
1083
+ else {
1084
+ // "s", blank, or anything unrecognized — leave it suggested and
1085
+ // move on rather than erroring on a mistyped key.
1086
+ skipped++;
1087
+ }
1088
+ }
1089
+ rl?.close();
1090
+ console.log(`Review complete. Kept ${kept}, dropped ${dropped}, skipped ${skipped}.`);
1091
+ }
1092
+ // --- cm edit ---------------------------------------------------------------
1093
+ async function cmdEdit(project, memoryId) {
1094
+ const { data: memory, error } = await supabase
1095
+ .from("memories")
1096
+ .select("id, content")
1097
+ .eq("project", project)
1098
+ .eq("id", memoryId)
1099
+ .single();
1100
+ if (error || !memory) {
1101
+ console.log(`No memory found with ID [${memoryId}]`);
1102
+ return;
1103
+ }
1104
+ const newContent = redactSecrets(await editContentInteractively(memory.content));
1105
+ const { error: updateError } = await supabase
1106
+ .from("memories")
1107
+ .update({ content: newContent })
1108
+ .eq("id", memoryId);
1109
+ if (updateError)
1110
+ fail(`Failed to update memory: ${updateError.message}`);
1111
+ console.log(`Updated memory [${memoryId}].`);
1112
+ }
821
1113
  // --- cm inspect ------------------------------------------------------------
822
1114
  function formatDate(dateString) {
823
- return new Date(dateString).toLocaleDateString("en-US", {
1115
+ return parseUtcDate(dateString).toLocaleDateString("en-US", {
824
1116
  month: "short",
825
1117
  day: "numeric",
826
1118
  year: "numeric",
@@ -836,7 +1128,7 @@ async function cmdInspect(project, memoryId) {
836
1128
  if (error || !memory)
837
1129
  fail(`Memory [${memoryId}] not found for "${project}".`);
838
1130
  const config = readStackmemConfig();
839
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
1131
+ const decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
840
1132
  const { data: links, error: linksError } = await supabase
841
1133
  .from("memory_links")
842
1134
  .select("source_id, target_id, score")
@@ -1516,6 +1808,108 @@ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()) {
1516
1808
  nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
1517
1809
  return true;
1518
1810
  }
1811
+ function mergeMcpServersJson(configPath, distIndexPath) {
1812
+ let config = {};
1813
+ if (nodeFs.existsSync(configPath)) {
1814
+ try {
1815
+ config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
1816
+ }
1817
+ catch {
1818
+ config = {};
1819
+ }
1820
+ }
1821
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
1822
+ config.mcpServers = {};
1823
+ }
1824
+ config.mcpServers.stackmem = { command: "node", args: [distIndexPath] };
1825
+ nodeFs.mkdirSync(nodePath.dirname(configPath), { recursive: true });
1826
+ nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
1827
+ }
1828
+ // VS Code installs the Copilot Chat extension under a version-suffixed
1829
+ // directory name (e.g. "github.copilot-chat-0.26.4") — this resolves the
1830
+ // "github.copilot-chat*" glob the task describes against whatever's
1831
+ // actually installed, rather than assuming a fixed folder name.
1832
+ function findCopilotChatExtensionDir(homeDir) {
1833
+ const extensionsDir = nodePath.join(homeDir, ".vscode", "extensions");
1834
+ if (!nodeFs.existsSync(extensionsDir))
1835
+ return null;
1836
+ try {
1837
+ const match = nodeFs
1838
+ .readdirSync(extensionsDir)
1839
+ .find((entry) => entry.startsWith("github.copilot-chat"));
1840
+ return match ? nodePath.join(extensionsDir, match) : null;
1841
+ }
1842
+ catch {
1843
+ return null;
1844
+ }
1845
+ }
1846
+ const EDITOR_MCP_TARGETS = [
1847
+ {
1848
+ name: "Cursor",
1849
+ configPath: (homeDir) => {
1850
+ const dir = nodePath.join(homeDir, ".cursor");
1851
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp.json") : null;
1852
+ },
1853
+ register: mergeMcpServersJson,
1854
+ },
1855
+ {
1856
+ name: "Windsurf",
1857
+ configPath: (homeDir) => {
1858
+ const dir = nodePath.join(homeDir, ".codeium", "windsurf");
1859
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp_config.json") : null;
1860
+ },
1861
+ register: mergeMcpServersJson,
1862
+ },
1863
+ {
1864
+ name: "VS Code (Copilot)",
1865
+ configPath: (homeDir) => {
1866
+ const extensionDir = findCopilotChatExtensionDir(homeDir);
1867
+ return extensionDir ? nodePath.join(extensionDir, "mcp.json") : null;
1868
+ },
1869
+ register: mergeMcpServersJson,
1870
+ },
1871
+ {
1872
+ name: "Zed",
1873
+ configPath: (homeDir) => {
1874
+ const dir = nodePath.join(homeDir, ".config", "zed");
1875
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "settings.json") : null;
1876
+ },
1877
+ register: (configPath, distIndexPath) => {
1878
+ let config = {};
1879
+ if (nodeFs.existsSync(configPath)) {
1880
+ try {
1881
+ config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
1882
+ }
1883
+ catch {
1884
+ config = {};
1885
+ }
1886
+ }
1887
+ if (!config.context_servers || typeof config.context_servers !== "object") {
1888
+ config.context_servers = {};
1889
+ }
1890
+ config.context_servers.stackmem = { command: "node", args: [distIndexPath] };
1891
+ nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
1892
+ },
1893
+ },
1894
+ ];
1895
+ // Registers stackmem's MCP server with every detected editor, printing a
1896
+ // line per success. An editor whose config directory doesn't exist is
1897
+ // skipped with no output and never an error.
1898
+ function registerEditorMcpServers(distIndexPath, homeDir = nodeOs.homedir()) {
1899
+ for (const target of EDITOR_MCP_TARGETS) {
1900
+ const configPath = target.configPath(homeDir);
1901
+ if (!configPath)
1902
+ continue;
1903
+ try {
1904
+ target.register(configPath, distIndexPath);
1905
+ console.log(`✓ MCP registered for ${target.name}`);
1906
+ }
1907
+ catch {
1908
+ // Best-effort — a permissions error or malformed existing config on
1909
+ // one editor shouldn't abort `cm init` or the other editors' writes.
1910
+ }
1911
+ }
1912
+ }
1519
1913
  const DEFAULT_GITIGNORE_CONTENT = `node_modules/
1520
1914
  dist/
1521
1915
  .env
@@ -1527,6 +1921,7 @@ coverage/
1527
1921
  *.db
1528
1922
  *cursorrules
1529
1923
  *windsurfrules
1924
+ .cursor/rules/stackmem.md
1530
1925
  `;
1531
1926
  // Returns true if the file was created, false if it already existed
1532
1927
  // (and was left untouched).
@@ -1558,6 +1953,78 @@ cm save ${project} <type> "<content>"
1558
1953
  nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
1559
1954
  console.log("CLAUDE.md written.");
1560
1955
  }
1956
+ // Same instructional purpose as writeClaudeMd — point the agent at
1957
+ // `cm start` rather than embedding a memory snapshot directly, since a
1958
+ // baked-in snapshot goes stale the moment the next commit lands. AGENTS.md
1959
+ // is the convention used by OpenAI Codex CLI, Gemini CLI, and others;
1960
+ // GEMINI.md is Gemini CLI's own equivalent — both get identical content.
1961
+ function writeAgentsMemoryFile(project, resolvedPath, filename) {
1962
+ const filePath = nodePath.join(resolvedPath, filename);
1963
+ if (nodeFs.existsSync(filePath)) {
1964
+ console.log(`${filename} already exists — skipping.`);
1965
+ return;
1966
+ }
1967
+ const content = `# ${project} — Agent Memory
1968
+
1969
+ This project uses stackmem for persistent agent memory.
1970
+ Run \`cm start ${project}\` at the start of every session
1971
+ to load relevant context.
1972
+
1973
+ ## Key decisions, constraints and discoveries
1974
+
1975
+ Decisions, constraints, discoveries, and rejections live in the stackmem
1976
+ backend, not duplicated in this file — run \`cm start ${project}\` to load
1977
+ the current list.
1978
+
1979
+ When you make a decision, discover a constraint, reject an approach, or
1980
+ make a discovery during this session, save it with:
1981
+ cm save ${project} <type> "<content>"
1982
+ `;
1983
+ nodeFs.writeFileSync(filePath, content, "utf-8");
1984
+ console.log(`${filename} written.`);
1985
+ }
1986
+ // --project (not positional) for the PreCompact command below: cmdContext's
1987
+ // CLI convention takes --project like cm search/cm context already do
1988
+ // everywhere else — cm start and cm session-end both take a positional
1989
+ // project, so those two keep the user's literal command shape.
1990
+ const STACKMEM_HOOK_EVENTS = [
1991
+ { event: "SessionStart", buildCommand: (project) => `cm start ${project} --hook` },
1992
+ { event: "Stop", buildCommand: (project) => `cm session-end ${project} --hook` },
1993
+ { event: "PreCompact", buildCommand: (project) => `cm context --project ${project} --hook` },
1994
+ ];
1995
+ // Writes (or merges into) <resolvedPath>/.claude/settings.json. Merges
1996
+ // rather than overwrites — an existing settings.json may have unrelated
1997
+ // hooks or settings that must survive this — and is idempotent: re-running
1998
+ // `cm init` checks for the exact command string already present in an
1999
+ // event's hook groups before adding another one.
2000
+ function writeClaudeCodeHooks(project, resolvedPath) {
2001
+ const settingsDir = nodePath.join(resolvedPath, ".claude");
2002
+ const settingsPath = nodePath.join(settingsDir, "settings.json");
2003
+ let settings = {};
2004
+ if (nodeFs.existsSync(settingsPath)) {
2005
+ try {
2006
+ settings = JSON.parse(nodeFs.readFileSync(settingsPath, "utf-8"));
2007
+ }
2008
+ catch {
2009
+ settings = {};
2010
+ }
2011
+ }
2012
+ if (!settings.hooks || typeof settings.hooks !== "object") {
2013
+ settings.hooks = {};
2014
+ }
2015
+ for (const { event, buildCommand } of STACKMEM_HOOK_EVENTS) {
2016
+ const command = buildCommand(project);
2017
+ if (!Array.isArray(settings.hooks[event])) {
2018
+ settings.hooks[event] = [];
2019
+ }
2020
+ const alreadyRegistered = settings.hooks[event].some((group) => Array.isArray(group?.hooks) && group.hooks.some((h) => h?.command === command));
2021
+ if (!alreadyRegistered) {
2022
+ settings.hooks[event].push({ hooks: [{ type: "command", command }] });
2023
+ }
2024
+ }
2025
+ nodeFs.mkdirSync(settingsDir, { recursive: true });
2026
+ nodeFs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
2027
+ }
1561
2028
  // Section order for the generated rules-file markdown — matches the order
1562
2029
  // memories are presented in everywhere else (cm context's CONTEXT_TYPE_ORDER),
1563
2030
  // not MEMORY_TYPES' declaration order.
@@ -1571,14 +2038,15 @@ async function generateRulesContent(project) {
1571
2038
  .from("memories")
1572
2039
  .select("*")
1573
2040
  .eq("project", project)
1574
- .eq("resolved", false);
2041
+ .eq("resolved", false)
2042
+ .eq("suggested", false);
1575
2043
  if (memoriesError)
1576
2044
  fail(`Failed to load memories: ${memoriesError.message}`);
1577
2045
  const config = readStackmemConfig();
1578
2046
  const scoredMemories = (memoryRows ?? [])
1579
2047
  .map((memory) => ({
1580
2048
  ...memory,
1581
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
2049
+ decay_score: computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
1582
2050
  }))
1583
2051
  .sort((a, b) => b.decay_score - a.decay_score);
1584
2052
  const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
@@ -1640,6 +2108,18 @@ function writeCursorRules(resolvedPath, content) {
1640
2108
  console.log(".cursorrules written.");
1641
2109
  return true;
1642
2110
  }
2111
+ // Cursor's newer rules format (.cursor/rules/*.md) — .cursorrules is
2112
+ // legacy but still read by older Cursor versions, so both are written
2113
+ // during the transition rather than one replacing the other. Unlike
2114
+ // writeCursorRules/writeWindsurfRules (write-once at init, skip if
2115
+ // present), this one unconditionally overwrites on every call — both at
2116
+ // init and on every `cm sync` — since it's gitignored content meant to
2117
+ // always reflect the current memory state, same as the legacy files.
2118
+ function writeCursorRulesMdFile(resolvedPath, content) {
2119
+ const rulesDir = nodePath.join(resolvedPath, ".cursor", "rules");
2120
+ nodeFs.mkdirSync(rulesDir, { recursive: true });
2121
+ nodeFs.writeFileSync(nodePath.join(rulesDir, "stackmem.md"), content, "utf-8");
2122
+ }
1643
2123
  // Returns true if the file was written, false if it already existed
1644
2124
  // (and was skipped).
1645
2125
  function writeWindsurfRules(resolvedPath, content) {
@@ -1811,12 +2291,17 @@ async function cmdInit(project, targetPath, writeConfig = false) {
1811
2291
  }
1812
2292
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1813
2293
  registerMcpServer(DIST_INDEX_PATH);
2294
+ registerEditorMcpServers(DIST_INDEX_PATH);
1814
2295
  writeClaudeMd(project, resolvedPath);
2296
+ writeAgentsMemoryFile(project, resolvedPath, "AGENTS.md");
2297
+ writeAgentsMemoryFile(project, resolvedPath, "GEMINI.md");
2298
+ writeClaudeCodeHooks(project, resolvedPath);
1815
2299
  const seededCount = await seedMemories(project, resolvedPath);
1816
2300
  console.log(`Seeded ${seededCount} initial memories from project scan.`);
1817
2301
  const rulesContent = await generateRulesContent(project);
1818
2302
  const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
1819
2303
  const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
2304
+ writeCursorRulesMdFile(resolvedPath, rulesContent);
1820
2305
  const backendOk = await verifyBackendConnection(project);
1821
2306
  const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
1822
2307
  console.log("");
@@ -1828,9 +2313,13 @@ async function cmdInit(project, targetPath, writeConfig = false) {
1828
2313
  }
1829
2314
  console.log("✓ Git hook installed");
1830
2315
  console.log("✓ MCP server registered");
2316
+ console.log("✓ Claude Code hooks registered (.claude/settings.json)");
1831
2317
  console.log("✓ CLAUDE.md written");
2318
+ console.log("✓ AGENTS.md written");
2319
+ console.log("✓ GEMINI.md written");
1832
2320
  console.log(`✓ .cursorrules ${cursorRulesWritten ? "written" : "already exists"}`);
1833
2321
  console.log(`✓ .windsurfrules ${windsurfRulesWritten ? "written" : "already exists"}`);
2322
+ console.log("✓ .cursor/rules/stackmem.md written");
1834
2323
  console.log(`✓ Seeded ${seededCount} memories from project scan`);
1835
2324
  if (backendOk) {
1836
2325
  console.log("✓ Backend connection verified");
@@ -1851,7 +2340,8 @@ async function cmdSync(project) {
1851
2340
  const rulesContent = await generateRulesContent(project);
1852
2341
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".cursorrules"), rulesContent, "utf-8");
1853
2342
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
1854
- console.log("Synced memories to .cursorrules and .windsurfrules");
2343
+ writeCursorRulesMdFile(resolvedPath, rulesContent);
2344
+ console.log("Synced memories to .cursorrules, .windsurfrules, and .cursor/rules/stackmem.md");
1855
2345
  }
1856
2346
  // --- cm link ---------------------------------------------------------------
1857
2347
  // Mirrors the link_codes.expires_at default in the migration
@@ -1920,38 +2410,150 @@ async function cmdLink() {
1920
2410
  }
1921
2411
  console.log("Link code expired. Run cm link again.");
1922
2412
  }
2413
+ // --- cm open -----------------------------------------------------------
2414
+ // Separated from cmdOpen so the platform-selection logic is unit-testable
2415
+ // without actually launching a browser — exercising this for real on
2416
+ // every platform isn't something a test (or a verification pass) should
2417
+ // do as a side effect.
2418
+ export function getOpenCommand(platform, url) {
2419
+ if (platform === "darwin")
2420
+ return { command: "open", args: [url] };
2421
+ if (platform === "win32")
2422
+ return { command: "cmd", args: ["/c", "start", "", url] };
2423
+ return { command: "xdg-open", args: [url] };
2424
+ }
2425
+ async function cmdOpen(project) {
2426
+ const url = `${VAULT_URL}/project/${project}`;
2427
+ const { command, args } = getOpenCommand(process.platform, url);
2428
+ try {
2429
+ execFileSync(command, args, { stdio: "ignore" });
2430
+ console.log(`Opened ${url}`);
2431
+ }
2432
+ catch (error) {
2433
+ fail(`Failed to open browser (${error instanceof Error ? error.message : String(error)}). Visit ${url} manually.`);
2434
+ }
2435
+ }
2436
+ // --- cm purge ------------------------------------------------------------
2437
+ async function cmdPurgeProject(project) {
2438
+ const { count: total, error: countError } = await supabase
2439
+ .from("memories")
2440
+ .select("id", { count: "exact", head: true })
2441
+ .eq("project", project);
2442
+ if (countError)
2443
+ fail(`Failed to count memories: ${countError.message}`);
2444
+ console.log(`This will permanently delete all ${total ?? 0} memories for "${project}".`);
2445
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2446
+ const answer = (await rl.question("Type the project name to confirm: ")).trim();
2447
+ rl.close();
2448
+ if (answer !== project) {
2449
+ console.log("Confirmation did not match — aborted. Nothing was deleted.");
2450
+ return;
2451
+ }
2452
+ const { data: memRows, error: memFetchError } = await supabase
2453
+ .from("memories")
2454
+ .select("id")
2455
+ .eq("project", project);
2456
+ if (memFetchError)
2457
+ fail(`Failed to load memories: ${memFetchError.message}`);
2458
+ const ids = (memRows ?? []).map((m) => m.id);
2459
+ let linksDeleted = 0;
2460
+ if (ids.length > 0) {
2461
+ const idList = ids.join(",");
2462
+ const { count, error: linksError } = await supabase
2463
+ .from("memory_links")
2464
+ .delete({ count: "exact" })
2465
+ .or(`source_id.in.(${idList}),target_id.in.(${idList})`);
2466
+ if (linksError)
2467
+ fail(`Failed to delete memory links: ${linksError.message}`);
2468
+ linksDeleted = count ?? 0;
2469
+ }
2470
+ const { count: memoriesDeleted, error: memError } = await supabase
2471
+ .from("memories")
2472
+ .delete({ count: "exact" })
2473
+ .eq("project", project);
2474
+ if (memError)
2475
+ fail(`Failed to delete memories: ${memError.message}`);
2476
+ const { count: fixesDeleted, error: fixError } = await supabase
2477
+ .from("execution_log")
2478
+ .delete({ count: "exact" })
2479
+ .eq("project", project);
2480
+ if (fixError)
2481
+ fail(`Failed to delete fixes: ${fixError.message}`);
2482
+ console.log(`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted} links for "${project}".`);
2483
+ }
2484
+ // RLS already scopes every table read/write to this device's device_id
2485
+ // via the x-device-id header (see src/storage.ts) — a filter-less delete
2486
+ // here is already confined to this device, never another one. PostgREST
2487
+ // still requires an explicit filter clause to allow a DELETE at all, so
2488
+ // `.not("id", "is", null)` (every row has a non-null id) is used purely
2489
+ // to satisfy that, not to narrow the scope further.
2490
+ async function cmdPurgeAll() {
2491
+ console.log("This will permanently delete ALL memories across ALL projects for this device.");
2492
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2493
+ const answer = (await rl.question("Type DELETE to confirm: ")).trim();
2494
+ rl.close();
2495
+ if (answer !== "DELETE") {
2496
+ console.log("Confirmation did not match — aborted. Nothing was deleted.");
2497
+ return;
2498
+ }
2499
+ const { count: linksDeleted, error: linksError } = await supabase
2500
+ .from("memory_links")
2501
+ .delete({ count: "exact" })
2502
+ .not("id", "is", null);
2503
+ if (linksError)
2504
+ fail(`Failed to delete memory links: ${linksError.message}`);
2505
+ const { count: memoriesDeleted, error: memError } = await supabase
2506
+ .from("memories")
2507
+ .delete({ count: "exact" })
2508
+ .not("id", "is", null);
2509
+ if (memError)
2510
+ fail(`Failed to delete memories: ${memError.message}`);
2511
+ const { count: fixesDeleted, error: fixError } = await supabase
2512
+ .from("execution_log")
2513
+ .delete({ count: "exact" })
2514
+ .not("id", "is", null);
2515
+ if (fixError)
2516
+ fail(`Failed to delete fixes: ${fixError.message}`);
2517
+ console.log(`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted ?? 0} links across all projects.`);
2518
+ }
1923
2519
  // --- cm context ------------------------------------------------------------
1924
2520
  const CONTEXT_TYPE_ORDER = ["decision", "constraint", "rejection", "discovery"];
1925
2521
  const CONTEXT_TOP_N = 5;
1926
- async function cmdContext(project, task) {
2522
+ // task is null for the PreCompact hook — there's no user-typed task at a
2523
+ // compaction boundary, just "save the most relevant context" generically
2524
+ // — so ranking falls back to decay score alone instead of diluting it
2525
+ // with a relevance score computed against an empty string.
2526
+ async function cmdContext(project, task, hookMode = false) {
1927
2527
  const { data, error } = await supabase
1928
2528
  .from("memories")
1929
2529
  .select("*")
1930
2530
  .eq("project", project)
1931
- .eq("resolved", false);
2531
+ .eq("resolved", false)
2532
+ .eq("suggested", false);
1932
2533
  if (error)
1933
2534
  fail(`Failed to load memories: ${error.message}`);
1934
2535
  const memories = data ?? [];
1935
2536
  if (memories.length === 0) {
1936
- console.log(`No memories found for "${project}".`);
2537
+ if (!hookMode)
2538
+ console.log(`No memories found for "${project}".`);
1937
2539
  return;
1938
2540
  }
1939
2541
  const config = readStackmemConfig();
1940
2542
  const tokenBudget = config.tokenBudget;
1941
2543
  const scoredMemories = memories
1942
2544
  .map((memory) => {
1943
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
1944
- const relevanceScore = scoreMemoryRelevance(memory.content, task);
1945
- return {
1946
- ...memory,
1947
- combined_score: relevanceScore * 0.6 + decayScore * 0.4,
1948
- };
2545
+ const decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
2546
+ const combinedScore = task == null ? decayScore : scoreMemoryRelevance(memory.content, task) * 0.6 + decayScore * 0.4;
2547
+ return { ...memory, combined_score: combinedScore };
1949
2548
  })
1950
2549
  .sort((a, b) => b.combined_score - a.combined_score);
1951
2550
  const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
1952
2551
  const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
1953
2552
  const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
1954
- const lines = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
2553
+ const lines = [`## Project Context: ${project}`];
2554
+ if (task != null)
2555
+ lines.push(`### Task: ${task}`);
2556
+ lines.push("");
1955
2557
  for (const type of CONTEXT_TYPE_ORDER) {
1956
2558
  const group = budgetedMemories.filter((memory) => memory.type === type);
1957
2559
  if (group.length === 0)
@@ -1962,7 +2564,8 @@ async function cmdContext(project, task) {
1962
2564
  }
1963
2565
  lines.push("");
1964
2566
  }
1965
- lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
2567
+ if (!hookMode)
2568
+ lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
1966
2569
  console.log(lines.join("\n"));
1967
2570
  }
1968
2571
  // --- cm why --------------------------------------------------------------
@@ -1981,7 +2584,8 @@ async function cmdWhy(project, file) {
1981
2584
  .from("memories")
1982
2585
  .select("*")
1983
2586
  .eq("project", project)
1984
- .eq("resolved", false);
2587
+ .eq("resolved", false)
2588
+ .eq("suggested", false);
1985
2589
  if (error)
1986
2590
  fail(`Failed to load memories: ${error.message}`);
1987
2591
  const memories = data ?? [];
@@ -2002,7 +2606,7 @@ async function cmdWhy(project, file) {
2002
2606
  const scoredMemories = matched
2003
2607
  .map((memory) => ({
2004
2608
  ...memory,
2005
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
2609
+ decay_score: computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
2006
2610
  }))
2007
2611
  .sort((a, b) => b.decay_score - a.decay_score);
2008
2612
  console.log(`Decisions and constraints tied to ${file}:\n`);
@@ -2058,6 +2662,11 @@ function parseSessionEndFlags(args) {
2058
2662
  approaches = splitCommaList(value);
2059
2663
  i++;
2060
2664
  }
2665
+ else if (flag === "--hook") {
2666
+ // Recognized explicitly (not just silently skipped as an unknown
2667
+ // flag) — see the comment on cmdSessionEnd for why it's currently
2668
+ // a no-op here specifically.
2669
+ }
2061
2670
  }
2062
2671
  return { project, summary, errors, files, approaches };
2063
2672
  }
@@ -2077,6 +2686,16 @@ async function readStdinJson() {
2077
2686
  return null;
2078
2687
  }
2079
2688
  }
2689
+ // --hook is accepted (see parseSessionEndFlags) but currently a no-op:
2690
+ // Claude Code's Stop hook pipes ITS OWN JSON payload on stdin (session
2691
+ // id, transcript path, cwd — see the Claude Code hooks docs), not the
2692
+ // {summary, errors_encountered, files_touched, approaches_tried} shape
2693
+ // this command expects. readStdinJson() parses that payload successfully
2694
+ // (it's valid JSON) but finds none of the fields it looks for, so every
2695
+ // array below comes back empty and this reports 0 memories saved — a
2696
+ // real, known limitation, not a bug. Extracting genuine learnings from
2697
+ // the Stop hook would mean parsing the session transcript itself, which
2698
+ // is out of scope here (Phase 2, per the user).
2080
2699
  async function cmdSessionEnd(project, input) {
2081
2700
  let memoriesSaved = 0;
2082
2701
  let errorsLogged = 0;
@@ -2188,6 +2807,124 @@ function isHookExecutable(hookPath) {
2188
2807
  return false;
2189
2808
  }
2190
2809
  }
2810
+ // --- cm status ---------------------------------------------------------
2811
+ function hasMcpServersStackmemKey(configPath) {
2812
+ if (!nodeFs.existsSync(configPath))
2813
+ return false;
2814
+ try {
2815
+ const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2816
+ return Boolean(config?.mcpServers?.stackmem);
2817
+ }
2818
+ catch {
2819
+ return false;
2820
+ }
2821
+ }
2822
+ function hasContextServersStackmemKey(configPath) {
2823
+ if (!nodeFs.existsSync(configPath))
2824
+ return false;
2825
+ try {
2826
+ const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2827
+ return Boolean(config?.context_servers?.stackmem);
2828
+ }
2829
+ catch {
2830
+ return false;
2831
+ }
2832
+ }
2833
+ const EDITOR_STATUS_CHECKS = [
2834
+ { name: "Claude Code", isRegistered: (homeDir) => isMcpRegistered(nodePath.join(homeDir, ".claude.json")) },
2835
+ { name: "Cursor", isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".cursor", "mcp.json")) },
2836
+ {
2837
+ name: "Windsurf",
2838
+ isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".codeium", "windsurf", "mcp_config.json")),
2839
+ },
2840
+ {
2841
+ name: "VS Code",
2842
+ isRegistered: (homeDir) => {
2843
+ const dir = findCopilotChatExtensionDir(homeDir);
2844
+ return dir ? hasMcpServersStackmemKey(nodePath.join(dir, "mcp.json")) : false;
2845
+ },
2846
+ },
2847
+ {
2848
+ name: "Zed",
2849
+ isRegistered: (homeDir) => hasContextServersStackmemKey(nodePath.join(homeDir, ".config", "zed", "settings.json")),
2850
+ },
2851
+ ];
2852
+ async function cmdStatus(project) {
2853
+ const { count: totalCount, error: totalError } = await supabase
2854
+ .from("memories")
2855
+ .select("id", { count: "exact", head: true })
2856
+ .eq("project", project)
2857
+ .eq("resolved", false);
2858
+ if (totalError)
2859
+ fail(`Failed to load memory count: ${totalError.message}`);
2860
+ const { count: pinnedCount, error: pinnedError } = await supabase
2861
+ .from("memories")
2862
+ .select("id", { count: "exact", head: true })
2863
+ .eq("project", project)
2864
+ .eq("resolved", false)
2865
+ .eq("pinned", true);
2866
+ if (pinnedError)
2867
+ fail(`Failed to load pinned count: ${pinnedError.message}`);
2868
+ const { count: suggestedCount, error: suggestedError } = await supabase
2869
+ .from("memories")
2870
+ .select("id", { count: "exact", head: true })
2871
+ .eq("project", project)
2872
+ .eq("resolved", false)
2873
+ .eq("suggested", true);
2874
+ if (suggestedError)
2875
+ fail(`Failed to load suggested count: ${suggestedError.message}`);
2876
+ const { count: fixesCount, error: fixesError } = await supabase
2877
+ .from("execution_log")
2878
+ .select("id", { count: "exact", head: true })
2879
+ .eq("project", project)
2880
+ .eq("resolved", false);
2881
+ if (fixesError)
2882
+ fail(`Failed to load fixes count: ${fixesError.message}`);
2883
+ const { data: lastCaptureRows, error: lastCaptureError } = await supabase
2884
+ .from("memories")
2885
+ .select("content, created_at")
2886
+ .eq("project", project)
2887
+ .order("created_at", { ascending: false })
2888
+ .limit(1);
2889
+ if (lastCaptureError)
2890
+ fail(`Failed to load last capture: ${lastCaptureError.message}`);
2891
+ const config = readStackmemConfig();
2892
+ const configPath = nodePath.join(process.cwd(), STACKMEM_CONFIG_FILENAME);
2893
+ let usesCustomBudget = false;
2894
+ if (nodeFs.existsSync(configPath)) {
2895
+ try {
2896
+ const raw = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2897
+ usesCustomBudget = typeof raw.tokenBudget === "number";
2898
+ }
2899
+ catch {
2900
+ // Malformed config — readStackmemConfig already falls back to the
2901
+ // default in this case too, so "default" is the accurate label.
2902
+ }
2903
+ }
2904
+ const hookOk = isHookExecutable(nodePath.join(process.cwd(), ".git", "hooks", "post-commit"));
2905
+ const mcpOk = isMcpRegistered(getClaudeConfigPath());
2906
+ const backendOk = await verifyBackendConnection(project);
2907
+ const homeDir = nodeOs.homedir();
2908
+ const registeredEditors = EDITOR_STATUS_CHECKS.filter((e) => e.isRegistered(homeDir)).map((e) => e.name);
2909
+ console.log(`stackmem status for "${project}"`);
2910
+ console.log("━".repeat(27));
2911
+ console.log(`Memories: ${totalCount ?? 0} total (${pinnedCount ?? 0} pinned, ${suggestedCount ?? 0} suggested)`);
2912
+ console.log(`Fixes: ${fixesCount ?? 0} recorded`);
2913
+ console.log(`Token budget: ${config.tokenBudget} (${usesCustomBudget ? "from .stackmem.json" : "default"})`);
2914
+ const lastCapture = (lastCaptureRows ?? [])[0];
2915
+ if (lastCapture) {
2916
+ const ago = formatRelativeTime(parseUtcDate(lastCapture.created_at));
2917
+ const snippet = lastCapture.content.length > 50 ? `${lastCapture.content.slice(0, 50)}…` : lastCapture.content;
2918
+ console.log(`Last capture: ${ago} (${snippet})`);
2919
+ }
2920
+ else {
2921
+ console.log("Last capture: none yet");
2922
+ }
2923
+ console.log(`Hook: ${hookOk ? "✓ installed" : "✗ not installed"}`);
2924
+ console.log(`MCP: ${mcpOk ? "✓ registered" : "✗ not registered"}`);
2925
+ console.log(`Backend: ${backendOk ? "✓ connected" : "✗ not connected"}`);
2926
+ console.log(`Editors: ${registeredEditors.length > 0 ? registeredEditors.map((name) => `${name} ✓`).join(" ") : "(none registered)"}`);
2927
+ }
2191
2928
  async function cmdDoctor(fix) {
2192
2929
  let issues = 0;
2193
2930
  const project = readProjectFromFile();
@@ -2464,11 +3201,14 @@ async function main() {
2464
3201
  const [, , command, ...args] = process.argv;
2465
3202
  switch (command) {
2466
3203
  case "start": {
2467
- const split = splitOptionalProject(args, 0);
3204
+ const hookIndex = args.indexOf("--hook");
3205
+ const hookMode = hookIndex !== -1;
3206
+ const positional = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
3207
+ const split = splitOptionalProject(positional, 0);
2468
3208
  if (!split)
2469
3209
  usage();
2470
3210
  const project = requireProject(split.project);
2471
- await cmdStart(project);
3211
+ await cmdStart(project, hookMode);
2472
3212
  break;
2473
3213
  }
2474
3214
  case "save": {
@@ -2590,6 +3330,23 @@ async function main() {
2590
3330
  await cmdUnpin(project, memoryId);
2591
3331
  break;
2592
3332
  }
3333
+ case "review": {
3334
+ const split = splitProjectFromVariadic(args);
3335
+ if (split.rest.length > 0)
3336
+ usage();
3337
+ const project = requireProject(split.project);
3338
+ await cmdReview(project);
3339
+ break;
3340
+ }
3341
+ case "edit": {
3342
+ const split = splitOptionalProject(args, 1);
3343
+ if (!split)
3344
+ usage();
3345
+ const project = requireProject(split.project);
3346
+ const [memoryId] = split.rest;
3347
+ await cmdEdit(project, memoryId);
3348
+ break;
3349
+ }
2593
3350
  case "delete": {
2594
3351
  const split = splitOptionalProject(args, 1);
2595
3352
  if (!split)
@@ -2634,6 +3391,30 @@ async function main() {
2634
3391
  await cmdLink();
2635
3392
  break;
2636
3393
  }
3394
+ case "open": {
3395
+ const split = splitProjectFromVariadic(args);
3396
+ if (split.rest.length > 0)
3397
+ usage();
3398
+ const project = requireProject(split.project);
3399
+ await cmdOpen(project);
3400
+ break;
3401
+ }
3402
+ case "purge": {
3403
+ const allIndex = args.indexOf("--all");
3404
+ const isAll = allIndex !== -1;
3405
+ const withoutAllFlag = isAll ? [...args.slice(0, allIndex), ...args.slice(allIndex + 1)] : args;
3406
+ const split = splitProjectFromVariadic(withoutAllFlag);
3407
+ if (split.rest.length > 0)
3408
+ usage();
3409
+ if (isAll) {
3410
+ await cmdPurgeAll();
3411
+ }
3412
+ else {
3413
+ const project = requireProject(split.project);
3414
+ await cmdPurgeProject(project);
3415
+ }
3416
+ break;
3417
+ }
2637
3418
  case "sync": {
2638
3419
  if (args.length > 0)
2639
3420
  usage();
@@ -2642,11 +3423,17 @@ async function main() {
2642
3423
  break;
2643
3424
  }
2644
3425
  case "context": {
2645
- const split = splitProjectFromVariadic(args);
3426
+ const hookIndex = args.indexOf("--hook");
3427
+ const hookMode = hookIndex !== -1;
3428
+ const withoutHookFlag = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
3429
+ const split = splitProjectFromVariadic(withoutHookFlag);
2646
3430
  const project = requireProject(split.project);
2647
- if (split.rest.length === 0)
3431
+ // task is optional only in --hook mode (the PreCompact hook has no
3432
+ // user-typed task to pass) — interactive use still requires one.
3433
+ if (split.rest.length === 0 && !hookMode)
2648
3434
  usage();
2649
- await cmdContext(project, split.rest.join(" "));
3435
+ const task = split.rest.length > 0 ? split.rest.join(" ") : null;
3436
+ await cmdContext(project, task, hookMode);
2650
3437
  break;
2651
3438
  }
2652
3439
  case "why": {
@@ -2666,6 +3453,14 @@ async function main() {
2666
3453
  await cmdDoctor(fix);
2667
3454
  break;
2668
3455
  }
3456
+ case "status": {
3457
+ const split = splitProjectFromVariadic(args);
3458
+ if (split.rest.length > 0)
3459
+ usage();
3460
+ const project = requireProject(split.project);
3461
+ await cmdStatus(project);
3462
+ break;
3463
+ }
2669
3464
  case "undo": {
2670
3465
  const split = splitOptionalProject(args, 0);
2671
3466
  if (!split)