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/dist/cli.js
CHANGED
|
@@ -2,14 +2,25 @@
|
|
|
2
2
|
import nodeFs from "node:fs";
|
|
3
3
|
import nodeOs from "node:os";
|
|
4
4
|
import nodePath from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
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
|
-
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, scoreMemoryRelevance, } from "./scoring.js";
|
|
13
|
+
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, hasTokenOverlap, isQualityMessage, parseUtcDate, redactSecrets, scoreMemoryRelevance, tokenize, } from "./scoring.js";
|
|
12
14
|
import { supabase } from "./storage.js";
|
|
15
|
+
// Checked as early as ESM import hoisting allows — before any command
|
|
16
|
+
// dispatch, config loading, or network call — so an unsupported Node
|
|
17
|
+
// version fails with a clear message instead of an obscure syntax or
|
|
18
|
+
// API error from deeper in the dependency chain.
|
|
19
|
+
const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
|
|
20
|
+
if (NODE_MAJOR_VERSION < 18) {
|
|
21
|
+
console.error(`Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
13
24
|
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
14
25
|
const COMPRESSION_THRESHOLD = 20;
|
|
15
26
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
@@ -24,7 +35,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
|
|
|
24
35
|
const STACKMEM_HOOK_MARKER = "# stackmem-managed";
|
|
25
36
|
const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
|
|
26
37
|
const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
|
|
27
|
-
const
|
|
38
|
+
const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
|
|
28
39
|
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
|
|
29
40
|
const TYPE_LABELS = {
|
|
30
41
|
decision: "Decisions",
|
|
@@ -34,22 +45,30 @@ const TYPE_LABELS = {
|
|
|
34
45
|
};
|
|
35
46
|
function usage() {
|
|
36
47
|
console.error(`Usage:
|
|
37
|
-
cm start [project]
|
|
48
|
+
cm start [project] [--hook]
|
|
38
49
|
cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
|
|
39
50
|
cm fix [project] <problem> <solution>
|
|
40
51
|
cm analyze [project] <path>
|
|
41
52
|
cm search [--project <name>] <query>
|
|
42
53
|
cm resolve [project] <memory-id>
|
|
54
|
+
cm pin [project] <memory-id>
|
|
55
|
+
cm unpin [project] <memory-id>
|
|
56
|
+
cm review [--project <name>]
|
|
57
|
+
cm edit [project] <memory-id>
|
|
43
58
|
cm delete [project] <memory-id>
|
|
44
59
|
cm inspect [project] <memory-id>
|
|
45
60
|
cm compress [project]
|
|
46
|
-
cm init <project> <path>
|
|
61
|
+
cm init <project> <path> [--config]
|
|
47
62
|
cm uninstall [project] [path]
|
|
48
63
|
cm link
|
|
64
|
+
cm open [--project <name>]
|
|
65
|
+
cm purge [--project <name>] [--all]
|
|
49
66
|
cm sync
|
|
50
|
-
cm context [--project <name>] <task>
|
|
51
|
-
cm
|
|
67
|
+
cm context [--project <name>] <task> [--hook]
|
|
68
|
+
cm why [--project <name>] <file>
|
|
69
|
+
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
|
|
52
70
|
cm doctor [--fix]
|
|
71
|
+
cm status [--project <name>]
|
|
53
72
|
cm undo [project]
|
|
54
73
|
cm export [filename]
|
|
55
74
|
cm import [project] <filename>
|
|
@@ -70,11 +89,13 @@ function cmdHelp() {
|
|
|
70
89
|
[project] is optional everywhere below if a .stackmem file exists in
|
|
71
90
|
the current directory (written by 'cm init'). Otherwise pass it explicitly.
|
|
72
91
|
|
|
73
|
-
cm start [project]
|
|
92
|
+
cm start [project] [--hook]
|
|
74
93
|
Load all unresolved memories for a project, sorted by decay
|
|
75
94
|
score and capped at a 2000 token context budget, grouped by
|
|
76
95
|
type (decisions, constraints, discoveries, rejections), with
|
|
77
|
-
linked memory ids shown under each entry.
|
|
96
|
+
linked memory ids shown under each entry. --hook switches to
|
|
97
|
+
plain-text output (no ids, scores, or separator lines) for use
|
|
98
|
+
as a Claude Code SessionStart hook command — see cm init.
|
|
78
99
|
|
|
79
100
|
cm save [project] <type> <content> [--force]
|
|
80
101
|
Save a new memory. <type> is one of: decision, rejection,
|
|
@@ -98,6 +119,26 @@ function cmdHelp() {
|
|
|
98
119
|
cm resolve [project] <memory-id>
|
|
99
120
|
Mark a memory as resolved.
|
|
100
121
|
|
|
122
|
+
cm pin [project] <memory-id>
|
|
123
|
+
Pin a memory: it scores 999 in decay ranking (always sorts
|
|
124
|
+
first, never decays) and shows in its own "Pinned" section at
|
|
125
|
+
the top of 'cm start', ahead of its normal type group.
|
|
126
|
+
|
|
127
|
+
cm unpin [project] <memory-id>
|
|
128
|
+
Un-pin a memory, returning it to normal decay-based ranking.
|
|
129
|
+
|
|
130
|
+
cm review [--project <name>]
|
|
131
|
+
Walk through suggested memories one at a time (hook-captured
|
|
132
|
+
memories start as suggested — see cm save/cm init). For each:
|
|
133
|
+
(k)eep promotes it to permanent, (e)dit opens $EDITOR (or an
|
|
134
|
+
inline prompt) for new content then keeps it, (d)rop deletes
|
|
135
|
+
it, (s)kip leaves it suggested for next time, (q)uit stops and
|
|
136
|
+
reports progress so far. Prints a kept/dropped/skipped summary.
|
|
137
|
+
|
|
138
|
+
cm edit [project] <memory-id>
|
|
139
|
+
Open a memory's content in $EDITOR (or an inline prompt if
|
|
140
|
+
$EDITOR isn't set), save the result.
|
|
141
|
+
|
|
101
142
|
cm delete [project] <memory-id>
|
|
102
143
|
Delete a memory and any memory_links referencing it.
|
|
103
144
|
|
|
@@ -112,7 +153,7 @@ function cmdHelp() {
|
|
|
112
153
|
Cluster related unresolved memories (needs 20+) and collapse
|
|
113
154
|
clusters of 3 or more into a single summary memory.
|
|
114
155
|
|
|
115
|
-
cm init <project> <path>
|
|
156
|
+
cm init <project> <path> [--config]
|
|
116
157
|
Install a git post-commit hook in the repo at <path>, register
|
|
117
158
|
the stackmem MCP server with Claude Code, write a CLAUDE.md,
|
|
118
159
|
.cursorrules, .windsurfrules, and .stackmem file, and seed
|
|
@@ -120,7 +161,12 @@ function cmdHelp() {
|
|
|
120
161
|
.windsurfrules are pre-filled with the project's current
|
|
121
162
|
memories (for Cursor and Windsurf) and skipped if they already
|
|
122
163
|
exist. The post-commit hook keeps them fresh afterward by
|
|
123
|
-
running 'cm sync' on every commit
|
|
164
|
+
running 'cm sync' on every commit — on disk only, never
|
|
165
|
+
committed, so they don't double your commit count or conflict
|
|
166
|
+
across branches. Both files are added to .gitignore. With
|
|
167
|
+
--config, also writes a default .stackmem.json (ignore patterns,
|
|
168
|
+
tokenBudget, decayLambda, captureTypes, minQualityScore — see
|
|
169
|
+
src/config-file.ts) if one doesn't already exist.
|
|
124
170
|
|
|
125
171
|
cm uninstall [project] [path]
|
|
126
172
|
Remove everything 'cm init' set up at <path> (defaults to the
|
|
@@ -140,19 +186,44 @@ function cmdHelp() {
|
|
|
140
186
|
single-use and expires after 10 minutes; polling stops when it
|
|
141
187
|
does. Links are per-device, so run this once on each machine.
|
|
142
188
|
|
|
189
|
+
cm open [--project <name>]
|
|
190
|
+
Open the hosted vault's page for this project in the default
|
|
191
|
+
browser (open on Mac, xdg-open on Linux, start on Windows).
|
|
192
|
+
|
|
193
|
+
cm purge [--project <name>] [--all]
|
|
194
|
+
Permanently delete memories, fixes, and memory_links — with a
|
|
195
|
+
typed confirmation first, never deletes on a flag alone.
|
|
196
|
+
--project (or the one in .stackmem): deletes that project's
|
|
197
|
+
data only, after you type the exact project name to confirm.
|
|
198
|
+
--all: deletes everything for this device's device_id across
|
|
199
|
+
every project, after you type DELETE to confirm. Prints the
|
|
200
|
+
count of deleted records, or "Confirmation did not match" if
|
|
201
|
+
you abort.
|
|
202
|
+
|
|
143
203
|
cm sync
|
|
144
204
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
145
205
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
146
206
|
token budget) and its 5 most recent open execution_log fixes,
|
|
147
|
-
overwriting both files
|
|
207
|
+
overwriting both files on disk only (never git add/commit).
|
|
208
|
+
Project is read from .stackmem.
|
|
148
209
|
|
|
149
|
-
cm context [--project <name>] <task>
|
|
210
|
+
cm context [--project <name>] <task> [--hook]
|
|
150
211
|
Return only the top 5 memories most relevant to a specific
|
|
151
212
|
task, ranked by a blend of task relevance and decay score,
|
|
152
213
|
as a markdown block capped at a 2000 token budget. Use
|
|
153
214
|
--project to target a project other than the one in .stackmem.
|
|
154
|
-
|
|
155
|
-
|
|
215
|
+
<task> is optional with --hook (ranks by decay alone) — used
|
|
216
|
+
as a Claude Code PreCompact hook command; see cm init.
|
|
217
|
+
|
|
218
|
+
cm why [--project <name>] <file>
|
|
219
|
+
Show memories tied to a file: matched by Item 6's file_paths
|
|
220
|
+
column (hook-captured commits that touched it) plus a plain
|
|
221
|
+
content search for the file's path or basename, ranked by decay
|
|
222
|
+
score and grouped by type. Prints a guidance message instead if
|
|
223
|
+
nothing matches. Use --project to target a project other than
|
|
224
|
+
the one in .stackmem.
|
|
225
|
+
|
|
226
|
+
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
|
|
156
227
|
Capture what happened in a session and extract memories: each
|
|
157
228
|
error becomes an unsolved execution_log entry, each approach
|
|
158
229
|
containing "failed", "didn't work", "reverted", or "switched
|
|
@@ -162,6 +233,11 @@ function cmdHelp() {
|
|
|
162
233
|
Accepts the same fields as JSON on stdin instead of flags
|
|
163
234
|
(project, summary, errors_encountered, files_touched,
|
|
164
235
|
approaches_tried) when stdin is piped rather than a terminal.
|
|
236
|
+
--hook is accepted for use as a Claude Code Stop hook command
|
|
237
|
+
(see cm init) but is currently a no-op: Claude Code pipes its
|
|
238
|
+
own hook payload on stdin, not this command's expected shape,
|
|
239
|
+
so a Stop-triggered run captures 0 memories until a future
|
|
240
|
+
phase parses the session transcript directly.
|
|
165
241
|
|
|
166
242
|
cm doctor [--fix]
|
|
167
243
|
Check every integration point in the current directory — backend
|
|
@@ -174,6 +250,12 @@ function cmdHelp() {
|
|
|
174
250
|
— those print guidance instead. Reports "Fixed: <item>" or
|
|
175
251
|
"Could not auto-fix: <item>" for each failing check.
|
|
176
252
|
|
|
253
|
+
cm status [--project <name>]
|
|
254
|
+
One-screen project summary: memory/pinned/suggested counts,
|
|
255
|
+
recorded fixes, the active token budget (and whether it's the
|
|
256
|
+
default or from .stackmem.json), time since the last capture,
|
|
257
|
+
and hook/MCP/backend/editor registration status.
|
|
258
|
+
|
|
177
259
|
cm undo [project]
|
|
178
260
|
Delete the most recently saved unresolved memory (and any
|
|
179
261
|
memory_links referencing it) for a project.
|
|
@@ -198,12 +280,33 @@ function fail(message) {
|
|
|
198
280
|
}
|
|
199
281
|
// --- optional project resolution ------------------------------------------
|
|
200
282
|
const STACKMEM_FILE = ".stackmem";
|
|
283
|
+
const MONOREPO_PARENT_LOOKUP_DEPTH = 3;
|
|
284
|
+
// Checks cwd, then up to 3 parent directories, for a .stackmem file — a
|
|
285
|
+
// monorepo subpackage (e.g. packages/api) won't have its own .stackmem,
|
|
286
|
+
// but the repo root will. When found in an ancestor, the project name
|
|
287
|
+
// gets the intervening path segments appended, e.g. "myapp/packages/api"
|
|
288
|
+
// — so a subpackage still reads/writes under its own namespace within
|
|
289
|
+
// the shared project, rather than silently resolving to just "myapp".
|
|
201
290
|
function readProjectFromFile() {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
291
|
+
let dir = process.cwd();
|
|
292
|
+
const subdirSegments = [];
|
|
293
|
+
for (let depth = 0; depth <= MONOREPO_PARENT_LOOKUP_DEPTH; depth++) {
|
|
294
|
+
const stackmemPath = nodePath.join(dir, STACKMEM_FILE);
|
|
295
|
+
if (nodeFs.existsSync(stackmemPath)) {
|
|
296
|
+
const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
|
|
297
|
+
if (content.length === 0)
|
|
298
|
+
return null;
|
|
299
|
+
return subdirSegments.length > 0
|
|
300
|
+
? `${content}/${subdirSegments.reverse().join("/")}`
|
|
301
|
+
: content;
|
|
302
|
+
}
|
|
303
|
+
const parent = nodePath.dirname(dir);
|
|
304
|
+
if (parent === dir)
|
|
305
|
+
break; // reached the filesystem root
|
|
306
|
+
subdirSegments.push(nodePath.basename(dir));
|
|
307
|
+
dir = parent;
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
207
310
|
}
|
|
208
311
|
// Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
|
|
209
312
|
// muscle memory from `cm init <project> .` — "." (or "..", or anything
|
|
@@ -257,29 +360,186 @@ function splitProjectFromVariadic(args) {
|
|
|
257
360
|
}
|
|
258
361
|
return { project: null, rest: args };
|
|
259
362
|
}
|
|
363
|
+
// Generic "--flag value" extractor for internal/provenance flags (not
|
|
364
|
+
// meant to be remembered by end users — only the hook and MCP server pass
|
|
365
|
+
// these). Unlike splitProjectFromVariadic's --project, these don't need
|
|
366
|
+
// special positional disambiguation, so one generic helper covers all of
|
|
367
|
+
// them: --commit-sha, --author, --source, --confidence, --files.
|
|
368
|
+
function extractValueFlag(args, flag) {
|
|
369
|
+
const idx = args.indexOf(flag);
|
|
370
|
+
if (idx === -1 || args[idx + 1] === undefined)
|
|
371
|
+
return { value: null, rest: args };
|
|
372
|
+
return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
|
|
373
|
+
}
|
|
260
374
|
// --- cm start ----------------------------------------------------------
|
|
261
|
-
|
|
375
|
+
const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
|
|
376
|
+
const BRANCH_NAME_BOOST = 1.2;
|
|
377
|
+
const RECENT_FILES_BOOST = 1.3;
|
|
378
|
+
const PR_TITLE_BOOST = 1.2;
|
|
379
|
+
// null means "signal unavailable" (git/gh failed, not enough history, gh
|
|
380
|
+
// not installed, no open PR, etc.) — every one of these is a soft skip,
|
|
381
|
+
// never an error, per the task spec.
|
|
382
|
+
function getBranchNameTokens() {
|
|
383
|
+
let branch;
|
|
384
|
+
try {
|
|
385
|
+
// symbolic-ref (not rev-parse --abbrev-ref) specifically because it
|
|
386
|
+
// fails on a detached HEAD rather than returning the literal string
|
|
387
|
+
// "HEAD" — that failure is the signal this needs to distinguish a
|
|
388
|
+
// real detached-HEAD state from "not a git repo at all" below.
|
|
389
|
+
branch = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
|
|
390
|
+
encoding: "utf-8",
|
|
391
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
392
|
+
}).trim();
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
try {
|
|
396
|
+
execFileSync("git", ["rev-parse", "HEAD"], { stdio: "pipe" });
|
|
397
|
+
// HEAD resolves to a real commit but symbolic-ref failed — genuinely
|
|
398
|
+
// detached, not a missing repo. Still a valid state to rank in, not
|
|
399
|
+
// a reason to skip the signal entirely.
|
|
400
|
+
branch = "detached";
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const tokens = tokenize(branch);
|
|
407
|
+
return tokens.size > 0 ? tokens : null;
|
|
408
|
+
}
|
|
409
|
+
function getRecentlyTouchedFiles() {
|
|
410
|
+
try {
|
|
411
|
+
const isShallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
|
|
412
|
+
encoding: "utf-8",
|
|
413
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
414
|
+
}).trim();
|
|
415
|
+
// A shallow clone can't look back RECENT_COMMITS_FOR_FILE_SIGNAL
|
|
416
|
+
// commits — degrade gracefully rather than attempting (and either
|
|
417
|
+
// failing, or silently returning an incomplete picture of "recent").
|
|
418
|
+
if (isShallow === "true")
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// --is-shallow-repository itself failing (very old git, or no repo at
|
|
423
|
+
// all) isn't a reason to skip on its own — the lookback below will
|
|
424
|
+
// fail (and get caught) or succeed on its own merits either way.
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
const output = execFileSync("git", ["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
428
|
+
const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
429
|
+
return files.length > 0 ? new Set(files) : null;
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
|
|
433
|
+
// not a git repo at all — skip gracefully.
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function getOpenPrTitleTokens() {
|
|
438
|
+
try {
|
|
439
|
+
const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
|
|
440
|
+
encoding: "utf-8",
|
|
441
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
442
|
+
}).trim();
|
|
443
|
+
if (!title)
|
|
444
|
+
return null;
|
|
445
|
+
const tokens = tokenize(title);
|
|
446
|
+
return tokens.size > 0 ? tokens : null;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// gh not installed, not authenticated, or no open PR for this branch.
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function fileListOverlaps(filePaths, recentFiles) {
|
|
454
|
+
if (!Array.isArray(filePaths))
|
|
455
|
+
return false;
|
|
456
|
+
return filePaths.some((candidate) => {
|
|
457
|
+
if (typeof candidate !== "string")
|
|
458
|
+
return false;
|
|
459
|
+
if (recentFiles.has(candidate))
|
|
460
|
+
return true;
|
|
461
|
+
const base = candidate.split("/").pop();
|
|
462
|
+
for (const recent of recentFiles) {
|
|
463
|
+
if (recent.split("/").pop() === base)
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
// Plain-text, no ids/scores/box-drawing — meant to be injected directly
|
|
470
|
+
// into a Claude Code SessionStart hook's context, not read by a human at
|
|
471
|
+
// a terminal (that's the default, non---hook rendering below).
|
|
472
|
+
function printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes) {
|
|
473
|
+
console.log(`# Project memory: ${project}`);
|
|
474
|
+
if (pinnedMemories.length > 0) {
|
|
475
|
+
console.log("");
|
|
476
|
+
console.log("## Pinned");
|
|
477
|
+
for (const memory of pinnedMemories) {
|
|
478
|
+
console.log(`- ${memory.content}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const type of MEMORY_TYPES) {
|
|
482
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
483
|
+
if (group.length === 0)
|
|
484
|
+
continue;
|
|
485
|
+
console.log("");
|
|
486
|
+
console.log(`## ${TYPE_LABELS[type]}`);
|
|
487
|
+
for (const memory of group) {
|
|
488
|
+
console.log(`- ${memory.content}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (budgetedFixes.length > 0) {
|
|
492
|
+
console.log("");
|
|
493
|
+
console.log("## Past Fixes");
|
|
494
|
+
for (const fix of budgetedFixes) {
|
|
495
|
+
console.log(`- ${fix.problem} -> ${fix.solution ?? "(pending)"}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function cmdStart(project, hookMode = false) {
|
|
262
500
|
const { data, error } = await supabase
|
|
263
501
|
.from("memories")
|
|
264
502
|
.select("*")
|
|
265
503
|
.eq("project", project)
|
|
266
|
-
.eq("resolved", false)
|
|
504
|
+
.eq("resolved", false)
|
|
505
|
+
.eq("suggested", false);
|
|
267
506
|
if (error)
|
|
268
507
|
fail(`Failed to load memories: ${error.message}`);
|
|
269
508
|
const memories = data ?? [];
|
|
270
509
|
if (memories.length === 0) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
510
|
+
if (!hookMode) {
|
|
511
|
+
console.log(`No memories found for "${project}".`);
|
|
512
|
+
console.log("");
|
|
513
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
514
|
+
}
|
|
274
515
|
return;
|
|
275
516
|
}
|
|
517
|
+
// Task-aware re-ranking: boost memories relevant to what's actively
|
|
518
|
+
// being worked on, multiplicatively on top of the base decay score.
|
|
519
|
+
// Every signal degrades to null (no boost, not an error) if the
|
|
520
|
+
// repo/branch/gh state it needs isn't available.
|
|
521
|
+
const branchTokens = getBranchNameTokens();
|
|
522
|
+
const recentFiles = getRecentlyTouchedFiles();
|
|
523
|
+
const prTitleTokens = getOpenPrTitleTokens();
|
|
524
|
+
const config = readStackmemConfig();
|
|
525
|
+
const tokenBudget = config.tokenBudget;
|
|
276
526
|
const scoredMemories = memories
|
|
277
|
-
.map((memory) =>
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
527
|
+
.map((memory) => {
|
|
528
|
+
let decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
|
|
529
|
+
const contentTokens = tokenize(memory.content);
|
|
530
|
+
if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
|
|
531
|
+
decayScore *= BRANCH_NAME_BOOST;
|
|
532
|
+
}
|
|
533
|
+
if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
|
|
534
|
+
decayScore *= RECENT_FILES_BOOST;
|
|
535
|
+
}
|
|
536
|
+
if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
|
|
537
|
+
decayScore *= PR_TITLE_BOOST;
|
|
538
|
+
}
|
|
539
|
+
return { ...memory, decay_score: decayScore };
|
|
540
|
+
})
|
|
281
541
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
282
|
-
const budgetedMemories = applyContextBudget(scoredMemories,
|
|
542
|
+
const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
|
|
283
543
|
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
284
544
|
let tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
|
|
285
545
|
const { data: fixData, error: fixError } = await supabase
|
|
@@ -291,12 +551,12 @@ async function cmdStart(project) {
|
|
|
291
551
|
.limit(5);
|
|
292
552
|
if (fixError)
|
|
293
553
|
fail(`Failed to load execution log: ${fixError.message}`);
|
|
294
|
-
// Fixes share the same
|
|
295
|
-
//
|
|
554
|
+
// Fixes share the same token budget as memories, which already consumed
|
|
555
|
+
// their portion above — fixes only get whatever room is left.
|
|
296
556
|
const budgetedFixes = [];
|
|
297
557
|
for (const fix of fixData ?? []) {
|
|
298
558
|
const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
|
|
299
|
-
if (tokensUsed + tokens >
|
|
559
|
+
if (tokensUsed + tokens > tokenBudget)
|
|
300
560
|
break;
|
|
301
561
|
budgetedFixes.push(fix);
|
|
302
562
|
tokensUsed += tokens;
|
|
@@ -318,39 +578,58 @@ async function cmdStart(project) {
|
|
|
318
578
|
linkedIdsByMemory.get(link.source_id).add(link.target_id);
|
|
319
579
|
linkedIdsByMemory.get(link.target_id).add(link.source_id);
|
|
320
580
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
console.log(
|
|
328
|
-
|
|
329
|
-
console.log(`
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
console.log(`
|
|
581
|
+
const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
|
|
582
|
+
const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
|
|
583
|
+
if (hookMode) {
|
|
584
|
+
printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
|
|
588
|
+
if (pinnedMemories.length > 0) {
|
|
589
|
+
console.log(`Pinned (${pinnedMemories.length})`);
|
|
590
|
+
console.log("-".repeat(11));
|
|
591
|
+
for (const memory of pinnedMemories) {
|
|
592
|
+
console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
|
|
593
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
594
|
+
if (linked.length > 0) {
|
|
595
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
596
|
+
}
|
|
333
597
|
}
|
|
598
|
+
console.log("");
|
|
334
599
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
600
|
+
for (const type of MEMORY_TYPES) {
|
|
601
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
602
|
+
if (group.length === 0)
|
|
603
|
+
continue;
|
|
604
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
605
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
606
|
+
for (const memory of group) {
|
|
607
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
608
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
609
|
+
if (linked.length > 0) {
|
|
610
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
343
613
|
console.log("");
|
|
344
614
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
615
|
+
if (budgetedFixes.length > 0) {
|
|
616
|
+
console.log(`Past Fixes (${budgetedFixes.length})`);
|
|
617
|
+
console.log("-".repeat(14));
|
|
618
|
+
for (const fix of budgetedFixes) {
|
|
619
|
+
console.log(` Problem: ${fix.problem}`);
|
|
620
|
+
console.log(` Solution: ${fix.solution ?? "(pending)"}`);
|
|
621
|
+
console.log("");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
|
|
625
|
+
if (omittedCount > 0) {
|
|
626
|
+
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
627
|
+
}
|
|
628
|
+
const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
|
|
629
|
+
if (!hasTechStackMemory) {
|
|
630
|
+
console.log("");
|
|
631
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
632
|
+
}
|
|
354
633
|
}
|
|
355
634
|
const incrementResults = await Promise.all(budgetedMemories.map((memory) => supabase
|
|
356
635
|
.from("memories")
|
|
@@ -386,7 +665,7 @@ async function confirmSupersede(oldId, newContent) {
|
|
|
386
665
|
console.log("Invalid input — kept original memory.");
|
|
387
666
|
return false;
|
|
388
667
|
}
|
|
389
|
-
async function cmdSave(project, type, content, force = false) {
|
|
668
|
+
async function cmdSave(project, type, content, force = false, provenance = {}) {
|
|
390
669
|
if (!MEMORY_TYPES.includes(type)) {
|
|
391
670
|
fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
|
|
392
671
|
}
|
|
@@ -423,9 +702,29 @@ async function cmdSave(project, type, content, force = false) {
|
|
|
423
702
|
if (resolveError)
|
|
424
703
|
fail(`Failed to resolve superseded memory: ${resolveError.message}`);
|
|
425
704
|
}
|
|
705
|
+
const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
|
|
706
|
+
const insertPayload = {
|
|
707
|
+
project,
|
|
708
|
+
type,
|
|
709
|
+
content,
|
|
710
|
+
device_id: getDeviceId(),
|
|
711
|
+
source,
|
|
712
|
+
confidence,
|
|
713
|
+
// Hook-captured memories are heuristically detected and could be
|
|
714
|
+
// noisy or wrong — they start as suggested, pending `cm review`, and
|
|
715
|
+
// are excluded from cm start/context/why until promoted. Manual and
|
|
716
|
+
// mcp saves are already user-vetted, so they're permanent immediately.
|
|
717
|
+
suggested: source === "hook",
|
|
718
|
+
};
|
|
719
|
+
if (commitSha)
|
|
720
|
+
insertPayload.commit_sha = commitSha;
|
|
721
|
+
if (filePaths && filePaths.length > 0)
|
|
722
|
+
insertPayload.file_paths = filePaths;
|
|
723
|
+
if (author)
|
|
724
|
+
insertPayload.author = author;
|
|
426
725
|
const { data: inserted, error } = await supabase
|
|
427
726
|
.from("memories")
|
|
428
|
-
.insert(
|
|
727
|
+
.insert(insertPayload)
|
|
429
728
|
.select("id")
|
|
430
729
|
.single();
|
|
431
730
|
if (error || !inserted)
|
|
@@ -455,13 +754,44 @@ async function cmdSave(project, type, content, force = false) {
|
|
|
455
754
|
await compressMemories(project);
|
|
456
755
|
}
|
|
457
756
|
}
|
|
757
|
+
// --- cm check-commit (internal, used by the hook) --------------------------
|
|
758
|
+
// Not a user-facing command (no usage()/help entry) — the hook calls this
|
|
759
|
+
// once per invocation, before doing any diff analysis, to decide whether
|
|
760
|
+
// this commit_sha has already been captured. post-commit and post-rewrite
|
|
761
|
+
// both run the exact same capture logic against current HEAD, so without
|
|
762
|
+
// this guard, a commit that fires both hooks (e.g. `git commit --amend`,
|
|
763
|
+
// which triggers post-commit for the new commit AND post-rewrite for the
|
|
764
|
+
// rewrite) would be captured twice. Checked once per invocation — not
|
|
765
|
+
// inside each individual cmSave call — because a single commit normally
|
|
766
|
+
// produces several distinct memories; a per-save check would see the
|
|
767
|
+
// first save's own commit_sha and wrongly skip the rest.
|
|
768
|
+
async function cmdCheckCommit(project, commitSha) {
|
|
769
|
+
const { data, error } = await supabase
|
|
770
|
+
.from("memories")
|
|
771
|
+
.select("id")
|
|
772
|
+
.eq("project", project)
|
|
773
|
+
.eq("commit_sha", commitSha)
|
|
774
|
+
.limit(1);
|
|
775
|
+
if (error) {
|
|
776
|
+
// Fail open: an error here (including "column does not exist" if the
|
|
777
|
+
// commit_sha migration hasn't landed yet) must not look identical to
|
|
778
|
+
// "duplicate, skip" below — that would silently suppress every hook
|
|
779
|
+
// capture with no log trail. Exit 0 so the normal save path runs;
|
|
780
|
+
// if commit_sha genuinely isn't a column, the save itself fails
|
|
781
|
+
// there instead, where the hook's outer error handler logs it.
|
|
782
|
+
console.error(`check-commit: ${error.message}`);
|
|
783
|
+
process.exit(0);
|
|
784
|
+
}
|
|
785
|
+
process.exit(data && data.length > 0 ? 1 : 0);
|
|
786
|
+
}
|
|
458
787
|
// --- memory compression ----------------------------------------------------
|
|
459
788
|
async function compressMemories(project) {
|
|
460
789
|
const { data, error } = await supabase
|
|
461
790
|
.from("memories")
|
|
462
791
|
.select("*")
|
|
463
792
|
.eq("project", project)
|
|
464
|
-
.eq("resolved", false)
|
|
793
|
+
.eq("resolved", false)
|
|
794
|
+
.eq("suggested", false);
|
|
465
795
|
if (error)
|
|
466
796
|
fail(`Failed to load memories for compression: ${error.message}`);
|
|
467
797
|
const memories = data ?? [];
|
|
@@ -583,6 +913,28 @@ async function cmdResolve(project, memoryId) {
|
|
|
583
913
|
}
|
|
584
914
|
console.log(`Resolved memory [${memoryId}]`);
|
|
585
915
|
}
|
|
916
|
+
// --- cm pin / cm unpin -------------------------------------------------
|
|
917
|
+
async function setPinned(project, memoryId, pinned) {
|
|
918
|
+
const { data, error } = await supabase
|
|
919
|
+
.from("memories")
|
|
920
|
+
.update({ pinned })
|
|
921
|
+
.eq("project", project)
|
|
922
|
+
.eq("id", memoryId)
|
|
923
|
+
.select("id");
|
|
924
|
+
if (error)
|
|
925
|
+
fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
|
|
926
|
+
if (!data || data.length === 0) {
|
|
927
|
+
console.log(`No memory found with ID [${memoryId}]`);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
|
|
931
|
+
}
|
|
932
|
+
async function cmdPin(project, memoryId) {
|
|
933
|
+
await setPinned(project, memoryId, true);
|
|
934
|
+
}
|
|
935
|
+
async function cmdUnpin(project, memoryId) {
|
|
936
|
+
await setPinned(project, memoryId, false);
|
|
937
|
+
}
|
|
586
938
|
// --- cm delete -------------------------------------------------------------
|
|
587
939
|
async function cmdDelete(project, memoryId) {
|
|
588
940
|
const { error: linksError } = await supabase
|
|
@@ -605,9 +957,162 @@ async function cmdDelete(project, memoryId) {
|
|
|
605
957
|
}
|
|
606
958
|
console.log(`Deleted memory [${memoryId}]`);
|
|
607
959
|
}
|
|
960
|
+
// --- shared: relative time + $EDITOR prompt (cm review, cm edit, cm status)
|
|
961
|
+
function formatRelativeTime(date) {
|
|
962
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
963
|
+
if (seconds < 60)
|
|
964
|
+
return `${seconds} second${seconds === 1 ? "" : "s"} ago`;
|
|
965
|
+
const minutes = Math.floor(seconds / 60);
|
|
966
|
+
if (minutes < 60)
|
|
967
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
968
|
+
const hours = Math.floor(minutes / 60);
|
|
969
|
+
if (hours < 24)
|
|
970
|
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
971
|
+
const days = Math.floor(hours / 24);
|
|
972
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
973
|
+
}
|
|
974
|
+
// Opens $EDITOR on a scratch file pre-filled with the current content, or
|
|
975
|
+
// falls back to an inline readline prompt when $EDITOR isn't set. Returns
|
|
976
|
+
// the original content unchanged if the result is blank (an empty save
|
|
977
|
+
// buffer almost certainly means "I didn't mean to clear this", not "make
|
|
978
|
+
// this memory empty").
|
|
979
|
+
async function editContentInteractively(currentContent) {
|
|
980
|
+
const editor = process.env.EDITOR;
|
|
981
|
+
if (editor) {
|
|
982
|
+
const tmpFile = nodePath.join(nodeOs.tmpdir(), `stackmem-edit-${randomInt(1_000_000_000)}.txt`);
|
|
983
|
+
nodeFs.writeFileSync(tmpFile, currentContent, "utf-8");
|
|
984
|
+
try {
|
|
985
|
+
execFileSync(editor, [tmpFile], { stdio: "inherit" });
|
|
986
|
+
const edited = nodeFs.readFileSync(tmpFile, "utf-8").trim();
|
|
987
|
+
return edited.length > 0 ? edited : currentContent;
|
|
988
|
+
}
|
|
989
|
+
finally {
|
|
990
|
+
try {
|
|
991
|
+
nodeFs.unlinkSync(tmpFile);
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
// Best-effort cleanup — a leftover temp file isn't worth failing on.
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
console.log(`Current: ${currentContent}`);
|
|
999
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1000
|
+
const answer = (await rl.question("New content (leave blank to keep unchanged): ")).trim();
|
|
1001
|
+
rl.close();
|
|
1002
|
+
return answer.length > 0 ? answer : currentContent;
|
|
1003
|
+
}
|
|
1004
|
+
// --- cm review -------------------------------------------------------------
|
|
1005
|
+
async function cmdReview(project) {
|
|
1006
|
+
const { data, error } = await supabase
|
|
1007
|
+
.from("memories")
|
|
1008
|
+
.select("*")
|
|
1009
|
+
.eq("project", project)
|
|
1010
|
+
.eq("resolved", false)
|
|
1011
|
+
.eq("suggested", true)
|
|
1012
|
+
.order("created_at", { ascending: true });
|
|
1013
|
+
if (error)
|
|
1014
|
+
fail(`Failed to load suggested memories: ${error.message}`);
|
|
1015
|
+
const memories = data ?? [];
|
|
1016
|
+
if (memories.length === 0) {
|
|
1017
|
+
console.log(`No memories to review for "${project}".`);
|
|
1018
|
+
console.log("Your inbox is empty.");
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
console.log(`Reviewing ${memories.length} suggested memories for "${project}"`);
|
|
1022
|
+
console.log("━".repeat(40));
|
|
1023
|
+
// Node's readline can't reliably answer multiple sequential
|
|
1024
|
+
// rl.question() calls over piped (non-TTY) stdin — all buffered input
|
|
1025
|
+
// arrives before the second question is even issued, and it never
|
|
1026
|
+
// resolves. A real interactive terminal doesn't hit this (keystrokes
|
|
1027
|
+
// arrive one at a time), so only the non-TTY path needs the
|
|
1028
|
+
// read-everything-up-front workaround.
|
|
1029
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
1030
|
+
const pipedAnswers = rl
|
|
1031
|
+
? []
|
|
1032
|
+
: (await new Promise((resolve) => {
|
|
1033
|
+
let buf = "";
|
|
1034
|
+
process.stdin.setEncoding("utf-8");
|
|
1035
|
+
process.stdin.on("data", (chunk) => (buf += chunk));
|
|
1036
|
+
process.stdin.on("end", () => resolve(buf));
|
|
1037
|
+
})).split("\n");
|
|
1038
|
+
async function nextAnswer(prompt) {
|
|
1039
|
+
if (rl)
|
|
1040
|
+
return (await rl.question(prompt)).trim().toLowerCase();
|
|
1041
|
+
console.log(prompt);
|
|
1042
|
+
return (pipedAnswers.shift() ?? "q").trim().toLowerCase();
|
|
1043
|
+
}
|
|
1044
|
+
let kept = 0;
|
|
1045
|
+
let dropped = 0;
|
|
1046
|
+
let skipped = 0;
|
|
1047
|
+
for (let i = 0; i < memories.length; i++) {
|
|
1048
|
+
const memory = memories[i];
|
|
1049
|
+
console.log(`[${i + 1}/${memories.length}] ${memory.type}: ${memory.content}`);
|
|
1050
|
+
console.log(` Captured: ${formatRelativeTime(parseUtcDate(memory.created_at))}`);
|
|
1051
|
+
console.log("");
|
|
1052
|
+
const answer = await nextAnswer("(k)eep (e)dit (d)rop (s)kip (q)uit > ");
|
|
1053
|
+
console.log("");
|
|
1054
|
+
if (answer === "k") {
|
|
1055
|
+
const { error: updateError } = await supabase
|
|
1056
|
+
.from("memories")
|
|
1057
|
+
.update({ suggested: false })
|
|
1058
|
+
.eq("id", memory.id);
|
|
1059
|
+
if (!updateError)
|
|
1060
|
+
kept++;
|
|
1061
|
+
}
|
|
1062
|
+
else if (answer === "e") {
|
|
1063
|
+
const newContent = redactSecrets(await editContentInteractively(memory.content));
|
|
1064
|
+
const { error: updateError } = await supabase
|
|
1065
|
+
.from("memories")
|
|
1066
|
+
.update({ content: newContent, suggested: false })
|
|
1067
|
+
.eq("id", memory.id);
|
|
1068
|
+
if (!updateError)
|
|
1069
|
+
kept++;
|
|
1070
|
+
}
|
|
1071
|
+
else if (answer === "d") {
|
|
1072
|
+
await supabase
|
|
1073
|
+
.from("memory_links")
|
|
1074
|
+
.delete()
|
|
1075
|
+
.or(`source_id.eq.${memory.id},target_id.eq.${memory.id}`);
|
|
1076
|
+
const { error: deleteError } = await supabase.from("memories").delete().eq("id", memory.id);
|
|
1077
|
+
if (!deleteError)
|
|
1078
|
+
dropped++;
|
|
1079
|
+
}
|
|
1080
|
+
else if (answer === "q") {
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
// "s", blank, or anything unrecognized — leave it suggested and
|
|
1085
|
+
// move on rather than erroring on a mistyped key.
|
|
1086
|
+
skipped++;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
rl?.close();
|
|
1090
|
+
console.log(`Review complete. Kept ${kept}, dropped ${dropped}, skipped ${skipped}.`);
|
|
1091
|
+
}
|
|
1092
|
+
// --- cm edit ---------------------------------------------------------------
|
|
1093
|
+
async function cmdEdit(project, memoryId) {
|
|
1094
|
+
const { data: memory, error } = await supabase
|
|
1095
|
+
.from("memories")
|
|
1096
|
+
.select("id, content")
|
|
1097
|
+
.eq("project", project)
|
|
1098
|
+
.eq("id", memoryId)
|
|
1099
|
+
.single();
|
|
1100
|
+
if (error || !memory) {
|
|
1101
|
+
console.log(`No memory found with ID [${memoryId}]`);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const newContent = redactSecrets(await editContentInteractively(memory.content));
|
|
1105
|
+
const { error: updateError } = await supabase
|
|
1106
|
+
.from("memories")
|
|
1107
|
+
.update({ content: newContent })
|
|
1108
|
+
.eq("id", memoryId);
|
|
1109
|
+
if (updateError)
|
|
1110
|
+
fail(`Failed to update memory: ${updateError.message}`);
|
|
1111
|
+
console.log(`Updated memory [${memoryId}].`);
|
|
1112
|
+
}
|
|
608
1113
|
// --- cm inspect ------------------------------------------------------------
|
|
609
1114
|
function formatDate(dateString) {
|
|
610
|
-
return
|
|
1115
|
+
return parseUtcDate(dateString).toLocaleDateString("en-US", {
|
|
611
1116
|
month: "short",
|
|
612
1117
|
day: "numeric",
|
|
613
1118
|
year: "numeric",
|
|
@@ -622,7 +1127,8 @@ async function cmdInspect(project, memoryId) {
|
|
|
622
1127
|
.single();
|
|
623
1128
|
if (error || !memory)
|
|
624
1129
|
fail(`Memory [${memoryId}] not found for "${project}".`);
|
|
625
|
-
const
|
|
1130
|
+
const config = readStackmemConfig();
|
|
1131
|
+
const decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
|
|
626
1132
|
const { data: links, error: linksError } = await supabase
|
|
627
1133
|
.from("memory_links")
|
|
628
1134
|
.select("source_id, target_id, score")
|
|
@@ -711,6 +1217,65 @@ try {
|
|
|
711
1217
|
|
|
712
1218
|
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
713
1219
|
|
|
1220
|
+
// Inlined copy of src/config-file.ts's defaults/merge logic — this script
|
|
1221
|
+
// runs standalone with no import access to that module.
|
|
1222
|
+
const DEFAULT_STACKMEM_CONFIG = {
|
|
1223
|
+
ignore: [],
|
|
1224
|
+
tokenBudget: 2000,
|
|
1225
|
+
decayLambda: 0.05,
|
|
1226
|
+
captureTypes: ["decision", "constraint", "discovery", "rejection"],
|
|
1227
|
+
minQualityScore: 0.3,
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
function isStringArray(value) {
|
|
1231
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function readStackmemConfig() {
|
|
1235
|
+
const configPath = join(process.cwd(), ".stackmem.json");
|
|
1236
|
+
try {
|
|
1237
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1238
|
+
return {
|
|
1239
|
+
ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
|
|
1240
|
+
tokenBudget:
|
|
1241
|
+
typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
|
|
1242
|
+
? raw.tokenBudget
|
|
1243
|
+
: DEFAULT_STACKMEM_CONFIG.tokenBudget,
|
|
1244
|
+
decayLambda:
|
|
1245
|
+
typeof raw.decayLambda === "number" && raw.decayLambda > 0
|
|
1246
|
+
? raw.decayLambda
|
|
1247
|
+
: DEFAULT_STACKMEM_CONFIG.decayLambda,
|
|
1248
|
+
captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
|
|
1249
|
+
minQualityScore:
|
|
1250
|
+
typeof raw.minQualityScore === "number"
|
|
1251
|
+
? raw.minQualityScore
|
|
1252
|
+
: DEFAULT_STACKMEM_CONFIG.minQualityScore,
|
|
1253
|
+
};
|
|
1254
|
+
} catch {
|
|
1255
|
+
return { ...DEFAULT_STACKMEM_CONFIG };
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function matchesIgnorePattern(filePath, pattern) {
|
|
1260
|
+
const base = filePath.split("/").pop() ?? filePath;
|
|
1261
|
+
|
|
1262
|
+
if (pattern.endsWith("/")) {
|
|
1263
|
+
const dirName = pattern.slice(0, -1);
|
|
1264
|
+
return filePath.split("/").includes(dirName);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (pattern.includes("*")) {
|
|
1268
|
+
const regex = new RegExp(
|
|
1269
|
+
"^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
|
|
1270
|
+
);
|
|
1271
|
+
return regex.test(base) || regex.test(filePath);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return filePath === pattern || base === pattern;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const STACKMEM_CONFIG = readStackmemConfig();
|
|
1278
|
+
|
|
714
1279
|
const SECRET_PATTERNS = [
|
|
715
1280
|
/AKIA[0-9A-Z]{16}/g,
|
|
716
1281
|
/(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
|
|
@@ -767,10 +1332,43 @@ function git(args) {
|
|
|
767
1332
|
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
768
1333
|
}
|
|
769
1334
|
|
|
1335
|
+
const CURRENT_SHA = git(["rev-parse", "HEAD"]);
|
|
1336
|
+
const CURRENT_AUTHOR = (() => {
|
|
1337
|
+
try {
|
|
1338
|
+
return git(["log", "--format=%ae", "-1", "HEAD"]);
|
|
1339
|
+
} catch {
|
|
1340
|
+
return "";
|
|
1341
|
+
}
|
|
1342
|
+
})();
|
|
1343
|
+
|
|
1344
|
+
// post-commit and post-rewrite both run this exact script against current
|
|
1345
|
+
// HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
|
|
1346
|
+
// would otherwise capture the same commit twice. Checked once up front,
|
|
1347
|
+
// not per-save — see cmdCheckCommit in src/cli.ts for why.
|
|
1348
|
+
try {
|
|
1349
|
+
execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
|
|
1350
|
+
} catch {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// file_paths is populated with every file the commit touched (not just
|
|
1355
|
+
// the filtered diffableFiles below) — "cm why <file>" should still find
|
|
1356
|
+
// a commit's decision/discovery even if that specific file's own diff
|
|
1357
|
+
// content was skipped as noise (a lockfile, say).
|
|
770
1358
|
function cmSave(type, content) {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1359
|
+
if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
|
|
1360
|
+
|
|
1361
|
+
const args = [
|
|
1362
|
+
CLI_PATH, "save", PROJECT, type, content, "--force",
|
|
1363
|
+
"--commit-sha", CURRENT_SHA,
|
|
1364
|
+
"--source", "hook",
|
|
1365
|
+
"--confidence", "0.8",
|
|
1366
|
+
];
|
|
1367
|
+
if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
|
|
1368
|
+
if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
|
|
1369
|
+
|
|
1370
|
+
execFileSync("node", args, { stdio: "inherit" });
|
|
1371
|
+
savedItems.push(\`\${type}: \${content}\`);
|
|
774
1372
|
}
|
|
775
1373
|
|
|
776
1374
|
function basename(filePath) {
|
|
@@ -797,6 +1395,29 @@ function isSchemaFile(filePath) {
|
|
|
797
1395
|
);
|
|
798
1396
|
}
|
|
799
1397
|
|
|
1398
|
+
// Noise that's never worth diffing: lockfiles, build output, vendored/
|
|
1399
|
+
// generated code, and timestamp-prefixed SQL migrations (their content is
|
|
1400
|
+
// a mechanical dump, not a human decision). Checked against path segments
|
|
1401
|
+
// (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
|
|
1402
|
+
// just a top-level "dist/...".
|
|
1403
|
+
const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
|
|
1404
|
+
const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
|
|
1405
|
+
const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
|
|
1406
|
+
const DIFF_MAX_BYTES = 50 * 1024;
|
|
1407
|
+
|
|
1408
|
+
function shouldSkipDiffFile(filePath) {
|
|
1409
|
+
const base = basename(filePath);
|
|
1410
|
+
if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
|
|
1411
|
+
if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
|
|
1412
|
+
if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
|
|
1413
|
+
if (base.endsWith(".map")) return true;
|
|
1414
|
+
if (base.endsWith(".pb.go")) return true;
|
|
1415
|
+
if (/\\.generated\\./.test(base)) return true;
|
|
1416
|
+
if (MIGRATION_FILE_RE.test(base)) return true;
|
|
1417
|
+
if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
800
1421
|
const LOW_QUALITY_PREFIXES = [
|
|
801
1422
|
"wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
|
|
802
1423
|
"tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
|
|
@@ -831,11 +1452,10 @@ const savedItems = [];
|
|
|
831
1452
|
|
|
832
1453
|
const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
833
1454
|
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
// rules-file content and triggers another sync commit, indefinitely.
|
|
1455
|
+
// Kept as a general escape hatch for other automation (CI bots, scripts)
|
|
1456
|
+
// that tags its own commits this way and shouldn't be captured as a
|
|
1457
|
+
// memory — cm sync no longer self-commits, so this isn't load-bearing for
|
|
1458
|
+
// that anymore, but other tools' commits still deserve the same skip.
|
|
839
1459
|
if (commitMessage.includes("[skip ci]")) {
|
|
840
1460
|
return;
|
|
841
1461
|
}
|
|
@@ -859,10 +1479,40 @@ const changedFiles = git(
|
|
|
859
1479
|
|
|
860
1480
|
let diff = "";
|
|
861
1481
|
if (!isFirstCommit) {
|
|
1482
|
+
// --numstat marks a binary file's added/deleted columns as "-" — that's
|
|
1483
|
+
// the cheapest way to identify them without fetching their (useless,
|
|
1484
|
+
// and potentially huge) diff content at all.
|
|
1485
|
+
let binaryFiles = new Set();
|
|
862
1486
|
try {
|
|
863
|
-
|
|
1487
|
+
const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
|
|
1488
|
+
encoding: "utf-8",
|
|
1489
|
+
});
|
|
1490
|
+
for (const line of numstat.split("\\n")) {
|
|
1491
|
+
const parts = line.split("\\t");
|
|
1492
|
+
if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
|
|
1493
|
+
binaryFiles.add(parts[2]);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
864
1496
|
} catch {
|
|
865
|
-
|
|
1497
|
+
// Nothing to compare against — leave binaryFiles empty.
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const diffableFiles = changedFiles.filter(
|
|
1501
|
+
(file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
|
|
1502
|
+
);
|
|
1503
|
+
|
|
1504
|
+
if (diffableFiles.length > 0) {
|
|
1505
|
+
try {
|
|
1506
|
+
diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
|
|
1507
|
+
encoding: "utf-8",
|
|
1508
|
+
});
|
|
1509
|
+
} catch {
|
|
1510
|
+
diff = "";
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
if (diff.length > DIFF_MAX_BYTES) {
|
|
1515
|
+
diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
|
|
866
1516
|
}
|
|
867
1517
|
}
|
|
868
1518
|
|
|
@@ -889,17 +1539,14 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
889
1539
|
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
890
1540
|
const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
|
|
891
1541
|
cmSave("discovery", content);
|
|
892
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
893
1542
|
}
|
|
894
1543
|
if (isConfigFile(currentFile)) {
|
|
895
1544
|
const content = redactSecrets(\`new config: \${currentFile}\`);
|
|
896
1545
|
cmSave("constraint", content);
|
|
897
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
898
1546
|
}
|
|
899
1547
|
if (isSchemaFile(currentFile)) {
|
|
900
1548
|
const content = redactSecrets(\`schema change: \${currentFile}\`);
|
|
901
1549
|
cmSave("discovery", content);
|
|
902
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
903
1550
|
}
|
|
904
1551
|
}
|
|
905
1552
|
continue;
|
|
@@ -936,7 +1583,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
936
1583
|
if (removedModulePath) {
|
|
937
1584
|
const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
|
|
938
1585
|
cmSave("rejection", content);
|
|
939
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
940
1586
|
}
|
|
941
1587
|
|
|
942
1588
|
if (isPackageJson && inDependenciesSection) {
|
|
@@ -944,7 +1590,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
944
1590
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
945
1591
|
const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
|
|
946
1592
|
cmSave("rejection", content);
|
|
947
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
948
1593
|
}
|
|
949
1594
|
}
|
|
950
1595
|
continue;
|
|
@@ -960,7 +1605,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
960
1605
|
if (envMatch) {
|
|
961
1606
|
const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
|
|
962
1607
|
cmSave("constraint", content);
|
|
963
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
964
1608
|
}
|
|
965
1609
|
}
|
|
966
1610
|
}
|
|
@@ -970,7 +1614,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
970
1614
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
971
1615
|
const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
|
|
972
1616
|
cmSave("decision", content);
|
|
973
|
-
savedItems.push(\`decision: \${content}\`);
|
|
974
1617
|
}
|
|
975
1618
|
}
|
|
976
1619
|
|
|
@@ -978,14 +1621,12 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
978
1621
|
const truncated = codeLine.trim().slice(0, 80);
|
|
979
1622
|
const content = redactSecrets(\`database change: \${truncated}\`);
|
|
980
1623
|
cmSave("discovery", content);
|
|
981
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
982
1624
|
}
|
|
983
1625
|
|
|
984
1626
|
if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
|
|
985
1627
|
const truncated = codeLine.trim().slice(0, 100);
|
|
986
1628
|
const content = redactSecrets(\`open issue from commit: \${truncated}\`);
|
|
987
1629
|
cmSave("discovery", content);
|
|
988
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
989
1630
|
}
|
|
990
1631
|
}
|
|
991
1632
|
}
|
|
@@ -1014,19 +1655,16 @@ if (breakingMatch) {
|
|
|
1014
1655
|
const breakingContent = redactSecrets(breakingMatch[1].trim());
|
|
1015
1656
|
const content = \`BREAKING CHANGE: \${breakingContent}\`;
|
|
1016
1657
|
cmSave("constraint", content);
|
|
1017
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1018
1658
|
}
|
|
1019
1659
|
|
|
1020
1660
|
if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
|
|
1021
1661
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1022
1662
|
cmSave("decision", trimmedMessage);
|
|
1023
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1024
1663
|
} else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
|
|
1025
1664
|
// docs/style/test/ci/build/chore — skip entirely, nothing saved.
|
|
1026
1665
|
} else if (isQualityCommitMessage(commitMessage)) {
|
|
1027
1666
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1028
1667
|
cmSave("decision", trimmedMessage);
|
|
1029
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1030
1668
|
}
|
|
1031
1669
|
|
|
1032
1670
|
console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
|
|
@@ -1034,21 +1672,13 @@ for (const item of savedItems) {
|
|
|
1034
1672
|
console.log(\` - \${item}\`);
|
|
1035
1673
|
}
|
|
1036
1674
|
|
|
1675
|
+
// Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
|
|
1676
|
+
// Editors read these files straight off disk, and they're gitignored by
|
|
1677
|
+
// default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
|
|
1678
|
+
// sync with git history; committing them here used to double the commit
|
|
1679
|
+
// count on every real commit and produced constant rules-file merge
|
|
1680
|
+
// conflicts on teams.
|
|
1037
1681
|
execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
|
|
1038
|
-
|
|
1039
|
-
// cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
|
|
1040
|
-
// the working directory dirty (breaking things like \`npm version patch\`
|
|
1041
|
-
// that require a clean tree). Commit the sync ourselves instead. --no-verify
|
|
1042
|
-
// skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
|
|
1043
|
-
// guard above, which is what actually stops this from recursing).
|
|
1044
|
-
try {
|
|
1045
|
-
execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
|
|
1046
|
-
execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
|
|
1047
|
-
stdio: "pipe",
|
|
1048
|
-
});
|
|
1049
|
-
} catch {
|
|
1050
|
-
// Nothing to commit (rules files unchanged) — fine, ignore.
|
|
1051
|
-
}
|
|
1052
1682
|
}
|
|
1053
1683
|
|
|
1054
1684
|
async function main() {
|
|
@@ -1073,14 +1703,24 @@ async function main() {
|
|
|
1073
1703
|
main().finally(() => process.exit(0));
|
|
1074
1704
|
`;
|
|
1075
1705
|
}
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1706
|
+
// Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
|
|
1707
|
+
// `git rebase` (neither of which is a plain commit, so post-commit alone
|
|
1708
|
+
// misses them) — it runs the exact same capture script as post-commit,
|
|
1709
|
+
// against current HEAD, with the check-commit idempotency guard in the
|
|
1710
|
+
// script itself preventing a double-capture when a single operation
|
|
1711
|
+
// (e.g. --amend) fires both hooks for the same resulting commit.
|
|
1712
|
+
const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"];
|
|
1713
|
+
function hookBackupFilename(hookName) {
|
|
1714
|
+
return `${hookName}${HOOK_BACKUP_SUFFIX}`;
|
|
1715
|
+
}
|
|
1716
|
+
// Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
|
|
1717
|
+
// hook of this name already exists and wasn't written by stackmem, it's
|
|
1078
1718
|
// preserved as a backup and chained to run before stackmem's own hook,
|
|
1079
1719
|
// rather than being clobbered — see `uninstallHook` for how it's restored.
|
|
1080
|
-
function installHook(hooksDir, hookLogicPath) {
|
|
1720
|
+
function installHook(hooksDir, hookLogicPath, hookName) {
|
|
1081
1721
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1082
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1083
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1722
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1723
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1084
1724
|
let chainedOriginal = false;
|
|
1085
1725
|
if (nodeFs.existsSync(hookPath)) {
|
|
1086
1726
|
const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
|
|
@@ -1096,21 +1736,21 @@ function installHook(hooksDir, hookLogicPath) {
|
|
|
1096
1736
|
}
|
|
1097
1737
|
}
|
|
1098
1738
|
const chainLine = chainedOriginal
|
|
1099
|
-
? `"$(dirname "$0")/${
|
|
1739
|
+
? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
|
|
1100
1740
|
: "";
|
|
1101
1741
|
nodeFs.writeFileSync(hookPath, `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`, "utf-8");
|
|
1102
1742
|
nodeFs.chmodSync(hookPath, 0o755);
|
|
1103
1743
|
if (chainedOriginal) {
|
|
1104
|
-
console.log(
|
|
1744
|
+
console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
|
|
1105
1745
|
}
|
|
1106
1746
|
}
|
|
1107
1747
|
// Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
|
|
1108
1748
|
// stackmem chained it after an existing hook, restores that original hook
|
|
1109
1749
|
// in its place. Returns a description of what happened, or null if there
|
|
1110
1750
|
// was no stackmem-managed hook to remove (nothing done).
|
|
1111
|
-
function uninstallHook(hooksDir) {
|
|
1112
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1113
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1751
|
+
function uninstallHook(hooksDir, hookName) {
|
|
1752
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1753
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1114
1754
|
if (!nodeFs.existsSync(hookPath))
|
|
1115
1755
|
return null;
|
|
1116
1756
|
const content = nodeFs.readFileSync(hookPath, "utf-8");
|
|
@@ -1118,10 +1758,10 @@ function uninstallHook(hooksDir) {
|
|
|
1118
1758
|
return null;
|
|
1119
1759
|
if (nodeFs.existsSync(backupPath)) {
|
|
1120
1760
|
nodeFs.renameSync(backupPath, hookPath);
|
|
1121
|
-
return
|
|
1761
|
+
return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
|
|
1122
1762
|
}
|
|
1123
1763
|
nodeFs.unlinkSync(hookPath);
|
|
1124
|
-
return
|
|
1764
|
+
return `.git/hooks/${hookName}`;
|
|
1125
1765
|
}
|
|
1126
1766
|
// Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
|
|
1127
1767
|
// at a scratch file instead of the real ~/.claude.json.
|
|
@@ -1168,6 +1808,108 @@ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()) {
|
|
|
1168
1808
|
nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
|
1169
1809
|
return true;
|
|
1170
1810
|
}
|
|
1811
|
+
function mergeMcpServersJson(configPath, distIndexPath) {
|
|
1812
|
+
let config = {};
|
|
1813
|
+
if (nodeFs.existsSync(configPath)) {
|
|
1814
|
+
try {
|
|
1815
|
+
config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
1816
|
+
}
|
|
1817
|
+
catch {
|
|
1818
|
+
config = {};
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
1822
|
+
config.mcpServers = {};
|
|
1823
|
+
}
|
|
1824
|
+
config.mcpServers.stackmem = { command: "node", args: [distIndexPath] };
|
|
1825
|
+
nodeFs.mkdirSync(nodePath.dirname(configPath), { recursive: true });
|
|
1826
|
+
nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
1827
|
+
}
|
|
1828
|
+
// VS Code installs the Copilot Chat extension under a version-suffixed
|
|
1829
|
+
// directory name (e.g. "github.copilot-chat-0.26.4") — this resolves the
|
|
1830
|
+
// "github.copilot-chat*" glob the task describes against whatever's
|
|
1831
|
+
// actually installed, rather than assuming a fixed folder name.
|
|
1832
|
+
function findCopilotChatExtensionDir(homeDir) {
|
|
1833
|
+
const extensionsDir = nodePath.join(homeDir, ".vscode", "extensions");
|
|
1834
|
+
if (!nodeFs.existsSync(extensionsDir))
|
|
1835
|
+
return null;
|
|
1836
|
+
try {
|
|
1837
|
+
const match = nodeFs
|
|
1838
|
+
.readdirSync(extensionsDir)
|
|
1839
|
+
.find((entry) => entry.startsWith("github.copilot-chat"));
|
|
1840
|
+
return match ? nodePath.join(extensionsDir, match) : null;
|
|
1841
|
+
}
|
|
1842
|
+
catch {
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
const EDITOR_MCP_TARGETS = [
|
|
1847
|
+
{
|
|
1848
|
+
name: "Cursor",
|
|
1849
|
+
configPath: (homeDir) => {
|
|
1850
|
+
const dir = nodePath.join(homeDir, ".cursor");
|
|
1851
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp.json") : null;
|
|
1852
|
+
},
|
|
1853
|
+
register: mergeMcpServersJson,
|
|
1854
|
+
},
|
|
1855
|
+
{
|
|
1856
|
+
name: "Windsurf",
|
|
1857
|
+
configPath: (homeDir) => {
|
|
1858
|
+
const dir = nodePath.join(homeDir, ".codeium", "windsurf");
|
|
1859
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp_config.json") : null;
|
|
1860
|
+
},
|
|
1861
|
+
register: mergeMcpServersJson,
|
|
1862
|
+
},
|
|
1863
|
+
{
|
|
1864
|
+
name: "VS Code (Copilot)",
|
|
1865
|
+
configPath: (homeDir) => {
|
|
1866
|
+
const extensionDir = findCopilotChatExtensionDir(homeDir);
|
|
1867
|
+
return extensionDir ? nodePath.join(extensionDir, "mcp.json") : null;
|
|
1868
|
+
},
|
|
1869
|
+
register: mergeMcpServersJson,
|
|
1870
|
+
},
|
|
1871
|
+
{
|
|
1872
|
+
name: "Zed",
|
|
1873
|
+
configPath: (homeDir) => {
|
|
1874
|
+
const dir = nodePath.join(homeDir, ".config", "zed");
|
|
1875
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "settings.json") : null;
|
|
1876
|
+
},
|
|
1877
|
+
register: (configPath, distIndexPath) => {
|
|
1878
|
+
let config = {};
|
|
1879
|
+
if (nodeFs.existsSync(configPath)) {
|
|
1880
|
+
try {
|
|
1881
|
+
config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
1882
|
+
}
|
|
1883
|
+
catch {
|
|
1884
|
+
config = {};
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
if (!config.context_servers || typeof config.context_servers !== "object") {
|
|
1888
|
+
config.context_servers = {};
|
|
1889
|
+
}
|
|
1890
|
+
config.context_servers.stackmem = { command: "node", args: [distIndexPath] };
|
|
1891
|
+
nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
1892
|
+
},
|
|
1893
|
+
},
|
|
1894
|
+
];
|
|
1895
|
+
// Registers stackmem's MCP server with every detected editor, printing a
|
|
1896
|
+
// line per success. An editor whose config directory doesn't exist is
|
|
1897
|
+
// skipped with no output and never an error.
|
|
1898
|
+
function registerEditorMcpServers(distIndexPath, homeDir = nodeOs.homedir()) {
|
|
1899
|
+
for (const target of EDITOR_MCP_TARGETS) {
|
|
1900
|
+
const configPath = target.configPath(homeDir);
|
|
1901
|
+
if (!configPath)
|
|
1902
|
+
continue;
|
|
1903
|
+
try {
|
|
1904
|
+
target.register(configPath, distIndexPath);
|
|
1905
|
+
console.log(`✓ MCP registered for ${target.name}`);
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
// Best-effort — a permissions error or malformed existing config on
|
|
1909
|
+
// one editor shouldn't abort `cm init` or the other editors' writes.
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1171
1913
|
const DEFAULT_GITIGNORE_CONTENT = `node_modules/
|
|
1172
1914
|
dist/
|
|
1173
1915
|
.env
|
|
@@ -1177,6 +1919,9 @@ dist/
|
|
|
1177
1919
|
coverage/
|
|
1178
1920
|
*-memories.json
|
|
1179
1921
|
*.db
|
|
1922
|
+
*cursorrules
|
|
1923
|
+
*windsurfrules
|
|
1924
|
+
.cursor/rules/stackmem.md
|
|
1180
1925
|
`;
|
|
1181
1926
|
// Returns true if the file was created, false if it already existed
|
|
1182
1927
|
// (and was left untouched).
|
|
@@ -1208,6 +1953,78 @@ cm save ${project} <type> "<content>"
|
|
|
1208
1953
|
nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
|
|
1209
1954
|
console.log("CLAUDE.md written.");
|
|
1210
1955
|
}
|
|
1956
|
+
// Same instructional purpose as writeClaudeMd — point the agent at
|
|
1957
|
+
// `cm start` rather than embedding a memory snapshot directly, since a
|
|
1958
|
+
// baked-in snapshot goes stale the moment the next commit lands. AGENTS.md
|
|
1959
|
+
// is the convention used by OpenAI Codex CLI, Gemini CLI, and others;
|
|
1960
|
+
// GEMINI.md is Gemini CLI's own equivalent — both get identical content.
|
|
1961
|
+
function writeAgentsMemoryFile(project, resolvedPath, filename) {
|
|
1962
|
+
const filePath = nodePath.join(resolvedPath, filename);
|
|
1963
|
+
if (nodeFs.existsSync(filePath)) {
|
|
1964
|
+
console.log(`${filename} already exists — skipping.`);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
const content = `# ${project} — Agent Memory
|
|
1968
|
+
|
|
1969
|
+
This project uses stackmem for persistent agent memory.
|
|
1970
|
+
Run \`cm start ${project}\` at the start of every session
|
|
1971
|
+
to load relevant context.
|
|
1972
|
+
|
|
1973
|
+
## Key decisions, constraints and discoveries
|
|
1974
|
+
|
|
1975
|
+
Decisions, constraints, discoveries, and rejections live in the stackmem
|
|
1976
|
+
backend, not duplicated in this file — run \`cm start ${project}\` to load
|
|
1977
|
+
the current list.
|
|
1978
|
+
|
|
1979
|
+
When you make a decision, discover a constraint, reject an approach, or
|
|
1980
|
+
make a discovery during this session, save it with:
|
|
1981
|
+
cm save ${project} <type> "<content>"
|
|
1982
|
+
`;
|
|
1983
|
+
nodeFs.writeFileSync(filePath, content, "utf-8");
|
|
1984
|
+
console.log(`${filename} written.`);
|
|
1985
|
+
}
|
|
1986
|
+
// --project (not positional) for the PreCompact command below: cmdContext's
|
|
1987
|
+
// CLI convention takes --project like cm search/cm context already do
|
|
1988
|
+
// everywhere else — cm start and cm session-end both take a positional
|
|
1989
|
+
// project, so those two keep the user's literal command shape.
|
|
1990
|
+
const STACKMEM_HOOK_EVENTS = [
|
|
1991
|
+
{ event: "SessionStart", buildCommand: (project) => `cm start ${project} --hook` },
|
|
1992
|
+
{ event: "Stop", buildCommand: (project) => `cm session-end ${project} --hook` },
|
|
1993
|
+
{ event: "PreCompact", buildCommand: (project) => `cm context --project ${project} --hook` },
|
|
1994
|
+
];
|
|
1995
|
+
// Writes (or merges into) <resolvedPath>/.claude/settings.json. Merges
|
|
1996
|
+
// rather than overwrites — an existing settings.json may have unrelated
|
|
1997
|
+
// hooks or settings that must survive this — and is idempotent: re-running
|
|
1998
|
+
// `cm init` checks for the exact command string already present in an
|
|
1999
|
+
// event's hook groups before adding another one.
|
|
2000
|
+
function writeClaudeCodeHooks(project, resolvedPath) {
|
|
2001
|
+
const settingsDir = nodePath.join(resolvedPath, ".claude");
|
|
2002
|
+
const settingsPath = nodePath.join(settingsDir, "settings.json");
|
|
2003
|
+
let settings = {};
|
|
2004
|
+
if (nodeFs.existsSync(settingsPath)) {
|
|
2005
|
+
try {
|
|
2006
|
+
settings = JSON.parse(nodeFs.readFileSync(settingsPath, "utf-8"));
|
|
2007
|
+
}
|
|
2008
|
+
catch {
|
|
2009
|
+
settings = {};
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
if (!settings.hooks || typeof settings.hooks !== "object") {
|
|
2013
|
+
settings.hooks = {};
|
|
2014
|
+
}
|
|
2015
|
+
for (const { event, buildCommand } of STACKMEM_HOOK_EVENTS) {
|
|
2016
|
+
const command = buildCommand(project);
|
|
2017
|
+
if (!Array.isArray(settings.hooks[event])) {
|
|
2018
|
+
settings.hooks[event] = [];
|
|
2019
|
+
}
|
|
2020
|
+
const alreadyRegistered = settings.hooks[event].some((group) => Array.isArray(group?.hooks) && group.hooks.some((h) => h?.command === command));
|
|
2021
|
+
if (!alreadyRegistered) {
|
|
2022
|
+
settings.hooks[event].push({ hooks: [{ type: "command", command }] });
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
nodeFs.mkdirSync(settingsDir, { recursive: true });
|
|
2026
|
+
nodeFs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
2027
|
+
}
|
|
1211
2028
|
// Section order for the generated rules-file markdown — matches the order
|
|
1212
2029
|
// memories are presented in everywhere else (cm context's CONTEXT_TYPE_ORDER),
|
|
1213
2030
|
// not MEMORY_TYPES' declaration order.
|
|
@@ -1221,13 +2038,15 @@ async function generateRulesContent(project) {
|
|
|
1221
2038
|
.from("memories")
|
|
1222
2039
|
.select("*")
|
|
1223
2040
|
.eq("project", project)
|
|
1224
|
-
.eq("resolved", false)
|
|
2041
|
+
.eq("resolved", false)
|
|
2042
|
+
.eq("suggested", false);
|
|
1225
2043
|
if (memoriesError)
|
|
1226
2044
|
fail(`Failed to load memories: ${memoriesError.message}`);
|
|
2045
|
+
const config = readStackmemConfig();
|
|
1227
2046
|
const scoredMemories = (memoryRows ?? [])
|
|
1228
2047
|
.map((memory) => ({
|
|
1229
2048
|
...memory,
|
|
1230
|
-
decay_score: computeDecayScore(
|
|
2049
|
+
decay_score: computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
|
|
1231
2050
|
}))
|
|
1232
2051
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
1233
2052
|
const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
|
|
@@ -1289,6 +2108,18 @@ function writeCursorRules(resolvedPath, content) {
|
|
|
1289
2108
|
console.log(".cursorrules written.");
|
|
1290
2109
|
return true;
|
|
1291
2110
|
}
|
|
2111
|
+
// Cursor's newer rules format (.cursor/rules/*.md) — .cursorrules is
|
|
2112
|
+
// legacy but still read by older Cursor versions, so both are written
|
|
2113
|
+
// during the transition rather than one replacing the other. Unlike
|
|
2114
|
+
// writeCursorRules/writeWindsurfRules (write-once at init, skip if
|
|
2115
|
+
// present), this one unconditionally overwrites on every call — both at
|
|
2116
|
+
// init and on every `cm sync` — since it's gitignored content meant to
|
|
2117
|
+
// always reflect the current memory state, same as the legacy files.
|
|
2118
|
+
function writeCursorRulesMdFile(resolvedPath, content) {
|
|
2119
|
+
const rulesDir = nodePath.join(resolvedPath, ".cursor", "rules");
|
|
2120
|
+
nodeFs.mkdirSync(rulesDir, { recursive: true });
|
|
2121
|
+
nodeFs.writeFileSync(nodePath.join(rulesDir, "stackmem.md"), content, "utf-8");
|
|
2122
|
+
}
|
|
1292
2123
|
// Returns true if the file was written, false if it already existed
|
|
1293
2124
|
// (and was skipped).
|
|
1294
2125
|
function writeWindsurfRules(resolvedPath, content) {
|
|
@@ -1319,6 +2150,10 @@ export async function trySeedMemory(project, existingMemories, type, content, cl
|
|
|
1319
2150
|
type,
|
|
1320
2151
|
content,
|
|
1321
2152
|
device_id: getDeviceId(),
|
|
2153
|
+
// Seeded by `cm init` scanning project files — an explicit user-run
|
|
2154
|
+
// command, not an auto-captured commit, so it's "manual" like cm save,
|
|
2155
|
+
// not the memories.source column's default of "hook".
|
|
2156
|
+
source: "manual",
|
|
1322
2157
|
});
|
|
1323
2158
|
return !error;
|
|
1324
2159
|
}
|
|
@@ -1427,7 +2262,17 @@ async function verifyBackendConnection(project) {
|
|
|
1427
2262
|
.eq("project", project);
|
|
1428
2263
|
return !error;
|
|
1429
2264
|
}
|
|
1430
|
-
|
|
2265
|
+
// Returns true if the file was created, false if it already existed
|
|
2266
|
+
// (and was left untouched).
|
|
2267
|
+
function writeStackmemConfigFile(resolvedPath) {
|
|
2268
|
+
const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
|
|
2269
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2270
|
+
return false;
|
|
2271
|
+
}
|
|
2272
|
+
nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
|
|
2273
|
+
return true;
|
|
2274
|
+
}
|
|
2275
|
+
async function cmdInit(project, targetPath, writeConfig = false) {
|
|
1431
2276
|
const resolvedPath = nodePath.resolve(targetPath);
|
|
1432
2277
|
const gitDir = nodePath.join(resolvedPath, ".git");
|
|
1433
2278
|
if (!nodeFs.existsSync(gitDir)) {
|
|
@@ -1441,25 +2286,40 @@ async function cmdInit(project, targetPath) {
|
|
|
1441
2286
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1442
2287
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1443
2288
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1444
|
-
|
|
2289
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
2290
|
+
installHook(hooksDir, hookLogicPath, hookName);
|
|
2291
|
+
}
|
|
1445
2292
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
1446
2293
|
registerMcpServer(DIST_INDEX_PATH);
|
|
2294
|
+
registerEditorMcpServers(DIST_INDEX_PATH);
|
|
1447
2295
|
writeClaudeMd(project, resolvedPath);
|
|
2296
|
+
writeAgentsMemoryFile(project, resolvedPath, "AGENTS.md");
|
|
2297
|
+
writeAgentsMemoryFile(project, resolvedPath, "GEMINI.md");
|
|
2298
|
+
writeClaudeCodeHooks(project, resolvedPath);
|
|
1448
2299
|
const seededCount = await seedMemories(project, resolvedPath);
|
|
1449
2300
|
console.log(`Seeded ${seededCount} initial memories from project scan.`);
|
|
1450
2301
|
const rulesContent = await generateRulesContent(project);
|
|
1451
2302
|
const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
|
|
1452
2303
|
const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
|
|
2304
|
+
writeCursorRulesMdFile(resolvedPath, rulesContent);
|
|
1453
2305
|
const backendOk = await verifyBackendConnection(project);
|
|
2306
|
+
const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
|
|
1454
2307
|
console.log("");
|
|
1455
2308
|
if (gitignoreCreated) {
|
|
1456
2309
|
console.log("✓ .gitignore created");
|
|
1457
2310
|
}
|
|
2311
|
+
if (writeConfig) {
|
|
2312
|
+
console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
|
|
2313
|
+
}
|
|
1458
2314
|
console.log("✓ Git hook installed");
|
|
1459
2315
|
console.log("✓ MCP server registered");
|
|
2316
|
+
console.log("✓ Claude Code hooks registered (.claude/settings.json)");
|
|
1460
2317
|
console.log("✓ CLAUDE.md written");
|
|
2318
|
+
console.log("✓ AGENTS.md written");
|
|
2319
|
+
console.log("✓ GEMINI.md written");
|
|
1461
2320
|
console.log(`✓ .cursorrules ${cursorRulesWritten ? "written" : "already exists"}`);
|
|
1462
2321
|
console.log(`✓ .windsurfrules ${windsurfRulesWritten ? "written" : "already exists"}`);
|
|
2322
|
+
console.log("✓ .cursor/rules/stackmem.md written");
|
|
1463
2323
|
console.log(`✓ Seeded ${seededCount} memories from project scan`);
|
|
1464
2324
|
if (backendOk) {
|
|
1465
2325
|
console.log("✓ Backend connection verified");
|
|
@@ -1480,7 +2340,8 @@ async function cmdSync(project) {
|
|
|
1480
2340
|
const rulesContent = await generateRulesContent(project);
|
|
1481
2341
|
nodeFs.writeFileSync(nodePath.join(resolvedPath, ".cursorrules"), rulesContent, "utf-8");
|
|
1482
2342
|
nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
|
|
1483
|
-
|
|
2343
|
+
writeCursorRulesMdFile(resolvedPath, rulesContent);
|
|
2344
|
+
console.log("Synced memories to .cursorrules, .windsurfrules, and .cursor/rules/stackmem.md");
|
|
1484
2345
|
}
|
|
1485
2346
|
// --- cm link ---------------------------------------------------------------
|
|
1486
2347
|
// Mirrors the link_codes.expires_at default in the migration
|
|
@@ -1549,36 +2410,150 @@ async function cmdLink() {
|
|
|
1549
2410
|
}
|
|
1550
2411
|
console.log("Link code expired. Run cm link again.");
|
|
1551
2412
|
}
|
|
2413
|
+
// --- cm open -----------------------------------------------------------
|
|
2414
|
+
// Separated from cmdOpen so the platform-selection logic is unit-testable
|
|
2415
|
+
// without actually launching a browser — exercising this for real on
|
|
2416
|
+
// every platform isn't something a test (or a verification pass) should
|
|
2417
|
+
// do as a side effect.
|
|
2418
|
+
export function getOpenCommand(platform, url) {
|
|
2419
|
+
if (platform === "darwin")
|
|
2420
|
+
return { command: "open", args: [url] };
|
|
2421
|
+
if (platform === "win32")
|
|
2422
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
2423
|
+
return { command: "xdg-open", args: [url] };
|
|
2424
|
+
}
|
|
2425
|
+
async function cmdOpen(project) {
|
|
2426
|
+
const url = `${VAULT_URL}/project/${project}`;
|
|
2427
|
+
const { command, args } = getOpenCommand(process.platform, url);
|
|
2428
|
+
try {
|
|
2429
|
+
execFileSync(command, args, { stdio: "ignore" });
|
|
2430
|
+
console.log(`Opened ${url}`);
|
|
2431
|
+
}
|
|
2432
|
+
catch (error) {
|
|
2433
|
+
fail(`Failed to open browser (${error instanceof Error ? error.message : String(error)}). Visit ${url} manually.`);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
// --- cm purge ------------------------------------------------------------
|
|
2437
|
+
async function cmdPurgeProject(project) {
|
|
2438
|
+
const { count: total, error: countError } = await supabase
|
|
2439
|
+
.from("memories")
|
|
2440
|
+
.select("id", { count: "exact", head: true })
|
|
2441
|
+
.eq("project", project);
|
|
2442
|
+
if (countError)
|
|
2443
|
+
fail(`Failed to count memories: ${countError.message}`);
|
|
2444
|
+
console.log(`This will permanently delete all ${total ?? 0} memories for "${project}".`);
|
|
2445
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2446
|
+
const answer = (await rl.question("Type the project name to confirm: ")).trim();
|
|
2447
|
+
rl.close();
|
|
2448
|
+
if (answer !== project) {
|
|
2449
|
+
console.log("Confirmation did not match — aborted. Nothing was deleted.");
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
const { data: memRows, error: memFetchError } = await supabase
|
|
2453
|
+
.from("memories")
|
|
2454
|
+
.select("id")
|
|
2455
|
+
.eq("project", project);
|
|
2456
|
+
if (memFetchError)
|
|
2457
|
+
fail(`Failed to load memories: ${memFetchError.message}`);
|
|
2458
|
+
const ids = (memRows ?? []).map((m) => m.id);
|
|
2459
|
+
let linksDeleted = 0;
|
|
2460
|
+
if (ids.length > 0) {
|
|
2461
|
+
const idList = ids.join(",");
|
|
2462
|
+
const { count, error: linksError } = await supabase
|
|
2463
|
+
.from("memory_links")
|
|
2464
|
+
.delete({ count: "exact" })
|
|
2465
|
+
.or(`source_id.in.(${idList}),target_id.in.(${idList})`);
|
|
2466
|
+
if (linksError)
|
|
2467
|
+
fail(`Failed to delete memory links: ${linksError.message}`);
|
|
2468
|
+
linksDeleted = count ?? 0;
|
|
2469
|
+
}
|
|
2470
|
+
const { count: memoriesDeleted, error: memError } = await supabase
|
|
2471
|
+
.from("memories")
|
|
2472
|
+
.delete({ count: "exact" })
|
|
2473
|
+
.eq("project", project);
|
|
2474
|
+
if (memError)
|
|
2475
|
+
fail(`Failed to delete memories: ${memError.message}`);
|
|
2476
|
+
const { count: fixesDeleted, error: fixError } = await supabase
|
|
2477
|
+
.from("execution_log")
|
|
2478
|
+
.delete({ count: "exact" })
|
|
2479
|
+
.eq("project", project);
|
|
2480
|
+
if (fixError)
|
|
2481
|
+
fail(`Failed to delete fixes: ${fixError.message}`);
|
|
2482
|
+
console.log(`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted} links for "${project}".`);
|
|
2483
|
+
}
|
|
2484
|
+
// RLS already scopes every table read/write to this device's device_id
|
|
2485
|
+
// via the x-device-id header (see src/storage.ts) — a filter-less delete
|
|
2486
|
+
// here is already confined to this device, never another one. PostgREST
|
|
2487
|
+
// still requires an explicit filter clause to allow a DELETE at all, so
|
|
2488
|
+
// `.not("id", "is", null)` (every row has a non-null id) is used purely
|
|
2489
|
+
// to satisfy that, not to narrow the scope further.
|
|
2490
|
+
async function cmdPurgeAll() {
|
|
2491
|
+
console.log("This will permanently delete ALL memories across ALL projects for this device.");
|
|
2492
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2493
|
+
const answer = (await rl.question("Type DELETE to confirm: ")).trim();
|
|
2494
|
+
rl.close();
|
|
2495
|
+
if (answer !== "DELETE") {
|
|
2496
|
+
console.log("Confirmation did not match — aborted. Nothing was deleted.");
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
const { count: linksDeleted, error: linksError } = await supabase
|
|
2500
|
+
.from("memory_links")
|
|
2501
|
+
.delete({ count: "exact" })
|
|
2502
|
+
.not("id", "is", null);
|
|
2503
|
+
if (linksError)
|
|
2504
|
+
fail(`Failed to delete memory links: ${linksError.message}`);
|
|
2505
|
+
const { count: memoriesDeleted, error: memError } = await supabase
|
|
2506
|
+
.from("memories")
|
|
2507
|
+
.delete({ count: "exact" })
|
|
2508
|
+
.not("id", "is", null);
|
|
2509
|
+
if (memError)
|
|
2510
|
+
fail(`Failed to delete memories: ${memError.message}`);
|
|
2511
|
+
const { count: fixesDeleted, error: fixError } = await supabase
|
|
2512
|
+
.from("execution_log")
|
|
2513
|
+
.delete({ count: "exact" })
|
|
2514
|
+
.not("id", "is", null);
|
|
2515
|
+
if (fixError)
|
|
2516
|
+
fail(`Failed to delete fixes: ${fixError.message}`);
|
|
2517
|
+
console.log(`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted ?? 0} links across all projects.`);
|
|
2518
|
+
}
|
|
1552
2519
|
// --- cm context ------------------------------------------------------------
|
|
1553
2520
|
const CONTEXT_TYPE_ORDER = ["decision", "constraint", "rejection", "discovery"];
|
|
1554
2521
|
const CONTEXT_TOP_N = 5;
|
|
1555
|
-
|
|
2522
|
+
// task is null for the PreCompact hook — there's no user-typed task at a
|
|
2523
|
+
// compaction boundary, just "save the most relevant context" generically
|
|
2524
|
+
// — so ranking falls back to decay score alone instead of diluting it
|
|
2525
|
+
// with a relevance score computed against an empty string.
|
|
2526
|
+
async function cmdContext(project, task, hookMode = false) {
|
|
1556
2527
|
const { data, error } = await supabase
|
|
1557
2528
|
.from("memories")
|
|
1558
2529
|
.select("*")
|
|
1559
2530
|
.eq("project", project)
|
|
1560
|
-
.eq("resolved", false)
|
|
2531
|
+
.eq("resolved", false)
|
|
2532
|
+
.eq("suggested", false);
|
|
1561
2533
|
if (error)
|
|
1562
2534
|
fail(`Failed to load memories: ${error.message}`);
|
|
1563
2535
|
const memories = data ?? [];
|
|
1564
2536
|
if (memories.length === 0) {
|
|
1565
|
-
|
|
2537
|
+
if (!hookMode)
|
|
2538
|
+
console.log(`No memories found for "${project}".`);
|
|
1566
2539
|
return;
|
|
1567
2540
|
}
|
|
2541
|
+
const config = readStackmemConfig();
|
|
2542
|
+
const tokenBudget = config.tokenBudget;
|
|
1568
2543
|
const scoredMemories = memories
|
|
1569
2544
|
.map((memory) => {
|
|
1570
|
-
const decayScore = computeDecayScore(
|
|
1571
|
-
const
|
|
1572
|
-
return {
|
|
1573
|
-
...memory,
|
|
1574
|
-
combined_score: relevanceScore * 0.6 + decayScore * 0.4,
|
|
1575
|
-
};
|
|
2545
|
+
const decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
|
|
2546
|
+
const combinedScore = task == null ? decayScore : scoreMemoryRelevance(memory.content, task) * 0.6 + decayScore * 0.4;
|
|
2547
|
+
return { ...memory, combined_score: combinedScore };
|
|
1576
2548
|
})
|
|
1577
2549
|
.sort((a, b) => b.combined_score - a.combined_score);
|
|
1578
2550
|
const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
|
|
1579
|
-
const budgetedMemories = applyContextBudget(topMemories,
|
|
2551
|
+
const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
|
|
1580
2552
|
const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
|
|
1581
|
-
const lines = [`## Project Context: ${project}
|
|
2553
|
+
const lines = [`## Project Context: ${project}`];
|
|
2554
|
+
if (task != null)
|
|
2555
|
+
lines.push(`### Task: ${task}`);
|
|
2556
|
+
lines.push("");
|
|
1582
2557
|
for (const type of CONTEXT_TYPE_ORDER) {
|
|
1583
2558
|
const group = budgetedMemories.filter((memory) => memory.type === type);
|
|
1584
2559
|
if (group.length === 0)
|
|
@@ -1589,9 +2564,64 @@ async function cmdContext(project, task) {
|
|
|
1589
2564
|
}
|
|
1590
2565
|
lines.push("");
|
|
1591
2566
|
}
|
|
1592
|
-
|
|
2567
|
+
if (!hookMode)
|
|
2568
|
+
lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
|
|
1593
2569
|
console.log(lines.join("\n"));
|
|
1594
2570
|
}
|
|
2571
|
+
// --- cm why --------------------------------------------------------------
|
|
2572
|
+
function fileMatchesPaths(filePaths, file, baseName) {
|
|
2573
|
+
if (!Array.isArray(filePaths))
|
|
2574
|
+
return false;
|
|
2575
|
+
return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
|
|
2576
|
+
}
|
|
2577
|
+
function fileMentionedInContent(content, file, baseName) {
|
|
2578
|
+
const lower = content.toLowerCase();
|
|
2579
|
+
return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
|
|
2580
|
+
}
|
|
2581
|
+
async function cmdWhy(project, file) {
|
|
2582
|
+
const baseName = file.split("/").pop() ?? file;
|
|
2583
|
+
const { data, error } = await supabase
|
|
2584
|
+
.from("memories")
|
|
2585
|
+
.select("*")
|
|
2586
|
+
.eq("project", project)
|
|
2587
|
+
.eq("resolved", false)
|
|
2588
|
+
.eq("suggested", false);
|
|
2589
|
+
if (error)
|
|
2590
|
+
fail(`Failed to load memories: ${error.message}`);
|
|
2591
|
+
const memories = data ?? [];
|
|
2592
|
+
// file_paths (structured, from Item 6) and a content substring search
|
|
2593
|
+
// are combined into one match set rather than one gating the other as
|
|
2594
|
+
// a strict fallback — most memories predate file_paths existing at all
|
|
2595
|
+
// (manual saves and seeds never set it), so content search is the
|
|
2596
|
+
// primary signal for everything except recent hook-captured commits,
|
|
2597
|
+
// not a rare last resort.
|
|
2598
|
+
const matched = memories.filter((memory) => fileMatchesPaths(memory.file_paths, file, baseName) ||
|
|
2599
|
+
fileMentionedInContent(memory.content, file, baseName));
|
|
2600
|
+
if (matched.length === 0) {
|
|
2601
|
+
console.log(`No memories found for ${file}.`);
|
|
2602
|
+
console.log("Make some commits touching this file to build up context.");
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
const config = readStackmemConfig();
|
|
2606
|
+
const scoredMemories = matched
|
|
2607
|
+
.map((memory) => ({
|
|
2608
|
+
...memory,
|
|
2609
|
+
decay_score: computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
|
|
2610
|
+
}))
|
|
2611
|
+
.sort((a, b) => b.decay_score - a.decay_score);
|
|
2612
|
+
console.log(`Decisions and constraints tied to ${file}:\n`);
|
|
2613
|
+
for (const type of MEMORY_TYPES) {
|
|
2614
|
+
const group = scoredMemories.filter((memory) => memory.type === type);
|
|
2615
|
+
if (group.length === 0)
|
|
2616
|
+
continue;
|
|
2617
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
2618
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
2619
|
+
for (const memory of group) {
|
|
2620
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
2621
|
+
}
|
|
2622
|
+
console.log("");
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
1595
2625
|
function splitCommaList(value) {
|
|
1596
2626
|
if (!value)
|
|
1597
2627
|
return [];
|
|
@@ -1632,6 +2662,11 @@ function parseSessionEndFlags(args) {
|
|
|
1632
2662
|
approaches = splitCommaList(value);
|
|
1633
2663
|
i++;
|
|
1634
2664
|
}
|
|
2665
|
+
else if (flag === "--hook") {
|
|
2666
|
+
// Recognized explicitly (not just silently skipped as an unknown
|
|
2667
|
+
// flag) — see the comment on cmdSessionEnd for why it's currently
|
|
2668
|
+
// a no-op here specifically.
|
|
2669
|
+
}
|
|
1635
2670
|
}
|
|
1636
2671
|
return { project, summary, errors, files, approaches };
|
|
1637
2672
|
}
|
|
@@ -1651,6 +2686,16 @@ async function readStdinJson() {
|
|
|
1651
2686
|
return null;
|
|
1652
2687
|
}
|
|
1653
2688
|
}
|
|
2689
|
+
// --hook is accepted (see parseSessionEndFlags) but currently a no-op:
|
|
2690
|
+
// Claude Code's Stop hook pipes ITS OWN JSON payload on stdin (session
|
|
2691
|
+
// id, transcript path, cwd — see the Claude Code hooks docs), not the
|
|
2692
|
+
// {summary, errors_encountered, files_touched, approaches_tried} shape
|
|
2693
|
+
// this command expects. readStdinJson() parses that payload successfully
|
|
2694
|
+
// (it's valid JSON) but finds none of the fields it looks for, so every
|
|
2695
|
+
// array below comes back empty and this reports 0 memories saved — a
|
|
2696
|
+
// real, known limitation, not a bug. Extracting genuine learnings from
|
|
2697
|
+
// the Stop hook would mean parsing the session transcript itself, which
|
|
2698
|
+
// is out of scope here (Phase 2, per the user).
|
|
1654
2699
|
async function cmdSessionEnd(project, input) {
|
|
1655
2700
|
let memoriesSaved = 0;
|
|
1656
2701
|
let errorsLogged = 0;
|
|
@@ -1762,6 +2807,124 @@ function isHookExecutable(hookPath) {
|
|
|
1762
2807
|
return false;
|
|
1763
2808
|
}
|
|
1764
2809
|
}
|
|
2810
|
+
// --- cm status ---------------------------------------------------------
|
|
2811
|
+
function hasMcpServersStackmemKey(configPath) {
|
|
2812
|
+
if (!nodeFs.existsSync(configPath))
|
|
2813
|
+
return false;
|
|
2814
|
+
try {
|
|
2815
|
+
const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
2816
|
+
return Boolean(config?.mcpServers?.stackmem);
|
|
2817
|
+
}
|
|
2818
|
+
catch {
|
|
2819
|
+
return false;
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
function hasContextServersStackmemKey(configPath) {
|
|
2823
|
+
if (!nodeFs.existsSync(configPath))
|
|
2824
|
+
return false;
|
|
2825
|
+
try {
|
|
2826
|
+
const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
2827
|
+
return Boolean(config?.context_servers?.stackmem);
|
|
2828
|
+
}
|
|
2829
|
+
catch {
|
|
2830
|
+
return false;
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
const EDITOR_STATUS_CHECKS = [
|
|
2834
|
+
{ name: "Claude Code", isRegistered: (homeDir) => isMcpRegistered(nodePath.join(homeDir, ".claude.json")) },
|
|
2835
|
+
{ name: "Cursor", isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".cursor", "mcp.json")) },
|
|
2836
|
+
{
|
|
2837
|
+
name: "Windsurf",
|
|
2838
|
+
isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".codeium", "windsurf", "mcp_config.json")),
|
|
2839
|
+
},
|
|
2840
|
+
{
|
|
2841
|
+
name: "VS Code",
|
|
2842
|
+
isRegistered: (homeDir) => {
|
|
2843
|
+
const dir = findCopilotChatExtensionDir(homeDir);
|
|
2844
|
+
return dir ? hasMcpServersStackmemKey(nodePath.join(dir, "mcp.json")) : false;
|
|
2845
|
+
},
|
|
2846
|
+
},
|
|
2847
|
+
{
|
|
2848
|
+
name: "Zed",
|
|
2849
|
+
isRegistered: (homeDir) => hasContextServersStackmemKey(nodePath.join(homeDir, ".config", "zed", "settings.json")),
|
|
2850
|
+
},
|
|
2851
|
+
];
|
|
2852
|
+
async function cmdStatus(project) {
|
|
2853
|
+
const { count: totalCount, error: totalError } = await supabase
|
|
2854
|
+
.from("memories")
|
|
2855
|
+
.select("id", { count: "exact", head: true })
|
|
2856
|
+
.eq("project", project)
|
|
2857
|
+
.eq("resolved", false);
|
|
2858
|
+
if (totalError)
|
|
2859
|
+
fail(`Failed to load memory count: ${totalError.message}`);
|
|
2860
|
+
const { count: pinnedCount, error: pinnedError } = await supabase
|
|
2861
|
+
.from("memories")
|
|
2862
|
+
.select("id", { count: "exact", head: true })
|
|
2863
|
+
.eq("project", project)
|
|
2864
|
+
.eq("resolved", false)
|
|
2865
|
+
.eq("pinned", true);
|
|
2866
|
+
if (pinnedError)
|
|
2867
|
+
fail(`Failed to load pinned count: ${pinnedError.message}`);
|
|
2868
|
+
const { count: suggestedCount, error: suggestedError } = await supabase
|
|
2869
|
+
.from("memories")
|
|
2870
|
+
.select("id", { count: "exact", head: true })
|
|
2871
|
+
.eq("project", project)
|
|
2872
|
+
.eq("resolved", false)
|
|
2873
|
+
.eq("suggested", true);
|
|
2874
|
+
if (suggestedError)
|
|
2875
|
+
fail(`Failed to load suggested count: ${suggestedError.message}`);
|
|
2876
|
+
const { count: fixesCount, error: fixesError } = await supabase
|
|
2877
|
+
.from("execution_log")
|
|
2878
|
+
.select("id", { count: "exact", head: true })
|
|
2879
|
+
.eq("project", project)
|
|
2880
|
+
.eq("resolved", false);
|
|
2881
|
+
if (fixesError)
|
|
2882
|
+
fail(`Failed to load fixes count: ${fixesError.message}`);
|
|
2883
|
+
const { data: lastCaptureRows, error: lastCaptureError } = await supabase
|
|
2884
|
+
.from("memories")
|
|
2885
|
+
.select("content, created_at")
|
|
2886
|
+
.eq("project", project)
|
|
2887
|
+
.order("created_at", { ascending: false })
|
|
2888
|
+
.limit(1);
|
|
2889
|
+
if (lastCaptureError)
|
|
2890
|
+
fail(`Failed to load last capture: ${lastCaptureError.message}`);
|
|
2891
|
+
const config = readStackmemConfig();
|
|
2892
|
+
const configPath = nodePath.join(process.cwd(), STACKMEM_CONFIG_FILENAME);
|
|
2893
|
+
let usesCustomBudget = false;
|
|
2894
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2895
|
+
try {
|
|
2896
|
+
const raw = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
2897
|
+
usesCustomBudget = typeof raw.tokenBudget === "number";
|
|
2898
|
+
}
|
|
2899
|
+
catch {
|
|
2900
|
+
// Malformed config — readStackmemConfig already falls back to the
|
|
2901
|
+
// default in this case too, so "default" is the accurate label.
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
const hookOk = isHookExecutable(nodePath.join(process.cwd(), ".git", "hooks", "post-commit"));
|
|
2905
|
+
const mcpOk = isMcpRegistered(getClaudeConfigPath());
|
|
2906
|
+
const backendOk = await verifyBackendConnection(project);
|
|
2907
|
+
const homeDir = nodeOs.homedir();
|
|
2908
|
+
const registeredEditors = EDITOR_STATUS_CHECKS.filter((e) => e.isRegistered(homeDir)).map((e) => e.name);
|
|
2909
|
+
console.log(`stackmem status for "${project}"`);
|
|
2910
|
+
console.log("━".repeat(27));
|
|
2911
|
+
console.log(`Memories: ${totalCount ?? 0} total (${pinnedCount ?? 0} pinned, ${suggestedCount ?? 0} suggested)`);
|
|
2912
|
+
console.log(`Fixes: ${fixesCount ?? 0} recorded`);
|
|
2913
|
+
console.log(`Token budget: ${config.tokenBudget} (${usesCustomBudget ? "from .stackmem.json" : "default"})`);
|
|
2914
|
+
const lastCapture = (lastCaptureRows ?? [])[0];
|
|
2915
|
+
if (lastCapture) {
|
|
2916
|
+
const ago = formatRelativeTime(parseUtcDate(lastCapture.created_at));
|
|
2917
|
+
const snippet = lastCapture.content.length > 50 ? `${lastCapture.content.slice(0, 50)}…` : lastCapture.content;
|
|
2918
|
+
console.log(`Last capture: ${ago} (${snippet})`);
|
|
2919
|
+
}
|
|
2920
|
+
else {
|
|
2921
|
+
console.log("Last capture: none yet");
|
|
2922
|
+
}
|
|
2923
|
+
console.log(`Hook: ${hookOk ? "✓ installed" : "✗ not installed"}`);
|
|
2924
|
+
console.log(`MCP: ${mcpOk ? "✓ registered" : "✗ not registered"}`);
|
|
2925
|
+
console.log(`Backend: ${backendOk ? "✓ connected" : "✗ not connected"}`);
|
|
2926
|
+
console.log(`Editors: ${registeredEditors.length > 0 ? registeredEditors.map((name) => `${name} ✓`).join(" ") : "(none registered)"}`);
|
|
2927
|
+
}
|
|
1765
2928
|
async function cmdDoctor(fix) {
|
|
1766
2929
|
let issues = 0;
|
|
1767
2930
|
const project = readProjectFromFile();
|
|
@@ -1828,7 +2991,9 @@ async function cmdDoctor(fix) {
|
|
|
1828
2991
|
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
1829
2992
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
1830
2993
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1831
|
-
|
|
2994
|
+
for (const name of MANAGED_HOOK_NAMES) {
|
|
2995
|
+
installHook(hooksDir, hookLogicPath, name);
|
|
2996
|
+
}
|
|
1832
2997
|
if (isHookExecutable(hookPath)) {
|
|
1833
2998
|
console.log("Fixed: git hook");
|
|
1834
2999
|
issues--;
|
|
@@ -1998,9 +3163,11 @@ function removeIfMarked(filePath, marker, label) {
|
|
|
1998
3163
|
async function cmdUninstall(project, targetPath) {
|
|
1999
3164
|
const gitDir = nodePath.join(targetPath, ".git");
|
|
2000
3165
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
2001
|
-
const
|
|
2002
|
-
|
|
2003
|
-
|
|
3166
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
3167
|
+
const hookResult = uninstallHook(hooksDir, hookName);
|
|
3168
|
+
if (hookResult)
|
|
3169
|
+
console.log(`Removed ${hookResult}`);
|
|
3170
|
+
}
|
|
2004
3171
|
const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
|
|
2005
3172
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
2006
3173
|
if (nodeFs.existsSync(hookLogicPath)) {
|
|
@@ -2034,19 +3201,32 @@ async function main() {
|
|
|
2034
3201
|
const [, , command, ...args] = process.argv;
|
|
2035
3202
|
switch (command) {
|
|
2036
3203
|
case "start": {
|
|
2037
|
-
const
|
|
3204
|
+
const hookIndex = args.indexOf("--hook");
|
|
3205
|
+
const hookMode = hookIndex !== -1;
|
|
3206
|
+
const positional = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
|
|
3207
|
+
const split = splitOptionalProject(positional, 0);
|
|
2038
3208
|
if (!split)
|
|
2039
3209
|
usage();
|
|
2040
3210
|
const project = requireProject(split.project);
|
|
2041
|
-
await cmdStart(project);
|
|
3211
|
+
await cmdStart(project, hookMode);
|
|
2042
3212
|
break;
|
|
2043
3213
|
}
|
|
2044
3214
|
case "save": {
|
|
2045
3215
|
const forceIndex = args.indexOf("--force");
|
|
2046
3216
|
const force = forceIndex !== -1;
|
|
2047
|
-
|
|
3217
|
+
let positional = force
|
|
2048
3218
|
? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
|
|
2049
3219
|
: args;
|
|
3220
|
+
const commitShaFlag = extractValueFlag(positional, "--commit-sha");
|
|
3221
|
+
positional = commitShaFlag.rest;
|
|
3222
|
+
const authorFlag = extractValueFlag(positional, "--author");
|
|
3223
|
+
positional = authorFlag.rest;
|
|
3224
|
+
const sourceFlag = extractValueFlag(positional, "--source");
|
|
3225
|
+
positional = sourceFlag.rest;
|
|
3226
|
+
const confidenceFlag = extractValueFlag(positional, "--confidence");
|
|
3227
|
+
positional = confidenceFlag.rest;
|
|
3228
|
+
const filesFlag = extractValueFlag(positional, "--files");
|
|
3229
|
+
positional = filesFlag.rest;
|
|
2050
3230
|
let explicitProject;
|
|
2051
3231
|
let type;
|
|
2052
3232
|
let rest;
|
|
@@ -2071,7 +3251,30 @@ async function main() {
|
|
|
2071
3251
|
if (!type || rest.length === 0)
|
|
2072
3252
|
usage();
|
|
2073
3253
|
const project = requireProject(explicitProject);
|
|
2074
|
-
|
|
3254
|
+
const provenance = {};
|
|
3255
|
+
if (commitShaFlag.value)
|
|
3256
|
+
provenance.commitSha = commitShaFlag.value;
|
|
3257
|
+
if (authorFlag.value)
|
|
3258
|
+
provenance.author = authorFlag.value;
|
|
3259
|
+
if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
|
|
3260
|
+
provenance.source = sourceFlag.value;
|
|
3261
|
+
}
|
|
3262
|
+
if (confidenceFlag.value) {
|
|
3263
|
+
const parsed = Number(confidenceFlag.value);
|
|
3264
|
+
if (!Number.isNaN(parsed))
|
|
3265
|
+
provenance.confidence = parsed;
|
|
3266
|
+
}
|
|
3267
|
+
if (filesFlag.value) {
|
|
3268
|
+
provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
|
|
3269
|
+
}
|
|
3270
|
+
await cmdSave(project, type, rest.join(" "), force, provenance);
|
|
3271
|
+
break;
|
|
3272
|
+
}
|
|
3273
|
+
case "check-commit": {
|
|
3274
|
+
const [project, commitSha] = args;
|
|
3275
|
+
if (!project || !commitSha)
|
|
3276
|
+
usage();
|
|
3277
|
+
await cmdCheckCommit(project, commitSha);
|
|
2075
3278
|
break;
|
|
2076
3279
|
}
|
|
2077
3280
|
case "fix": {
|
|
@@ -2109,6 +3312,41 @@ async function main() {
|
|
|
2109
3312
|
await cmdResolve(project, memoryId);
|
|
2110
3313
|
break;
|
|
2111
3314
|
}
|
|
3315
|
+
case "pin": {
|
|
3316
|
+
const split = splitOptionalProject(args, 1);
|
|
3317
|
+
if (!split)
|
|
3318
|
+
usage();
|
|
3319
|
+
const project = requireProject(split.project);
|
|
3320
|
+
const [memoryId] = split.rest;
|
|
3321
|
+
await cmdPin(project, memoryId);
|
|
3322
|
+
break;
|
|
3323
|
+
}
|
|
3324
|
+
case "unpin": {
|
|
3325
|
+
const split = splitOptionalProject(args, 1);
|
|
3326
|
+
if (!split)
|
|
3327
|
+
usage();
|
|
3328
|
+
const project = requireProject(split.project);
|
|
3329
|
+
const [memoryId] = split.rest;
|
|
3330
|
+
await cmdUnpin(project, memoryId);
|
|
3331
|
+
break;
|
|
3332
|
+
}
|
|
3333
|
+
case "review": {
|
|
3334
|
+
const split = splitProjectFromVariadic(args);
|
|
3335
|
+
if (split.rest.length > 0)
|
|
3336
|
+
usage();
|
|
3337
|
+
const project = requireProject(split.project);
|
|
3338
|
+
await cmdReview(project);
|
|
3339
|
+
break;
|
|
3340
|
+
}
|
|
3341
|
+
case "edit": {
|
|
3342
|
+
const split = splitOptionalProject(args, 1);
|
|
3343
|
+
if (!split)
|
|
3344
|
+
usage();
|
|
3345
|
+
const project = requireProject(split.project);
|
|
3346
|
+
const [memoryId] = split.rest;
|
|
3347
|
+
await cmdEdit(project, memoryId);
|
|
3348
|
+
break;
|
|
3349
|
+
}
|
|
2112
3350
|
case "delete": {
|
|
2113
3351
|
const split = splitOptionalProject(args, 1);
|
|
2114
3352
|
if (!split)
|
|
@@ -2136,10 +3374,15 @@ async function main() {
|
|
|
2136
3374
|
break;
|
|
2137
3375
|
}
|
|
2138
3376
|
case "init": {
|
|
2139
|
-
const
|
|
3377
|
+
const configIndex = args.indexOf("--config");
|
|
3378
|
+
const writeConfig = configIndex !== -1;
|
|
3379
|
+
const positional = writeConfig
|
|
3380
|
+
? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
|
|
3381
|
+
: args;
|
|
3382
|
+
const [project, targetPath] = positional;
|
|
2140
3383
|
if (!project || !targetPath)
|
|
2141
3384
|
usage();
|
|
2142
|
-
await cmdInit(project, targetPath);
|
|
3385
|
+
await cmdInit(project, targetPath, writeConfig);
|
|
2143
3386
|
break;
|
|
2144
3387
|
}
|
|
2145
3388
|
case "link": {
|
|
@@ -2148,6 +3391,30 @@ async function main() {
|
|
|
2148
3391
|
await cmdLink();
|
|
2149
3392
|
break;
|
|
2150
3393
|
}
|
|
3394
|
+
case "open": {
|
|
3395
|
+
const split = splitProjectFromVariadic(args);
|
|
3396
|
+
if (split.rest.length > 0)
|
|
3397
|
+
usage();
|
|
3398
|
+
const project = requireProject(split.project);
|
|
3399
|
+
await cmdOpen(project);
|
|
3400
|
+
break;
|
|
3401
|
+
}
|
|
3402
|
+
case "purge": {
|
|
3403
|
+
const allIndex = args.indexOf("--all");
|
|
3404
|
+
const isAll = allIndex !== -1;
|
|
3405
|
+
const withoutAllFlag = isAll ? [...args.slice(0, allIndex), ...args.slice(allIndex + 1)] : args;
|
|
3406
|
+
const split = splitProjectFromVariadic(withoutAllFlag);
|
|
3407
|
+
if (split.rest.length > 0)
|
|
3408
|
+
usage();
|
|
3409
|
+
if (isAll) {
|
|
3410
|
+
await cmdPurgeAll();
|
|
3411
|
+
}
|
|
3412
|
+
else {
|
|
3413
|
+
const project = requireProject(split.project);
|
|
3414
|
+
await cmdPurgeProject(project);
|
|
3415
|
+
}
|
|
3416
|
+
break;
|
|
3417
|
+
}
|
|
2151
3418
|
case "sync": {
|
|
2152
3419
|
if (args.length > 0)
|
|
2153
3420
|
usage();
|
|
@@ -2156,11 +3423,25 @@ async function main() {
|
|
|
2156
3423
|
break;
|
|
2157
3424
|
}
|
|
2158
3425
|
case "context": {
|
|
3426
|
+
const hookIndex = args.indexOf("--hook");
|
|
3427
|
+
const hookMode = hookIndex !== -1;
|
|
3428
|
+
const withoutHookFlag = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
|
|
3429
|
+
const split = splitProjectFromVariadic(withoutHookFlag);
|
|
3430
|
+
const project = requireProject(split.project);
|
|
3431
|
+
// task is optional only in --hook mode (the PreCompact hook has no
|
|
3432
|
+
// user-typed task to pass) — interactive use still requires one.
|
|
3433
|
+
if (split.rest.length === 0 && !hookMode)
|
|
3434
|
+
usage();
|
|
3435
|
+
const task = split.rest.length > 0 ? split.rest.join(" ") : null;
|
|
3436
|
+
await cmdContext(project, task, hookMode);
|
|
3437
|
+
break;
|
|
3438
|
+
}
|
|
3439
|
+
case "why": {
|
|
2159
3440
|
const split = splitProjectFromVariadic(args);
|
|
2160
3441
|
const project = requireProject(split.project);
|
|
2161
3442
|
if (split.rest.length === 0)
|
|
2162
3443
|
usage();
|
|
2163
|
-
await
|
|
3444
|
+
await cmdWhy(project, split.rest.join(" "));
|
|
2164
3445
|
break;
|
|
2165
3446
|
}
|
|
2166
3447
|
case "session-end": {
|
|
@@ -2172,6 +3453,14 @@ async function main() {
|
|
|
2172
3453
|
await cmdDoctor(fix);
|
|
2173
3454
|
break;
|
|
2174
3455
|
}
|
|
3456
|
+
case "status": {
|
|
3457
|
+
const split = splitProjectFromVariadic(args);
|
|
3458
|
+
if (split.rest.length > 0)
|
|
3459
|
+
usage();
|
|
3460
|
+
const project = requireProject(split.project);
|
|
3461
|
+
await cmdStatus(project);
|
|
3462
|
+
break;
|
|
3463
|
+
}
|
|
2175
3464
|
case "undo": {
|
|
2176
3465
|
const split = splitOptionalProject(args, 0);
|
|
2177
3466
|
if (!split)
|