stackmem 1.0.25 → 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 +1431 -142
- package/dist/config-file.js +72 -0
- package/dist/index.js +8 -5
- package/dist/scoring.js +51 -6
- package/dist/storage.js +79 -1
- package/dist/tests/crud.test.js +93 -0
- package/dist/tests/hook.test.js +296 -0
- package/dist/tests/scoring.test.js +86 -0
- package/package.json +4 -1
- package/src/cli.ts +1612 -154
- package/src/config-file.ts +98 -0
- package/src/index.ts +10 -4
- package/src/scoring.ts +62 -6
- package/src/storage.ts +101 -1
- package/src/tests/crud.test.ts +110 -0
- package/src/tests/hook.test.ts +352 -0
- package/src/tests/scoring.test.ts +123 -0
package/src/cli.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
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";
|
|
5
6
|
import { randomInt } from "node:crypto";
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
8
|
import { createInterface } from "node:readline/promises";
|
|
8
9
|
import { analyzeCodebase } from "./ast-index.js";
|
|
9
10
|
import { VAULT_URL } from "./config.js";
|
|
11
|
+
import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
|
|
10
12
|
import { getDeviceId } from "./device.js";
|
|
11
13
|
import {
|
|
12
14
|
applyContextBudget,
|
|
@@ -20,12 +22,27 @@ import {
|
|
|
20
22
|
indicatesFailedApproach,
|
|
21
23
|
isAuthRelatedFile,
|
|
22
24
|
isCodeFile,
|
|
25
|
+
hasTokenOverlap,
|
|
23
26
|
isQualityMessage,
|
|
27
|
+
parseUtcDate,
|
|
24
28
|
redactSecrets,
|
|
25
29
|
scoreMemoryRelevance,
|
|
30
|
+
tokenize,
|
|
26
31
|
} from "./scoring.js";
|
|
27
32
|
import { supabase } from "./storage.js";
|
|
28
33
|
|
|
34
|
+
// Checked as early as ESM import hoisting allows — before any command
|
|
35
|
+
// dispatch, config loading, or network call — so an unsupported Node
|
|
36
|
+
// version fails with a clear message instead of an obscure syntax or
|
|
37
|
+
// API error from deeper in the dependency chain.
|
|
38
|
+
const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
|
|
39
|
+
if (NODE_MAJOR_VERSION < 18) {
|
|
40
|
+
console.error(
|
|
41
|
+
`Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`
|
|
42
|
+
);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
30
47
|
const COMPRESSION_THRESHOLD = 20;
|
|
31
48
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
@@ -41,7 +58,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
|
|
|
41
58
|
const STACKMEM_HOOK_MARKER = "# stackmem-managed";
|
|
42
59
|
const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
|
|
43
60
|
const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
|
|
44
|
-
const
|
|
61
|
+
const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
|
|
45
62
|
|
|
46
63
|
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
|
|
47
64
|
type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
@@ -55,22 +72,30 @@ const TYPE_LABELS: Record<MemoryType, string> = {
|
|
|
55
72
|
|
|
56
73
|
function usage(): never {
|
|
57
74
|
console.error(`Usage:
|
|
58
|
-
cm start [project]
|
|
75
|
+
cm start [project] [--hook]
|
|
59
76
|
cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
|
|
60
77
|
cm fix [project] <problem> <solution>
|
|
61
78
|
cm analyze [project] <path>
|
|
62
79
|
cm search [--project <name>] <query>
|
|
63
80
|
cm resolve [project] <memory-id>
|
|
81
|
+
cm pin [project] <memory-id>
|
|
82
|
+
cm unpin [project] <memory-id>
|
|
83
|
+
cm review [--project <name>]
|
|
84
|
+
cm edit [project] <memory-id>
|
|
64
85
|
cm delete [project] <memory-id>
|
|
65
86
|
cm inspect [project] <memory-id>
|
|
66
87
|
cm compress [project]
|
|
67
|
-
cm init <project> <path>
|
|
88
|
+
cm init <project> <path> [--config]
|
|
68
89
|
cm uninstall [project] [path]
|
|
69
90
|
cm link
|
|
91
|
+
cm open [--project <name>]
|
|
92
|
+
cm purge [--project <name>] [--all]
|
|
70
93
|
cm sync
|
|
71
|
-
cm context [--project <name>] <task>
|
|
72
|
-
cm
|
|
94
|
+
cm context [--project <name>] <task> [--hook]
|
|
95
|
+
cm why [--project <name>] <file>
|
|
96
|
+
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
|
|
73
97
|
cm doctor [--fix]
|
|
98
|
+
cm status [--project <name>]
|
|
74
99
|
cm undo [project]
|
|
75
100
|
cm export [filename]
|
|
76
101
|
cm import [project] <filename>
|
|
@@ -92,11 +117,13 @@ function cmdHelp() {
|
|
|
92
117
|
[project] is optional everywhere below if a .stackmem file exists in
|
|
93
118
|
the current directory (written by 'cm init'). Otherwise pass it explicitly.
|
|
94
119
|
|
|
95
|
-
cm start [project]
|
|
120
|
+
cm start [project] [--hook]
|
|
96
121
|
Load all unresolved memories for a project, sorted by decay
|
|
97
122
|
score and capped at a 2000 token context budget, grouped by
|
|
98
123
|
type (decisions, constraints, discoveries, rejections), with
|
|
99
|
-
linked memory ids shown under each entry.
|
|
124
|
+
linked memory ids shown under each entry. --hook switches to
|
|
125
|
+
plain-text output (no ids, scores, or separator lines) for use
|
|
126
|
+
as a Claude Code SessionStart hook command — see cm init.
|
|
100
127
|
|
|
101
128
|
cm save [project] <type> <content> [--force]
|
|
102
129
|
Save a new memory. <type> is one of: decision, rejection,
|
|
@@ -120,6 +147,26 @@ function cmdHelp() {
|
|
|
120
147
|
cm resolve [project] <memory-id>
|
|
121
148
|
Mark a memory as resolved.
|
|
122
149
|
|
|
150
|
+
cm pin [project] <memory-id>
|
|
151
|
+
Pin a memory: it scores 999 in decay ranking (always sorts
|
|
152
|
+
first, never decays) and shows in its own "Pinned" section at
|
|
153
|
+
the top of 'cm start', ahead of its normal type group.
|
|
154
|
+
|
|
155
|
+
cm unpin [project] <memory-id>
|
|
156
|
+
Un-pin a memory, returning it to normal decay-based ranking.
|
|
157
|
+
|
|
158
|
+
cm review [--project <name>]
|
|
159
|
+
Walk through suggested memories one at a time (hook-captured
|
|
160
|
+
memories start as suggested — see cm save/cm init). For each:
|
|
161
|
+
(k)eep promotes it to permanent, (e)dit opens $EDITOR (or an
|
|
162
|
+
inline prompt) for new content then keeps it, (d)rop deletes
|
|
163
|
+
it, (s)kip leaves it suggested for next time, (q)uit stops and
|
|
164
|
+
reports progress so far. Prints a kept/dropped/skipped summary.
|
|
165
|
+
|
|
166
|
+
cm edit [project] <memory-id>
|
|
167
|
+
Open a memory's content in $EDITOR (or an inline prompt if
|
|
168
|
+
$EDITOR isn't set), save the result.
|
|
169
|
+
|
|
123
170
|
cm delete [project] <memory-id>
|
|
124
171
|
Delete a memory and any memory_links referencing it.
|
|
125
172
|
|
|
@@ -134,7 +181,7 @@ function cmdHelp() {
|
|
|
134
181
|
Cluster related unresolved memories (needs 20+) and collapse
|
|
135
182
|
clusters of 3 or more into a single summary memory.
|
|
136
183
|
|
|
137
|
-
cm init <project> <path>
|
|
184
|
+
cm init <project> <path> [--config]
|
|
138
185
|
Install a git post-commit hook in the repo at <path>, register
|
|
139
186
|
the stackmem MCP server with Claude Code, write a CLAUDE.md,
|
|
140
187
|
.cursorrules, .windsurfrules, and .stackmem file, and seed
|
|
@@ -142,7 +189,12 @@ function cmdHelp() {
|
|
|
142
189
|
.windsurfrules are pre-filled with the project's current
|
|
143
190
|
memories (for Cursor and Windsurf) and skipped if they already
|
|
144
191
|
exist. The post-commit hook keeps them fresh afterward by
|
|
145
|
-
running 'cm sync' on every commit
|
|
192
|
+
running 'cm sync' on every commit — on disk only, never
|
|
193
|
+
committed, so they don't double your commit count or conflict
|
|
194
|
+
across branches. Both files are added to .gitignore. With
|
|
195
|
+
--config, also writes a default .stackmem.json (ignore patterns,
|
|
196
|
+
tokenBudget, decayLambda, captureTypes, minQualityScore — see
|
|
197
|
+
src/config-file.ts) if one doesn't already exist.
|
|
146
198
|
|
|
147
199
|
cm uninstall [project] [path]
|
|
148
200
|
Remove everything 'cm init' set up at <path> (defaults to the
|
|
@@ -162,19 +214,44 @@ function cmdHelp() {
|
|
|
162
214
|
single-use and expires after 10 minutes; polling stops when it
|
|
163
215
|
does. Links are per-device, so run this once on each machine.
|
|
164
216
|
|
|
217
|
+
cm open [--project <name>]
|
|
218
|
+
Open the hosted vault's page for this project in the default
|
|
219
|
+
browser (open on Mac, xdg-open on Linux, start on Windows).
|
|
220
|
+
|
|
221
|
+
cm purge [--project <name>] [--all]
|
|
222
|
+
Permanently delete memories, fixes, and memory_links — with a
|
|
223
|
+
typed confirmation first, never deletes on a flag alone.
|
|
224
|
+
--project (or the one in .stackmem): deletes that project's
|
|
225
|
+
data only, after you type the exact project name to confirm.
|
|
226
|
+
--all: deletes everything for this device's device_id across
|
|
227
|
+
every project, after you type DELETE to confirm. Prints the
|
|
228
|
+
count of deleted records, or "Confirmation did not match" if
|
|
229
|
+
you abort.
|
|
230
|
+
|
|
165
231
|
cm sync
|
|
166
232
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
167
233
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
168
234
|
token budget) and its 5 most recent open execution_log fixes,
|
|
169
|
-
overwriting both files
|
|
235
|
+
overwriting both files on disk only (never git add/commit).
|
|
236
|
+
Project is read from .stackmem.
|
|
170
237
|
|
|
171
|
-
cm context [--project <name>] <task>
|
|
238
|
+
cm context [--project <name>] <task> [--hook]
|
|
172
239
|
Return only the top 5 memories most relevant to a specific
|
|
173
240
|
task, ranked by a blend of task relevance and decay score,
|
|
174
241
|
as a markdown block capped at a 2000 token budget. Use
|
|
175
242
|
--project to target a project other than the one in .stackmem.
|
|
176
|
-
|
|
177
|
-
|
|
243
|
+
<task> is optional with --hook (ranks by decay alone) — used
|
|
244
|
+
as a Claude Code PreCompact hook command; see cm init.
|
|
245
|
+
|
|
246
|
+
cm why [--project <name>] <file>
|
|
247
|
+
Show memories tied to a file: matched by Item 6's file_paths
|
|
248
|
+
column (hook-captured commits that touched it) plus a plain
|
|
249
|
+
content search for the file's path or basename, ranked by decay
|
|
250
|
+
score and grouped by type. Prints a guidance message instead if
|
|
251
|
+
nothing matches. Use --project to target a project other than
|
|
252
|
+
the one in .stackmem.
|
|
253
|
+
|
|
254
|
+
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
|
|
178
255
|
Capture what happened in a session and extract memories: each
|
|
179
256
|
error becomes an unsolved execution_log entry, each approach
|
|
180
257
|
containing "failed", "didn't work", "reverted", or "switched
|
|
@@ -184,6 +261,11 @@ function cmdHelp() {
|
|
|
184
261
|
Accepts the same fields as JSON on stdin instead of flags
|
|
185
262
|
(project, summary, errors_encountered, files_touched,
|
|
186
263
|
approaches_tried) when stdin is piped rather than a terminal.
|
|
264
|
+
--hook is accepted for use as a Claude Code Stop hook command
|
|
265
|
+
(see cm init) but is currently a no-op: Claude Code pipes its
|
|
266
|
+
own hook payload on stdin, not this command's expected shape,
|
|
267
|
+
so a Stop-triggered run captures 0 memories until a future
|
|
268
|
+
phase parses the session transcript directly.
|
|
187
269
|
|
|
188
270
|
cm doctor [--fix]
|
|
189
271
|
Check every integration point in the current directory — backend
|
|
@@ -196,6 +278,12 @@ function cmdHelp() {
|
|
|
196
278
|
— those print guidance instead. Reports "Fixed: <item>" or
|
|
197
279
|
"Could not auto-fix: <item>" for each failing check.
|
|
198
280
|
|
|
281
|
+
cm status [--project <name>]
|
|
282
|
+
One-screen project summary: memory/pinned/suggested counts,
|
|
283
|
+
recorded fixes, the active token budget (and whether it's the
|
|
284
|
+
default or from .stackmem.json), time since the last capture,
|
|
285
|
+
and hook/MCP/backend/editor registration status.
|
|
286
|
+
|
|
199
287
|
cm undo [project]
|
|
200
288
|
Delete the most recently saved unresolved memory (and any
|
|
201
289
|
memory_links referencing it) for a project.
|
|
@@ -224,11 +312,35 @@ function fail(message: string): never {
|
|
|
224
312
|
|
|
225
313
|
const STACKMEM_FILE = ".stackmem";
|
|
226
314
|
|
|
315
|
+
const MONOREPO_PARENT_LOOKUP_DEPTH = 3;
|
|
316
|
+
|
|
317
|
+
// Checks cwd, then up to 3 parent directories, for a .stackmem file — a
|
|
318
|
+
// monorepo subpackage (e.g. packages/api) won't have its own .stackmem,
|
|
319
|
+
// but the repo root will. When found in an ancestor, the project name
|
|
320
|
+
// gets the intervening path segments appended, e.g. "myapp/packages/api"
|
|
321
|
+
// — so a subpackage still reads/writes under its own namespace within
|
|
322
|
+
// the shared project, rather than silently resolving to just "myapp".
|
|
227
323
|
function readProjectFromFile(): string | null {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
324
|
+
let dir = process.cwd();
|
|
325
|
+
const subdirSegments: string[] = [];
|
|
326
|
+
|
|
327
|
+
for (let depth = 0; depth <= MONOREPO_PARENT_LOOKUP_DEPTH; depth++) {
|
|
328
|
+
const stackmemPath = nodePath.join(dir, STACKMEM_FILE);
|
|
329
|
+
if (nodeFs.existsSync(stackmemPath)) {
|
|
330
|
+
const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
|
|
331
|
+
if (content.length === 0) return null;
|
|
332
|
+
return subdirSegments.length > 0
|
|
333
|
+
? `${content}/${subdirSegments.reverse().join("/")}`
|
|
334
|
+
: content;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const parent = nodePath.dirname(dir);
|
|
338
|
+
if (parent === dir) break; // reached the filesystem root
|
|
339
|
+
subdirSegments.push(nodePath.basename(dir));
|
|
340
|
+
dir = parent;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return null;
|
|
232
344
|
}
|
|
233
345
|
|
|
234
346
|
// Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
|
|
@@ -293,33 +405,208 @@ function splitProjectFromVariadic(args: string[]): { project: string | null; res
|
|
|
293
405
|
return { project: null, rest: args };
|
|
294
406
|
}
|
|
295
407
|
|
|
408
|
+
// Generic "--flag value" extractor for internal/provenance flags (not
|
|
409
|
+
// meant to be remembered by end users — only the hook and MCP server pass
|
|
410
|
+
// these). Unlike splitProjectFromVariadic's --project, these don't need
|
|
411
|
+
// special positional disambiguation, so one generic helper covers all of
|
|
412
|
+
// them: --commit-sha, --author, --source, --confidence, --files.
|
|
413
|
+
function extractValueFlag(args: string[], flag: string): { value: string | null; rest: string[] } {
|
|
414
|
+
const idx = args.indexOf(flag);
|
|
415
|
+
if (idx === -1 || args[idx + 1] === undefined) return { value: null, rest: args };
|
|
416
|
+
return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
|
|
417
|
+
}
|
|
418
|
+
|
|
296
419
|
// --- cm start ----------------------------------------------------------
|
|
297
420
|
|
|
298
|
-
|
|
421
|
+
const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
|
|
422
|
+
const BRANCH_NAME_BOOST = 1.2;
|
|
423
|
+
const RECENT_FILES_BOOST = 1.3;
|
|
424
|
+
const PR_TITLE_BOOST = 1.2;
|
|
425
|
+
|
|
426
|
+
// null means "signal unavailable" (git/gh failed, not enough history, gh
|
|
427
|
+
// not installed, no open PR, etc.) — every one of these is a soft skip,
|
|
428
|
+
// never an error, per the task spec.
|
|
429
|
+
function getBranchNameTokens(): Set<string> | null {
|
|
430
|
+
let branch: string;
|
|
431
|
+
try {
|
|
432
|
+
// symbolic-ref (not rev-parse --abbrev-ref) specifically because it
|
|
433
|
+
// fails on a detached HEAD rather than returning the literal string
|
|
434
|
+
// "HEAD" — that failure is the signal this needs to distinguish a
|
|
435
|
+
// real detached-HEAD state from "not a git repo at all" below.
|
|
436
|
+
branch = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
|
|
437
|
+
encoding: "utf-8",
|
|
438
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
439
|
+
}).trim();
|
|
440
|
+
} catch {
|
|
441
|
+
try {
|
|
442
|
+
execFileSync("git", ["rev-parse", "HEAD"], { stdio: "pipe" });
|
|
443
|
+
// HEAD resolves to a real commit but symbolic-ref failed — genuinely
|
|
444
|
+
// detached, not a missing repo. Still a valid state to rank in, not
|
|
445
|
+
// a reason to skip the signal entirely.
|
|
446
|
+
branch = "detached";
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const tokens = tokenize(branch);
|
|
453
|
+
return tokens.size > 0 ? tokens : null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function getRecentlyTouchedFiles(): Set<string> | null {
|
|
457
|
+
try {
|
|
458
|
+
const isShallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
|
|
459
|
+
encoding: "utf-8",
|
|
460
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
461
|
+
}).trim();
|
|
462
|
+
// A shallow clone can't look back RECENT_COMMITS_FOR_FILE_SIGNAL
|
|
463
|
+
// commits — degrade gracefully rather than attempting (and either
|
|
464
|
+
// failing, or silently returning an incomplete picture of "recent").
|
|
465
|
+
if (isShallow === "true") return null;
|
|
466
|
+
} catch {
|
|
467
|
+
// --is-shallow-repository itself failing (very old git, or no repo at
|
|
468
|
+
// all) isn't a reason to skip on its own — the lookback below will
|
|
469
|
+
// fail (and get caught) or succeed on its own merits either way.
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const output = execFileSync(
|
|
474
|
+
"git",
|
|
475
|
+
["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"],
|
|
476
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
|
|
477
|
+
);
|
|
478
|
+
const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
479
|
+
return files.length > 0 ? new Set(files) : null;
|
|
480
|
+
} catch {
|
|
481
|
+
// Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
|
|
482
|
+
// not a git repo at all — skip gracefully.
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function getOpenPrTitleTokens(): Set<string> | null {
|
|
488
|
+
try {
|
|
489
|
+
const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
|
|
490
|
+
encoding: "utf-8",
|
|
491
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
492
|
+
}).trim();
|
|
493
|
+
if (!title) return null;
|
|
494
|
+
const tokens = tokenize(title);
|
|
495
|
+
return tokens.size > 0 ? tokens : null;
|
|
496
|
+
} catch {
|
|
497
|
+
// gh not installed, not authenticated, or no open PR for this branch.
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function fileListOverlaps(filePaths: unknown, recentFiles: Set<string>): boolean {
|
|
503
|
+
if (!Array.isArray(filePaths)) return false;
|
|
504
|
+
return filePaths.some((candidate) => {
|
|
505
|
+
if (typeof candidate !== "string") return false;
|
|
506
|
+
if (recentFiles.has(candidate)) return true;
|
|
507
|
+
const base = candidate.split("/").pop();
|
|
508
|
+
for (const recent of recentFiles) {
|
|
509
|
+
if (recent.split("/").pop() === base) return true;
|
|
510
|
+
}
|
|
511
|
+
return false;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Plain-text, no ids/scores/box-drawing — meant to be injected directly
|
|
516
|
+
// into a Claude Code SessionStart hook's context, not read by a human at
|
|
517
|
+
// a terminal (that's the default, non---hook rendering below).
|
|
518
|
+
function printStartHookOutput(
|
|
519
|
+
project: string,
|
|
520
|
+
pinnedMemories: any[],
|
|
521
|
+
unpinnedMemories: any[],
|
|
522
|
+
budgetedFixes: { problem: string; solution: string | null }[]
|
|
523
|
+
): void {
|
|
524
|
+
console.log(`# Project memory: ${project}`);
|
|
525
|
+
|
|
526
|
+
if (pinnedMemories.length > 0) {
|
|
527
|
+
console.log("");
|
|
528
|
+
console.log("## Pinned");
|
|
529
|
+
for (const memory of pinnedMemories) {
|
|
530
|
+
console.log(`- ${memory.content}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
for (const type of MEMORY_TYPES) {
|
|
535
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
536
|
+
if (group.length === 0) continue;
|
|
537
|
+
|
|
538
|
+
console.log("");
|
|
539
|
+
console.log(`## ${TYPE_LABELS[type]}`);
|
|
540
|
+
for (const memory of group) {
|
|
541
|
+
console.log(`- ${memory.content}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (budgetedFixes.length > 0) {
|
|
546
|
+
console.log("");
|
|
547
|
+
console.log("## Past Fixes");
|
|
548
|
+
for (const fix of budgetedFixes) {
|
|
549
|
+
console.log(`- ${fix.problem} -> ${fix.solution ?? "(pending)"}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function cmdStart(project: string, hookMode = false) {
|
|
299
555
|
const { data, error } = await supabase
|
|
300
556
|
.from("memories")
|
|
301
557
|
.select("*")
|
|
302
558
|
.eq("project", project)
|
|
303
|
-
.eq("resolved", false)
|
|
559
|
+
.eq("resolved", false)
|
|
560
|
+
.eq("suggested", false);
|
|
304
561
|
|
|
305
562
|
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
306
563
|
|
|
307
564
|
const memories = data ?? [];
|
|
308
565
|
if (memories.length === 0) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
566
|
+
if (!hookMode) {
|
|
567
|
+
console.log(`No memories found for "${project}".`);
|
|
568
|
+
console.log("");
|
|
569
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
570
|
+
}
|
|
312
571
|
return;
|
|
313
572
|
}
|
|
314
573
|
|
|
574
|
+
// Task-aware re-ranking: boost memories relevant to what's actively
|
|
575
|
+
// being worked on, multiplicatively on top of the base decay score.
|
|
576
|
+
// Every signal degrades to null (no boost, not an error) if the
|
|
577
|
+
// repo/branch/gh state it needs isn't available.
|
|
578
|
+
const branchTokens = getBranchNameTokens();
|
|
579
|
+
const recentFiles = getRecentlyTouchedFiles();
|
|
580
|
+
const prTitleTokens = getOpenPrTitleTokens();
|
|
581
|
+
|
|
582
|
+
const config = readStackmemConfig();
|
|
583
|
+
const tokenBudget = config.tokenBudget;
|
|
584
|
+
|
|
315
585
|
const scoredMemories = memories
|
|
316
|
-
.map((memory) =>
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
586
|
+
.map((memory) => {
|
|
587
|
+
let decayScore = computeDecayScore(
|
|
588
|
+
parseUtcDate(memory.created_at),
|
|
589
|
+
memory.access_count ?? 0,
|
|
590
|
+
memory.pinned ?? false,
|
|
591
|
+
config.decayLambda
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
const contentTokens = tokenize(memory.content);
|
|
595
|
+
if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
|
|
596
|
+
decayScore *= BRANCH_NAME_BOOST;
|
|
597
|
+
}
|
|
598
|
+
if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
|
|
599
|
+
decayScore *= RECENT_FILES_BOOST;
|
|
600
|
+
}
|
|
601
|
+
if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
|
|
602
|
+
decayScore *= PR_TITLE_BOOST;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return { ...memory, decay_score: decayScore };
|
|
606
|
+
})
|
|
320
607
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
321
608
|
|
|
322
|
-
const budgetedMemories = applyContextBudget(scoredMemories,
|
|
609
|
+
const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
|
|
323
610
|
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
324
611
|
let tokensUsed = budgetedMemories.reduce(
|
|
325
612
|
(sum, memory) => sum + estimateTokens(memory.content),
|
|
@@ -336,12 +623,12 @@ async function cmdStart(project: string) {
|
|
|
336
623
|
|
|
337
624
|
if (fixError) fail(`Failed to load execution log: ${fixError.message}`);
|
|
338
625
|
|
|
339
|
-
// Fixes share the same
|
|
340
|
-
//
|
|
626
|
+
// Fixes share the same token budget as memories, which already consumed
|
|
627
|
+
// their portion above — fixes only get whatever room is left.
|
|
341
628
|
const budgetedFixes: { problem: string; solution: string | null }[] = [];
|
|
342
629
|
for (const fix of fixData ?? []) {
|
|
343
630
|
const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
|
|
344
|
-
if (tokensUsed + tokens >
|
|
631
|
+
if (tokensUsed + tokens > tokenBudget) break;
|
|
345
632
|
budgetedFixes.push(fix);
|
|
346
633
|
tokensUsed += tokens;
|
|
347
634
|
}
|
|
@@ -364,43 +651,63 @@ async function cmdStart(project: string) {
|
|
|
364
651
|
linkedIdsByMemory.get(link.target_id)!.add(link.source_id);
|
|
365
652
|
}
|
|
366
653
|
|
|
367
|
-
|
|
654
|
+
const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
|
|
655
|
+
const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
|
|
368
656
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
console.log(
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
657
|
+
if (hookMode) {
|
|
658
|
+
printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes);
|
|
659
|
+
} else {
|
|
660
|
+
console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
|
|
661
|
+
|
|
662
|
+
if (pinnedMemories.length > 0) {
|
|
663
|
+
console.log(`Pinned (${pinnedMemories.length})`);
|
|
664
|
+
console.log("-".repeat(11));
|
|
665
|
+
for (const memory of pinnedMemories) {
|
|
666
|
+
console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
|
|
667
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
668
|
+
if (linked.length > 0) {
|
|
669
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
670
|
+
}
|
|
380
671
|
}
|
|
672
|
+
console.log("");
|
|
381
673
|
}
|
|
382
|
-
console.log("");
|
|
383
|
-
}
|
|
384
674
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
console.log(
|
|
390
|
-
console.log(
|
|
675
|
+
for (const type of MEMORY_TYPES) {
|
|
676
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
677
|
+
if (group.length === 0) continue;
|
|
678
|
+
|
|
679
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
680
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
681
|
+
for (const memory of group) {
|
|
682
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
683
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
684
|
+
if (linked.length > 0) {
|
|
685
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
391
688
|
console.log("");
|
|
392
689
|
}
|
|
393
|
-
}
|
|
394
690
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
691
|
+
if (budgetedFixes.length > 0) {
|
|
692
|
+
console.log(`Past Fixes (${budgetedFixes.length})`);
|
|
693
|
+
console.log("-".repeat(14));
|
|
694
|
+
for (const fix of budgetedFixes) {
|
|
695
|
+
console.log(` Problem: ${fix.problem}`);
|
|
696
|
+
console.log(` Solution: ${fix.solution ?? "(pending)"}`);
|
|
697
|
+
console.log("");
|
|
698
|
+
}
|
|
699
|
+
}
|
|
399
700
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
701
|
+
console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
|
|
702
|
+
if (omittedCount > 0) {
|
|
703
|
+
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
|
|
707
|
+
if (!hasTechStackMemory) {
|
|
708
|
+
console.log("");
|
|
709
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
710
|
+
}
|
|
404
711
|
}
|
|
405
712
|
|
|
406
713
|
const incrementResults = await Promise.all(
|
|
@@ -446,7 +753,26 @@ async function confirmSupersede(oldId: string, newContent: string): Promise<bool
|
|
|
446
753
|
return false;
|
|
447
754
|
}
|
|
448
755
|
|
|
449
|
-
|
|
756
|
+
// Provenance metadata attached to a memory at save time. Manual `cm save`
|
|
757
|
+
// calls (a human at the keyboard) leave this at its defaults — source
|
|
758
|
+
// "manual", confidence 1.0, no commit/file/author info. The hook passes
|
|
759
|
+
// commitSha/filePaths/author and source "hook" with a lower confidence;
|
|
760
|
+
// MCP tool saves pass source "mcp".
|
|
761
|
+
interface SaveProvenance {
|
|
762
|
+
commitSha?: string;
|
|
763
|
+
filePaths?: string[];
|
|
764
|
+
author?: string;
|
|
765
|
+
source?: "hook" | "manual" | "mcp";
|
|
766
|
+
confidence?: number;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function cmdSave(
|
|
770
|
+
project: string,
|
|
771
|
+
type: string,
|
|
772
|
+
content: string,
|
|
773
|
+
force = false,
|
|
774
|
+
provenance: SaveProvenance = {}
|
|
775
|
+
) {
|
|
450
776
|
if (!MEMORY_TYPES.includes(type as MemoryType)) {
|
|
451
777
|
fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
|
|
452
778
|
}
|
|
@@ -492,9 +818,27 @@ async function cmdSave(project: string, type: string, content: string, force = f
|
|
|
492
818
|
if (resolveError) fail(`Failed to resolve superseded memory: ${resolveError.message}`);
|
|
493
819
|
}
|
|
494
820
|
|
|
821
|
+
const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
|
|
822
|
+
const insertPayload: Record<string, unknown> = {
|
|
823
|
+
project,
|
|
824
|
+
type,
|
|
825
|
+
content,
|
|
826
|
+
device_id: getDeviceId(),
|
|
827
|
+
source,
|
|
828
|
+
confidence,
|
|
829
|
+
// Hook-captured memories are heuristically detected and could be
|
|
830
|
+
// noisy or wrong — they start as suggested, pending `cm review`, and
|
|
831
|
+
// are excluded from cm start/context/why until promoted. Manual and
|
|
832
|
+
// mcp saves are already user-vetted, so they're permanent immediately.
|
|
833
|
+
suggested: source === "hook",
|
|
834
|
+
};
|
|
835
|
+
if (commitSha) insertPayload.commit_sha = commitSha;
|
|
836
|
+
if (filePaths && filePaths.length > 0) insertPayload.file_paths = filePaths;
|
|
837
|
+
if (author) insertPayload.author = author;
|
|
838
|
+
|
|
495
839
|
const { data: inserted, error } = await supabase
|
|
496
840
|
.from("memories")
|
|
497
|
-
.insert(
|
|
841
|
+
.insert(insertPayload)
|
|
498
842
|
.select("id")
|
|
499
843
|
.single();
|
|
500
844
|
|
|
@@ -530,6 +874,40 @@ async function cmdSave(project: string, type: string, content: string, force = f
|
|
|
530
874
|
}
|
|
531
875
|
}
|
|
532
876
|
|
|
877
|
+
// --- cm check-commit (internal, used by the hook) --------------------------
|
|
878
|
+
|
|
879
|
+
// Not a user-facing command (no usage()/help entry) — the hook calls this
|
|
880
|
+
// once per invocation, before doing any diff analysis, to decide whether
|
|
881
|
+
// this commit_sha has already been captured. post-commit and post-rewrite
|
|
882
|
+
// both run the exact same capture logic against current HEAD, so without
|
|
883
|
+
// this guard, a commit that fires both hooks (e.g. `git commit --amend`,
|
|
884
|
+
// which triggers post-commit for the new commit AND post-rewrite for the
|
|
885
|
+
// rewrite) would be captured twice. Checked once per invocation — not
|
|
886
|
+
// inside each individual cmSave call — because a single commit normally
|
|
887
|
+
// produces several distinct memories; a per-save check would see the
|
|
888
|
+
// first save's own commit_sha and wrongly skip the rest.
|
|
889
|
+
async function cmdCheckCommit(project: string, commitSha: string): Promise<void> {
|
|
890
|
+
const { data, error } = await supabase
|
|
891
|
+
.from("memories")
|
|
892
|
+
.select("id")
|
|
893
|
+
.eq("project", project)
|
|
894
|
+
.eq("commit_sha", commitSha)
|
|
895
|
+
.limit(1);
|
|
896
|
+
|
|
897
|
+
if (error) {
|
|
898
|
+
// Fail open: an error here (including "column does not exist" if the
|
|
899
|
+
// commit_sha migration hasn't landed yet) must not look identical to
|
|
900
|
+
// "duplicate, skip" below — that would silently suppress every hook
|
|
901
|
+
// capture with no log trail. Exit 0 so the normal save path runs;
|
|
902
|
+
// if commit_sha genuinely isn't a column, the save itself fails
|
|
903
|
+
// there instead, where the hook's outer error handler logs it.
|
|
904
|
+
console.error(`check-commit: ${error.message}`);
|
|
905
|
+
process.exit(0);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
process.exit(data && data.length > 0 ? 1 : 0);
|
|
909
|
+
}
|
|
910
|
+
|
|
533
911
|
// --- memory compression ----------------------------------------------------
|
|
534
912
|
|
|
535
913
|
async function compressMemories(
|
|
@@ -539,7 +917,8 @@ async function compressMemories(
|
|
|
539
917
|
.from("memories")
|
|
540
918
|
.select("*")
|
|
541
919
|
.eq("project", project)
|
|
542
|
-
.eq("resolved", false)
|
|
920
|
+
.eq("resolved", false)
|
|
921
|
+
.eq("suggested", false);
|
|
543
922
|
|
|
544
923
|
if (error) fail(`Failed to load memories for compression: ${error.message}`);
|
|
545
924
|
|
|
@@ -688,6 +1067,34 @@ async function cmdResolve(project: string, memoryId: string) {
|
|
|
688
1067
|
console.log(`Resolved memory [${memoryId}]`);
|
|
689
1068
|
}
|
|
690
1069
|
|
|
1070
|
+
// --- cm pin / cm unpin -------------------------------------------------
|
|
1071
|
+
|
|
1072
|
+
async function setPinned(project: string, memoryId: string, pinned: boolean): Promise<void> {
|
|
1073
|
+
const { data, error } = await supabase
|
|
1074
|
+
.from("memories")
|
|
1075
|
+
.update({ pinned })
|
|
1076
|
+
.eq("project", project)
|
|
1077
|
+
.eq("id", memoryId)
|
|
1078
|
+
.select("id");
|
|
1079
|
+
|
|
1080
|
+
if (error) fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
|
|
1081
|
+
|
|
1082
|
+
if (!data || data.length === 0) {
|
|
1083
|
+
console.log(`No memory found with ID [${memoryId}]`);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function cmdPin(project: string, memoryId: string): Promise<void> {
|
|
1091
|
+
await setPinned(project, memoryId, true);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function cmdUnpin(project: string, memoryId: string): Promise<void> {
|
|
1095
|
+
await setPinned(project, memoryId, false);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
691
1098
|
// --- cm delete -------------------------------------------------------------
|
|
692
1099
|
|
|
693
1100
|
async function cmdDelete(project: string, memoryId: string) {
|
|
@@ -715,10 +1122,173 @@ async function cmdDelete(project: string, memoryId: string) {
|
|
|
715
1122
|
console.log(`Deleted memory [${memoryId}]`);
|
|
716
1123
|
}
|
|
717
1124
|
|
|
1125
|
+
// --- shared: relative time + $EDITOR prompt (cm review, cm edit, cm status)
|
|
1126
|
+
|
|
1127
|
+
function formatRelativeTime(date: Date): string {
|
|
1128
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
1129
|
+
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"} ago`;
|
|
1130
|
+
const minutes = Math.floor(seconds / 60);
|
|
1131
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
1132
|
+
const hours = Math.floor(minutes / 60);
|
|
1133
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
1134
|
+
const days = Math.floor(hours / 24);
|
|
1135
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Opens $EDITOR on a scratch file pre-filled with the current content, or
|
|
1139
|
+
// falls back to an inline readline prompt when $EDITOR isn't set. Returns
|
|
1140
|
+
// the original content unchanged if the result is blank (an empty save
|
|
1141
|
+
// buffer almost certainly means "I didn't mean to clear this", not "make
|
|
1142
|
+
// this memory empty").
|
|
1143
|
+
async function editContentInteractively(currentContent: string): Promise<string> {
|
|
1144
|
+
const editor = process.env.EDITOR;
|
|
1145
|
+
|
|
1146
|
+
if (editor) {
|
|
1147
|
+
const tmpFile = nodePath.join(nodeOs.tmpdir(), `stackmem-edit-${randomInt(1_000_000_000)}.txt`);
|
|
1148
|
+
nodeFs.writeFileSync(tmpFile, currentContent, "utf-8");
|
|
1149
|
+
try {
|
|
1150
|
+
execFileSync(editor, [tmpFile], { stdio: "inherit" });
|
|
1151
|
+
const edited = nodeFs.readFileSync(tmpFile, "utf-8").trim();
|
|
1152
|
+
return edited.length > 0 ? edited : currentContent;
|
|
1153
|
+
} finally {
|
|
1154
|
+
try {
|
|
1155
|
+
nodeFs.unlinkSync(tmpFile);
|
|
1156
|
+
} catch {
|
|
1157
|
+
// Best-effort cleanup — a leftover temp file isn't worth failing on.
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
console.log(`Current: ${currentContent}`);
|
|
1163
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1164
|
+
const answer = (await rl.question("New content (leave blank to keep unchanged): ")).trim();
|
|
1165
|
+
rl.close();
|
|
1166
|
+
return answer.length > 0 ? answer : currentContent;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// --- cm review -------------------------------------------------------------
|
|
1170
|
+
|
|
1171
|
+
async function cmdReview(project: string): Promise<void> {
|
|
1172
|
+
const { data, error } = await supabase
|
|
1173
|
+
.from("memories")
|
|
1174
|
+
.select("*")
|
|
1175
|
+
.eq("project", project)
|
|
1176
|
+
.eq("resolved", false)
|
|
1177
|
+
.eq("suggested", true)
|
|
1178
|
+
.order("created_at", { ascending: true });
|
|
1179
|
+
|
|
1180
|
+
if (error) fail(`Failed to load suggested memories: ${error.message}`);
|
|
1181
|
+
|
|
1182
|
+
const memories = data ?? [];
|
|
1183
|
+
if (memories.length === 0) {
|
|
1184
|
+
console.log(`No memories to review for "${project}".`);
|
|
1185
|
+
console.log("Your inbox is empty.");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
console.log(`Reviewing ${memories.length} suggested memories for "${project}"`);
|
|
1190
|
+
console.log("━".repeat(40));
|
|
1191
|
+
|
|
1192
|
+
// Node's readline can't reliably answer multiple sequential
|
|
1193
|
+
// rl.question() calls over piped (non-TTY) stdin — all buffered input
|
|
1194
|
+
// arrives before the second question is even issued, and it never
|
|
1195
|
+
// resolves. A real interactive terminal doesn't hit this (keystrokes
|
|
1196
|
+
// arrive one at a time), so only the non-TTY path needs the
|
|
1197
|
+
// read-everything-up-front workaround.
|
|
1198
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
1199
|
+
const pipedAnswers: string[] = rl
|
|
1200
|
+
? []
|
|
1201
|
+
: (
|
|
1202
|
+
await new Promise<string>((resolve) => {
|
|
1203
|
+
let buf = "";
|
|
1204
|
+
process.stdin.setEncoding("utf-8");
|
|
1205
|
+
process.stdin.on("data", (chunk) => (buf += chunk));
|
|
1206
|
+
process.stdin.on("end", () => resolve(buf));
|
|
1207
|
+
})
|
|
1208
|
+
).split("\n");
|
|
1209
|
+
|
|
1210
|
+
async function nextAnswer(prompt: string): Promise<string> {
|
|
1211
|
+
if (rl) return (await rl.question(prompt)).trim().toLowerCase();
|
|
1212
|
+
console.log(prompt);
|
|
1213
|
+
return (pipedAnswers.shift() ?? "q").trim().toLowerCase();
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
let kept = 0;
|
|
1217
|
+
let dropped = 0;
|
|
1218
|
+
let skipped = 0;
|
|
1219
|
+
|
|
1220
|
+
for (let i = 0; i < memories.length; i++) {
|
|
1221
|
+
const memory = memories[i];
|
|
1222
|
+
console.log(`[${i + 1}/${memories.length}] ${memory.type}: ${memory.content}`);
|
|
1223
|
+
console.log(` Captured: ${formatRelativeTime(parseUtcDate(memory.created_at))}`);
|
|
1224
|
+
console.log("");
|
|
1225
|
+
|
|
1226
|
+
const answer = await nextAnswer("(k)eep (e)dit (d)rop (s)kip (q)uit > ");
|
|
1227
|
+
console.log("");
|
|
1228
|
+
|
|
1229
|
+
if (answer === "k") {
|
|
1230
|
+
const { error: updateError } = await supabase
|
|
1231
|
+
.from("memories")
|
|
1232
|
+
.update({ suggested: false })
|
|
1233
|
+
.eq("id", memory.id);
|
|
1234
|
+
if (!updateError) kept++;
|
|
1235
|
+
} else if (answer === "e") {
|
|
1236
|
+
const newContent = redactSecrets(await editContentInteractively(memory.content));
|
|
1237
|
+
const { error: updateError } = await supabase
|
|
1238
|
+
.from("memories")
|
|
1239
|
+
.update({ content: newContent, suggested: false })
|
|
1240
|
+
.eq("id", memory.id);
|
|
1241
|
+
if (!updateError) kept++;
|
|
1242
|
+
} else if (answer === "d") {
|
|
1243
|
+
await supabase
|
|
1244
|
+
.from("memory_links")
|
|
1245
|
+
.delete()
|
|
1246
|
+
.or(`source_id.eq.${memory.id},target_id.eq.${memory.id}`);
|
|
1247
|
+
const { error: deleteError } = await supabase.from("memories").delete().eq("id", memory.id);
|
|
1248
|
+
if (!deleteError) dropped++;
|
|
1249
|
+
} else if (answer === "q") {
|
|
1250
|
+
break;
|
|
1251
|
+
} else {
|
|
1252
|
+
// "s", blank, or anything unrecognized — leave it suggested and
|
|
1253
|
+
// move on rather than erroring on a mistyped key.
|
|
1254
|
+
skipped++;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
rl?.close();
|
|
1259
|
+
console.log(`Review complete. Kept ${kept}, dropped ${dropped}, skipped ${skipped}.`);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// --- cm edit ---------------------------------------------------------------
|
|
1263
|
+
|
|
1264
|
+
async function cmdEdit(project: string, memoryId: string): Promise<void> {
|
|
1265
|
+
const { data: memory, error } = await supabase
|
|
1266
|
+
.from("memories")
|
|
1267
|
+
.select("id, content")
|
|
1268
|
+
.eq("project", project)
|
|
1269
|
+
.eq("id", memoryId)
|
|
1270
|
+
.single();
|
|
1271
|
+
|
|
1272
|
+
if (error || !memory) {
|
|
1273
|
+
console.log(`No memory found with ID [${memoryId}]`);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const newContent = redactSecrets(await editContentInteractively(memory.content));
|
|
1278
|
+
const { error: updateError } = await supabase
|
|
1279
|
+
.from("memories")
|
|
1280
|
+
.update({ content: newContent })
|
|
1281
|
+
.eq("id", memoryId);
|
|
1282
|
+
|
|
1283
|
+
if (updateError) fail(`Failed to update memory: ${updateError.message}`);
|
|
1284
|
+
|
|
1285
|
+
console.log(`Updated memory [${memoryId}].`);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
718
1288
|
// --- cm inspect ------------------------------------------------------------
|
|
719
1289
|
|
|
720
1290
|
function formatDate(dateString: string): string {
|
|
721
|
-
return
|
|
1291
|
+
return parseUtcDate(dateString).toLocaleDateString("en-US", {
|
|
722
1292
|
month: "short",
|
|
723
1293
|
day: "numeric",
|
|
724
1294
|
year: "numeric",
|
|
@@ -735,7 +1305,13 @@ async function cmdInspect(project: string, memoryId: string) {
|
|
|
735
1305
|
|
|
736
1306
|
if (error || !memory) fail(`Memory [${memoryId}] not found for "${project}".`);
|
|
737
1307
|
|
|
738
|
-
const
|
|
1308
|
+
const config = readStackmemConfig();
|
|
1309
|
+
const decayScore = computeDecayScore(
|
|
1310
|
+
parseUtcDate(memory.created_at),
|
|
1311
|
+
memory.access_count ?? 0,
|
|
1312
|
+
memory.pinned ?? false,
|
|
1313
|
+
config.decayLambda
|
|
1314
|
+
);
|
|
739
1315
|
|
|
740
1316
|
const { data: links, error: linksError } = await supabase
|
|
741
1317
|
.from("memory_links")
|
|
@@ -843,6 +1419,65 @@ try {
|
|
|
843
1419
|
|
|
844
1420
|
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
845
1421
|
|
|
1422
|
+
// Inlined copy of src/config-file.ts's defaults/merge logic — this script
|
|
1423
|
+
// runs standalone with no import access to that module.
|
|
1424
|
+
const DEFAULT_STACKMEM_CONFIG = {
|
|
1425
|
+
ignore: [],
|
|
1426
|
+
tokenBudget: 2000,
|
|
1427
|
+
decayLambda: 0.05,
|
|
1428
|
+
captureTypes: ["decision", "constraint", "discovery", "rejection"],
|
|
1429
|
+
minQualityScore: 0.3,
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
function isStringArray(value) {
|
|
1433
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function readStackmemConfig() {
|
|
1437
|
+
const configPath = join(process.cwd(), ".stackmem.json");
|
|
1438
|
+
try {
|
|
1439
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1440
|
+
return {
|
|
1441
|
+
ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
|
|
1442
|
+
tokenBudget:
|
|
1443
|
+
typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
|
|
1444
|
+
? raw.tokenBudget
|
|
1445
|
+
: DEFAULT_STACKMEM_CONFIG.tokenBudget,
|
|
1446
|
+
decayLambda:
|
|
1447
|
+
typeof raw.decayLambda === "number" && raw.decayLambda > 0
|
|
1448
|
+
? raw.decayLambda
|
|
1449
|
+
: DEFAULT_STACKMEM_CONFIG.decayLambda,
|
|
1450
|
+
captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
|
|
1451
|
+
minQualityScore:
|
|
1452
|
+
typeof raw.minQualityScore === "number"
|
|
1453
|
+
? raw.minQualityScore
|
|
1454
|
+
: DEFAULT_STACKMEM_CONFIG.minQualityScore,
|
|
1455
|
+
};
|
|
1456
|
+
} catch {
|
|
1457
|
+
return { ...DEFAULT_STACKMEM_CONFIG };
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function matchesIgnorePattern(filePath, pattern) {
|
|
1462
|
+
const base = filePath.split("/").pop() ?? filePath;
|
|
1463
|
+
|
|
1464
|
+
if (pattern.endsWith("/")) {
|
|
1465
|
+
const dirName = pattern.slice(0, -1);
|
|
1466
|
+
return filePath.split("/").includes(dirName);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (pattern.includes("*")) {
|
|
1470
|
+
const regex = new RegExp(
|
|
1471
|
+
"^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
|
|
1472
|
+
);
|
|
1473
|
+
return regex.test(base) || regex.test(filePath);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
return filePath === pattern || base === pattern;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const STACKMEM_CONFIG = readStackmemConfig();
|
|
1480
|
+
|
|
846
1481
|
const SECRET_PATTERNS = [
|
|
847
1482
|
/AKIA[0-9A-Z]{16}/g,
|
|
848
1483
|
/(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
|
|
@@ -899,10 +1534,43 @@ function git(args) {
|
|
|
899
1534
|
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
900
1535
|
}
|
|
901
1536
|
|
|
1537
|
+
const CURRENT_SHA = git(["rev-parse", "HEAD"]);
|
|
1538
|
+
const CURRENT_AUTHOR = (() => {
|
|
1539
|
+
try {
|
|
1540
|
+
return git(["log", "--format=%ae", "-1", "HEAD"]);
|
|
1541
|
+
} catch {
|
|
1542
|
+
return "";
|
|
1543
|
+
}
|
|
1544
|
+
})();
|
|
1545
|
+
|
|
1546
|
+
// post-commit and post-rewrite both run this exact script against current
|
|
1547
|
+
// HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
|
|
1548
|
+
// would otherwise capture the same commit twice. Checked once up front,
|
|
1549
|
+
// not per-save — see cmdCheckCommit in src/cli.ts for why.
|
|
1550
|
+
try {
|
|
1551
|
+
execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
|
|
1552
|
+
} catch {
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// file_paths is populated with every file the commit touched (not just
|
|
1557
|
+
// the filtered diffableFiles below) — "cm why <file>" should still find
|
|
1558
|
+
// a commit's decision/discovery even if that specific file's own diff
|
|
1559
|
+
// content was skipped as noise (a lockfile, say).
|
|
902
1560
|
function cmSave(type, content) {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1561
|
+
if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
|
|
1562
|
+
|
|
1563
|
+
const args = [
|
|
1564
|
+
CLI_PATH, "save", PROJECT, type, content, "--force",
|
|
1565
|
+
"--commit-sha", CURRENT_SHA,
|
|
1566
|
+
"--source", "hook",
|
|
1567
|
+
"--confidence", "0.8",
|
|
1568
|
+
];
|
|
1569
|
+
if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
|
|
1570
|
+
if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
|
|
1571
|
+
|
|
1572
|
+
execFileSync("node", args, { stdio: "inherit" });
|
|
1573
|
+
savedItems.push(\`\${type}: \${content}\`);
|
|
906
1574
|
}
|
|
907
1575
|
|
|
908
1576
|
function basename(filePath) {
|
|
@@ -929,6 +1597,29 @@ function isSchemaFile(filePath) {
|
|
|
929
1597
|
);
|
|
930
1598
|
}
|
|
931
1599
|
|
|
1600
|
+
// Noise that's never worth diffing: lockfiles, build output, vendored/
|
|
1601
|
+
// generated code, and timestamp-prefixed SQL migrations (their content is
|
|
1602
|
+
// a mechanical dump, not a human decision). Checked against path segments
|
|
1603
|
+
// (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
|
|
1604
|
+
// just a top-level "dist/...".
|
|
1605
|
+
const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
|
|
1606
|
+
const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
|
|
1607
|
+
const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
|
|
1608
|
+
const DIFF_MAX_BYTES = 50 * 1024;
|
|
1609
|
+
|
|
1610
|
+
function shouldSkipDiffFile(filePath) {
|
|
1611
|
+
const base = basename(filePath);
|
|
1612
|
+
if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
|
|
1613
|
+
if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
|
|
1614
|
+
if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
|
|
1615
|
+
if (base.endsWith(".map")) return true;
|
|
1616
|
+
if (base.endsWith(".pb.go")) return true;
|
|
1617
|
+
if (/\\.generated\\./.test(base)) return true;
|
|
1618
|
+
if (MIGRATION_FILE_RE.test(base)) return true;
|
|
1619
|
+
if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
|
|
1620
|
+
return false;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
932
1623
|
const LOW_QUALITY_PREFIXES = [
|
|
933
1624
|
"wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
|
|
934
1625
|
"tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
|
|
@@ -963,11 +1654,10 @@ const savedItems = [];
|
|
|
963
1654
|
|
|
964
1655
|
const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
965
1656
|
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
// rules-file content and triggers another sync commit, indefinitely.
|
|
1657
|
+
// Kept as a general escape hatch for other automation (CI bots, scripts)
|
|
1658
|
+
// that tags its own commits this way and shouldn't be captured as a
|
|
1659
|
+
// memory — cm sync no longer self-commits, so this isn't load-bearing for
|
|
1660
|
+
// that anymore, but other tools' commits still deserve the same skip.
|
|
971
1661
|
if (commitMessage.includes("[skip ci]")) {
|
|
972
1662
|
return;
|
|
973
1663
|
}
|
|
@@ -991,10 +1681,40 @@ const changedFiles = git(
|
|
|
991
1681
|
|
|
992
1682
|
let diff = "";
|
|
993
1683
|
if (!isFirstCommit) {
|
|
1684
|
+
// --numstat marks a binary file's added/deleted columns as "-" — that's
|
|
1685
|
+
// the cheapest way to identify them without fetching their (useless,
|
|
1686
|
+
// and potentially huge) diff content at all.
|
|
1687
|
+
let binaryFiles = new Set();
|
|
994
1688
|
try {
|
|
995
|
-
|
|
1689
|
+
const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
|
|
1690
|
+
encoding: "utf-8",
|
|
1691
|
+
});
|
|
1692
|
+
for (const line of numstat.split("\\n")) {
|
|
1693
|
+
const parts = line.split("\\t");
|
|
1694
|
+
if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
|
|
1695
|
+
binaryFiles.add(parts[2]);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
996
1698
|
} catch {
|
|
997
|
-
|
|
1699
|
+
// Nothing to compare against — leave binaryFiles empty.
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const diffableFiles = changedFiles.filter(
|
|
1703
|
+
(file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
|
|
1704
|
+
);
|
|
1705
|
+
|
|
1706
|
+
if (diffableFiles.length > 0) {
|
|
1707
|
+
try {
|
|
1708
|
+
diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
|
|
1709
|
+
encoding: "utf-8",
|
|
1710
|
+
});
|
|
1711
|
+
} catch {
|
|
1712
|
+
diff = "";
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (diff.length > DIFF_MAX_BYTES) {
|
|
1717
|
+
diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
|
|
998
1718
|
}
|
|
999
1719
|
}
|
|
1000
1720
|
|
|
@@ -1021,17 +1741,14 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1021
1741
|
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
1022
1742
|
const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
|
|
1023
1743
|
cmSave("discovery", content);
|
|
1024
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1025
1744
|
}
|
|
1026
1745
|
if (isConfigFile(currentFile)) {
|
|
1027
1746
|
const content = redactSecrets(\`new config: \${currentFile}\`);
|
|
1028
1747
|
cmSave("constraint", content);
|
|
1029
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1030
1748
|
}
|
|
1031
1749
|
if (isSchemaFile(currentFile)) {
|
|
1032
1750
|
const content = redactSecrets(\`schema change: \${currentFile}\`);
|
|
1033
1751
|
cmSave("discovery", content);
|
|
1034
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1035
1752
|
}
|
|
1036
1753
|
}
|
|
1037
1754
|
continue;
|
|
@@ -1068,7 +1785,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1068
1785
|
if (removedModulePath) {
|
|
1069
1786
|
const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
|
|
1070
1787
|
cmSave("rejection", content);
|
|
1071
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
1072
1788
|
}
|
|
1073
1789
|
|
|
1074
1790
|
if (isPackageJson && inDependenciesSection) {
|
|
@@ -1076,7 +1792,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1076
1792
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
1077
1793
|
const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
|
|
1078
1794
|
cmSave("rejection", content);
|
|
1079
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
1080
1795
|
}
|
|
1081
1796
|
}
|
|
1082
1797
|
continue;
|
|
@@ -1092,7 +1807,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1092
1807
|
if (envMatch) {
|
|
1093
1808
|
const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
|
|
1094
1809
|
cmSave("constraint", content);
|
|
1095
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1096
1810
|
}
|
|
1097
1811
|
}
|
|
1098
1812
|
}
|
|
@@ -1102,7 +1816,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1102
1816
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
1103
1817
|
const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
|
|
1104
1818
|
cmSave("decision", content);
|
|
1105
|
-
savedItems.push(\`decision: \${content}\`);
|
|
1106
1819
|
}
|
|
1107
1820
|
}
|
|
1108
1821
|
|
|
@@ -1110,14 +1823,12 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1110
1823
|
const truncated = codeLine.trim().slice(0, 80);
|
|
1111
1824
|
const content = redactSecrets(\`database change: \${truncated}\`);
|
|
1112
1825
|
cmSave("discovery", content);
|
|
1113
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1114
1826
|
}
|
|
1115
1827
|
|
|
1116
1828
|
if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
|
|
1117
1829
|
const truncated = codeLine.trim().slice(0, 100);
|
|
1118
1830
|
const content = redactSecrets(\`open issue from commit: \${truncated}\`);
|
|
1119
1831
|
cmSave("discovery", content);
|
|
1120
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1121
1832
|
}
|
|
1122
1833
|
}
|
|
1123
1834
|
}
|
|
@@ -1146,19 +1857,16 @@ if (breakingMatch) {
|
|
|
1146
1857
|
const breakingContent = redactSecrets(breakingMatch[1].trim());
|
|
1147
1858
|
const content = \`BREAKING CHANGE: \${breakingContent}\`;
|
|
1148
1859
|
cmSave("constraint", content);
|
|
1149
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1150
1860
|
}
|
|
1151
1861
|
|
|
1152
1862
|
if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
|
|
1153
1863
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1154
1864
|
cmSave("decision", trimmedMessage);
|
|
1155
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1156
1865
|
} else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
|
|
1157
1866
|
// docs/style/test/ci/build/chore — skip entirely, nothing saved.
|
|
1158
1867
|
} else if (isQualityCommitMessage(commitMessage)) {
|
|
1159
1868
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1160
1869
|
cmSave("decision", trimmedMessage);
|
|
1161
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1162
1870
|
}
|
|
1163
1871
|
|
|
1164
1872
|
console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
|
|
@@ -1166,21 +1874,13 @@ for (const item of savedItems) {
|
|
|
1166
1874
|
console.log(\` - \${item}\`);
|
|
1167
1875
|
}
|
|
1168
1876
|
|
|
1877
|
+
// Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
|
|
1878
|
+
// Editors read these files straight off disk, and they're gitignored by
|
|
1879
|
+
// default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
|
|
1880
|
+
// sync with git history; committing them here used to double the commit
|
|
1881
|
+
// count on every real commit and produced constant rules-file merge
|
|
1882
|
+
// conflicts on teams.
|
|
1169
1883
|
execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
|
|
1170
|
-
|
|
1171
|
-
// cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
|
|
1172
|
-
// the working directory dirty (breaking things like \`npm version patch\`
|
|
1173
|
-
// that require a clean tree). Commit the sync ourselves instead. --no-verify
|
|
1174
|
-
// skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
|
|
1175
|
-
// guard above, which is what actually stops this from recursing).
|
|
1176
|
-
try {
|
|
1177
|
-
execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
|
|
1178
|
-
execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
|
|
1179
|
-
stdio: "pipe",
|
|
1180
|
-
});
|
|
1181
|
-
} catch {
|
|
1182
|
-
// Nothing to commit (rules files unchanged) — fine, ignore.
|
|
1183
|
-
}
|
|
1184
1884
|
}
|
|
1185
1885
|
|
|
1186
1886
|
async function main() {
|
|
@@ -1206,15 +1906,27 @@ main().finally(() => process.exit(0));
|
|
|
1206
1906
|
`;
|
|
1207
1907
|
}
|
|
1208
1908
|
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1909
|
+
// Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
|
|
1910
|
+
// `git rebase` (neither of which is a plain commit, so post-commit alone
|
|
1911
|
+
// misses them) — it runs the exact same capture script as post-commit,
|
|
1912
|
+
// against current HEAD, with the check-commit idempotency guard in the
|
|
1913
|
+
// script itself preventing a double-capture when a single operation
|
|
1914
|
+
// (e.g. --amend) fires both hooks for the same resulting commit.
|
|
1915
|
+
const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"] as const;
|
|
1916
|
+
|
|
1917
|
+
function hookBackupFilename(hookName: string): string {
|
|
1918
|
+
return `${hookName}${HOOK_BACKUP_SUFFIX}`;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
|
|
1922
|
+
// hook of this name already exists and wasn't written by stackmem, it's
|
|
1211
1923
|
// preserved as a backup and chained to run before stackmem's own hook,
|
|
1212
1924
|
// rather than being clobbered — see `uninstallHook` for how it's restored.
|
|
1213
|
-
function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
1925
|
+
function installHook(hooksDir: string, hookLogicPath: string, hookName: string): void {
|
|
1214
1926
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1215
1927
|
|
|
1216
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1217
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1928
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1929
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1218
1930
|
|
|
1219
1931
|
let chainedOriginal = false;
|
|
1220
1932
|
if (nodeFs.existsSync(hookPath)) {
|
|
@@ -1231,7 +1943,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1231
1943
|
}
|
|
1232
1944
|
|
|
1233
1945
|
const chainLine = chainedOriginal
|
|
1234
|
-
? `"$(dirname "$0")/${
|
|
1946
|
+
? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
|
|
1235
1947
|
: "";
|
|
1236
1948
|
|
|
1237
1949
|
nodeFs.writeFileSync(
|
|
@@ -1242,7 +1954,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1242
1954
|
nodeFs.chmodSync(hookPath, 0o755);
|
|
1243
1955
|
|
|
1244
1956
|
if (chainedOriginal) {
|
|
1245
|
-
console.log(
|
|
1957
|
+
console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
|
|
1246
1958
|
}
|
|
1247
1959
|
}
|
|
1248
1960
|
|
|
@@ -1250,9 +1962,9 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1250
1962
|
// stackmem chained it after an existing hook, restores that original hook
|
|
1251
1963
|
// in its place. Returns a description of what happened, or null if there
|
|
1252
1964
|
// was no stackmem-managed hook to remove (nothing done).
|
|
1253
|
-
function uninstallHook(hooksDir: string): string | null {
|
|
1254
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1255
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1965
|
+
function uninstallHook(hooksDir: string, hookName: string): string | null {
|
|
1966
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1967
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1256
1968
|
|
|
1257
1969
|
if (!nodeFs.existsSync(hookPath)) return null;
|
|
1258
1970
|
|
|
@@ -1261,11 +1973,11 @@ function uninstallHook(hooksDir: string): string | null {
|
|
|
1261
1973
|
|
|
1262
1974
|
if (nodeFs.existsSync(backupPath)) {
|
|
1263
1975
|
nodeFs.renameSync(backupPath, hookPath);
|
|
1264
|
-
return
|
|
1976
|
+
return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
|
|
1265
1977
|
}
|
|
1266
1978
|
|
|
1267
1979
|
nodeFs.unlinkSync(hookPath);
|
|
1268
|
-
return
|
|
1980
|
+
return `.git/hooks/${hookName}`;
|
|
1269
1981
|
}
|
|
1270
1982
|
|
|
1271
1983
|
// Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
|
|
@@ -1319,6 +2031,122 @@ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()): boolean
|
|
|
1319
2031
|
return true;
|
|
1320
2032
|
}
|
|
1321
2033
|
|
|
2034
|
+
// --- MCP registration for other editors ---------------------------------
|
|
2035
|
+
|
|
2036
|
+
// Each entry detects its editor by an existing config directory (never
|
|
2037
|
+
// erroring if absent — "skip silently" per the task) and merges stackmem
|
|
2038
|
+
// into whatever's already there, same spirit as registerMcpServer above.
|
|
2039
|
+
// homeDir is a parameter (not a hardcoded nodeOs.homedir() call) so tests
|
|
2040
|
+
// can point at a scratch directory instead of the real one.
|
|
2041
|
+
interface EditorMcpTarget {
|
|
2042
|
+
name: string;
|
|
2043
|
+
configPath: (homeDir: string) => string | null;
|
|
2044
|
+
register: (configPath: string, distIndexPath: string) => void;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function mergeMcpServersJson(configPath: string, distIndexPath: string): void {
|
|
2048
|
+
let config: { mcpServers?: Record<string, unknown> } = {};
|
|
2049
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2050
|
+
try {
|
|
2051
|
+
config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
2052
|
+
} catch {
|
|
2053
|
+
config = {};
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
2057
|
+
config.mcpServers = {};
|
|
2058
|
+
}
|
|
2059
|
+
config.mcpServers.stackmem = { command: "node", args: [distIndexPath] };
|
|
2060
|
+
|
|
2061
|
+
nodeFs.mkdirSync(nodePath.dirname(configPath), { recursive: true });
|
|
2062
|
+
nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// VS Code installs the Copilot Chat extension under a version-suffixed
|
|
2066
|
+
// directory name (e.g. "github.copilot-chat-0.26.4") — this resolves the
|
|
2067
|
+
// "github.copilot-chat*" glob the task describes against whatever's
|
|
2068
|
+
// actually installed, rather than assuming a fixed folder name.
|
|
2069
|
+
function findCopilotChatExtensionDir(homeDir: string): string | null {
|
|
2070
|
+
const extensionsDir = nodePath.join(homeDir, ".vscode", "extensions");
|
|
2071
|
+
if (!nodeFs.existsSync(extensionsDir)) return null;
|
|
2072
|
+
|
|
2073
|
+
try {
|
|
2074
|
+
const match = nodeFs
|
|
2075
|
+
.readdirSync(extensionsDir)
|
|
2076
|
+
.find((entry) => entry.startsWith("github.copilot-chat"));
|
|
2077
|
+
return match ? nodePath.join(extensionsDir, match) : null;
|
|
2078
|
+
} catch {
|
|
2079
|
+
return null;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
const EDITOR_MCP_TARGETS: EditorMcpTarget[] = [
|
|
2084
|
+
{
|
|
2085
|
+
name: "Cursor",
|
|
2086
|
+
configPath: (homeDir) => {
|
|
2087
|
+
const dir = nodePath.join(homeDir, ".cursor");
|
|
2088
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp.json") : null;
|
|
2089
|
+
},
|
|
2090
|
+
register: mergeMcpServersJson,
|
|
2091
|
+
},
|
|
2092
|
+
{
|
|
2093
|
+
name: "Windsurf",
|
|
2094
|
+
configPath: (homeDir) => {
|
|
2095
|
+
const dir = nodePath.join(homeDir, ".codeium", "windsurf");
|
|
2096
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp_config.json") : null;
|
|
2097
|
+
},
|
|
2098
|
+
register: mergeMcpServersJson,
|
|
2099
|
+
},
|
|
2100
|
+
{
|
|
2101
|
+
name: "VS Code (Copilot)",
|
|
2102
|
+
configPath: (homeDir) => {
|
|
2103
|
+
const extensionDir = findCopilotChatExtensionDir(homeDir);
|
|
2104
|
+
return extensionDir ? nodePath.join(extensionDir, "mcp.json") : null;
|
|
2105
|
+
},
|
|
2106
|
+
register: mergeMcpServersJson,
|
|
2107
|
+
},
|
|
2108
|
+
{
|
|
2109
|
+
name: "Zed",
|
|
2110
|
+
configPath: (homeDir) => {
|
|
2111
|
+
const dir = nodePath.join(homeDir, ".config", "zed");
|
|
2112
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "settings.json") : null;
|
|
2113
|
+
},
|
|
2114
|
+
register: (configPath, distIndexPath) => {
|
|
2115
|
+
let config: { context_servers?: Record<string, unknown> } = {};
|
|
2116
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2117
|
+
try {
|
|
2118
|
+
config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
2119
|
+
} catch {
|
|
2120
|
+
config = {};
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
if (!config.context_servers || typeof config.context_servers !== "object") {
|
|
2124
|
+
config.context_servers = {};
|
|
2125
|
+
}
|
|
2126
|
+
config.context_servers.stackmem = { command: "node", args: [distIndexPath] };
|
|
2127
|
+
nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
2128
|
+
},
|
|
2129
|
+
},
|
|
2130
|
+
];
|
|
2131
|
+
|
|
2132
|
+
// Registers stackmem's MCP server with every detected editor, printing a
|
|
2133
|
+
// line per success. An editor whose config directory doesn't exist is
|
|
2134
|
+
// skipped with no output and never an error.
|
|
2135
|
+
function registerEditorMcpServers(distIndexPath: string, homeDir = nodeOs.homedir()): void {
|
|
2136
|
+
for (const target of EDITOR_MCP_TARGETS) {
|
|
2137
|
+
const configPath = target.configPath(homeDir);
|
|
2138
|
+
if (!configPath) continue;
|
|
2139
|
+
|
|
2140
|
+
try {
|
|
2141
|
+
target.register(configPath, distIndexPath);
|
|
2142
|
+
console.log(`✓ MCP registered for ${target.name}`);
|
|
2143
|
+
} catch {
|
|
2144
|
+
// Best-effort — a permissions error or malformed existing config on
|
|
2145
|
+
// one editor shouldn't abort `cm init` or the other editors' writes.
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
1322
2150
|
const DEFAULT_GITIGNORE_CONTENT = `node_modules/
|
|
1323
2151
|
dist/
|
|
1324
2152
|
.env
|
|
@@ -1328,6 +2156,9 @@ dist/
|
|
|
1328
2156
|
coverage/
|
|
1329
2157
|
*-memories.json
|
|
1330
2158
|
*.db
|
|
2159
|
+
*cursorrules
|
|
2160
|
+
*windsurfrules
|
|
2161
|
+
.cursor/rules/stackmem.md
|
|
1331
2162
|
`;
|
|
1332
2163
|
|
|
1333
2164
|
// Returns true if the file was created, false if it already existed
|
|
@@ -1343,28 +2174,131 @@ function writeGitignore(resolvedPath: string): boolean {
|
|
|
1343
2174
|
return true;
|
|
1344
2175
|
}
|
|
1345
2176
|
|
|
1346
|
-
function writeClaudeMd(project: string, resolvedPath: string): void {
|
|
1347
|
-
const claudeMdPath = nodePath.join(resolvedPath, "CLAUDE.md");
|
|
2177
|
+
function writeClaudeMd(project: string, resolvedPath: string): void {
|
|
2178
|
+
const claudeMdPath = nodePath.join(resolvedPath, "CLAUDE.md");
|
|
2179
|
+
|
|
2180
|
+
if (nodeFs.existsSync(claudeMdPath)) {
|
|
2181
|
+
console.log("CLAUDE.md already exists — skipping.");
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
const content = `# stackmem
|
|
2186
|
+
|
|
2187
|
+
At the start of every session, the stackmem MCP server will automatically load memories for this project. The project name is "${project}".
|
|
2188
|
+
|
|
2189
|
+
If the MCP server is not connected, run:
|
|
2190
|
+
cm start ${project}
|
|
2191
|
+
and paste the output here before starting work.
|
|
2192
|
+
|
|
2193
|
+
When you make a decision, discover a constraint, reject an approach, or make a discovery during this session, save it with:
|
|
2194
|
+
cm save ${project} <type> "<content>"
|
|
2195
|
+
`;
|
|
2196
|
+
|
|
2197
|
+
nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
|
|
2198
|
+
console.log("CLAUDE.md written.");
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// Same instructional purpose as writeClaudeMd — point the agent at
|
|
2202
|
+
// `cm start` rather than embedding a memory snapshot directly, since a
|
|
2203
|
+
// baked-in snapshot goes stale the moment the next commit lands. AGENTS.md
|
|
2204
|
+
// is the convention used by OpenAI Codex CLI, Gemini CLI, and others;
|
|
2205
|
+
// GEMINI.md is Gemini CLI's own equivalent — both get identical content.
|
|
2206
|
+
function writeAgentsMemoryFile(project: string, resolvedPath: string, filename: string): void {
|
|
2207
|
+
const filePath = nodePath.join(resolvedPath, filename);
|
|
2208
|
+
|
|
2209
|
+
if (nodeFs.existsSync(filePath)) {
|
|
2210
|
+
console.log(`${filename} already exists — skipping.`);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
const content = `# ${project} — Agent Memory
|
|
2215
|
+
|
|
2216
|
+
This project uses stackmem for persistent agent memory.
|
|
2217
|
+
Run \`cm start ${project}\` at the start of every session
|
|
2218
|
+
to load relevant context.
|
|
2219
|
+
|
|
2220
|
+
## Key decisions, constraints and discoveries
|
|
2221
|
+
|
|
2222
|
+
Decisions, constraints, discoveries, and rejections live in the stackmem
|
|
2223
|
+
backend, not duplicated in this file — run \`cm start ${project}\` to load
|
|
2224
|
+
the current list.
|
|
2225
|
+
|
|
2226
|
+
When you make a decision, discover a constraint, reject an approach, or
|
|
2227
|
+
make a discovery during this session, save it with:
|
|
2228
|
+
cm save ${project} <type> "<content>"
|
|
2229
|
+
`;
|
|
2230
|
+
|
|
2231
|
+
nodeFs.writeFileSync(filePath, content, "utf-8");
|
|
2232
|
+
console.log(`${filename} written.`);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// --- Claude Code hooks (.claude/settings.json) ------------------------
|
|
2236
|
+
|
|
2237
|
+
interface ClaudeHookHandler {
|
|
2238
|
+
type: string;
|
|
2239
|
+
command: string;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
interface ClaudeHookGroup {
|
|
2243
|
+
matcher?: string;
|
|
2244
|
+
hooks: ClaudeHookHandler[];
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
interface ClaudeSettingsFile {
|
|
2248
|
+
hooks?: Record<string, ClaudeHookGroup[]>;
|
|
2249
|
+
[key: string]: unknown;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// --project (not positional) for the PreCompact command below: cmdContext's
|
|
2253
|
+
// CLI convention takes --project like cm search/cm context already do
|
|
2254
|
+
// everywhere else — cm start and cm session-end both take a positional
|
|
2255
|
+
// project, so those two keep the user's literal command shape.
|
|
2256
|
+
const STACKMEM_HOOK_EVENTS: { event: string; buildCommand: (project: string) => string }[] = [
|
|
2257
|
+
{ event: "SessionStart", buildCommand: (project) => `cm start ${project} --hook` },
|
|
2258
|
+
{ event: "Stop", buildCommand: (project) => `cm session-end ${project} --hook` },
|
|
2259
|
+
{ event: "PreCompact", buildCommand: (project) => `cm context --project ${project} --hook` },
|
|
2260
|
+
];
|
|
1348
2261
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
2262
|
+
// Writes (or merges into) <resolvedPath>/.claude/settings.json. Merges
|
|
2263
|
+
// rather than overwrites — an existing settings.json may have unrelated
|
|
2264
|
+
// hooks or settings that must survive this — and is idempotent: re-running
|
|
2265
|
+
// `cm init` checks for the exact command string already present in an
|
|
2266
|
+
// event's hook groups before adding another one.
|
|
2267
|
+
function writeClaudeCodeHooks(project: string, resolvedPath: string): void {
|
|
2268
|
+
const settingsDir = nodePath.join(resolvedPath, ".claude");
|
|
2269
|
+
const settingsPath = nodePath.join(settingsDir, "settings.json");
|
|
2270
|
+
|
|
2271
|
+
let settings: ClaudeSettingsFile = {};
|
|
2272
|
+
if (nodeFs.existsSync(settingsPath)) {
|
|
2273
|
+
try {
|
|
2274
|
+
settings = JSON.parse(nodeFs.readFileSync(settingsPath, "utf-8"));
|
|
2275
|
+
} catch {
|
|
2276
|
+
settings = {};
|
|
2277
|
+
}
|
|
1352
2278
|
}
|
|
1353
2279
|
|
|
1354
|
-
|
|
2280
|
+
if (!settings.hooks || typeof settings.hooks !== "object") {
|
|
2281
|
+
settings.hooks = {};
|
|
2282
|
+
}
|
|
1355
2283
|
|
|
1356
|
-
|
|
2284
|
+
for (const { event, buildCommand } of STACKMEM_HOOK_EVENTS) {
|
|
2285
|
+
const command = buildCommand(project);
|
|
1357
2286
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
2287
|
+
if (!Array.isArray(settings.hooks[event])) {
|
|
2288
|
+
settings.hooks[event] = [];
|
|
2289
|
+
}
|
|
1361
2290
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
2291
|
+
const alreadyRegistered = settings.hooks[event].some(
|
|
2292
|
+
(group) => Array.isArray(group?.hooks) && group.hooks.some((h) => h?.command === command)
|
|
2293
|
+
);
|
|
1365
2294
|
|
|
1366
|
-
|
|
1367
|
-
|
|
2295
|
+
if (!alreadyRegistered) {
|
|
2296
|
+
settings.hooks[event].push({ hooks: [{ type: "command", command }] });
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
nodeFs.mkdirSync(settingsDir, { recursive: true });
|
|
2301
|
+
nodeFs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
1368
2302
|
}
|
|
1369
2303
|
|
|
1370
2304
|
// Section order for the generated rules-file markdown — matches the order
|
|
@@ -1381,14 +2315,21 @@ async function generateRulesContent(project: string): Promise<string> {
|
|
|
1381
2315
|
.from("memories")
|
|
1382
2316
|
.select("*")
|
|
1383
2317
|
.eq("project", project)
|
|
1384
|
-
.eq("resolved", false)
|
|
2318
|
+
.eq("resolved", false)
|
|
2319
|
+
.eq("suggested", false);
|
|
1385
2320
|
|
|
1386
2321
|
if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
|
|
1387
2322
|
|
|
2323
|
+
const config = readStackmemConfig();
|
|
1388
2324
|
const scoredMemories = (memoryRows ?? [])
|
|
1389
2325
|
.map((memory) => ({
|
|
1390
2326
|
...memory,
|
|
1391
|
-
decay_score: computeDecayScore(
|
|
2327
|
+
decay_score: computeDecayScore(
|
|
2328
|
+
parseUtcDate(memory.created_at),
|
|
2329
|
+
memory.access_count ?? 0,
|
|
2330
|
+
memory.pinned ?? false,
|
|
2331
|
+
config.decayLambda
|
|
2332
|
+
),
|
|
1392
2333
|
}))
|
|
1393
2334
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
1394
2335
|
|
|
@@ -1461,6 +2402,19 @@ function writeCursorRules(resolvedPath: string, content: string): boolean {
|
|
|
1461
2402
|
return true;
|
|
1462
2403
|
}
|
|
1463
2404
|
|
|
2405
|
+
// Cursor's newer rules format (.cursor/rules/*.md) — .cursorrules is
|
|
2406
|
+
// legacy but still read by older Cursor versions, so both are written
|
|
2407
|
+
// during the transition rather than one replacing the other. Unlike
|
|
2408
|
+
// writeCursorRules/writeWindsurfRules (write-once at init, skip if
|
|
2409
|
+
// present), this one unconditionally overwrites on every call — both at
|
|
2410
|
+
// init and on every `cm sync` — since it's gitignored content meant to
|
|
2411
|
+
// always reflect the current memory state, same as the legacy files.
|
|
2412
|
+
function writeCursorRulesMdFile(resolvedPath: string, content: string): void {
|
|
2413
|
+
const rulesDir = nodePath.join(resolvedPath, ".cursor", "rules");
|
|
2414
|
+
nodeFs.mkdirSync(rulesDir, { recursive: true });
|
|
2415
|
+
nodeFs.writeFileSync(nodePath.join(rulesDir, "stackmem.md"), content, "utf-8");
|
|
2416
|
+
}
|
|
2417
|
+
|
|
1464
2418
|
// Returns true if the file was written, false if it already existed
|
|
1465
2419
|
// (and was skipped).
|
|
1466
2420
|
function writeWindsurfRules(resolvedPath: string, content: string): boolean {
|
|
@@ -1507,6 +2461,10 @@ export async function trySeedMemory(
|
|
|
1507
2461
|
type,
|
|
1508
2462
|
content,
|
|
1509
2463
|
device_id: getDeviceId(),
|
|
2464
|
+
// Seeded by `cm init` scanning project files — an explicit user-run
|
|
2465
|
+
// command, not an auto-captured commit, so it's "manual" like cm save,
|
|
2466
|
+
// not the memories.source column's default of "hook".
|
|
2467
|
+
source: "manual",
|
|
1510
2468
|
});
|
|
1511
2469
|
return !error;
|
|
1512
2470
|
}
|
|
@@ -1621,7 +2579,20 @@ async function verifyBackendConnection(project: string): Promise<boolean> {
|
|
|
1621
2579
|
return !error;
|
|
1622
2580
|
}
|
|
1623
2581
|
|
|
1624
|
-
|
|
2582
|
+
// Returns true if the file was created, false if it already existed
|
|
2583
|
+
// (and was left untouched).
|
|
2584
|
+
function writeStackmemConfigFile(resolvedPath: string): boolean {
|
|
2585
|
+
const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
|
|
2586
|
+
|
|
2587
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2588
|
+
return false;
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
|
|
2592
|
+
return true;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
async function cmdInit(project: string, targetPath: string, writeConfig = false): Promise<void> {
|
|
1625
2596
|
const resolvedPath = nodePath.resolve(targetPath);
|
|
1626
2597
|
const gitDir = nodePath.join(resolvedPath, ".git");
|
|
1627
2598
|
|
|
@@ -1641,12 +2612,18 @@ async function cmdInit(project: string, targetPath: string) {
|
|
|
1641
2612
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1642
2613
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1643
2614
|
|
|
1644
|
-
|
|
2615
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
2616
|
+
installHook(hooksDir, hookLogicPath, hookName);
|
|
2617
|
+
}
|
|
1645
2618
|
|
|
1646
2619
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
1647
2620
|
|
|
1648
2621
|
registerMcpServer(DIST_INDEX_PATH);
|
|
2622
|
+
registerEditorMcpServers(DIST_INDEX_PATH);
|
|
1649
2623
|
writeClaudeMd(project, resolvedPath);
|
|
2624
|
+
writeAgentsMemoryFile(project, resolvedPath, "AGENTS.md");
|
|
2625
|
+
writeAgentsMemoryFile(project, resolvedPath, "GEMINI.md");
|
|
2626
|
+
writeClaudeCodeHooks(project, resolvedPath);
|
|
1650
2627
|
|
|
1651
2628
|
const seededCount = await seedMemories(project, resolvedPath);
|
|
1652
2629
|
console.log(`Seeded ${seededCount} initial memories from project scan.`);
|
|
@@ -1654,22 +2631,32 @@ async function cmdInit(project: string, targetPath: string) {
|
|
|
1654
2631
|
const rulesContent = await generateRulesContent(project);
|
|
1655
2632
|
const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
|
|
1656
2633
|
const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
|
|
2634
|
+
writeCursorRulesMdFile(resolvedPath, rulesContent);
|
|
1657
2635
|
|
|
1658
2636
|
const backendOk = await verifyBackendConnection(project);
|
|
1659
2637
|
|
|
2638
|
+
const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
|
|
2639
|
+
|
|
1660
2640
|
console.log("");
|
|
1661
2641
|
if (gitignoreCreated) {
|
|
1662
2642
|
console.log("✓ .gitignore created");
|
|
1663
2643
|
}
|
|
2644
|
+
if (writeConfig) {
|
|
2645
|
+
console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
|
|
2646
|
+
}
|
|
1664
2647
|
console.log("✓ Git hook installed");
|
|
1665
2648
|
console.log("✓ MCP server registered");
|
|
2649
|
+
console.log("✓ Claude Code hooks registered (.claude/settings.json)");
|
|
1666
2650
|
console.log("✓ CLAUDE.md written");
|
|
2651
|
+
console.log("✓ AGENTS.md written");
|
|
2652
|
+
console.log("✓ GEMINI.md written");
|
|
1667
2653
|
console.log(
|
|
1668
2654
|
`✓ .cursorrules ${cursorRulesWritten ? "written" : "already exists"}`
|
|
1669
2655
|
);
|
|
1670
2656
|
console.log(
|
|
1671
2657
|
`✓ .windsurfrules ${windsurfRulesWritten ? "written" : "already exists"}`
|
|
1672
2658
|
);
|
|
2659
|
+
console.log("✓ .cursor/rules/stackmem.md written");
|
|
1673
2660
|
console.log(`✓ Seeded ${seededCount} memories from project scan`);
|
|
1674
2661
|
if (backendOk) {
|
|
1675
2662
|
console.log("✓ Backend connection verified");
|
|
@@ -1695,8 +2682,9 @@ async function cmdSync(project: string) {
|
|
|
1695
2682
|
|
|
1696
2683
|
nodeFs.writeFileSync(nodePath.join(resolvedPath, ".cursorrules"), rulesContent, "utf-8");
|
|
1697
2684
|
nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
|
|
2685
|
+
writeCursorRulesMdFile(resolvedPath, rulesContent);
|
|
1698
2686
|
|
|
1699
|
-
console.log("Synced memories to .cursorrules and .
|
|
2687
|
+
console.log("Synced memories to .cursorrules, .windsurfrules, and .cursor/rules/stackmem.md");
|
|
1700
2688
|
}
|
|
1701
2689
|
|
|
1702
2690
|
// --- cm link ---------------------------------------------------------------
|
|
@@ -1781,45 +2769,178 @@ async function cmdLink() {
|
|
|
1781
2769
|
console.log("Link code expired. Run cm link again.");
|
|
1782
2770
|
}
|
|
1783
2771
|
|
|
2772
|
+
// --- cm open -----------------------------------------------------------
|
|
2773
|
+
|
|
2774
|
+
// Separated from cmdOpen so the platform-selection logic is unit-testable
|
|
2775
|
+
// without actually launching a browser — exercising this for real on
|
|
2776
|
+
// every platform isn't something a test (or a verification pass) should
|
|
2777
|
+
// do as a side effect.
|
|
2778
|
+
export function getOpenCommand(platform: string, url: string): { command: string; args: string[] } {
|
|
2779
|
+
if (platform === "darwin") return { command: "open", args: [url] };
|
|
2780
|
+
if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
|
|
2781
|
+
return { command: "xdg-open", args: [url] };
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
async function cmdOpen(project: string): Promise<void> {
|
|
2785
|
+
const url = `${VAULT_URL}/project/${project}`;
|
|
2786
|
+
const { command, args } = getOpenCommand(process.platform, url);
|
|
2787
|
+
|
|
2788
|
+
try {
|
|
2789
|
+
execFileSync(command, args, { stdio: "ignore" });
|
|
2790
|
+
console.log(`Opened ${url}`);
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
fail(
|
|
2793
|
+
`Failed to open browser (${error instanceof Error ? error.message : String(error)}). Visit ${url} manually.`
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// --- cm purge ------------------------------------------------------------
|
|
2799
|
+
|
|
2800
|
+
async function cmdPurgeProject(project: string): Promise<void> {
|
|
2801
|
+
const { count: total, error: countError } = await supabase
|
|
2802
|
+
.from("memories")
|
|
2803
|
+
.select("id", { count: "exact", head: true })
|
|
2804
|
+
.eq("project", project);
|
|
2805
|
+
if (countError) fail(`Failed to count memories: ${countError.message}`);
|
|
2806
|
+
|
|
2807
|
+
console.log(`This will permanently delete all ${total ?? 0} memories for "${project}".`);
|
|
2808
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2809
|
+
const answer = (await rl.question("Type the project name to confirm: ")).trim();
|
|
2810
|
+
rl.close();
|
|
2811
|
+
|
|
2812
|
+
if (answer !== project) {
|
|
2813
|
+
console.log("Confirmation did not match — aborted. Nothing was deleted.");
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
const { data: memRows, error: memFetchError } = await supabase
|
|
2818
|
+
.from("memories")
|
|
2819
|
+
.select("id")
|
|
2820
|
+
.eq("project", project);
|
|
2821
|
+
if (memFetchError) fail(`Failed to load memories: ${memFetchError.message}`);
|
|
2822
|
+
|
|
2823
|
+
const ids = (memRows ?? []).map((m) => m.id);
|
|
2824
|
+
let linksDeleted = 0;
|
|
2825
|
+
if (ids.length > 0) {
|
|
2826
|
+
const idList = ids.join(",");
|
|
2827
|
+
const { count, error: linksError } = await supabase
|
|
2828
|
+
.from("memory_links")
|
|
2829
|
+
.delete({ count: "exact" })
|
|
2830
|
+
.or(`source_id.in.(${idList}),target_id.in.(${idList})`);
|
|
2831
|
+
if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
|
|
2832
|
+
linksDeleted = count ?? 0;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
const { count: memoriesDeleted, error: memError } = await supabase
|
|
2836
|
+
.from("memories")
|
|
2837
|
+
.delete({ count: "exact" })
|
|
2838
|
+
.eq("project", project);
|
|
2839
|
+
if (memError) fail(`Failed to delete memories: ${memError.message}`);
|
|
2840
|
+
|
|
2841
|
+
const { count: fixesDeleted, error: fixError } = await supabase
|
|
2842
|
+
.from("execution_log")
|
|
2843
|
+
.delete({ count: "exact" })
|
|
2844
|
+
.eq("project", project);
|
|
2845
|
+
if (fixError) fail(`Failed to delete fixes: ${fixError.message}`);
|
|
2846
|
+
|
|
2847
|
+
console.log(
|
|
2848
|
+
`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted} links for "${project}".`
|
|
2849
|
+
);
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// RLS already scopes every table read/write to this device's device_id
|
|
2853
|
+
// via the x-device-id header (see src/storage.ts) — a filter-less delete
|
|
2854
|
+
// here is already confined to this device, never another one. PostgREST
|
|
2855
|
+
// still requires an explicit filter clause to allow a DELETE at all, so
|
|
2856
|
+
// `.not("id", "is", null)` (every row has a non-null id) is used purely
|
|
2857
|
+
// to satisfy that, not to narrow the scope further.
|
|
2858
|
+
async function cmdPurgeAll(): Promise<void> {
|
|
2859
|
+
console.log("This will permanently delete ALL memories across ALL projects for this device.");
|
|
2860
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2861
|
+
const answer = (await rl.question("Type DELETE to confirm: ")).trim();
|
|
2862
|
+
rl.close();
|
|
2863
|
+
|
|
2864
|
+
if (answer !== "DELETE") {
|
|
2865
|
+
console.log("Confirmation did not match — aborted. Nothing was deleted.");
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
const { count: linksDeleted, error: linksError } = await supabase
|
|
2870
|
+
.from("memory_links")
|
|
2871
|
+
.delete({ count: "exact" })
|
|
2872
|
+
.not("id", "is", null);
|
|
2873
|
+
if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
|
|
2874
|
+
|
|
2875
|
+
const { count: memoriesDeleted, error: memError } = await supabase
|
|
2876
|
+
.from("memories")
|
|
2877
|
+
.delete({ count: "exact" })
|
|
2878
|
+
.not("id", "is", null);
|
|
2879
|
+
if (memError) fail(`Failed to delete memories: ${memError.message}`);
|
|
2880
|
+
|
|
2881
|
+
const { count: fixesDeleted, error: fixError } = await supabase
|
|
2882
|
+
.from("execution_log")
|
|
2883
|
+
.delete({ count: "exact" })
|
|
2884
|
+
.not("id", "is", null);
|
|
2885
|
+
if (fixError) fail(`Failed to delete fixes: ${fixError.message}`);
|
|
2886
|
+
|
|
2887
|
+
console.log(
|
|
2888
|
+
`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted ?? 0} links across all projects.`
|
|
2889
|
+
);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
1784
2892
|
// --- cm context ------------------------------------------------------------
|
|
1785
2893
|
|
|
1786
2894
|
const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
|
|
1787
2895
|
const CONTEXT_TOP_N = 5;
|
|
1788
2896
|
|
|
1789
|
-
|
|
2897
|
+
// task is null for the PreCompact hook — there's no user-typed task at a
|
|
2898
|
+
// compaction boundary, just "save the most relevant context" generically
|
|
2899
|
+
// — so ranking falls back to decay score alone instead of diluting it
|
|
2900
|
+
// with a relevance score computed against an empty string.
|
|
2901
|
+
async function cmdContext(project: string, task: string | null, hookMode = false) {
|
|
1790
2902
|
const { data, error } = await supabase
|
|
1791
2903
|
.from("memories")
|
|
1792
2904
|
.select("*")
|
|
1793
2905
|
.eq("project", project)
|
|
1794
|
-
.eq("resolved", false)
|
|
2906
|
+
.eq("resolved", false)
|
|
2907
|
+
.eq("suggested", false);
|
|
1795
2908
|
|
|
1796
2909
|
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
1797
2910
|
|
|
1798
2911
|
const memories = data ?? [];
|
|
1799
2912
|
if (memories.length === 0) {
|
|
1800
|
-
console.log(`No memories found for "${project}".`);
|
|
2913
|
+
if (!hookMode) console.log(`No memories found for "${project}".`);
|
|
1801
2914
|
return;
|
|
1802
2915
|
}
|
|
1803
2916
|
|
|
2917
|
+
const config = readStackmemConfig();
|
|
2918
|
+
const tokenBudget = config.tokenBudget;
|
|
2919
|
+
|
|
1804
2920
|
const scoredMemories = memories
|
|
1805
2921
|
.map((memory) => {
|
|
1806
|
-
const decayScore = computeDecayScore(
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
2922
|
+
const decayScore = computeDecayScore(
|
|
2923
|
+
parseUtcDate(memory.created_at),
|
|
2924
|
+
memory.access_count ?? 0,
|
|
2925
|
+
memory.pinned ?? false,
|
|
2926
|
+
config.decayLambda
|
|
2927
|
+
);
|
|
2928
|
+
const combinedScore =
|
|
2929
|
+
task == null ? decayScore : scoreMemoryRelevance(memory.content, task) * 0.6 + decayScore * 0.4;
|
|
2930
|
+
return { ...memory, combined_score: combinedScore };
|
|
1812
2931
|
})
|
|
1813
2932
|
.sort((a, b) => b.combined_score - a.combined_score);
|
|
1814
2933
|
|
|
1815
2934
|
const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
|
|
1816
|
-
const budgetedMemories = applyContextBudget(topMemories,
|
|
2935
|
+
const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
|
|
1817
2936
|
const tokensUsed = budgetedMemories.reduce(
|
|
1818
2937
|
(sum, memory) => sum + estimateTokens(memory.content),
|
|
1819
2938
|
0
|
|
1820
2939
|
);
|
|
1821
2940
|
|
|
1822
|
-
const lines: string[] = [`## Project Context: ${project}
|
|
2941
|
+
const lines: string[] = [`## Project Context: ${project}`];
|
|
2942
|
+
if (task != null) lines.push(`### Task: ${task}`);
|
|
2943
|
+
lines.push("");
|
|
1823
2944
|
|
|
1824
2945
|
for (const type of CONTEXT_TYPE_ORDER) {
|
|
1825
2946
|
const group = budgetedMemories.filter((memory) => memory.type === type);
|
|
@@ -1832,11 +2953,83 @@ async function cmdContext(project: string, task: string) {
|
|
|
1832
2953
|
lines.push("");
|
|
1833
2954
|
}
|
|
1834
2955
|
|
|
1835
|
-
lines.push(`*(${tokensUsed}/${
|
|
2956
|
+
if (!hookMode) lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
|
|
1836
2957
|
|
|
1837
2958
|
console.log(lines.join("\n"));
|
|
1838
2959
|
}
|
|
1839
2960
|
|
|
2961
|
+
// --- cm why --------------------------------------------------------------
|
|
2962
|
+
|
|
2963
|
+
function fileMatchesPaths(filePaths: unknown, file: string, baseName: string): boolean {
|
|
2964
|
+
if (!Array.isArray(filePaths)) return false;
|
|
2965
|
+
return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
function fileMentionedInContent(content: string, file: string, baseName: string): boolean {
|
|
2969
|
+
const lower = content.toLowerCase();
|
|
2970
|
+
return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
async function cmdWhy(project: string, file: string) {
|
|
2974
|
+
const baseName = file.split("/").pop() ?? file;
|
|
2975
|
+
|
|
2976
|
+
const { data, error } = await supabase
|
|
2977
|
+
.from("memories")
|
|
2978
|
+
.select("*")
|
|
2979
|
+
.eq("project", project)
|
|
2980
|
+
.eq("resolved", false)
|
|
2981
|
+
.eq("suggested", false);
|
|
2982
|
+
|
|
2983
|
+
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
2984
|
+
|
|
2985
|
+
const memories = data ?? [];
|
|
2986
|
+
|
|
2987
|
+
// file_paths (structured, from Item 6) and a content substring search
|
|
2988
|
+
// are combined into one match set rather than one gating the other as
|
|
2989
|
+
// a strict fallback — most memories predate file_paths existing at all
|
|
2990
|
+
// (manual saves and seeds never set it), so content search is the
|
|
2991
|
+
// primary signal for everything except recent hook-captured commits,
|
|
2992
|
+
// not a rare last resort.
|
|
2993
|
+
const matched = memories.filter(
|
|
2994
|
+
(memory) =>
|
|
2995
|
+
fileMatchesPaths(memory.file_paths, file, baseName) ||
|
|
2996
|
+
fileMentionedInContent(memory.content, file, baseName)
|
|
2997
|
+
);
|
|
2998
|
+
|
|
2999
|
+
if (matched.length === 0) {
|
|
3000
|
+
console.log(`No memories found for ${file}.`);
|
|
3001
|
+
console.log("Make some commits touching this file to build up context.");
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
const config = readStackmemConfig();
|
|
3006
|
+
const scoredMemories = matched
|
|
3007
|
+
.map((memory) => ({
|
|
3008
|
+
...memory,
|
|
3009
|
+
decay_score: computeDecayScore(
|
|
3010
|
+
parseUtcDate(memory.created_at),
|
|
3011
|
+
memory.access_count ?? 0,
|
|
3012
|
+
memory.pinned ?? false,
|
|
3013
|
+
config.decayLambda
|
|
3014
|
+
),
|
|
3015
|
+
}))
|
|
3016
|
+
.sort((a, b) => b.decay_score - a.decay_score);
|
|
3017
|
+
|
|
3018
|
+
console.log(`Decisions and constraints tied to ${file}:\n`);
|
|
3019
|
+
|
|
3020
|
+
for (const type of MEMORY_TYPES) {
|
|
3021
|
+
const group = scoredMemories.filter((memory) => memory.type === type);
|
|
3022
|
+
if (group.length === 0) continue;
|
|
3023
|
+
|
|
3024
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
3025
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
3026
|
+
for (const memory of group) {
|
|
3027
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
3028
|
+
}
|
|
3029
|
+
console.log("");
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
|
|
1840
3033
|
// --- cm session-end --------------------------------------------------------
|
|
1841
3034
|
|
|
1842
3035
|
interface SessionEndInput {
|
|
@@ -1885,6 +3078,10 @@ function parseSessionEndFlags(args: string[]): SessionEndInput {
|
|
|
1885
3078
|
} else if (flag === "--approaches") {
|
|
1886
3079
|
approaches = splitCommaList(value);
|
|
1887
3080
|
i++;
|
|
3081
|
+
} else if (flag === "--hook") {
|
|
3082
|
+
// Recognized explicitly (not just silently skipped as an unknown
|
|
3083
|
+
// flag) — see the comment on cmdSessionEnd for why it's currently
|
|
3084
|
+
// a no-op here specifically.
|
|
1888
3085
|
}
|
|
1889
3086
|
}
|
|
1890
3087
|
|
|
@@ -1907,6 +3104,16 @@ async function readStdinJson(): Promise<Record<string, unknown> | null> {
|
|
|
1907
3104
|
}
|
|
1908
3105
|
}
|
|
1909
3106
|
|
|
3107
|
+
// --hook is accepted (see parseSessionEndFlags) but currently a no-op:
|
|
3108
|
+
// Claude Code's Stop hook pipes ITS OWN JSON payload on stdin (session
|
|
3109
|
+
// id, transcript path, cwd — see the Claude Code hooks docs), not the
|
|
3110
|
+
// {summary, errors_encountered, files_touched, approaches_tried} shape
|
|
3111
|
+
// this command expects. readStdinJson() parses that payload successfully
|
|
3112
|
+
// (it's valid JSON) but finds none of the fields it looks for, so every
|
|
3113
|
+
// array below comes back empty and this reports 0 memories saved — a
|
|
3114
|
+
// real, known limitation, not a bug. Extracting genuine learnings from
|
|
3115
|
+
// the Stop hook would mean parsing the session transcript itself, which
|
|
3116
|
+
// is out of scope here (Phase 2, per the user).
|
|
1910
3117
|
async function cmdSessionEnd(project: string, input: Omit<SessionEndInput, "project">) {
|
|
1911
3118
|
let memoriesSaved = 0;
|
|
1912
3119
|
let errorsLogged = 0;
|
|
@@ -2020,6 +3227,136 @@ function isHookExecutable(hookPath: string): boolean {
|
|
|
2020
3227
|
}
|
|
2021
3228
|
}
|
|
2022
3229
|
|
|
3230
|
+
// --- cm status ---------------------------------------------------------
|
|
3231
|
+
|
|
3232
|
+
function hasMcpServersStackmemKey(configPath: string): boolean {
|
|
3233
|
+
if (!nodeFs.existsSync(configPath)) return false;
|
|
3234
|
+
try {
|
|
3235
|
+
const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
3236
|
+
return Boolean(config?.mcpServers?.stackmem);
|
|
3237
|
+
} catch {
|
|
3238
|
+
return false;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
function hasContextServersStackmemKey(configPath: string): boolean {
|
|
3243
|
+
if (!nodeFs.existsSync(configPath)) return false;
|
|
3244
|
+
try {
|
|
3245
|
+
const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
3246
|
+
return Boolean(config?.context_servers?.stackmem);
|
|
3247
|
+
} catch {
|
|
3248
|
+
return false;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const EDITOR_STATUS_CHECKS: { name: string; isRegistered: (homeDir: string) => boolean }[] = [
|
|
3253
|
+
{ name: "Claude Code", isRegistered: (homeDir) => isMcpRegistered(nodePath.join(homeDir, ".claude.json")) },
|
|
3254
|
+
{ name: "Cursor", isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".cursor", "mcp.json")) },
|
|
3255
|
+
{
|
|
3256
|
+
name: "Windsurf",
|
|
3257
|
+
isRegistered: (homeDir) =>
|
|
3258
|
+
hasMcpServersStackmemKey(nodePath.join(homeDir, ".codeium", "windsurf", "mcp_config.json")),
|
|
3259
|
+
},
|
|
3260
|
+
{
|
|
3261
|
+
name: "VS Code",
|
|
3262
|
+
isRegistered: (homeDir) => {
|
|
3263
|
+
const dir = findCopilotChatExtensionDir(homeDir);
|
|
3264
|
+
return dir ? hasMcpServersStackmemKey(nodePath.join(dir, "mcp.json")) : false;
|
|
3265
|
+
},
|
|
3266
|
+
},
|
|
3267
|
+
{
|
|
3268
|
+
name: "Zed",
|
|
3269
|
+
isRegistered: (homeDir) => hasContextServersStackmemKey(nodePath.join(homeDir, ".config", "zed", "settings.json")),
|
|
3270
|
+
},
|
|
3271
|
+
];
|
|
3272
|
+
|
|
3273
|
+
async function cmdStatus(project: string): Promise<void> {
|
|
3274
|
+
const { count: totalCount, error: totalError } = await supabase
|
|
3275
|
+
.from("memories")
|
|
3276
|
+
.select("id", { count: "exact", head: true })
|
|
3277
|
+
.eq("project", project)
|
|
3278
|
+
.eq("resolved", false);
|
|
3279
|
+
if (totalError) fail(`Failed to load memory count: ${totalError.message}`);
|
|
3280
|
+
|
|
3281
|
+
const { count: pinnedCount, error: pinnedError } = await supabase
|
|
3282
|
+
.from("memories")
|
|
3283
|
+
.select("id", { count: "exact", head: true })
|
|
3284
|
+
.eq("project", project)
|
|
3285
|
+
.eq("resolved", false)
|
|
3286
|
+
.eq("pinned", true);
|
|
3287
|
+
if (pinnedError) fail(`Failed to load pinned count: ${pinnedError.message}`);
|
|
3288
|
+
|
|
3289
|
+
const { count: suggestedCount, error: suggestedError } = await supabase
|
|
3290
|
+
.from("memories")
|
|
3291
|
+
.select("id", { count: "exact", head: true })
|
|
3292
|
+
.eq("project", project)
|
|
3293
|
+
.eq("resolved", false)
|
|
3294
|
+
.eq("suggested", true);
|
|
3295
|
+
if (suggestedError) fail(`Failed to load suggested count: ${suggestedError.message}`);
|
|
3296
|
+
|
|
3297
|
+
const { count: fixesCount, error: fixesError } = await supabase
|
|
3298
|
+
.from("execution_log")
|
|
3299
|
+
.select("id", { count: "exact", head: true })
|
|
3300
|
+
.eq("project", project)
|
|
3301
|
+
.eq("resolved", false);
|
|
3302
|
+
if (fixesError) fail(`Failed to load fixes count: ${fixesError.message}`);
|
|
3303
|
+
|
|
3304
|
+
const { data: lastCaptureRows, error: lastCaptureError } = await supabase
|
|
3305
|
+
.from("memories")
|
|
3306
|
+
.select("content, created_at")
|
|
3307
|
+
.eq("project", project)
|
|
3308
|
+
.order("created_at", { ascending: false })
|
|
3309
|
+
.limit(1);
|
|
3310
|
+
if (lastCaptureError) fail(`Failed to load last capture: ${lastCaptureError.message}`);
|
|
3311
|
+
|
|
3312
|
+
const config = readStackmemConfig();
|
|
3313
|
+
const configPath = nodePath.join(process.cwd(), STACKMEM_CONFIG_FILENAME);
|
|
3314
|
+
let usesCustomBudget = false;
|
|
3315
|
+
if (nodeFs.existsSync(configPath)) {
|
|
3316
|
+
try {
|
|
3317
|
+
const raw = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
3318
|
+
usesCustomBudget = typeof raw.tokenBudget === "number";
|
|
3319
|
+
} catch {
|
|
3320
|
+
// Malformed config — readStackmemConfig already falls back to the
|
|
3321
|
+
// default in this case too, so "default" is the accurate label.
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
const hookOk = isHookExecutable(nodePath.join(process.cwd(), ".git", "hooks", "post-commit"));
|
|
3326
|
+
const mcpOk = isMcpRegistered(getClaudeConfigPath());
|
|
3327
|
+
const backendOk = await verifyBackendConnection(project);
|
|
3328
|
+
|
|
3329
|
+
const homeDir = nodeOs.homedir();
|
|
3330
|
+
const registeredEditors = EDITOR_STATUS_CHECKS.filter((e) => e.isRegistered(homeDir)).map((e) => e.name);
|
|
3331
|
+
|
|
3332
|
+
console.log(`stackmem status for "${project}"`);
|
|
3333
|
+
console.log("━".repeat(27));
|
|
3334
|
+
console.log(
|
|
3335
|
+
`Memories: ${totalCount ?? 0} total (${pinnedCount ?? 0} pinned, ${suggestedCount ?? 0} suggested)`
|
|
3336
|
+
);
|
|
3337
|
+
console.log(`Fixes: ${fixesCount ?? 0} recorded`);
|
|
3338
|
+
console.log(`Token budget: ${config.tokenBudget} (${usesCustomBudget ? "from .stackmem.json" : "default"})`);
|
|
3339
|
+
|
|
3340
|
+
const lastCapture = (lastCaptureRows ?? [])[0];
|
|
3341
|
+
if (lastCapture) {
|
|
3342
|
+
const ago = formatRelativeTime(parseUtcDate(lastCapture.created_at));
|
|
3343
|
+
const snippet =
|
|
3344
|
+
lastCapture.content.length > 50 ? `${lastCapture.content.slice(0, 50)}…` : lastCapture.content;
|
|
3345
|
+
console.log(`Last capture: ${ago} (${snippet})`);
|
|
3346
|
+
} else {
|
|
3347
|
+
console.log("Last capture: none yet");
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
console.log(`Hook: ${hookOk ? "✓ installed" : "✗ not installed"}`);
|
|
3351
|
+
console.log(`MCP: ${mcpOk ? "✓ registered" : "✗ not registered"}`);
|
|
3352
|
+
console.log(`Backend: ${backendOk ? "✓ connected" : "✗ not connected"}`);
|
|
3353
|
+
console.log(
|
|
3354
|
+
`Editors: ${
|
|
3355
|
+
registeredEditors.length > 0 ? registeredEditors.map((name) => `${name} ✓`).join(" ") : "(none registered)"
|
|
3356
|
+
}`
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
3359
|
+
|
|
2023
3360
|
async function cmdDoctor(fix: boolean) {
|
|
2024
3361
|
let issues = 0;
|
|
2025
3362
|
const project = readProjectFromFile();
|
|
@@ -2087,7 +3424,9 @@ async function cmdDoctor(fix: boolean) {
|
|
|
2087
3424
|
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
2088
3425
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
2089
3426
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
2090
|
-
|
|
3427
|
+
for (const name of MANAGED_HOOK_NAMES) {
|
|
3428
|
+
installHook(hooksDir, hookLogicPath, name);
|
|
3429
|
+
}
|
|
2091
3430
|
|
|
2092
3431
|
if (isHookExecutable(hookPath)) {
|
|
2093
3432
|
console.log("Fixed: git hook");
|
|
@@ -2284,8 +3623,10 @@ async function cmdUninstall(project: string, targetPath: string) {
|
|
|
2284
3623
|
const gitDir = nodePath.join(targetPath, ".git");
|
|
2285
3624
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
2286
3625
|
|
|
2287
|
-
const
|
|
2288
|
-
|
|
3626
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
3627
|
+
const hookResult = uninstallHook(hooksDir, hookName);
|
|
3628
|
+
if (hookResult) console.log(`Removed ${hookResult}`);
|
|
3629
|
+
}
|
|
2289
3630
|
|
|
2290
3631
|
const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
|
|
2291
3632
|
|
|
@@ -2330,19 +3671,34 @@ async function main() {
|
|
|
2330
3671
|
|
|
2331
3672
|
switch (command) {
|
|
2332
3673
|
case "start": {
|
|
2333
|
-
const
|
|
3674
|
+
const hookIndex = args.indexOf("--hook");
|
|
3675
|
+
const hookMode = hookIndex !== -1;
|
|
3676
|
+
const positional = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
|
|
3677
|
+
|
|
3678
|
+
const split = splitOptionalProject(positional, 0);
|
|
2334
3679
|
if (!split) usage();
|
|
2335
3680
|
const project = requireProject(split.project);
|
|
2336
|
-
await cmdStart(project);
|
|
3681
|
+
await cmdStart(project, hookMode);
|
|
2337
3682
|
break;
|
|
2338
3683
|
}
|
|
2339
3684
|
case "save": {
|
|
2340
3685
|
const forceIndex = args.indexOf("--force");
|
|
2341
3686
|
const force = forceIndex !== -1;
|
|
2342
|
-
|
|
3687
|
+
let positional = force
|
|
2343
3688
|
? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
|
|
2344
3689
|
: args;
|
|
2345
3690
|
|
|
3691
|
+
const commitShaFlag = extractValueFlag(positional, "--commit-sha");
|
|
3692
|
+
positional = commitShaFlag.rest;
|
|
3693
|
+
const authorFlag = extractValueFlag(positional, "--author");
|
|
3694
|
+
positional = authorFlag.rest;
|
|
3695
|
+
const sourceFlag = extractValueFlag(positional, "--source");
|
|
3696
|
+
positional = sourceFlag.rest;
|
|
3697
|
+
const confidenceFlag = extractValueFlag(positional, "--confidence");
|
|
3698
|
+
positional = confidenceFlag.rest;
|
|
3699
|
+
const filesFlag = extractValueFlag(positional, "--files");
|
|
3700
|
+
positional = filesFlag.rest;
|
|
3701
|
+
|
|
2346
3702
|
let explicitProject: string | null;
|
|
2347
3703
|
let type: string | undefined;
|
|
2348
3704
|
let rest: string[];
|
|
@@ -2367,7 +3723,28 @@ async function main() {
|
|
|
2367
3723
|
|
|
2368
3724
|
if (!type || rest.length === 0) usage();
|
|
2369
3725
|
const project = requireProject(explicitProject);
|
|
2370
|
-
|
|
3726
|
+
|
|
3727
|
+
const provenance: SaveProvenance = {};
|
|
3728
|
+
if (commitShaFlag.value) provenance.commitSha = commitShaFlag.value;
|
|
3729
|
+
if (authorFlag.value) provenance.author = authorFlag.value;
|
|
3730
|
+
if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
|
|
3731
|
+
provenance.source = sourceFlag.value;
|
|
3732
|
+
}
|
|
3733
|
+
if (confidenceFlag.value) {
|
|
3734
|
+
const parsed = Number(confidenceFlag.value);
|
|
3735
|
+
if (!Number.isNaN(parsed)) provenance.confidence = parsed;
|
|
3736
|
+
}
|
|
3737
|
+
if (filesFlag.value) {
|
|
3738
|
+
provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
await cmdSave(project, type, rest.join(" "), force, provenance);
|
|
3742
|
+
break;
|
|
3743
|
+
}
|
|
3744
|
+
case "check-commit": {
|
|
3745
|
+
const [project, commitSha] = args;
|
|
3746
|
+
if (!project || !commitSha) usage();
|
|
3747
|
+
await cmdCheckCommit(project, commitSha);
|
|
2371
3748
|
break;
|
|
2372
3749
|
}
|
|
2373
3750
|
case "fix": {
|
|
@@ -2401,6 +3778,37 @@ async function main() {
|
|
|
2401
3778
|
await cmdResolve(project, memoryId);
|
|
2402
3779
|
break;
|
|
2403
3780
|
}
|
|
3781
|
+
case "pin": {
|
|
3782
|
+
const split = splitOptionalProject(args, 1);
|
|
3783
|
+
if (!split) usage();
|
|
3784
|
+
const project = requireProject(split.project);
|
|
3785
|
+
const [memoryId] = split.rest;
|
|
3786
|
+
await cmdPin(project, memoryId);
|
|
3787
|
+
break;
|
|
3788
|
+
}
|
|
3789
|
+
case "unpin": {
|
|
3790
|
+
const split = splitOptionalProject(args, 1);
|
|
3791
|
+
if (!split) usage();
|
|
3792
|
+
const project = requireProject(split.project);
|
|
3793
|
+
const [memoryId] = split.rest;
|
|
3794
|
+
await cmdUnpin(project, memoryId);
|
|
3795
|
+
break;
|
|
3796
|
+
}
|
|
3797
|
+
case "review": {
|
|
3798
|
+
const split = splitProjectFromVariadic(args);
|
|
3799
|
+
if (split.rest.length > 0) usage();
|
|
3800
|
+
const project = requireProject(split.project);
|
|
3801
|
+
await cmdReview(project);
|
|
3802
|
+
break;
|
|
3803
|
+
}
|
|
3804
|
+
case "edit": {
|
|
3805
|
+
const split = splitOptionalProject(args, 1);
|
|
3806
|
+
if (!split) usage();
|
|
3807
|
+
const project = requireProject(split.project);
|
|
3808
|
+
const [memoryId] = split.rest;
|
|
3809
|
+
await cmdEdit(project, memoryId);
|
|
3810
|
+
break;
|
|
3811
|
+
}
|
|
2404
3812
|
case "delete": {
|
|
2405
3813
|
const split = splitOptionalProject(args, 1);
|
|
2406
3814
|
if (!split) usage();
|
|
@@ -2425,9 +3833,15 @@ async function main() {
|
|
|
2425
3833
|
break;
|
|
2426
3834
|
}
|
|
2427
3835
|
case "init": {
|
|
2428
|
-
const
|
|
3836
|
+
const configIndex = args.indexOf("--config");
|
|
3837
|
+
const writeConfig = configIndex !== -1;
|
|
3838
|
+
const positional = writeConfig
|
|
3839
|
+
? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
|
|
3840
|
+
: args;
|
|
3841
|
+
|
|
3842
|
+
const [project, targetPath] = positional;
|
|
2429
3843
|
if (!project || !targetPath) usage();
|
|
2430
|
-
await cmdInit(project, targetPath);
|
|
3844
|
+
await cmdInit(project, targetPath, writeConfig);
|
|
2431
3845
|
break;
|
|
2432
3846
|
}
|
|
2433
3847
|
case "link": {
|
|
@@ -2435,6 +3849,29 @@ async function main() {
|
|
|
2435
3849
|
await cmdLink();
|
|
2436
3850
|
break;
|
|
2437
3851
|
}
|
|
3852
|
+
case "open": {
|
|
3853
|
+
const split = splitProjectFromVariadic(args);
|
|
3854
|
+
if (split.rest.length > 0) usage();
|
|
3855
|
+
const project = requireProject(split.project);
|
|
3856
|
+
await cmdOpen(project);
|
|
3857
|
+
break;
|
|
3858
|
+
}
|
|
3859
|
+
case "purge": {
|
|
3860
|
+
const allIndex = args.indexOf("--all");
|
|
3861
|
+
const isAll = allIndex !== -1;
|
|
3862
|
+
const withoutAllFlag = isAll ? [...args.slice(0, allIndex), ...args.slice(allIndex + 1)] : args;
|
|
3863
|
+
|
|
3864
|
+
const split = splitProjectFromVariadic(withoutAllFlag);
|
|
3865
|
+
if (split.rest.length > 0) usage();
|
|
3866
|
+
|
|
3867
|
+
if (isAll) {
|
|
3868
|
+
await cmdPurgeAll();
|
|
3869
|
+
} else {
|
|
3870
|
+
const project = requireProject(split.project);
|
|
3871
|
+
await cmdPurgeProject(project);
|
|
3872
|
+
}
|
|
3873
|
+
break;
|
|
3874
|
+
}
|
|
2438
3875
|
case "sync": {
|
|
2439
3876
|
if (args.length > 0) usage();
|
|
2440
3877
|
const project = requireProject(null);
|
|
@@ -2442,10 +3879,24 @@ async function main() {
|
|
|
2442
3879
|
break;
|
|
2443
3880
|
}
|
|
2444
3881
|
case "context": {
|
|
3882
|
+
const hookIndex = args.indexOf("--hook");
|
|
3883
|
+
const hookMode = hookIndex !== -1;
|
|
3884
|
+
const withoutHookFlag = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
|
|
3885
|
+
|
|
3886
|
+
const split = splitProjectFromVariadic(withoutHookFlag);
|
|
3887
|
+
const project = requireProject(split.project);
|
|
3888
|
+
// task is optional only in --hook mode (the PreCompact hook has no
|
|
3889
|
+
// user-typed task to pass) — interactive use still requires one.
|
|
3890
|
+
if (split.rest.length === 0 && !hookMode) usage();
|
|
3891
|
+
const task = split.rest.length > 0 ? split.rest.join(" ") : null;
|
|
3892
|
+
await cmdContext(project, task, hookMode);
|
|
3893
|
+
break;
|
|
3894
|
+
}
|
|
3895
|
+
case "why": {
|
|
2445
3896
|
const split = splitProjectFromVariadic(args);
|
|
2446
3897
|
const project = requireProject(split.project);
|
|
2447
3898
|
if (split.rest.length === 0) usage();
|
|
2448
|
-
await
|
|
3899
|
+
await cmdWhy(project, split.rest.join(" "));
|
|
2449
3900
|
break;
|
|
2450
3901
|
}
|
|
2451
3902
|
case "session-end": {
|
|
@@ -2457,6 +3908,13 @@ async function main() {
|
|
|
2457
3908
|
await cmdDoctor(fix);
|
|
2458
3909
|
break;
|
|
2459
3910
|
}
|
|
3911
|
+
case "status": {
|
|
3912
|
+
const split = splitProjectFromVariadic(args);
|
|
3913
|
+
if (split.rest.length > 0) usage();
|
|
3914
|
+
const project = requireProject(split.project);
|
|
3915
|
+
await cmdStatus(project);
|
|
3916
|
+
break;
|
|
3917
|
+
}
|
|
2460
3918
|
case "undo": {
|
|
2461
3919
|
const split = splitOptionalProject(args, 0);
|
|
2462
3920
|
if (!split) usage();
|