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