stackmem 1.0.24 → 1.0.26

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
@@ -2,12 +2,25 @@
2
2
  import nodeFs from "node:fs";
3
3
  import nodeOs from "node:os";
4
4
  import nodePath from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { randomInt } from "node:crypto";
5
7
  import { fileURLToPath, pathToFileURL } from "node:url";
6
8
  import { createInterface } from "node:readline/promises";
7
9
  import { analyzeCodebase } from "./ast-index.js";
10
+ import { VAULT_URL } from "./config.js";
11
+ import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
8
12
  import { getDeviceId } from "./device.js";
9
- import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, scoreMemoryRelevance, } from "./scoring.js";
13
+ import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, hasTokenOverlap, isQualityMessage, redactSecrets, scoreMemoryRelevance, tokenize, } from "./scoring.js";
10
14
  import { supabase } from "./storage.js";
15
+ // Checked as early as ESM import hoisting allows — before any command
16
+ // dispatch, config loading, or network call — so an unsupported Node
17
+ // version fails with a clear message instead of an obscure syntax or
18
+ // API error from deeper in the dependency chain.
19
+ const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
20
+ if (NODE_MAJOR_VERSION < 18) {
21
+ console.error(`Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`);
22
+ process.exit(1);
23
+ }
11
24
  const CONTEXT_BUDGET_TOKENS = 2000;
12
25
  const COMPRESSION_THRESHOLD = 20;
13
26
  const CLI_PATH = fileURLToPath(import.meta.url);
@@ -22,7 +35,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
22
35
  const STACKMEM_HOOK_MARKER = "# stackmem-managed";
23
36
  const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
24
37
  const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
25
- const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
38
+ const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
26
39
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
27
40
  const TYPE_LABELS = {
28
41
  decision: "Decisions",
@@ -38,13 +51,17 @@ function usage() {
38
51
  cm analyze [project] <path>
39
52
  cm search [--project <name>] <query>
40
53
  cm resolve [project] <memory-id>
54
+ cm pin [project] <memory-id>
55
+ cm unpin [project] <memory-id>
41
56
  cm delete [project] <memory-id>
42
57
  cm inspect [project] <memory-id>
43
58
  cm compress [project]
44
- cm init <project> <path>
59
+ cm init <project> <path> [--config]
45
60
  cm uninstall [project] [path]
61
+ cm link
46
62
  cm sync
47
63
  cm context [--project <name>] <task>
64
+ cm why [--project <name>] <file>
48
65
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
49
66
  cm doctor [--fix]
50
67
  cm undo [project]
@@ -95,6 +112,14 @@ function cmdHelp() {
95
112
  cm resolve [project] <memory-id>
96
113
  Mark a memory as resolved.
97
114
 
115
+ cm pin [project] <memory-id>
116
+ Pin a memory: it scores 999 in decay ranking (always sorts
117
+ first, never decays) and shows in its own "Pinned" section at
118
+ the top of 'cm start', ahead of its normal type group.
119
+
120
+ cm unpin [project] <memory-id>
121
+ Un-pin a memory, returning it to normal decay-based ranking.
122
+
98
123
  cm delete [project] <memory-id>
99
124
  Delete a memory and any memory_links referencing it.
100
125
 
@@ -109,7 +134,7 @@ function cmdHelp() {
109
134
  Cluster related unresolved memories (needs 20+) and collapse
110
135
  clusters of 3 or more into a single summary memory.
111
136
 
112
- cm init <project> <path>
137
+ cm init <project> <path> [--config]
113
138
  Install a git post-commit hook in the repo at <path>, register
114
139
  the stackmem MCP server with Claude Code, write a CLAUDE.md,
115
140
  .cursorrules, .windsurfrules, and .stackmem file, and seed
@@ -117,7 +142,12 @@ function cmdHelp() {
117
142
  .windsurfrules are pre-filled with the project's current
118
143
  memories (for Cursor and Windsurf) and skipped if they already
119
144
  exist. The post-commit hook keeps them fresh afterward by
120
- running 'cm sync' on every commit.
145
+ running 'cm sync' on every commit — on disk only, never
146
+ committed, so they don't double your commit count or conflict
147
+ across branches. Both files are added to .gitignore. With
148
+ --config, also writes a default .stackmem.json (ignore patterns,
149
+ tokenBudget, decayLambda, captureTypes, minQualityScore — see
150
+ src/config-file.ts) if one doesn't already exist.
121
151
 
122
152
  cm uninstall [project] [path]
123
153
  Remove everything 'cm init' set up at <path> (defaults to the
@@ -130,11 +160,19 @@ function cmdHelp() {
130
160
  Also removes the stackmem entry from ~/.claude.json's mcpServers.
131
161
  [project] defaults to the .stackmem file at <path> if omitted.
132
162
 
163
+ cm link
164
+ Connect this device to your GitHub account in the hosted vault.
165
+ Prints a 6-digit code, then waits for you to enter it at
166
+ stackmem-vault.vercel.app/link while signed in. The code is
167
+ single-use and expires after 10 minutes; polling stops when it
168
+ does. Links are per-device, so run this once on each machine.
169
+
133
170
  cm sync
134
171
  Regenerate .cursorrules and .windsurfrules from the project's
135
172
  current unresolved memories (decay-ranked, capped at a 2000
136
173
  token budget) and its 5 most recent open execution_log fixes,
137
- overwriting both files. Project is read from .stackmem.
174
+ overwriting both files on disk only (never git add/commit).
175
+ Project is read from .stackmem.
138
176
 
139
177
  cm context [--project <name>] <task>
140
178
  Return only the top 5 memories most relevant to a specific
@@ -142,6 +180,14 @@ function cmdHelp() {
142
180
  as a markdown block capped at a 2000 token budget. Use
143
181
  --project to target a project other than the one in .stackmem.
144
182
 
183
+ cm why [--project <name>] <file>
184
+ Show memories tied to a file: matched by Item 6's file_paths
185
+ column (hook-captured commits that touched it) plus a plain
186
+ content search for the file's path or basename, ranked by decay
187
+ score and grouped by type. Prints a guidance message instead if
188
+ nothing matches. Use --project to target a project other than
189
+ the one in .stackmem.
190
+
145
191
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
146
192
  Capture what happened in a session and extract memories: each
147
193
  error becomes an unsolved execution_log entry, each approach
@@ -247,7 +293,84 @@ function splitProjectFromVariadic(args) {
247
293
  }
248
294
  return { project: null, rest: args };
249
295
  }
296
+ // Generic "--flag value" extractor for internal/provenance flags (not
297
+ // meant to be remembered by end users — only the hook and MCP server pass
298
+ // these). Unlike splitProjectFromVariadic's --project, these don't need
299
+ // special positional disambiguation, so one generic helper covers all of
300
+ // them: --commit-sha, --author, --source, --confidence, --files.
301
+ function extractValueFlag(args, flag) {
302
+ const idx = args.indexOf(flag);
303
+ if (idx === -1 || args[idx + 1] === undefined)
304
+ return { value: null, rest: args };
305
+ return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
306
+ }
250
307
  // --- cm start ----------------------------------------------------------
308
+ const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
309
+ const BRANCH_NAME_BOOST = 1.2;
310
+ const RECENT_FILES_BOOST = 1.3;
311
+ 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.
315
+ function getBranchNameTokens() {
316
+ try {
317
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
318
+ encoding: "utf-8",
319
+ stdio: ["ignore", "pipe", "ignore"],
320
+ }).trim();
321
+ if (!branch || branch === "HEAD")
322
+ return null; // detached HEAD
323
+ const tokens = tokenize(branch);
324
+ return tokens.size > 0 ? tokens : null;
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ }
330
+ function getRecentlyTouchedFiles() {
331
+ try {
332
+ const output = execFileSync("git", ["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
333
+ const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
334
+ return files.length > 0 ? new Set(files) : null;
335
+ }
336
+ catch {
337
+ // Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
338
+ // not a git repo at all — skip gracefully.
339
+ return null;
340
+ }
341
+ }
342
+ function getOpenPrTitleTokens() {
343
+ try {
344
+ const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
345
+ encoding: "utf-8",
346
+ stdio: ["ignore", "pipe", "ignore"],
347
+ }).trim();
348
+ if (!title)
349
+ return null;
350
+ const tokens = tokenize(title);
351
+ return tokens.size > 0 ? tokens : null;
352
+ }
353
+ catch {
354
+ // gh not installed, not authenticated, or no open PR for this branch.
355
+ return null;
356
+ }
357
+ }
358
+ function fileListOverlaps(filePaths, recentFiles) {
359
+ if (!Array.isArray(filePaths))
360
+ return false;
361
+ return filePaths.some((candidate) => {
362
+ if (typeof candidate !== "string")
363
+ return false;
364
+ if (recentFiles.has(candidate))
365
+ return true;
366
+ const base = candidate.split("/").pop();
367
+ for (const recent of recentFiles) {
368
+ if (recent.split("/").pop() === base)
369
+ return true;
370
+ }
371
+ return false;
372
+ });
373
+ }
251
374
  async function cmdStart(project) {
252
375
  const { data, error } = await supabase
253
376
  .from("memories")
@@ -263,13 +386,32 @@ async function cmdStart(project) {
263
386
  console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
264
387
  return;
265
388
  }
389
+ // Task-aware re-ranking: boost memories relevant to what's actively
390
+ // being worked on, multiplicatively on top of the base decay score.
391
+ // Every signal degrades to null (no boost, not an error) if the
392
+ // repo/branch/gh state it needs isn't available.
393
+ const branchTokens = getBranchNameTokens();
394
+ const recentFiles = getRecentlyTouchedFiles();
395
+ const prTitleTokens = getOpenPrTitleTokens();
396
+ const config = readStackmemConfig();
397
+ const tokenBudget = config.tokenBudget;
266
398
  const scoredMemories = memories
267
- .map((memory) => ({
268
- ...memory,
269
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
270
- }))
399
+ .map((memory) => {
400
+ let decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
401
+ const contentTokens = tokenize(memory.content);
402
+ if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
403
+ decayScore *= BRANCH_NAME_BOOST;
404
+ }
405
+ if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
406
+ decayScore *= RECENT_FILES_BOOST;
407
+ }
408
+ if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
409
+ decayScore *= PR_TITLE_BOOST;
410
+ }
411
+ return { ...memory, decay_score: decayScore };
412
+ })
271
413
  .sort((a, b) => b.decay_score - a.decay_score);
272
- const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
414
+ const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
273
415
  const omittedCount = scoredMemories.length - budgetedMemories.length;
274
416
  let tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
275
417
  const { data: fixData, error: fixError } = await supabase
@@ -281,12 +423,12 @@ async function cmdStart(project) {
281
423
  .limit(5);
282
424
  if (fixError)
283
425
  fail(`Failed to load execution log: ${fixError.message}`);
284
- // Fixes share the same 2000-token budget as memories, which already
285
- // consumed their portion above — fixes only get whatever room is left.
426
+ // Fixes share the same token budget as memories, which already consumed
427
+ // their portion above — fixes only get whatever room is left.
286
428
  const budgetedFixes = [];
287
429
  for (const fix of fixData ?? []) {
288
430
  const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
289
- if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS)
431
+ if (tokensUsed + tokens > tokenBudget)
290
432
  break;
291
433
  budgetedFixes.push(fix);
292
434
  tokensUsed += tokens;
@@ -309,8 +451,22 @@ async function cmdStart(project) {
309
451
  linkedIdsByMemory.get(link.target_id).add(link.source_id);
310
452
  }
311
453
  console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
454
+ const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
455
+ 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(", ")}`);
464
+ }
465
+ }
466
+ console.log("");
467
+ }
312
468
  for (const type of MEMORY_TYPES) {
313
- const group = budgetedMemories.filter((m) => m.type === type);
469
+ const group = unpinnedMemories.filter((m) => m.type === type);
314
470
  if (group.length === 0)
315
471
  continue;
316
472
  console.log(`${TYPE_LABELS[type]} (${group.length})`);
@@ -333,7 +489,7 @@ async function cmdStart(project) {
333
489
  console.log("");
334
490
  }
335
491
  }
336
- console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
492
+ console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
337
493
  if (omittedCount > 0) {
338
494
  console.log(`${omittedCount} memories omitted (budget exceeded)`);
339
495
  }
@@ -376,7 +532,7 @@ async function confirmSupersede(oldId, newContent) {
376
532
  console.log("Invalid input — kept original memory.");
377
533
  return false;
378
534
  }
379
- async function cmdSave(project, type, content, force = false) {
535
+ async function cmdSave(project, type, content, force = false, provenance = {}) {
380
536
  if (!MEMORY_TYPES.includes(type)) {
381
537
  fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
382
538
  }
@@ -413,9 +569,24 @@ async function cmdSave(project, type, content, force = false) {
413
569
  if (resolveError)
414
570
  fail(`Failed to resolve superseded memory: ${resolveError.message}`);
415
571
  }
572
+ const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
573
+ const insertPayload = {
574
+ project,
575
+ type,
576
+ content,
577
+ device_id: getDeviceId(),
578
+ source,
579
+ confidence,
580
+ };
581
+ if (commitSha)
582
+ insertPayload.commit_sha = commitSha;
583
+ if (filePaths && filePaths.length > 0)
584
+ insertPayload.file_paths = filePaths;
585
+ if (author)
586
+ insertPayload.author = author;
416
587
  const { data: inserted, error } = await supabase
417
588
  .from("memories")
418
- .insert({ project, type, content, device_id: getDeviceId() })
589
+ .insert(insertPayload)
419
590
  .select("id")
420
591
  .single();
421
592
  if (error || !inserted)
@@ -445,6 +616,36 @@ async function cmdSave(project, type, content, force = false) {
445
616
  await compressMemories(project);
446
617
  }
447
618
  }
619
+ // --- cm check-commit (internal, used by the hook) --------------------------
620
+ // Not a user-facing command (no usage()/help entry) — the hook calls this
621
+ // once per invocation, before doing any diff analysis, to decide whether
622
+ // this commit_sha has already been captured. post-commit and post-rewrite
623
+ // both run the exact same capture logic against current HEAD, so without
624
+ // this guard, a commit that fires both hooks (e.g. `git commit --amend`,
625
+ // which triggers post-commit for the new commit AND post-rewrite for the
626
+ // rewrite) would be captured twice. Checked once per invocation — not
627
+ // inside each individual cmSave call — because a single commit normally
628
+ // produces several distinct memories; a per-save check would see the
629
+ // first save's own commit_sha and wrongly skip the rest.
630
+ async function cmdCheckCommit(project, commitSha) {
631
+ const { data, error } = await supabase
632
+ .from("memories")
633
+ .select("id")
634
+ .eq("project", project)
635
+ .eq("commit_sha", commitSha)
636
+ .limit(1);
637
+ if (error) {
638
+ // Fail open: an error here (including "column does not exist" if the
639
+ // commit_sha migration hasn't landed yet) must not look identical to
640
+ // "duplicate, skip" below — that would silently suppress every hook
641
+ // capture with no log trail. Exit 0 so the normal save path runs;
642
+ // if commit_sha genuinely isn't a column, the save itself fails
643
+ // there instead, where the hook's outer error handler logs it.
644
+ console.error(`check-commit: ${error.message}`);
645
+ process.exit(0);
646
+ }
647
+ process.exit(data && data.length > 0 ? 1 : 0);
648
+ }
448
649
  // --- memory compression ----------------------------------------------------
449
650
  async function compressMemories(project) {
450
651
  const { data, error } = await supabase
@@ -573,6 +774,28 @@ async function cmdResolve(project, memoryId) {
573
774
  }
574
775
  console.log(`Resolved memory [${memoryId}]`);
575
776
  }
777
+ // --- cm pin / cm unpin -------------------------------------------------
778
+ async function setPinned(project, memoryId, pinned) {
779
+ const { data, error } = await supabase
780
+ .from("memories")
781
+ .update({ pinned })
782
+ .eq("project", project)
783
+ .eq("id", memoryId)
784
+ .select("id");
785
+ if (error)
786
+ fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
787
+ if (!data || data.length === 0) {
788
+ console.log(`No memory found with ID [${memoryId}]`);
789
+ return;
790
+ }
791
+ console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
792
+ }
793
+ async function cmdPin(project, memoryId) {
794
+ await setPinned(project, memoryId, true);
795
+ }
796
+ async function cmdUnpin(project, memoryId) {
797
+ await setPinned(project, memoryId, false);
798
+ }
576
799
  // --- cm delete -------------------------------------------------------------
577
800
  async function cmdDelete(project, memoryId) {
578
801
  const { error: linksError } = await supabase
@@ -612,7 +835,8 @@ async function cmdInspect(project, memoryId) {
612
835
  .single();
613
836
  if (error || !memory)
614
837
  fail(`Memory [${memoryId}] not found for "${project}".`);
615
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
838
+ const config = readStackmemConfig();
839
+ const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
616
840
  const { data: links, error: linksError } = await supabase
617
841
  .from("memory_links")
618
842
  .select("source_id, target_id, score")
@@ -701,6 +925,65 @@ try {
701
925
 
702
926
  const CLI_PATH = ${JSON.stringify(cliPath)};
703
927
 
928
+ // Inlined copy of src/config-file.ts's defaults/merge logic — this script
929
+ // runs standalone with no import access to that module.
930
+ const DEFAULT_STACKMEM_CONFIG = {
931
+ ignore: [],
932
+ tokenBudget: 2000,
933
+ decayLambda: 0.05,
934
+ captureTypes: ["decision", "constraint", "discovery", "rejection"],
935
+ minQualityScore: 0.3,
936
+ };
937
+
938
+ function isStringArray(value) {
939
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
940
+ }
941
+
942
+ function readStackmemConfig() {
943
+ const configPath = join(process.cwd(), ".stackmem.json");
944
+ try {
945
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
946
+ return {
947
+ ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
948
+ tokenBudget:
949
+ typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
950
+ ? raw.tokenBudget
951
+ : DEFAULT_STACKMEM_CONFIG.tokenBudget,
952
+ decayLambda:
953
+ typeof raw.decayLambda === "number" && raw.decayLambda > 0
954
+ ? raw.decayLambda
955
+ : DEFAULT_STACKMEM_CONFIG.decayLambda,
956
+ captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
957
+ minQualityScore:
958
+ typeof raw.minQualityScore === "number"
959
+ ? raw.minQualityScore
960
+ : DEFAULT_STACKMEM_CONFIG.minQualityScore,
961
+ };
962
+ } catch {
963
+ return { ...DEFAULT_STACKMEM_CONFIG };
964
+ }
965
+ }
966
+
967
+ function matchesIgnorePattern(filePath, pattern) {
968
+ const base = filePath.split("/").pop() ?? filePath;
969
+
970
+ if (pattern.endsWith("/")) {
971
+ const dirName = pattern.slice(0, -1);
972
+ return filePath.split("/").includes(dirName);
973
+ }
974
+
975
+ if (pattern.includes("*")) {
976
+ const regex = new RegExp(
977
+ "^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
978
+ );
979
+ return regex.test(base) || regex.test(filePath);
980
+ }
981
+
982
+ return filePath === pattern || base === pattern;
983
+ }
984
+
985
+ const STACKMEM_CONFIG = readStackmemConfig();
986
+
704
987
  const SECRET_PATTERNS = [
705
988
  /AKIA[0-9A-Z]{16}/g,
706
989
  /(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
@@ -757,10 +1040,43 @@ function git(args) {
757
1040
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
758
1041
  }
759
1042
 
1043
+ const CURRENT_SHA = git(["rev-parse", "HEAD"]);
1044
+ const CURRENT_AUTHOR = (() => {
1045
+ try {
1046
+ return git(["log", "--format=%ae", "-1", "HEAD"]);
1047
+ } catch {
1048
+ return "";
1049
+ }
1050
+ })();
1051
+
1052
+ // post-commit and post-rewrite both run this exact script against current
1053
+ // HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
1054
+ // would otherwise capture the same commit twice. Checked once up front,
1055
+ // not per-save — see cmdCheckCommit in src/cli.ts for why.
1056
+ try {
1057
+ execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
1058
+ } catch {
1059
+ return;
1060
+ }
1061
+
1062
+ // file_paths is populated with every file the commit touched (not just
1063
+ // the filtered diffableFiles below) — "cm why <file>" should still find
1064
+ // a commit's decision/discovery even if that specific file's own diff
1065
+ // content was skipped as noise (a lockfile, say).
760
1066
  function cmSave(type, content) {
761
- execFileSync("node", [CLI_PATH, "save", PROJECT, type, content, "--force"], {
762
- stdio: "inherit",
763
- });
1067
+ if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
1068
+
1069
+ const args = [
1070
+ CLI_PATH, "save", PROJECT, type, content, "--force",
1071
+ "--commit-sha", CURRENT_SHA,
1072
+ "--source", "hook",
1073
+ "--confidence", "0.8",
1074
+ ];
1075
+ if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
1076
+ if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
1077
+
1078
+ execFileSync("node", args, { stdio: "inherit" });
1079
+ savedItems.push(\`\${type}: \${content}\`);
764
1080
  }
765
1081
 
766
1082
  function basename(filePath) {
@@ -787,6 +1103,29 @@ function isSchemaFile(filePath) {
787
1103
  );
788
1104
  }
789
1105
 
1106
+ // Noise that's never worth diffing: lockfiles, build output, vendored/
1107
+ // generated code, and timestamp-prefixed SQL migrations (their content is
1108
+ // a mechanical dump, not a human decision). Checked against path segments
1109
+ // (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
1110
+ // just a top-level "dist/...".
1111
+ const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
1112
+ const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
1113
+ const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
1114
+ const DIFF_MAX_BYTES = 50 * 1024;
1115
+
1116
+ function shouldSkipDiffFile(filePath) {
1117
+ const base = basename(filePath);
1118
+ if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
1119
+ if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
1120
+ if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
1121
+ if (base.endsWith(".map")) return true;
1122
+ if (base.endsWith(".pb.go")) return true;
1123
+ if (/\\.generated\\./.test(base)) return true;
1124
+ if (MIGRATION_FILE_RE.test(base)) return true;
1125
+ if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
1126
+ return false;
1127
+ }
1128
+
790
1129
  const LOW_QUALITY_PREFIXES = [
791
1130
  "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
792
1131
  "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
@@ -821,11 +1160,10 @@ const savedItems = [];
821
1160
 
822
1161
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
823
1162
 
824
- // The sync commit below is itself a commit, so it fires this same
825
- // post-commit hook again — git's --no-verify only skips pre-commit and
826
- // commit-msg hooks, never post-commit. Without this guard, the hook would
827
- // save its own commit message as a new decision memory, which changes the
828
- // rules-file content and triggers another sync commit, indefinitely.
1163
+ // Kept as a general escape hatch for other automation (CI bots, scripts)
1164
+ // that tags its own commits this way and shouldn't be captured as a
1165
+ // memory — cm sync no longer self-commits, so this isn't load-bearing for
1166
+ // that anymore, but other tools' commits still deserve the same skip.
829
1167
  if (commitMessage.includes("[skip ci]")) {
830
1168
  return;
831
1169
  }
@@ -849,10 +1187,40 @@ const changedFiles = git(
849
1187
 
850
1188
  let diff = "";
851
1189
  if (!isFirstCommit) {
1190
+ // --numstat marks a binary file's added/deleted columns as "-" — that's
1191
+ // the cheapest way to identify them without fetching their (useless,
1192
+ // and potentially huge) diff content at all.
1193
+ let binaryFiles = new Set();
852
1194
  try {
853
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
1195
+ const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
1196
+ encoding: "utf-8",
1197
+ });
1198
+ for (const line of numstat.split("\\n")) {
1199
+ const parts = line.split("\\t");
1200
+ if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
1201
+ binaryFiles.add(parts[2]);
1202
+ }
1203
+ }
854
1204
  } catch {
855
- diff = "";
1205
+ // Nothing to compare against — leave binaryFiles empty.
1206
+ }
1207
+
1208
+ const diffableFiles = changedFiles.filter(
1209
+ (file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
1210
+ );
1211
+
1212
+ if (diffableFiles.length > 0) {
1213
+ try {
1214
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
1215
+ encoding: "utf-8",
1216
+ });
1217
+ } catch {
1218
+ diff = "";
1219
+ }
1220
+ }
1221
+
1222
+ if (diff.length > DIFF_MAX_BYTES) {
1223
+ diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
856
1224
  }
857
1225
  }
858
1226
 
@@ -879,17 +1247,14 @@ for (const line of isFirstCommit ? [] : lines) {
879
1247
  if (/(auth|login|session|token)/i.test(currentFile)) {
880
1248
  const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
881
1249
  cmSave("discovery", content);
882
- savedItems.push(\`discovery: \${content}\`);
883
1250
  }
884
1251
  if (isConfigFile(currentFile)) {
885
1252
  const content = redactSecrets(\`new config: \${currentFile}\`);
886
1253
  cmSave("constraint", content);
887
- savedItems.push(\`constraint: \${content}\`);
888
1254
  }
889
1255
  if (isSchemaFile(currentFile)) {
890
1256
  const content = redactSecrets(\`schema change: \${currentFile}\`);
891
1257
  cmSave("discovery", content);
892
- savedItems.push(\`discovery: \${content}\`);
893
1258
  }
894
1259
  }
895
1260
  continue;
@@ -926,7 +1291,6 @@ for (const line of isFirstCommit ? [] : lines) {
926
1291
  if (removedModulePath) {
927
1292
  const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
928
1293
  cmSave("rejection", content);
929
- savedItems.push(\`rejection: \${content}\`);
930
1294
  }
931
1295
 
932
1296
  if (isPackageJson && inDependenciesSection) {
@@ -934,7 +1298,6 @@ for (const line of isFirstCommit ? [] : lines) {
934
1298
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
935
1299
  const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
936
1300
  cmSave("rejection", content);
937
- savedItems.push(\`rejection: \${content}\`);
938
1301
  }
939
1302
  }
940
1303
  continue;
@@ -950,7 +1313,6 @@ for (const line of isFirstCommit ? [] : lines) {
950
1313
  if (envMatch) {
951
1314
  const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
952
1315
  cmSave("constraint", content);
953
- savedItems.push(\`constraint: \${content}\`);
954
1316
  }
955
1317
  }
956
1318
  }
@@ -960,7 +1322,6 @@ for (const line of isFirstCommit ? [] : lines) {
960
1322
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
961
1323
  const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
962
1324
  cmSave("decision", content);
963
- savedItems.push(\`decision: \${content}\`);
964
1325
  }
965
1326
  }
966
1327
 
@@ -968,14 +1329,12 @@ for (const line of isFirstCommit ? [] : lines) {
968
1329
  const truncated = codeLine.trim().slice(0, 80);
969
1330
  const content = redactSecrets(\`database change: \${truncated}\`);
970
1331
  cmSave("discovery", content);
971
- savedItems.push(\`discovery: \${content}\`);
972
1332
  }
973
1333
 
974
1334
  if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
975
1335
  const truncated = codeLine.trim().slice(0, 100);
976
1336
  const content = redactSecrets(\`open issue from commit: \${truncated}\`);
977
1337
  cmSave("discovery", content);
978
- savedItems.push(\`discovery: \${content}\`);
979
1338
  }
980
1339
  }
981
1340
  }
@@ -1004,19 +1363,16 @@ if (breakingMatch) {
1004
1363
  const breakingContent = redactSecrets(breakingMatch[1].trim());
1005
1364
  const content = \`BREAKING CHANGE: \${breakingContent}\`;
1006
1365
  cmSave("constraint", content);
1007
- savedItems.push(\`constraint: \${content}\`);
1008
1366
  }
1009
1367
 
1010
1368
  if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
1011
1369
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1012
1370
  cmSave("decision", trimmedMessage);
1013
- savedItems.push(\`decision: \${trimmedMessage}\`);
1014
1371
  } else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
1015
1372
  // docs/style/test/ci/build/chore — skip entirely, nothing saved.
1016
1373
  } else if (isQualityCommitMessage(commitMessage)) {
1017
1374
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1018
1375
  cmSave("decision", trimmedMessage);
1019
- savedItems.push(\`decision: \${trimmedMessage}\`);
1020
1376
  }
1021
1377
 
1022
1378
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
@@ -1024,21 +1380,13 @@ for (const item of savedItems) {
1024
1380
  console.log(\` - \${item}\`);
1025
1381
  }
1026
1382
 
1383
+ // Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
1384
+ // Editors read these files straight off disk, and they're gitignored by
1385
+ // default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
1386
+ // sync with git history; committing them here used to double the commit
1387
+ // count on every real commit and produced constant rules-file merge
1388
+ // conflicts on teams.
1027
1389
  execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
1028
-
1029
- // cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
1030
- // the working directory dirty (breaking things like \`npm version patch\`
1031
- // that require a clean tree). Commit the sync ourselves instead. --no-verify
1032
- // skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
1033
- // guard above, which is what actually stops this from recursing).
1034
- try {
1035
- execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
1036
- execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
1037
- stdio: "pipe",
1038
- });
1039
- } catch {
1040
- // Nothing to commit (rules files unchanged) — fine, ignore.
1041
- }
1042
1390
  }
1043
1391
 
1044
1392
  async function main() {
@@ -1063,14 +1411,24 @@ async function main() {
1063
1411
  main().finally(() => process.exit(0));
1064
1412
  `;
1065
1413
  }
1066
- // Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
1067
- // a post-commit hook already exists and wasn't written by stackmem, it's
1414
+ // Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
1415
+ // `git rebase` (neither of which is a plain commit, so post-commit alone
1416
+ // misses them) — it runs the exact same capture script as post-commit,
1417
+ // against current HEAD, with the check-commit idempotency guard in the
1418
+ // script itself preventing a double-capture when a single operation
1419
+ // (e.g. --amend) fires both hooks for the same resulting commit.
1420
+ const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"];
1421
+ function hookBackupFilename(hookName) {
1422
+ return `${hookName}${HOOK_BACKUP_SUFFIX}`;
1423
+ }
1424
+ // Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
1425
+ // hook of this name already exists and wasn't written by stackmem, it's
1068
1426
  // preserved as a backup and chained to run before stackmem's own hook,
1069
1427
  // rather than being clobbered — see `uninstallHook` for how it's restored.
1070
- function installHook(hooksDir, hookLogicPath) {
1428
+ function installHook(hooksDir, hookLogicPath, hookName) {
1071
1429
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1072
- const hookPath = nodePath.join(hooksDir, "post-commit");
1073
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1430
+ const hookPath = nodePath.join(hooksDir, hookName);
1431
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1074
1432
  let chainedOriginal = false;
1075
1433
  if (nodeFs.existsSync(hookPath)) {
1076
1434
  const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
@@ -1086,21 +1444,21 @@ function installHook(hooksDir, hookLogicPath) {
1086
1444
  }
1087
1445
  }
1088
1446
  const chainLine = chainedOriginal
1089
- ? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
1447
+ ? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
1090
1448
  : "";
1091
1449
  nodeFs.writeFileSync(hookPath, `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`, "utf-8");
1092
1450
  nodeFs.chmodSync(hookPath, 0o755);
1093
1451
  if (chainedOriginal) {
1094
- console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
1452
+ console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
1095
1453
  }
1096
1454
  }
1097
1455
  // Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
1098
1456
  // stackmem chained it after an existing hook, restores that original hook
1099
1457
  // in its place. Returns a description of what happened, or null if there
1100
1458
  // was no stackmem-managed hook to remove (nothing done).
1101
- function uninstallHook(hooksDir) {
1102
- const hookPath = nodePath.join(hooksDir, "post-commit");
1103
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1459
+ function uninstallHook(hooksDir, hookName) {
1460
+ const hookPath = nodePath.join(hooksDir, hookName);
1461
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1104
1462
  if (!nodeFs.existsSync(hookPath))
1105
1463
  return null;
1106
1464
  const content = nodeFs.readFileSync(hookPath, "utf-8");
@@ -1108,10 +1466,10 @@ function uninstallHook(hooksDir) {
1108
1466
  return null;
1109
1467
  if (nodeFs.existsSync(backupPath)) {
1110
1468
  nodeFs.renameSync(backupPath, hookPath);
1111
- return ".git/hooks/post-commit (restored original hook stackmem had chained)";
1469
+ return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
1112
1470
  }
1113
1471
  nodeFs.unlinkSync(hookPath);
1114
- return ".git/hooks/post-commit";
1472
+ return `.git/hooks/${hookName}`;
1115
1473
  }
1116
1474
  // Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
1117
1475
  // at a scratch file instead of the real ~/.claude.json.
@@ -1167,6 +1525,8 @@ dist/
1167
1525
  coverage/
1168
1526
  *-memories.json
1169
1527
  *.db
1528
+ *cursorrules
1529
+ *windsurfrules
1170
1530
  `;
1171
1531
  // Returns true if the file was created, false if it already existed
1172
1532
  // (and was left untouched).
@@ -1214,10 +1574,11 @@ async function generateRulesContent(project) {
1214
1574
  .eq("resolved", false);
1215
1575
  if (memoriesError)
1216
1576
  fail(`Failed to load memories: ${memoriesError.message}`);
1577
+ const config = readStackmemConfig();
1217
1578
  const scoredMemories = (memoryRows ?? [])
1218
1579
  .map((memory) => ({
1219
1580
  ...memory,
1220
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
1581
+ decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
1221
1582
  }))
1222
1583
  .sort((a, b) => b.decay_score - a.decay_score);
1223
1584
  const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
@@ -1309,6 +1670,10 @@ export async function trySeedMemory(project, existingMemories, type, content, cl
1309
1670
  type,
1310
1671
  content,
1311
1672
  device_id: getDeviceId(),
1673
+ // Seeded by `cm init` scanning project files — an explicit user-run
1674
+ // command, not an auto-captured commit, so it's "manual" like cm save,
1675
+ // not the memories.source column's default of "hook".
1676
+ source: "manual",
1312
1677
  });
1313
1678
  return !error;
1314
1679
  }
@@ -1417,7 +1782,17 @@ async function verifyBackendConnection(project) {
1417
1782
  .eq("project", project);
1418
1783
  return !error;
1419
1784
  }
1420
- async function cmdInit(project, targetPath) {
1785
+ // Returns true if the file was created, false if it already existed
1786
+ // (and was left untouched).
1787
+ function writeStackmemConfigFile(resolvedPath) {
1788
+ const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
1789
+ if (nodeFs.existsSync(configPath)) {
1790
+ return false;
1791
+ }
1792
+ nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
1793
+ return true;
1794
+ }
1795
+ async function cmdInit(project, targetPath, writeConfig = false) {
1421
1796
  const resolvedPath = nodePath.resolve(targetPath);
1422
1797
  const gitDir = nodePath.join(resolvedPath, ".git");
1423
1798
  if (!nodeFs.existsSync(gitDir)) {
@@ -1431,7 +1806,9 @@ async function cmdInit(project, targetPath) {
1431
1806
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1432
1807
  const hooksDir = nodePath.join(gitDir, "hooks");
1433
1808
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1434
- installHook(hooksDir, hookLogicPath);
1809
+ for (const hookName of MANAGED_HOOK_NAMES) {
1810
+ installHook(hooksDir, hookLogicPath, hookName);
1811
+ }
1435
1812
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1436
1813
  registerMcpServer(DIST_INDEX_PATH);
1437
1814
  writeClaudeMd(project, resolvedPath);
@@ -1441,10 +1818,14 @@ async function cmdInit(project, targetPath) {
1441
1818
  const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
1442
1819
  const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
1443
1820
  const backendOk = await verifyBackendConnection(project);
1821
+ const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
1444
1822
  console.log("");
1445
1823
  if (gitignoreCreated) {
1446
1824
  console.log("✓ .gitignore created");
1447
1825
  }
1826
+ if (writeConfig) {
1827
+ console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
1828
+ }
1448
1829
  console.log("✓ Git hook installed");
1449
1830
  console.log("✓ MCP server registered");
1450
1831
  console.log("✓ CLAUDE.md written");
@@ -1472,6 +1853,73 @@ async function cmdSync(project) {
1472
1853
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
1473
1854
  console.log("Synced memories to .cursorrules and .windsurfrules");
1474
1855
  }
1856
+ // --- cm link ---------------------------------------------------------------
1857
+ // Mirrors the link_codes.expires_at default in the migration
1858
+ // (supabase/migrations/001_user_devices_and_link_codes.sql in the vault repo).
1859
+ // If that interval changes, change this too — polling past expires_at just
1860
+ // burns requests against a row the API will refuse.
1861
+ const LINK_CODE_TTL_MS = 10 * 60 * 1000;
1862
+ const LINK_POLL_INTERVAL_MS = 3_000;
1863
+ const LINK_CODE_INSERT_ATTEMPTS = 5;
1864
+ const PG_UNIQUE_VIOLATION = "23505";
1865
+ function sleep(ms) {
1866
+ return new Promise((resolve) => setTimeout(resolve, ms));
1867
+ }
1868
+ // randomInt is a CSPRNG and uniform over the range. Math.random() would do
1869
+ // neither, and a link code is a short-lived credential for claiming this
1870
+ // device's memories — a guessable one is a hijack.
1871
+ function generateLinkCode() {
1872
+ return String(randomInt(0, 1_000_000)).padStart(6, "0");
1873
+ }
1874
+ async function cmdLink() {
1875
+ const deviceId = getDeviceId();
1876
+ // A 6-digit space collides rarely, but only unclaimed codes occupy it and
1877
+ // the primary key makes a collision a hard error rather than a silent
1878
+ // overwrite — so retry with a fresh code instead of failing the command.
1879
+ let code = null;
1880
+ for (let attempt = 0; attempt < LINK_CODE_INSERT_ATTEMPTS; attempt++) {
1881
+ const candidate = generateLinkCode();
1882
+ const { error } = await supabase
1883
+ .from("link_codes")
1884
+ .insert({ code: candidate, device_id: deviceId });
1885
+ if (!error) {
1886
+ code = candidate;
1887
+ break;
1888
+ }
1889
+ if (error.code !== PG_UNIQUE_VIOLATION) {
1890
+ fail(`Failed to create a link code: ${error.message}`);
1891
+ }
1892
+ }
1893
+ if (!code) {
1894
+ fail("Could not get an unused link code. Try again in a minute.");
1895
+ }
1896
+ console.log(`Go to ${VAULT_URL}/link and enter code: ${code}`);
1897
+ const deadline = Date.now() + LINK_CODE_TTL_MS;
1898
+ while (Date.now() < deadline) {
1899
+ await sleep(LINK_POLL_INTERVAL_MS);
1900
+ const { data, error } = await supabase
1901
+ .from("link_codes")
1902
+ .select("claimed_at")
1903
+ .eq("code", code)
1904
+ .maybeSingle();
1905
+ // A transient read failure shouldn't abandon a link the user may have
1906
+ // already completed in the browser — keep polling until the code expires.
1907
+ if (error)
1908
+ continue;
1909
+ // The row is gone: either housekeeping swept it or it was never visible
1910
+ // to this device. Either way there's nothing left to claim.
1911
+ if (!data)
1912
+ break;
1913
+ if (data.claimed_at) {
1914
+ console.log("Device linked to your GitHub account.");
1915
+ // Best-effort cleanup — the code is spent either way, and the API
1916
+ // rejects claimed codes, so a failure here is harmless.
1917
+ await supabase.from("link_codes").delete().eq("code", code);
1918
+ return;
1919
+ }
1920
+ }
1921
+ console.log("Link code expired. Run cm link again.");
1922
+ }
1475
1923
  // --- cm context ------------------------------------------------------------
1476
1924
  const CONTEXT_TYPE_ORDER = ["decision", "constraint", "rejection", "discovery"];
1477
1925
  const CONTEXT_TOP_N = 5;
@@ -1488,9 +1936,11 @@ async function cmdContext(project, task) {
1488
1936
  console.log(`No memories found for "${project}".`);
1489
1937
  return;
1490
1938
  }
1939
+ const config = readStackmemConfig();
1940
+ const tokenBudget = config.tokenBudget;
1491
1941
  const scoredMemories = memories
1492
1942
  .map((memory) => {
1493
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
1943
+ const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
1494
1944
  const relevanceScore = scoreMemoryRelevance(memory.content, task);
1495
1945
  return {
1496
1946
  ...memory,
@@ -1499,7 +1949,7 @@ async function cmdContext(project, task) {
1499
1949
  })
1500
1950
  .sort((a, b) => b.combined_score - a.combined_score);
1501
1951
  const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
1502
- const budgetedMemories = applyContextBudget(topMemories, CONTEXT_BUDGET_TOKENS);
1952
+ const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
1503
1953
  const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
1504
1954
  const lines = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
1505
1955
  for (const type of CONTEXT_TYPE_ORDER) {
@@ -1512,9 +1962,62 @@ async function cmdContext(project, task) {
1512
1962
  }
1513
1963
  lines.push("");
1514
1964
  }
1515
- lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
1965
+ lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
1516
1966
  console.log(lines.join("\n"));
1517
1967
  }
1968
+ // --- cm why --------------------------------------------------------------
1969
+ function fileMatchesPaths(filePaths, file, baseName) {
1970
+ if (!Array.isArray(filePaths))
1971
+ return false;
1972
+ return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
1973
+ }
1974
+ function fileMentionedInContent(content, file, baseName) {
1975
+ const lower = content.toLowerCase();
1976
+ return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
1977
+ }
1978
+ async function cmdWhy(project, file) {
1979
+ const baseName = file.split("/").pop() ?? file;
1980
+ const { data, error } = await supabase
1981
+ .from("memories")
1982
+ .select("*")
1983
+ .eq("project", project)
1984
+ .eq("resolved", false);
1985
+ if (error)
1986
+ fail(`Failed to load memories: ${error.message}`);
1987
+ const memories = data ?? [];
1988
+ // file_paths (structured, from Item 6) and a content substring search
1989
+ // are combined into one match set rather than one gating the other as
1990
+ // a strict fallback — most memories predate file_paths existing at all
1991
+ // (manual saves and seeds never set it), so content search is the
1992
+ // primary signal for everything except recent hook-captured commits,
1993
+ // not a rare last resort.
1994
+ const matched = memories.filter((memory) => fileMatchesPaths(memory.file_paths, file, baseName) ||
1995
+ fileMentionedInContent(memory.content, file, baseName));
1996
+ if (matched.length === 0) {
1997
+ console.log(`No memories found for ${file}.`);
1998
+ console.log("Make some commits touching this file to build up context.");
1999
+ return;
2000
+ }
2001
+ const config = readStackmemConfig();
2002
+ const scoredMemories = matched
2003
+ .map((memory) => ({
2004
+ ...memory,
2005
+ decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
2006
+ }))
2007
+ .sort((a, b) => b.decay_score - a.decay_score);
2008
+ console.log(`Decisions and constraints tied to ${file}:\n`);
2009
+ for (const type of MEMORY_TYPES) {
2010
+ const group = scoredMemories.filter((memory) => memory.type === type);
2011
+ if (group.length === 0)
2012
+ continue;
2013
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
2014
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
2015
+ for (const memory of group) {
2016
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
2017
+ }
2018
+ console.log("");
2019
+ }
2020
+ }
1518
2021
  function splitCommaList(value) {
1519
2022
  if (!value)
1520
2023
  return [];
@@ -1751,7 +2254,9 @@ async function cmdDoctor(fix) {
1751
2254
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
1752
2255
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
1753
2256
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1754
- installHook(hooksDir, hookLogicPath);
2257
+ for (const name of MANAGED_HOOK_NAMES) {
2258
+ installHook(hooksDir, hookLogicPath, name);
2259
+ }
1755
2260
  if (isHookExecutable(hookPath)) {
1756
2261
  console.log("Fixed: git hook");
1757
2262
  issues--;
@@ -1921,9 +2426,11 @@ function removeIfMarked(filePath, marker, label) {
1921
2426
  async function cmdUninstall(project, targetPath) {
1922
2427
  const gitDir = nodePath.join(targetPath, ".git");
1923
2428
  const hooksDir = nodePath.join(gitDir, "hooks");
1924
- const hookResult = uninstallHook(hooksDir);
1925
- if (hookResult)
1926
- console.log(`Removed ${hookResult}`);
2429
+ for (const hookName of MANAGED_HOOK_NAMES) {
2430
+ const hookResult = uninstallHook(hooksDir, hookName);
2431
+ if (hookResult)
2432
+ console.log(`Removed ${hookResult}`);
2433
+ }
1927
2434
  const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
1928
2435
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
1929
2436
  if (nodeFs.existsSync(hookLogicPath)) {
@@ -1967,9 +2474,19 @@ async function main() {
1967
2474
  case "save": {
1968
2475
  const forceIndex = args.indexOf("--force");
1969
2476
  const force = forceIndex !== -1;
1970
- const positional = force
2477
+ let positional = force
1971
2478
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
1972
2479
  : args;
2480
+ const commitShaFlag = extractValueFlag(positional, "--commit-sha");
2481
+ positional = commitShaFlag.rest;
2482
+ const authorFlag = extractValueFlag(positional, "--author");
2483
+ positional = authorFlag.rest;
2484
+ const sourceFlag = extractValueFlag(positional, "--source");
2485
+ positional = sourceFlag.rest;
2486
+ const confidenceFlag = extractValueFlag(positional, "--confidence");
2487
+ positional = confidenceFlag.rest;
2488
+ const filesFlag = extractValueFlag(positional, "--files");
2489
+ positional = filesFlag.rest;
1973
2490
  let explicitProject;
1974
2491
  let type;
1975
2492
  let rest;
@@ -1994,7 +2511,30 @@ async function main() {
1994
2511
  if (!type || rest.length === 0)
1995
2512
  usage();
1996
2513
  const project = requireProject(explicitProject);
1997
- await cmdSave(project, type, rest.join(" "), force);
2514
+ const provenance = {};
2515
+ if (commitShaFlag.value)
2516
+ provenance.commitSha = commitShaFlag.value;
2517
+ if (authorFlag.value)
2518
+ provenance.author = authorFlag.value;
2519
+ if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
2520
+ provenance.source = sourceFlag.value;
2521
+ }
2522
+ if (confidenceFlag.value) {
2523
+ const parsed = Number(confidenceFlag.value);
2524
+ if (!Number.isNaN(parsed))
2525
+ provenance.confidence = parsed;
2526
+ }
2527
+ if (filesFlag.value) {
2528
+ provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
2529
+ }
2530
+ await cmdSave(project, type, rest.join(" "), force, provenance);
2531
+ break;
2532
+ }
2533
+ case "check-commit": {
2534
+ const [project, commitSha] = args;
2535
+ if (!project || !commitSha)
2536
+ usage();
2537
+ await cmdCheckCommit(project, commitSha);
1998
2538
  break;
1999
2539
  }
2000
2540
  case "fix": {
@@ -2032,6 +2572,24 @@ async function main() {
2032
2572
  await cmdResolve(project, memoryId);
2033
2573
  break;
2034
2574
  }
2575
+ case "pin": {
2576
+ const split = splitOptionalProject(args, 1);
2577
+ if (!split)
2578
+ usage();
2579
+ const project = requireProject(split.project);
2580
+ const [memoryId] = split.rest;
2581
+ await cmdPin(project, memoryId);
2582
+ break;
2583
+ }
2584
+ case "unpin": {
2585
+ const split = splitOptionalProject(args, 1);
2586
+ if (!split)
2587
+ usage();
2588
+ const project = requireProject(split.project);
2589
+ const [memoryId] = split.rest;
2590
+ await cmdUnpin(project, memoryId);
2591
+ break;
2592
+ }
2035
2593
  case "delete": {
2036
2594
  const split = splitOptionalProject(args, 1);
2037
2595
  if (!split)
@@ -2059,10 +2617,21 @@ async function main() {
2059
2617
  break;
2060
2618
  }
2061
2619
  case "init": {
2062
- const [project, targetPath] = args;
2620
+ const configIndex = args.indexOf("--config");
2621
+ const writeConfig = configIndex !== -1;
2622
+ const positional = writeConfig
2623
+ ? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
2624
+ : args;
2625
+ const [project, targetPath] = positional;
2063
2626
  if (!project || !targetPath)
2064
2627
  usage();
2065
- await cmdInit(project, targetPath);
2628
+ await cmdInit(project, targetPath, writeConfig);
2629
+ break;
2630
+ }
2631
+ case "link": {
2632
+ if (args.length > 0)
2633
+ usage();
2634
+ await cmdLink();
2066
2635
  break;
2067
2636
  }
2068
2637
  case "sync": {
@@ -2080,6 +2649,14 @@ async function main() {
2080
2649
  await cmdContext(project, split.rest.join(" "));
2081
2650
  break;
2082
2651
  }
2652
+ case "why": {
2653
+ const split = splitProjectFromVariadic(args);
2654
+ const project = requireProject(split.project);
2655
+ if (split.rest.length === 0)
2656
+ usage();
2657
+ await cmdWhy(project, split.rest.join(" "));
2658
+ break;
2659
+ }
2083
2660
  case "session-end": {
2084
2661
  await cmdSessionEndEntry(args);
2085
2662
  break;