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/src/cli.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
isCodeFile,
|
|
25
25
|
hasTokenOverlap,
|
|
26
26
|
isQualityMessage,
|
|
27
|
+
parseUtcDate,
|
|
27
28
|
redactSecrets,
|
|
28
29
|
scoreMemoryRelevance,
|
|
29
30
|
tokenize,
|
|
@@ -71,7 +72,7 @@ const TYPE_LABELS: Record<MemoryType, string> = {
|
|
|
71
72
|
|
|
72
73
|
function usage(): never {
|
|
73
74
|
console.error(`Usage:
|
|
74
|
-
cm start [project]
|
|
75
|
+
cm start [project] [--hook]
|
|
75
76
|
cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
|
|
76
77
|
cm fix [project] <problem> <solution>
|
|
77
78
|
cm analyze [project] <path>
|
|
@@ -79,17 +80,22 @@ function usage(): never {
|
|
|
79
80
|
cm resolve [project] <memory-id>
|
|
80
81
|
cm pin [project] <memory-id>
|
|
81
82
|
cm unpin [project] <memory-id>
|
|
83
|
+
cm review [--project <name>]
|
|
84
|
+
cm edit [project] <memory-id>
|
|
82
85
|
cm delete [project] <memory-id>
|
|
83
86
|
cm inspect [project] <memory-id>
|
|
84
87
|
cm compress [project]
|
|
85
88
|
cm init <project> <path> [--config]
|
|
86
89
|
cm uninstall [project] [path]
|
|
87
90
|
cm link
|
|
91
|
+
cm open [--project <name>]
|
|
92
|
+
cm purge [--project <name>] [--all]
|
|
88
93
|
cm sync
|
|
89
|
-
cm context [--project <name>] <task>
|
|
94
|
+
cm context [--project <name>] <task> [--hook]
|
|
90
95
|
cm why [--project <name>] <file>
|
|
91
|
-
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
96
|
+
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
|
|
92
97
|
cm doctor [--fix]
|
|
98
|
+
cm status [--project <name>]
|
|
93
99
|
cm undo [project]
|
|
94
100
|
cm export [filename]
|
|
95
101
|
cm import [project] <filename>
|
|
@@ -111,11 +117,13 @@ function cmdHelp() {
|
|
|
111
117
|
[project] is optional everywhere below if a .stackmem file exists in
|
|
112
118
|
the current directory (written by 'cm init'). Otherwise pass it explicitly.
|
|
113
119
|
|
|
114
|
-
cm start [project]
|
|
120
|
+
cm start [project] [--hook]
|
|
115
121
|
Load all unresolved memories for a project, sorted by decay
|
|
116
122
|
score and capped at a 2000 token context budget, grouped by
|
|
117
123
|
type (decisions, constraints, discoveries, rejections), with
|
|
118
|
-
linked memory ids shown under each entry.
|
|
124
|
+
linked memory ids shown under each entry. --hook switches to
|
|
125
|
+
plain-text output (no ids, scores, or separator lines) for use
|
|
126
|
+
as a Claude Code SessionStart hook command — see cm init.
|
|
119
127
|
|
|
120
128
|
cm save [project] <type> <content> [--force]
|
|
121
129
|
Save a new memory. <type> is one of: decision, rejection,
|
|
@@ -147,6 +155,18 @@ function cmdHelp() {
|
|
|
147
155
|
cm unpin [project] <memory-id>
|
|
148
156
|
Un-pin a memory, returning it to normal decay-based ranking.
|
|
149
157
|
|
|
158
|
+
cm review [--project <name>]
|
|
159
|
+
Walk through suggested memories one at a time (hook-captured
|
|
160
|
+
memories start as suggested — see cm save/cm init). For each:
|
|
161
|
+
(k)eep promotes it to permanent, (e)dit opens $EDITOR (or an
|
|
162
|
+
inline prompt) for new content then keeps it, (d)rop deletes
|
|
163
|
+
it, (s)kip leaves it suggested for next time, (q)uit stops and
|
|
164
|
+
reports progress so far. Prints a kept/dropped/skipped summary.
|
|
165
|
+
|
|
166
|
+
cm edit [project] <memory-id>
|
|
167
|
+
Open a memory's content in $EDITOR (or an inline prompt if
|
|
168
|
+
$EDITOR isn't set), save the result.
|
|
169
|
+
|
|
150
170
|
cm delete [project] <memory-id>
|
|
151
171
|
Delete a memory and any memory_links referencing it.
|
|
152
172
|
|
|
@@ -194,6 +214,20 @@ function cmdHelp() {
|
|
|
194
214
|
single-use and expires after 10 minutes; polling stops when it
|
|
195
215
|
does. Links are per-device, so run this once on each machine.
|
|
196
216
|
|
|
217
|
+
cm open [--project <name>]
|
|
218
|
+
Open the hosted vault's page for this project in the default
|
|
219
|
+
browser (open on Mac, xdg-open on Linux, start on Windows).
|
|
220
|
+
|
|
221
|
+
cm purge [--project <name>] [--all]
|
|
222
|
+
Permanently delete memories, fixes, and memory_links — with a
|
|
223
|
+
typed confirmation first, never deletes on a flag alone.
|
|
224
|
+
--project (or the one in .stackmem): deletes that project's
|
|
225
|
+
data only, after you type the exact project name to confirm.
|
|
226
|
+
--all: deletes everything for this device's device_id across
|
|
227
|
+
every project, after you type DELETE to confirm. Prints the
|
|
228
|
+
count of deleted records, or "Confirmation did not match" if
|
|
229
|
+
you abort.
|
|
230
|
+
|
|
197
231
|
cm sync
|
|
198
232
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
199
233
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
@@ -201,11 +235,13 @@ function cmdHelp() {
|
|
|
201
235
|
overwriting both files on disk only (never git add/commit).
|
|
202
236
|
Project is read from .stackmem.
|
|
203
237
|
|
|
204
|
-
cm context [--project <name>] <task>
|
|
238
|
+
cm context [--project <name>] <task> [--hook]
|
|
205
239
|
Return only the top 5 memories most relevant to a specific
|
|
206
240
|
task, ranked by a blend of task relevance and decay score,
|
|
207
241
|
as a markdown block capped at a 2000 token budget. Use
|
|
208
242
|
--project to target a project other than the one in .stackmem.
|
|
243
|
+
<task> is optional with --hook (ranks by decay alone) — used
|
|
244
|
+
as a Claude Code PreCompact hook command; see cm init.
|
|
209
245
|
|
|
210
246
|
cm why [--project <name>] <file>
|
|
211
247
|
Show memories tied to a file: matched by Item 6's file_paths
|
|
@@ -215,7 +251,7 @@ function cmdHelp() {
|
|
|
215
251
|
nothing matches. Use --project to target a project other than
|
|
216
252
|
the one in .stackmem.
|
|
217
253
|
|
|
218
|
-
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
254
|
+
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
|
|
219
255
|
Capture what happened in a session and extract memories: each
|
|
220
256
|
error becomes an unsolved execution_log entry, each approach
|
|
221
257
|
containing "failed", "didn't work", "reverted", or "switched
|
|
@@ -225,6 +261,11 @@ function cmdHelp() {
|
|
|
225
261
|
Accepts the same fields as JSON on stdin instead of flags
|
|
226
262
|
(project, summary, errors_encountered, files_touched,
|
|
227
263
|
approaches_tried) when stdin is piped rather than a terminal.
|
|
264
|
+
--hook is accepted for use as a Claude Code Stop hook command
|
|
265
|
+
(see cm init) but is currently a no-op: Claude Code pipes its
|
|
266
|
+
own hook payload on stdin, not this command's expected shape,
|
|
267
|
+
so a Stop-triggered run captures 0 memories until a future
|
|
268
|
+
phase parses the session transcript directly.
|
|
228
269
|
|
|
229
270
|
cm doctor [--fix]
|
|
230
271
|
Check every integration point in the current directory — backend
|
|
@@ -237,6 +278,12 @@ function cmdHelp() {
|
|
|
237
278
|
— those print guidance instead. Reports "Fixed: <item>" or
|
|
238
279
|
"Could not auto-fix: <item>" for each failing check.
|
|
239
280
|
|
|
281
|
+
cm status [--project <name>]
|
|
282
|
+
One-screen project summary: memory/pinned/suggested counts,
|
|
283
|
+
recorded fixes, the active token budget (and whether it's the
|
|
284
|
+
default or from .stackmem.json), time since the last capture,
|
|
285
|
+
and hook/MCP/backend/editor registration status.
|
|
286
|
+
|
|
240
287
|
cm undo [project]
|
|
241
288
|
Delete the most recently saved unresolved memory (and any
|
|
242
289
|
memory_links referencing it) for a project.
|
|
@@ -265,11 +312,35 @@ function fail(message: string): never {
|
|
|
265
312
|
|
|
266
313
|
const STACKMEM_FILE = ".stackmem";
|
|
267
314
|
|
|
315
|
+
const MONOREPO_PARENT_LOOKUP_DEPTH = 3;
|
|
316
|
+
|
|
317
|
+
// Checks cwd, then up to 3 parent directories, for a .stackmem file — a
|
|
318
|
+
// monorepo subpackage (e.g. packages/api) won't have its own .stackmem,
|
|
319
|
+
// but the repo root will. When found in an ancestor, the project name
|
|
320
|
+
// gets the intervening path segments appended, e.g. "myapp/packages/api"
|
|
321
|
+
// — so a subpackage still reads/writes under its own namespace within
|
|
322
|
+
// the shared project, rather than silently resolving to just "myapp".
|
|
268
323
|
function readProjectFromFile(): string | null {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
324
|
+
let dir = process.cwd();
|
|
325
|
+
const subdirSegments: string[] = [];
|
|
326
|
+
|
|
327
|
+
for (let depth = 0; depth <= MONOREPO_PARENT_LOOKUP_DEPTH; depth++) {
|
|
328
|
+
const stackmemPath = nodePath.join(dir, STACKMEM_FILE);
|
|
329
|
+
if (nodeFs.existsSync(stackmemPath)) {
|
|
330
|
+
const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
|
|
331
|
+
if (content.length === 0) return null;
|
|
332
|
+
return subdirSegments.length > 0
|
|
333
|
+
? `${content}/${subdirSegments.reverse().join("/")}`
|
|
334
|
+
: content;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const parent = nodePath.dirname(dir);
|
|
338
|
+
if (parent === dir) break; // reached the filesystem root
|
|
339
|
+
subdirSegments.push(nodePath.basename(dir));
|
|
340
|
+
dir = parent;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return null;
|
|
273
344
|
}
|
|
274
345
|
|
|
275
346
|
// Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
|
|
@@ -352,24 +423,52 @@ const BRANCH_NAME_BOOST = 1.2;
|
|
|
352
423
|
const RECENT_FILES_BOOST = 1.3;
|
|
353
424
|
const PR_TITLE_BOOST = 1.2;
|
|
354
425
|
|
|
355
|
-
// null means "signal unavailable" (git/gh failed,
|
|
356
|
-
//
|
|
357
|
-
//
|
|
426
|
+
// null means "signal unavailable" (git/gh failed, not enough history, gh
|
|
427
|
+
// not installed, no open PR, etc.) — every one of these is a soft skip,
|
|
428
|
+
// never an error, per the task spec.
|
|
358
429
|
function getBranchNameTokens(): Set<string> | null {
|
|
430
|
+
let branch: string;
|
|
359
431
|
try {
|
|
360
|
-
|
|
432
|
+
// symbolic-ref (not rev-parse --abbrev-ref) specifically because it
|
|
433
|
+
// fails on a detached HEAD rather than returning the literal string
|
|
434
|
+
// "HEAD" — that failure is the signal this needs to distinguish a
|
|
435
|
+
// real detached-HEAD state from "not a git repo at all" below.
|
|
436
|
+
branch = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
|
|
361
437
|
encoding: "utf-8",
|
|
362
438
|
stdio: ["ignore", "pipe", "ignore"],
|
|
363
439
|
}).trim();
|
|
364
|
-
if (!branch || branch === "HEAD") return null; // detached HEAD
|
|
365
|
-
const tokens = tokenize(branch);
|
|
366
|
-
return tokens.size > 0 ? tokens : null;
|
|
367
440
|
} catch {
|
|
368
|
-
|
|
441
|
+
try {
|
|
442
|
+
execFileSync("git", ["rev-parse", "HEAD"], { stdio: "pipe" });
|
|
443
|
+
// HEAD resolves to a real commit but symbolic-ref failed — genuinely
|
|
444
|
+
// detached, not a missing repo. Still a valid state to rank in, not
|
|
445
|
+
// a reason to skip the signal entirely.
|
|
446
|
+
branch = "detached";
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
369
450
|
}
|
|
451
|
+
|
|
452
|
+
const tokens = tokenize(branch);
|
|
453
|
+
return tokens.size > 0 ? tokens : null;
|
|
370
454
|
}
|
|
371
455
|
|
|
372
456
|
function getRecentlyTouchedFiles(): Set<string> | null {
|
|
457
|
+
try {
|
|
458
|
+
const isShallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
|
|
459
|
+
encoding: "utf-8",
|
|
460
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
461
|
+
}).trim();
|
|
462
|
+
// A shallow clone can't look back RECENT_COMMITS_FOR_FILE_SIGNAL
|
|
463
|
+
// commits — degrade gracefully rather than attempting (and either
|
|
464
|
+
// failing, or silently returning an incomplete picture of "recent").
|
|
465
|
+
if (isShallow === "true") return null;
|
|
466
|
+
} catch {
|
|
467
|
+
// --is-shallow-repository itself failing (very old git, or no repo at
|
|
468
|
+
// all) isn't a reason to skip on its own — the lookback below will
|
|
469
|
+
// fail (and get caught) or succeed on its own merits either way.
|
|
470
|
+
}
|
|
471
|
+
|
|
373
472
|
try {
|
|
374
473
|
const output = execFileSync(
|
|
375
474
|
"git",
|
|
@@ -413,20 +512,62 @@ function fileListOverlaps(filePaths: unknown, recentFiles: Set<string>): boolean
|
|
|
413
512
|
});
|
|
414
513
|
}
|
|
415
514
|
|
|
416
|
-
|
|
515
|
+
// Plain-text, no ids/scores/box-drawing — meant to be injected directly
|
|
516
|
+
// into a Claude Code SessionStart hook's context, not read by a human at
|
|
517
|
+
// a terminal (that's the default, non---hook rendering below).
|
|
518
|
+
function printStartHookOutput(
|
|
519
|
+
project: string,
|
|
520
|
+
pinnedMemories: any[],
|
|
521
|
+
unpinnedMemories: any[],
|
|
522
|
+
budgetedFixes: { problem: string; solution: string | null }[]
|
|
523
|
+
): void {
|
|
524
|
+
console.log(`# Project memory: ${project}`);
|
|
525
|
+
|
|
526
|
+
if (pinnedMemories.length > 0) {
|
|
527
|
+
console.log("");
|
|
528
|
+
console.log("## Pinned");
|
|
529
|
+
for (const memory of pinnedMemories) {
|
|
530
|
+
console.log(`- ${memory.content}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
for (const type of MEMORY_TYPES) {
|
|
535
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
536
|
+
if (group.length === 0) continue;
|
|
537
|
+
|
|
538
|
+
console.log("");
|
|
539
|
+
console.log(`## ${TYPE_LABELS[type]}`);
|
|
540
|
+
for (const memory of group) {
|
|
541
|
+
console.log(`- ${memory.content}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (budgetedFixes.length > 0) {
|
|
546
|
+
console.log("");
|
|
547
|
+
console.log("## Past Fixes");
|
|
548
|
+
for (const fix of budgetedFixes) {
|
|
549
|
+
console.log(`- ${fix.problem} -> ${fix.solution ?? "(pending)"}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function cmdStart(project: string, hookMode = false) {
|
|
417
555
|
const { data, error } = await supabase
|
|
418
556
|
.from("memories")
|
|
419
557
|
.select("*")
|
|
420
558
|
.eq("project", project)
|
|
421
|
-
.eq("resolved", false)
|
|
559
|
+
.eq("resolved", false)
|
|
560
|
+
.eq("suggested", false);
|
|
422
561
|
|
|
423
562
|
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
424
563
|
|
|
425
564
|
const memories = data ?? [];
|
|
426
565
|
if (memories.length === 0) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
566
|
+
if (!hookMode) {
|
|
567
|
+
console.log(`No memories found for "${project}".`);
|
|
568
|
+
console.log("");
|
|
569
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
570
|
+
}
|
|
430
571
|
return;
|
|
431
572
|
}
|
|
432
573
|
|
|
@@ -444,7 +585,7 @@ async function cmdStart(project: string) {
|
|
|
444
585
|
const scoredMemories = memories
|
|
445
586
|
.map((memory) => {
|
|
446
587
|
let decayScore = computeDecayScore(
|
|
447
|
-
|
|
588
|
+
parseUtcDate(memory.created_at),
|
|
448
589
|
memory.access_count ?? 0,
|
|
449
590
|
memory.pinned ?? false,
|
|
450
591
|
config.decayLambda
|
|
@@ -510,59 +651,63 @@ async function cmdStart(project: string) {
|
|
|
510
651
|
linkedIdsByMemory.get(link.target_id)!.add(link.source_id);
|
|
511
652
|
}
|
|
512
653
|
|
|
513
|
-
console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
|
|
514
|
-
|
|
515
654
|
const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
|
|
516
655
|
const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
|
|
517
656
|
|
|
518
|
-
if (
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
657
|
+
if (hookMode) {
|
|
658
|
+
printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes);
|
|
659
|
+
} else {
|
|
660
|
+
console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
|
|
661
|
+
|
|
662
|
+
if (pinnedMemories.length > 0) {
|
|
663
|
+
console.log(`Pinned (${pinnedMemories.length})`);
|
|
664
|
+
console.log("-".repeat(11));
|
|
665
|
+
for (const memory of pinnedMemories) {
|
|
666
|
+
console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
|
|
667
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
668
|
+
if (linked.length > 0) {
|
|
669
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
670
|
+
}
|
|
526
671
|
}
|
|
672
|
+
console.log("");
|
|
527
673
|
}
|
|
528
|
-
console.log("");
|
|
529
|
-
}
|
|
530
674
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
675
|
+
for (const type of MEMORY_TYPES) {
|
|
676
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
677
|
+
if (group.length === 0) continue;
|
|
534
678
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
679
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
680
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
681
|
+
for (const memory of group) {
|
|
682
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
683
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
684
|
+
if (linked.length > 0) {
|
|
685
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
686
|
+
}
|
|
542
687
|
}
|
|
688
|
+
console.log("");
|
|
543
689
|
}
|
|
544
|
-
console.log("");
|
|
545
|
-
}
|
|
546
690
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
691
|
+
if (budgetedFixes.length > 0) {
|
|
692
|
+
console.log(`Past Fixes (${budgetedFixes.length})`);
|
|
693
|
+
console.log("-".repeat(14));
|
|
694
|
+
for (const fix of budgetedFixes) {
|
|
695
|
+
console.log(` Problem: ${fix.problem}`);
|
|
696
|
+
console.log(` Solution: ${fix.solution ?? "(pending)"}`);
|
|
697
|
+
console.log("");
|
|
698
|
+
}
|
|
554
699
|
}
|
|
555
|
-
}
|
|
556
700
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
701
|
+
console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
|
|
702
|
+
if (omittedCount > 0) {
|
|
703
|
+
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
704
|
+
}
|
|
561
705
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
706
|
+
const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
|
|
707
|
+
if (!hasTechStackMemory) {
|
|
708
|
+
console.log("");
|
|
709
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
710
|
+
}
|
|
566
711
|
}
|
|
567
712
|
|
|
568
713
|
const incrementResults = await Promise.all(
|
|
@@ -681,6 +826,11 @@ async function cmdSave(
|
|
|
681
826
|
device_id: getDeviceId(),
|
|
682
827
|
source,
|
|
683
828
|
confidence,
|
|
829
|
+
// Hook-captured memories are heuristically detected and could be
|
|
830
|
+
// noisy or wrong — they start as suggested, pending `cm review`, and
|
|
831
|
+
// are excluded from cm start/context/why until promoted. Manual and
|
|
832
|
+
// mcp saves are already user-vetted, so they're permanent immediately.
|
|
833
|
+
suggested: source === "hook",
|
|
684
834
|
};
|
|
685
835
|
if (commitSha) insertPayload.commit_sha = commitSha;
|
|
686
836
|
if (filePaths && filePaths.length > 0) insertPayload.file_paths = filePaths;
|
|
@@ -767,7 +917,8 @@ async function compressMemories(
|
|
|
767
917
|
.from("memories")
|
|
768
918
|
.select("*")
|
|
769
919
|
.eq("project", project)
|
|
770
|
-
.eq("resolved", false)
|
|
920
|
+
.eq("resolved", false)
|
|
921
|
+
.eq("suggested", false);
|
|
771
922
|
|
|
772
923
|
if (error) fail(`Failed to load memories for compression: ${error.message}`);
|
|
773
924
|
|
|
@@ -971,10 +1122,173 @@ async function cmdDelete(project: string, memoryId: string) {
|
|
|
971
1122
|
console.log(`Deleted memory [${memoryId}]`);
|
|
972
1123
|
}
|
|
973
1124
|
|
|
1125
|
+
// --- shared: relative time + $EDITOR prompt (cm review, cm edit, cm status)
|
|
1126
|
+
|
|
1127
|
+
function formatRelativeTime(date: Date): string {
|
|
1128
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
1129
|
+
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"} ago`;
|
|
1130
|
+
const minutes = Math.floor(seconds / 60);
|
|
1131
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
1132
|
+
const hours = Math.floor(minutes / 60);
|
|
1133
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
1134
|
+
const days = Math.floor(hours / 24);
|
|
1135
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Opens $EDITOR on a scratch file pre-filled with the current content, or
|
|
1139
|
+
// falls back to an inline readline prompt when $EDITOR isn't set. Returns
|
|
1140
|
+
// the original content unchanged if the result is blank (an empty save
|
|
1141
|
+
// buffer almost certainly means "I didn't mean to clear this", not "make
|
|
1142
|
+
// this memory empty").
|
|
1143
|
+
async function editContentInteractively(currentContent: string): Promise<string> {
|
|
1144
|
+
const editor = process.env.EDITOR;
|
|
1145
|
+
|
|
1146
|
+
if (editor) {
|
|
1147
|
+
const tmpFile = nodePath.join(nodeOs.tmpdir(), `stackmem-edit-${randomInt(1_000_000_000)}.txt`);
|
|
1148
|
+
nodeFs.writeFileSync(tmpFile, currentContent, "utf-8");
|
|
1149
|
+
try {
|
|
1150
|
+
execFileSync(editor, [tmpFile], { stdio: "inherit" });
|
|
1151
|
+
const edited = nodeFs.readFileSync(tmpFile, "utf-8").trim();
|
|
1152
|
+
return edited.length > 0 ? edited : currentContent;
|
|
1153
|
+
} finally {
|
|
1154
|
+
try {
|
|
1155
|
+
nodeFs.unlinkSync(tmpFile);
|
|
1156
|
+
} catch {
|
|
1157
|
+
// Best-effort cleanup — a leftover temp file isn't worth failing on.
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
console.log(`Current: ${currentContent}`);
|
|
1163
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1164
|
+
const answer = (await rl.question("New content (leave blank to keep unchanged): ")).trim();
|
|
1165
|
+
rl.close();
|
|
1166
|
+
return answer.length > 0 ? answer : currentContent;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// --- cm review -------------------------------------------------------------
|
|
1170
|
+
|
|
1171
|
+
async function cmdReview(project: string): Promise<void> {
|
|
1172
|
+
const { data, error } = await supabase
|
|
1173
|
+
.from("memories")
|
|
1174
|
+
.select("*")
|
|
1175
|
+
.eq("project", project)
|
|
1176
|
+
.eq("resolved", false)
|
|
1177
|
+
.eq("suggested", true)
|
|
1178
|
+
.order("created_at", { ascending: true });
|
|
1179
|
+
|
|
1180
|
+
if (error) fail(`Failed to load suggested memories: ${error.message}`);
|
|
1181
|
+
|
|
1182
|
+
const memories = data ?? [];
|
|
1183
|
+
if (memories.length === 0) {
|
|
1184
|
+
console.log(`No memories to review for "${project}".`);
|
|
1185
|
+
console.log("Your inbox is empty.");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
console.log(`Reviewing ${memories.length} suggested memories for "${project}"`);
|
|
1190
|
+
console.log("━".repeat(40));
|
|
1191
|
+
|
|
1192
|
+
// Node's readline can't reliably answer multiple sequential
|
|
1193
|
+
// rl.question() calls over piped (non-TTY) stdin — all buffered input
|
|
1194
|
+
// arrives before the second question is even issued, and it never
|
|
1195
|
+
// resolves. A real interactive terminal doesn't hit this (keystrokes
|
|
1196
|
+
// arrive one at a time), so only the non-TTY path needs the
|
|
1197
|
+
// read-everything-up-front workaround.
|
|
1198
|
+
const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
1199
|
+
const pipedAnswers: string[] = rl
|
|
1200
|
+
? []
|
|
1201
|
+
: (
|
|
1202
|
+
await new Promise<string>((resolve) => {
|
|
1203
|
+
let buf = "";
|
|
1204
|
+
process.stdin.setEncoding("utf-8");
|
|
1205
|
+
process.stdin.on("data", (chunk) => (buf += chunk));
|
|
1206
|
+
process.stdin.on("end", () => resolve(buf));
|
|
1207
|
+
})
|
|
1208
|
+
).split("\n");
|
|
1209
|
+
|
|
1210
|
+
async function nextAnswer(prompt: string): Promise<string> {
|
|
1211
|
+
if (rl) return (await rl.question(prompt)).trim().toLowerCase();
|
|
1212
|
+
console.log(prompt);
|
|
1213
|
+
return (pipedAnswers.shift() ?? "q").trim().toLowerCase();
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
let kept = 0;
|
|
1217
|
+
let dropped = 0;
|
|
1218
|
+
let skipped = 0;
|
|
1219
|
+
|
|
1220
|
+
for (let i = 0; i < memories.length; i++) {
|
|
1221
|
+
const memory = memories[i];
|
|
1222
|
+
console.log(`[${i + 1}/${memories.length}] ${memory.type}: ${memory.content}`);
|
|
1223
|
+
console.log(` Captured: ${formatRelativeTime(parseUtcDate(memory.created_at))}`);
|
|
1224
|
+
console.log("");
|
|
1225
|
+
|
|
1226
|
+
const answer = await nextAnswer("(k)eep (e)dit (d)rop (s)kip (q)uit > ");
|
|
1227
|
+
console.log("");
|
|
1228
|
+
|
|
1229
|
+
if (answer === "k") {
|
|
1230
|
+
const { error: updateError } = await supabase
|
|
1231
|
+
.from("memories")
|
|
1232
|
+
.update({ suggested: false })
|
|
1233
|
+
.eq("id", memory.id);
|
|
1234
|
+
if (!updateError) kept++;
|
|
1235
|
+
} else if (answer === "e") {
|
|
1236
|
+
const newContent = redactSecrets(await editContentInteractively(memory.content));
|
|
1237
|
+
const { error: updateError } = await supabase
|
|
1238
|
+
.from("memories")
|
|
1239
|
+
.update({ content: newContent, suggested: false })
|
|
1240
|
+
.eq("id", memory.id);
|
|
1241
|
+
if (!updateError) kept++;
|
|
1242
|
+
} else if (answer === "d") {
|
|
1243
|
+
await supabase
|
|
1244
|
+
.from("memory_links")
|
|
1245
|
+
.delete()
|
|
1246
|
+
.or(`source_id.eq.${memory.id},target_id.eq.${memory.id}`);
|
|
1247
|
+
const { error: deleteError } = await supabase.from("memories").delete().eq("id", memory.id);
|
|
1248
|
+
if (!deleteError) dropped++;
|
|
1249
|
+
} else if (answer === "q") {
|
|
1250
|
+
break;
|
|
1251
|
+
} else {
|
|
1252
|
+
// "s", blank, or anything unrecognized — leave it suggested and
|
|
1253
|
+
// move on rather than erroring on a mistyped key.
|
|
1254
|
+
skipped++;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
rl?.close();
|
|
1259
|
+
console.log(`Review complete. Kept ${kept}, dropped ${dropped}, skipped ${skipped}.`);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// --- cm edit ---------------------------------------------------------------
|
|
1263
|
+
|
|
1264
|
+
async function cmdEdit(project: string, memoryId: string): Promise<void> {
|
|
1265
|
+
const { data: memory, error } = await supabase
|
|
1266
|
+
.from("memories")
|
|
1267
|
+
.select("id, content")
|
|
1268
|
+
.eq("project", project)
|
|
1269
|
+
.eq("id", memoryId)
|
|
1270
|
+
.single();
|
|
1271
|
+
|
|
1272
|
+
if (error || !memory) {
|
|
1273
|
+
console.log(`No memory found with ID [${memoryId}]`);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const newContent = redactSecrets(await editContentInteractively(memory.content));
|
|
1278
|
+
const { error: updateError } = await supabase
|
|
1279
|
+
.from("memories")
|
|
1280
|
+
.update({ content: newContent })
|
|
1281
|
+
.eq("id", memoryId);
|
|
1282
|
+
|
|
1283
|
+
if (updateError) fail(`Failed to update memory: ${updateError.message}`);
|
|
1284
|
+
|
|
1285
|
+
console.log(`Updated memory [${memoryId}].`);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
974
1288
|
// --- cm inspect ------------------------------------------------------------
|
|
975
1289
|
|
|
976
1290
|
function formatDate(dateString: string): string {
|
|
977
|
-
return
|
|
1291
|
+
return parseUtcDate(dateString).toLocaleDateString("en-US", {
|
|
978
1292
|
month: "short",
|
|
979
1293
|
day: "numeric",
|
|
980
1294
|
year: "numeric",
|
|
@@ -993,7 +1307,7 @@ async function cmdInspect(project: string, memoryId: string) {
|
|
|
993
1307
|
|
|
994
1308
|
const config = readStackmemConfig();
|
|
995
1309
|
const decayScore = computeDecayScore(
|
|
996
|
-
|
|
1310
|
+
parseUtcDate(memory.created_at),
|
|
997
1311
|
memory.access_count ?? 0,
|
|
998
1312
|
memory.pinned ?? false,
|
|
999
1313
|
config.decayLambda
|
|
@@ -1717,6 +2031,122 @@ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()): boolean
|
|
|
1717
2031
|
return true;
|
|
1718
2032
|
}
|
|
1719
2033
|
|
|
2034
|
+
// --- MCP registration for other editors ---------------------------------
|
|
2035
|
+
|
|
2036
|
+
// Each entry detects its editor by an existing config directory (never
|
|
2037
|
+
// erroring if absent — "skip silently" per the task) and merges stackmem
|
|
2038
|
+
// into whatever's already there, same spirit as registerMcpServer above.
|
|
2039
|
+
// homeDir is a parameter (not a hardcoded nodeOs.homedir() call) so tests
|
|
2040
|
+
// can point at a scratch directory instead of the real one.
|
|
2041
|
+
interface EditorMcpTarget {
|
|
2042
|
+
name: string;
|
|
2043
|
+
configPath: (homeDir: string) => string | null;
|
|
2044
|
+
register: (configPath: string, distIndexPath: string) => void;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function mergeMcpServersJson(configPath: string, distIndexPath: string): void {
|
|
2048
|
+
let config: { mcpServers?: Record<string, unknown> } = {};
|
|
2049
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2050
|
+
try {
|
|
2051
|
+
config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
2052
|
+
} catch {
|
|
2053
|
+
config = {};
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
2057
|
+
config.mcpServers = {};
|
|
2058
|
+
}
|
|
2059
|
+
config.mcpServers.stackmem = { command: "node", args: [distIndexPath] };
|
|
2060
|
+
|
|
2061
|
+
nodeFs.mkdirSync(nodePath.dirname(configPath), { recursive: true });
|
|
2062
|
+
nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// VS Code installs the Copilot Chat extension under a version-suffixed
|
|
2066
|
+
// directory name (e.g. "github.copilot-chat-0.26.4") — this resolves the
|
|
2067
|
+
// "github.copilot-chat*" glob the task describes against whatever's
|
|
2068
|
+
// actually installed, rather than assuming a fixed folder name.
|
|
2069
|
+
function findCopilotChatExtensionDir(homeDir: string): string | null {
|
|
2070
|
+
const extensionsDir = nodePath.join(homeDir, ".vscode", "extensions");
|
|
2071
|
+
if (!nodeFs.existsSync(extensionsDir)) return null;
|
|
2072
|
+
|
|
2073
|
+
try {
|
|
2074
|
+
const match = nodeFs
|
|
2075
|
+
.readdirSync(extensionsDir)
|
|
2076
|
+
.find((entry) => entry.startsWith("github.copilot-chat"));
|
|
2077
|
+
return match ? nodePath.join(extensionsDir, match) : null;
|
|
2078
|
+
} catch {
|
|
2079
|
+
return null;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
const EDITOR_MCP_TARGETS: EditorMcpTarget[] = [
|
|
2084
|
+
{
|
|
2085
|
+
name: "Cursor",
|
|
2086
|
+
configPath: (homeDir) => {
|
|
2087
|
+
const dir = nodePath.join(homeDir, ".cursor");
|
|
2088
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp.json") : null;
|
|
2089
|
+
},
|
|
2090
|
+
register: mergeMcpServersJson,
|
|
2091
|
+
},
|
|
2092
|
+
{
|
|
2093
|
+
name: "Windsurf",
|
|
2094
|
+
configPath: (homeDir) => {
|
|
2095
|
+
const dir = nodePath.join(homeDir, ".codeium", "windsurf");
|
|
2096
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp_config.json") : null;
|
|
2097
|
+
},
|
|
2098
|
+
register: mergeMcpServersJson,
|
|
2099
|
+
},
|
|
2100
|
+
{
|
|
2101
|
+
name: "VS Code (Copilot)",
|
|
2102
|
+
configPath: (homeDir) => {
|
|
2103
|
+
const extensionDir = findCopilotChatExtensionDir(homeDir);
|
|
2104
|
+
return extensionDir ? nodePath.join(extensionDir, "mcp.json") : null;
|
|
2105
|
+
},
|
|
2106
|
+
register: mergeMcpServersJson,
|
|
2107
|
+
},
|
|
2108
|
+
{
|
|
2109
|
+
name: "Zed",
|
|
2110
|
+
configPath: (homeDir) => {
|
|
2111
|
+
const dir = nodePath.join(homeDir, ".config", "zed");
|
|
2112
|
+
return nodeFs.existsSync(dir) ? nodePath.join(dir, "settings.json") : null;
|
|
2113
|
+
},
|
|
2114
|
+
register: (configPath, distIndexPath) => {
|
|
2115
|
+
let config: { context_servers?: Record<string, unknown> } = {};
|
|
2116
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2117
|
+
try {
|
|
2118
|
+
config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
2119
|
+
} catch {
|
|
2120
|
+
config = {};
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
if (!config.context_servers || typeof config.context_servers !== "object") {
|
|
2124
|
+
config.context_servers = {};
|
|
2125
|
+
}
|
|
2126
|
+
config.context_servers.stackmem = { command: "node", args: [distIndexPath] };
|
|
2127
|
+
nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
2128
|
+
},
|
|
2129
|
+
},
|
|
2130
|
+
];
|
|
2131
|
+
|
|
2132
|
+
// Registers stackmem's MCP server with every detected editor, printing a
|
|
2133
|
+
// line per success. An editor whose config directory doesn't exist is
|
|
2134
|
+
// skipped with no output and never an error.
|
|
2135
|
+
function registerEditorMcpServers(distIndexPath: string, homeDir = nodeOs.homedir()): void {
|
|
2136
|
+
for (const target of EDITOR_MCP_TARGETS) {
|
|
2137
|
+
const configPath = target.configPath(homeDir);
|
|
2138
|
+
if (!configPath) continue;
|
|
2139
|
+
|
|
2140
|
+
try {
|
|
2141
|
+
target.register(configPath, distIndexPath);
|
|
2142
|
+
console.log(`✓ MCP registered for ${target.name}`);
|
|
2143
|
+
} catch {
|
|
2144
|
+
// Best-effort — a permissions error or malformed existing config on
|
|
2145
|
+
// one editor shouldn't abort `cm init` or the other editors' writes.
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
1720
2150
|
const DEFAULT_GITIGNORE_CONTENT = `node_modules/
|
|
1721
2151
|
dist/
|
|
1722
2152
|
.env
|
|
@@ -1728,6 +2158,7 @@ coverage/
|
|
|
1728
2158
|
*.db
|
|
1729
2159
|
*cursorrules
|
|
1730
2160
|
*windsurfrules
|
|
2161
|
+
.cursor/rules/stackmem.md
|
|
1731
2162
|
`;
|
|
1732
2163
|
|
|
1733
2164
|
// Returns true if the file was created, false if it already existed
|
|
@@ -1767,6 +2198,109 @@ cm save ${project} <type> "<content>"
|
|
|
1767
2198
|
console.log("CLAUDE.md written.");
|
|
1768
2199
|
}
|
|
1769
2200
|
|
|
2201
|
+
// Same instructional purpose as writeClaudeMd — point the agent at
|
|
2202
|
+
// `cm start` rather than embedding a memory snapshot directly, since a
|
|
2203
|
+
// baked-in snapshot goes stale the moment the next commit lands. AGENTS.md
|
|
2204
|
+
// is the convention used by OpenAI Codex CLI, Gemini CLI, and others;
|
|
2205
|
+
// GEMINI.md is Gemini CLI's own equivalent — both get identical content.
|
|
2206
|
+
function writeAgentsMemoryFile(project: string, resolvedPath: string, filename: string): void {
|
|
2207
|
+
const filePath = nodePath.join(resolvedPath, filename);
|
|
2208
|
+
|
|
2209
|
+
if (nodeFs.existsSync(filePath)) {
|
|
2210
|
+
console.log(`${filename} already exists — skipping.`);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
const content = `# ${project} — Agent Memory
|
|
2215
|
+
|
|
2216
|
+
This project uses stackmem for persistent agent memory.
|
|
2217
|
+
Run \`cm start ${project}\` at the start of every session
|
|
2218
|
+
to load relevant context.
|
|
2219
|
+
|
|
2220
|
+
## Key decisions, constraints and discoveries
|
|
2221
|
+
|
|
2222
|
+
Decisions, constraints, discoveries, and rejections live in the stackmem
|
|
2223
|
+
backend, not duplicated in this file — run \`cm start ${project}\` to load
|
|
2224
|
+
the current list.
|
|
2225
|
+
|
|
2226
|
+
When you make a decision, discover a constraint, reject an approach, or
|
|
2227
|
+
make a discovery during this session, save it with:
|
|
2228
|
+
cm save ${project} <type> "<content>"
|
|
2229
|
+
`;
|
|
2230
|
+
|
|
2231
|
+
nodeFs.writeFileSync(filePath, content, "utf-8");
|
|
2232
|
+
console.log(`${filename} written.`);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// --- Claude Code hooks (.claude/settings.json) ------------------------
|
|
2236
|
+
|
|
2237
|
+
interface ClaudeHookHandler {
|
|
2238
|
+
type: string;
|
|
2239
|
+
command: string;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
interface ClaudeHookGroup {
|
|
2243
|
+
matcher?: string;
|
|
2244
|
+
hooks: ClaudeHookHandler[];
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
interface ClaudeSettingsFile {
|
|
2248
|
+
hooks?: Record<string, ClaudeHookGroup[]>;
|
|
2249
|
+
[key: string]: unknown;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// --project (not positional) for the PreCompact command below: cmdContext's
|
|
2253
|
+
// CLI convention takes --project like cm search/cm context already do
|
|
2254
|
+
// everywhere else — cm start and cm session-end both take a positional
|
|
2255
|
+
// project, so those two keep the user's literal command shape.
|
|
2256
|
+
const STACKMEM_HOOK_EVENTS: { event: string; buildCommand: (project: string) => string }[] = [
|
|
2257
|
+
{ event: "SessionStart", buildCommand: (project) => `cm start ${project} --hook` },
|
|
2258
|
+
{ event: "Stop", buildCommand: (project) => `cm session-end ${project} --hook` },
|
|
2259
|
+
{ event: "PreCompact", buildCommand: (project) => `cm context --project ${project} --hook` },
|
|
2260
|
+
];
|
|
2261
|
+
|
|
2262
|
+
// Writes (or merges into) <resolvedPath>/.claude/settings.json. Merges
|
|
2263
|
+
// rather than overwrites — an existing settings.json may have unrelated
|
|
2264
|
+
// hooks or settings that must survive this — and is idempotent: re-running
|
|
2265
|
+
// `cm init` checks for the exact command string already present in an
|
|
2266
|
+
// event's hook groups before adding another one.
|
|
2267
|
+
function writeClaudeCodeHooks(project: string, resolvedPath: string): void {
|
|
2268
|
+
const settingsDir = nodePath.join(resolvedPath, ".claude");
|
|
2269
|
+
const settingsPath = nodePath.join(settingsDir, "settings.json");
|
|
2270
|
+
|
|
2271
|
+
let settings: ClaudeSettingsFile = {};
|
|
2272
|
+
if (nodeFs.existsSync(settingsPath)) {
|
|
2273
|
+
try {
|
|
2274
|
+
settings = JSON.parse(nodeFs.readFileSync(settingsPath, "utf-8"));
|
|
2275
|
+
} catch {
|
|
2276
|
+
settings = {};
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
if (!settings.hooks || typeof settings.hooks !== "object") {
|
|
2281
|
+
settings.hooks = {};
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
for (const { event, buildCommand } of STACKMEM_HOOK_EVENTS) {
|
|
2285
|
+
const command = buildCommand(project);
|
|
2286
|
+
|
|
2287
|
+
if (!Array.isArray(settings.hooks[event])) {
|
|
2288
|
+
settings.hooks[event] = [];
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
const alreadyRegistered = settings.hooks[event].some(
|
|
2292
|
+
(group) => Array.isArray(group?.hooks) && group.hooks.some((h) => h?.command === command)
|
|
2293
|
+
);
|
|
2294
|
+
|
|
2295
|
+
if (!alreadyRegistered) {
|
|
2296
|
+
settings.hooks[event].push({ hooks: [{ type: "command", command }] });
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
nodeFs.mkdirSync(settingsDir, { recursive: true });
|
|
2301
|
+
nodeFs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
2302
|
+
}
|
|
2303
|
+
|
|
1770
2304
|
// Section order for the generated rules-file markdown — matches the order
|
|
1771
2305
|
// memories are presented in everywhere else (cm context's CONTEXT_TYPE_ORDER),
|
|
1772
2306
|
// not MEMORY_TYPES' declaration order.
|
|
@@ -1781,7 +2315,8 @@ async function generateRulesContent(project: string): Promise<string> {
|
|
|
1781
2315
|
.from("memories")
|
|
1782
2316
|
.select("*")
|
|
1783
2317
|
.eq("project", project)
|
|
1784
|
-
.eq("resolved", false)
|
|
2318
|
+
.eq("resolved", false)
|
|
2319
|
+
.eq("suggested", false);
|
|
1785
2320
|
|
|
1786
2321
|
if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
|
|
1787
2322
|
|
|
@@ -1790,7 +2325,7 @@ async function generateRulesContent(project: string): Promise<string> {
|
|
|
1790
2325
|
.map((memory) => ({
|
|
1791
2326
|
...memory,
|
|
1792
2327
|
decay_score: computeDecayScore(
|
|
1793
|
-
|
|
2328
|
+
parseUtcDate(memory.created_at),
|
|
1794
2329
|
memory.access_count ?? 0,
|
|
1795
2330
|
memory.pinned ?? false,
|
|
1796
2331
|
config.decayLambda
|
|
@@ -1867,6 +2402,19 @@ function writeCursorRules(resolvedPath: string, content: string): boolean {
|
|
|
1867
2402
|
return true;
|
|
1868
2403
|
}
|
|
1869
2404
|
|
|
2405
|
+
// Cursor's newer rules format (.cursor/rules/*.md) — .cursorrules is
|
|
2406
|
+
// legacy but still read by older Cursor versions, so both are written
|
|
2407
|
+
// during the transition rather than one replacing the other. Unlike
|
|
2408
|
+
// writeCursorRules/writeWindsurfRules (write-once at init, skip if
|
|
2409
|
+
// present), this one unconditionally overwrites on every call — both at
|
|
2410
|
+
// init and on every `cm sync` — since it's gitignored content meant to
|
|
2411
|
+
// always reflect the current memory state, same as the legacy files.
|
|
2412
|
+
function writeCursorRulesMdFile(resolvedPath: string, content: string): void {
|
|
2413
|
+
const rulesDir = nodePath.join(resolvedPath, ".cursor", "rules");
|
|
2414
|
+
nodeFs.mkdirSync(rulesDir, { recursive: true });
|
|
2415
|
+
nodeFs.writeFileSync(nodePath.join(rulesDir, "stackmem.md"), content, "utf-8");
|
|
2416
|
+
}
|
|
2417
|
+
|
|
1870
2418
|
// Returns true if the file was written, false if it already existed
|
|
1871
2419
|
// (and was skipped).
|
|
1872
2420
|
function writeWindsurfRules(resolvedPath: string, content: string): boolean {
|
|
@@ -2071,7 +2619,11 @@ async function cmdInit(project: string, targetPath: string, writeConfig = false)
|
|
|
2071
2619
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
2072
2620
|
|
|
2073
2621
|
registerMcpServer(DIST_INDEX_PATH);
|
|
2622
|
+
registerEditorMcpServers(DIST_INDEX_PATH);
|
|
2074
2623
|
writeClaudeMd(project, resolvedPath);
|
|
2624
|
+
writeAgentsMemoryFile(project, resolvedPath, "AGENTS.md");
|
|
2625
|
+
writeAgentsMemoryFile(project, resolvedPath, "GEMINI.md");
|
|
2626
|
+
writeClaudeCodeHooks(project, resolvedPath);
|
|
2075
2627
|
|
|
2076
2628
|
const seededCount = await seedMemories(project, resolvedPath);
|
|
2077
2629
|
console.log(`Seeded ${seededCount} initial memories from project scan.`);
|
|
@@ -2079,6 +2631,7 @@ async function cmdInit(project: string, targetPath: string, writeConfig = false)
|
|
|
2079
2631
|
const rulesContent = await generateRulesContent(project);
|
|
2080
2632
|
const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
|
|
2081
2633
|
const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
|
|
2634
|
+
writeCursorRulesMdFile(resolvedPath, rulesContent);
|
|
2082
2635
|
|
|
2083
2636
|
const backendOk = await verifyBackendConnection(project);
|
|
2084
2637
|
|
|
@@ -2093,13 +2646,17 @@ async function cmdInit(project: string, targetPath: string, writeConfig = false)
|
|
|
2093
2646
|
}
|
|
2094
2647
|
console.log("✓ Git hook installed");
|
|
2095
2648
|
console.log("✓ MCP server registered");
|
|
2649
|
+
console.log("✓ Claude Code hooks registered (.claude/settings.json)");
|
|
2096
2650
|
console.log("✓ CLAUDE.md written");
|
|
2651
|
+
console.log("✓ AGENTS.md written");
|
|
2652
|
+
console.log("✓ GEMINI.md written");
|
|
2097
2653
|
console.log(
|
|
2098
2654
|
`✓ .cursorrules ${cursorRulesWritten ? "written" : "already exists"}`
|
|
2099
2655
|
);
|
|
2100
2656
|
console.log(
|
|
2101
2657
|
`✓ .windsurfrules ${windsurfRulesWritten ? "written" : "already exists"}`
|
|
2102
2658
|
);
|
|
2659
|
+
console.log("✓ .cursor/rules/stackmem.md written");
|
|
2103
2660
|
console.log(`✓ Seeded ${seededCount} memories from project scan`);
|
|
2104
2661
|
if (backendOk) {
|
|
2105
2662
|
console.log("✓ Backend connection verified");
|
|
@@ -2125,8 +2682,9 @@ async function cmdSync(project: string) {
|
|
|
2125
2682
|
|
|
2126
2683
|
nodeFs.writeFileSync(nodePath.join(resolvedPath, ".cursorrules"), rulesContent, "utf-8");
|
|
2127
2684
|
nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
|
|
2685
|
+
writeCursorRulesMdFile(resolvedPath, rulesContent);
|
|
2128
2686
|
|
|
2129
|
-
console.log("Synced memories to .cursorrules and .
|
|
2687
|
+
console.log("Synced memories to .cursorrules, .windsurfrules, and .cursor/rules/stackmem.md");
|
|
2130
2688
|
}
|
|
2131
2689
|
|
|
2132
2690
|
// --- cm link ---------------------------------------------------------------
|
|
@@ -2211,23 +2769,148 @@ async function cmdLink() {
|
|
|
2211
2769
|
console.log("Link code expired. Run cm link again.");
|
|
2212
2770
|
}
|
|
2213
2771
|
|
|
2772
|
+
// --- cm open -----------------------------------------------------------
|
|
2773
|
+
|
|
2774
|
+
// Separated from cmdOpen so the platform-selection logic is unit-testable
|
|
2775
|
+
// without actually launching a browser — exercising this for real on
|
|
2776
|
+
// every platform isn't something a test (or a verification pass) should
|
|
2777
|
+
// do as a side effect.
|
|
2778
|
+
export function getOpenCommand(platform: string, url: string): { command: string; args: string[] } {
|
|
2779
|
+
if (platform === "darwin") return { command: "open", args: [url] };
|
|
2780
|
+
if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
|
|
2781
|
+
return { command: "xdg-open", args: [url] };
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
async function cmdOpen(project: string): Promise<void> {
|
|
2785
|
+
const url = `${VAULT_URL}/project/${project}`;
|
|
2786
|
+
const { command, args } = getOpenCommand(process.platform, url);
|
|
2787
|
+
|
|
2788
|
+
try {
|
|
2789
|
+
execFileSync(command, args, { stdio: "ignore" });
|
|
2790
|
+
console.log(`Opened ${url}`);
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
fail(
|
|
2793
|
+
`Failed to open browser (${error instanceof Error ? error.message : String(error)}). Visit ${url} manually.`
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// --- cm purge ------------------------------------------------------------
|
|
2799
|
+
|
|
2800
|
+
async function cmdPurgeProject(project: string): Promise<void> {
|
|
2801
|
+
const { count: total, error: countError } = await supabase
|
|
2802
|
+
.from("memories")
|
|
2803
|
+
.select("id", { count: "exact", head: true })
|
|
2804
|
+
.eq("project", project);
|
|
2805
|
+
if (countError) fail(`Failed to count memories: ${countError.message}`);
|
|
2806
|
+
|
|
2807
|
+
console.log(`This will permanently delete all ${total ?? 0} memories for "${project}".`);
|
|
2808
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2809
|
+
const answer = (await rl.question("Type the project name to confirm: ")).trim();
|
|
2810
|
+
rl.close();
|
|
2811
|
+
|
|
2812
|
+
if (answer !== project) {
|
|
2813
|
+
console.log("Confirmation did not match — aborted. Nothing was deleted.");
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
const { data: memRows, error: memFetchError } = await supabase
|
|
2818
|
+
.from("memories")
|
|
2819
|
+
.select("id")
|
|
2820
|
+
.eq("project", project);
|
|
2821
|
+
if (memFetchError) fail(`Failed to load memories: ${memFetchError.message}`);
|
|
2822
|
+
|
|
2823
|
+
const ids = (memRows ?? []).map((m) => m.id);
|
|
2824
|
+
let linksDeleted = 0;
|
|
2825
|
+
if (ids.length > 0) {
|
|
2826
|
+
const idList = ids.join(",");
|
|
2827
|
+
const { count, error: linksError } = await supabase
|
|
2828
|
+
.from("memory_links")
|
|
2829
|
+
.delete({ count: "exact" })
|
|
2830
|
+
.or(`source_id.in.(${idList}),target_id.in.(${idList})`);
|
|
2831
|
+
if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
|
|
2832
|
+
linksDeleted = count ?? 0;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
const { count: memoriesDeleted, error: memError } = await supabase
|
|
2836
|
+
.from("memories")
|
|
2837
|
+
.delete({ count: "exact" })
|
|
2838
|
+
.eq("project", project);
|
|
2839
|
+
if (memError) fail(`Failed to delete memories: ${memError.message}`);
|
|
2840
|
+
|
|
2841
|
+
const { count: fixesDeleted, error: fixError } = await supabase
|
|
2842
|
+
.from("execution_log")
|
|
2843
|
+
.delete({ count: "exact" })
|
|
2844
|
+
.eq("project", project);
|
|
2845
|
+
if (fixError) fail(`Failed to delete fixes: ${fixError.message}`);
|
|
2846
|
+
|
|
2847
|
+
console.log(
|
|
2848
|
+
`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted} links for "${project}".`
|
|
2849
|
+
);
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// RLS already scopes every table read/write to this device's device_id
|
|
2853
|
+
// via the x-device-id header (see src/storage.ts) — a filter-less delete
|
|
2854
|
+
// here is already confined to this device, never another one. PostgREST
|
|
2855
|
+
// still requires an explicit filter clause to allow a DELETE at all, so
|
|
2856
|
+
// `.not("id", "is", null)` (every row has a non-null id) is used purely
|
|
2857
|
+
// to satisfy that, not to narrow the scope further.
|
|
2858
|
+
async function cmdPurgeAll(): Promise<void> {
|
|
2859
|
+
console.log("This will permanently delete ALL memories across ALL projects for this device.");
|
|
2860
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2861
|
+
const answer = (await rl.question("Type DELETE to confirm: ")).trim();
|
|
2862
|
+
rl.close();
|
|
2863
|
+
|
|
2864
|
+
if (answer !== "DELETE") {
|
|
2865
|
+
console.log("Confirmation did not match — aborted. Nothing was deleted.");
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
const { count: linksDeleted, error: linksError } = await supabase
|
|
2870
|
+
.from("memory_links")
|
|
2871
|
+
.delete({ count: "exact" })
|
|
2872
|
+
.not("id", "is", null);
|
|
2873
|
+
if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
|
|
2874
|
+
|
|
2875
|
+
const { count: memoriesDeleted, error: memError } = await supabase
|
|
2876
|
+
.from("memories")
|
|
2877
|
+
.delete({ count: "exact" })
|
|
2878
|
+
.not("id", "is", null);
|
|
2879
|
+
if (memError) fail(`Failed to delete memories: ${memError.message}`);
|
|
2880
|
+
|
|
2881
|
+
const { count: fixesDeleted, error: fixError } = await supabase
|
|
2882
|
+
.from("execution_log")
|
|
2883
|
+
.delete({ count: "exact" })
|
|
2884
|
+
.not("id", "is", null);
|
|
2885
|
+
if (fixError) fail(`Failed to delete fixes: ${fixError.message}`);
|
|
2886
|
+
|
|
2887
|
+
console.log(
|
|
2888
|
+
`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted ?? 0} links across all projects.`
|
|
2889
|
+
);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2214
2892
|
// --- cm context ------------------------------------------------------------
|
|
2215
2893
|
|
|
2216
2894
|
const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
|
|
2217
2895
|
const CONTEXT_TOP_N = 5;
|
|
2218
2896
|
|
|
2219
|
-
|
|
2897
|
+
// task is null for the PreCompact hook — there's no user-typed task at a
|
|
2898
|
+
// compaction boundary, just "save the most relevant context" generically
|
|
2899
|
+
// — so ranking falls back to decay score alone instead of diluting it
|
|
2900
|
+
// with a relevance score computed against an empty string.
|
|
2901
|
+
async function cmdContext(project: string, task: string | null, hookMode = false) {
|
|
2220
2902
|
const { data, error } = await supabase
|
|
2221
2903
|
.from("memories")
|
|
2222
2904
|
.select("*")
|
|
2223
2905
|
.eq("project", project)
|
|
2224
|
-
.eq("resolved", false)
|
|
2906
|
+
.eq("resolved", false)
|
|
2907
|
+
.eq("suggested", false);
|
|
2225
2908
|
|
|
2226
2909
|
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
2227
2910
|
|
|
2228
2911
|
const memories = data ?? [];
|
|
2229
2912
|
if (memories.length === 0) {
|
|
2230
|
-
console.log(`No memories found for "${project}".`);
|
|
2913
|
+
if (!hookMode) console.log(`No memories found for "${project}".`);
|
|
2231
2914
|
return;
|
|
2232
2915
|
}
|
|
2233
2916
|
|
|
@@ -2237,16 +2920,14 @@ async function cmdContext(project: string, task: string) {
|
|
|
2237
2920
|
const scoredMemories = memories
|
|
2238
2921
|
.map((memory) => {
|
|
2239
2922
|
const decayScore = computeDecayScore(
|
|
2240
|
-
|
|
2923
|
+
parseUtcDate(memory.created_at),
|
|
2241
2924
|
memory.access_count ?? 0,
|
|
2242
2925
|
memory.pinned ?? false,
|
|
2243
2926
|
config.decayLambda
|
|
2244
2927
|
);
|
|
2245
|
-
const
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
combined_score: relevanceScore * 0.6 + decayScore * 0.4,
|
|
2249
|
-
};
|
|
2928
|
+
const combinedScore =
|
|
2929
|
+
task == null ? decayScore : scoreMemoryRelevance(memory.content, task) * 0.6 + decayScore * 0.4;
|
|
2930
|
+
return { ...memory, combined_score: combinedScore };
|
|
2250
2931
|
})
|
|
2251
2932
|
.sort((a, b) => b.combined_score - a.combined_score);
|
|
2252
2933
|
|
|
@@ -2257,7 +2938,9 @@ async function cmdContext(project: string, task: string) {
|
|
|
2257
2938
|
0
|
|
2258
2939
|
);
|
|
2259
2940
|
|
|
2260
|
-
const lines: string[] = [`## Project Context: ${project}
|
|
2941
|
+
const lines: string[] = [`## Project Context: ${project}`];
|
|
2942
|
+
if (task != null) lines.push(`### Task: ${task}`);
|
|
2943
|
+
lines.push("");
|
|
2261
2944
|
|
|
2262
2945
|
for (const type of CONTEXT_TYPE_ORDER) {
|
|
2263
2946
|
const group = budgetedMemories.filter((memory) => memory.type === type);
|
|
@@ -2270,7 +2953,7 @@ async function cmdContext(project: string, task: string) {
|
|
|
2270
2953
|
lines.push("");
|
|
2271
2954
|
}
|
|
2272
2955
|
|
|
2273
|
-
lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
|
|
2956
|
+
if (!hookMode) lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
|
|
2274
2957
|
|
|
2275
2958
|
console.log(lines.join("\n"));
|
|
2276
2959
|
}
|
|
@@ -2294,7 +2977,8 @@ async function cmdWhy(project: string, file: string) {
|
|
|
2294
2977
|
.from("memories")
|
|
2295
2978
|
.select("*")
|
|
2296
2979
|
.eq("project", project)
|
|
2297
|
-
.eq("resolved", false)
|
|
2980
|
+
.eq("resolved", false)
|
|
2981
|
+
.eq("suggested", false);
|
|
2298
2982
|
|
|
2299
2983
|
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
2300
2984
|
|
|
@@ -2323,7 +3007,7 @@ async function cmdWhy(project: string, file: string) {
|
|
|
2323
3007
|
.map((memory) => ({
|
|
2324
3008
|
...memory,
|
|
2325
3009
|
decay_score: computeDecayScore(
|
|
2326
|
-
|
|
3010
|
+
parseUtcDate(memory.created_at),
|
|
2327
3011
|
memory.access_count ?? 0,
|
|
2328
3012
|
memory.pinned ?? false,
|
|
2329
3013
|
config.decayLambda
|
|
@@ -2394,6 +3078,10 @@ function parseSessionEndFlags(args: string[]): SessionEndInput {
|
|
|
2394
3078
|
} else if (flag === "--approaches") {
|
|
2395
3079
|
approaches = splitCommaList(value);
|
|
2396
3080
|
i++;
|
|
3081
|
+
} else if (flag === "--hook") {
|
|
3082
|
+
// Recognized explicitly (not just silently skipped as an unknown
|
|
3083
|
+
// flag) — see the comment on cmdSessionEnd for why it's currently
|
|
3084
|
+
// a no-op here specifically.
|
|
2397
3085
|
}
|
|
2398
3086
|
}
|
|
2399
3087
|
|
|
@@ -2416,6 +3104,16 @@ async function readStdinJson(): Promise<Record<string, unknown> | null> {
|
|
|
2416
3104
|
}
|
|
2417
3105
|
}
|
|
2418
3106
|
|
|
3107
|
+
// --hook is accepted (see parseSessionEndFlags) but currently a no-op:
|
|
3108
|
+
// Claude Code's Stop hook pipes ITS OWN JSON payload on stdin (session
|
|
3109
|
+
// id, transcript path, cwd — see the Claude Code hooks docs), not the
|
|
3110
|
+
// {summary, errors_encountered, files_touched, approaches_tried} shape
|
|
3111
|
+
// this command expects. readStdinJson() parses that payload successfully
|
|
3112
|
+
// (it's valid JSON) but finds none of the fields it looks for, so every
|
|
3113
|
+
// array below comes back empty and this reports 0 memories saved — a
|
|
3114
|
+
// real, known limitation, not a bug. Extracting genuine learnings from
|
|
3115
|
+
// the Stop hook would mean parsing the session transcript itself, which
|
|
3116
|
+
// is out of scope here (Phase 2, per the user).
|
|
2419
3117
|
async function cmdSessionEnd(project: string, input: Omit<SessionEndInput, "project">) {
|
|
2420
3118
|
let memoriesSaved = 0;
|
|
2421
3119
|
let errorsLogged = 0;
|
|
@@ -2529,6 +3227,136 @@ function isHookExecutable(hookPath: string): boolean {
|
|
|
2529
3227
|
}
|
|
2530
3228
|
}
|
|
2531
3229
|
|
|
3230
|
+
// --- cm status ---------------------------------------------------------
|
|
3231
|
+
|
|
3232
|
+
function hasMcpServersStackmemKey(configPath: string): boolean {
|
|
3233
|
+
if (!nodeFs.existsSync(configPath)) return false;
|
|
3234
|
+
try {
|
|
3235
|
+
const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
3236
|
+
return Boolean(config?.mcpServers?.stackmem);
|
|
3237
|
+
} catch {
|
|
3238
|
+
return false;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
function hasContextServersStackmemKey(configPath: string): boolean {
|
|
3243
|
+
if (!nodeFs.existsSync(configPath)) return false;
|
|
3244
|
+
try {
|
|
3245
|
+
const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
3246
|
+
return Boolean(config?.context_servers?.stackmem);
|
|
3247
|
+
} catch {
|
|
3248
|
+
return false;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const EDITOR_STATUS_CHECKS: { name: string; isRegistered: (homeDir: string) => boolean }[] = [
|
|
3253
|
+
{ name: "Claude Code", isRegistered: (homeDir) => isMcpRegistered(nodePath.join(homeDir, ".claude.json")) },
|
|
3254
|
+
{ name: "Cursor", isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".cursor", "mcp.json")) },
|
|
3255
|
+
{
|
|
3256
|
+
name: "Windsurf",
|
|
3257
|
+
isRegistered: (homeDir) =>
|
|
3258
|
+
hasMcpServersStackmemKey(nodePath.join(homeDir, ".codeium", "windsurf", "mcp_config.json")),
|
|
3259
|
+
},
|
|
3260
|
+
{
|
|
3261
|
+
name: "VS Code",
|
|
3262
|
+
isRegistered: (homeDir) => {
|
|
3263
|
+
const dir = findCopilotChatExtensionDir(homeDir);
|
|
3264
|
+
return dir ? hasMcpServersStackmemKey(nodePath.join(dir, "mcp.json")) : false;
|
|
3265
|
+
},
|
|
3266
|
+
},
|
|
3267
|
+
{
|
|
3268
|
+
name: "Zed",
|
|
3269
|
+
isRegistered: (homeDir) => hasContextServersStackmemKey(nodePath.join(homeDir, ".config", "zed", "settings.json")),
|
|
3270
|
+
},
|
|
3271
|
+
];
|
|
3272
|
+
|
|
3273
|
+
async function cmdStatus(project: string): Promise<void> {
|
|
3274
|
+
const { count: totalCount, error: totalError } = await supabase
|
|
3275
|
+
.from("memories")
|
|
3276
|
+
.select("id", { count: "exact", head: true })
|
|
3277
|
+
.eq("project", project)
|
|
3278
|
+
.eq("resolved", false);
|
|
3279
|
+
if (totalError) fail(`Failed to load memory count: ${totalError.message}`);
|
|
3280
|
+
|
|
3281
|
+
const { count: pinnedCount, error: pinnedError } = await supabase
|
|
3282
|
+
.from("memories")
|
|
3283
|
+
.select("id", { count: "exact", head: true })
|
|
3284
|
+
.eq("project", project)
|
|
3285
|
+
.eq("resolved", false)
|
|
3286
|
+
.eq("pinned", true);
|
|
3287
|
+
if (pinnedError) fail(`Failed to load pinned count: ${pinnedError.message}`);
|
|
3288
|
+
|
|
3289
|
+
const { count: suggestedCount, error: suggestedError } = await supabase
|
|
3290
|
+
.from("memories")
|
|
3291
|
+
.select("id", { count: "exact", head: true })
|
|
3292
|
+
.eq("project", project)
|
|
3293
|
+
.eq("resolved", false)
|
|
3294
|
+
.eq("suggested", true);
|
|
3295
|
+
if (suggestedError) fail(`Failed to load suggested count: ${suggestedError.message}`);
|
|
3296
|
+
|
|
3297
|
+
const { count: fixesCount, error: fixesError } = await supabase
|
|
3298
|
+
.from("execution_log")
|
|
3299
|
+
.select("id", { count: "exact", head: true })
|
|
3300
|
+
.eq("project", project)
|
|
3301
|
+
.eq("resolved", false);
|
|
3302
|
+
if (fixesError) fail(`Failed to load fixes count: ${fixesError.message}`);
|
|
3303
|
+
|
|
3304
|
+
const { data: lastCaptureRows, error: lastCaptureError } = await supabase
|
|
3305
|
+
.from("memories")
|
|
3306
|
+
.select("content, created_at")
|
|
3307
|
+
.eq("project", project)
|
|
3308
|
+
.order("created_at", { ascending: false })
|
|
3309
|
+
.limit(1);
|
|
3310
|
+
if (lastCaptureError) fail(`Failed to load last capture: ${lastCaptureError.message}`);
|
|
3311
|
+
|
|
3312
|
+
const config = readStackmemConfig();
|
|
3313
|
+
const configPath = nodePath.join(process.cwd(), STACKMEM_CONFIG_FILENAME);
|
|
3314
|
+
let usesCustomBudget = false;
|
|
3315
|
+
if (nodeFs.existsSync(configPath)) {
|
|
3316
|
+
try {
|
|
3317
|
+
const raw = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
|
|
3318
|
+
usesCustomBudget = typeof raw.tokenBudget === "number";
|
|
3319
|
+
} catch {
|
|
3320
|
+
// Malformed config — readStackmemConfig already falls back to the
|
|
3321
|
+
// default in this case too, so "default" is the accurate label.
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
const hookOk = isHookExecutable(nodePath.join(process.cwd(), ".git", "hooks", "post-commit"));
|
|
3326
|
+
const mcpOk = isMcpRegistered(getClaudeConfigPath());
|
|
3327
|
+
const backendOk = await verifyBackendConnection(project);
|
|
3328
|
+
|
|
3329
|
+
const homeDir = nodeOs.homedir();
|
|
3330
|
+
const registeredEditors = EDITOR_STATUS_CHECKS.filter((e) => e.isRegistered(homeDir)).map((e) => e.name);
|
|
3331
|
+
|
|
3332
|
+
console.log(`stackmem status for "${project}"`);
|
|
3333
|
+
console.log("━".repeat(27));
|
|
3334
|
+
console.log(
|
|
3335
|
+
`Memories: ${totalCount ?? 0} total (${pinnedCount ?? 0} pinned, ${suggestedCount ?? 0} suggested)`
|
|
3336
|
+
);
|
|
3337
|
+
console.log(`Fixes: ${fixesCount ?? 0} recorded`);
|
|
3338
|
+
console.log(`Token budget: ${config.tokenBudget} (${usesCustomBudget ? "from .stackmem.json" : "default"})`);
|
|
3339
|
+
|
|
3340
|
+
const lastCapture = (lastCaptureRows ?? [])[0];
|
|
3341
|
+
if (lastCapture) {
|
|
3342
|
+
const ago = formatRelativeTime(parseUtcDate(lastCapture.created_at));
|
|
3343
|
+
const snippet =
|
|
3344
|
+
lastCapture.content.length > 50 ? `${lastCapture.content.slice(0, 50)}…` : lastCapture.content;
|
|
3345
|
+
console.log(`Last capture: ${ago} (${snippet})`);
|
|
3346
|
+
} else {
|
|
3347
|
+
console.log("Last capture: none yet");
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
console.log(`Hook: ${hookOk ? "✓ installed" : "✗ not installed"}`);
|
|
3351
|
+
console.log(`MCP: ${mcpOk ? "✓ registered" : "✗ not registered"}`);
|
|
3352
|
+
console.log(`Backend: ${backendOk ? "✓ connected" : "✗ not connected"}`);
|
|
3353
|
+
console.log(
|
|
3354
|
+
`Editors: ${
|
|
3355
|
+
registeredEditors.length > 0 ? registeredEditors.map((name) => `${name} ✓`).join(" ") : "(none registered)"
|
|
3356
|
+
}`
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
3359
|
+
|
|
2532
3360
|
async function cmdDoctor(fix: boolean) {
|
|
2533
3361
|
let issues = 0;
|
|
2534
3362
|
const project = readProjectFromFile();
|
|
@@ -2843,10 +3671,14 @@ async function main() {
|
|
|
2843
3671
|
|
|
2844
3672
|
switch (command) {
|
|
2845
3673
|
case "start": {
|
|
2846
|
-
const
|
|
3674
|
+
const hookIndex = args.indexOf("--hook");
|
|
3675
|
+
const hookMode = hookIndex !== -1;
|
|
3676
|
+
const positional = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
|
|
3677
|
+
|
|
3678
|
+
const split = splitOptionalProject(positional, 0);
|
|
2847
3679
|
if (!split) usage();
|
|
2848
3680
|
const project = requireProject(split.project);
|
|
2849
|
-
await cmdStart(project);
|
|
3681
|
+
await cmdStart(project, hookMode);
|
|
2850
3682
|
break;
|
|
2851
3683
|
}
|
|
2852
3684
|
case "save": {
|
|
@@ -2962,6 +3794,21 @@ async function main() {
|
|
|
2962
3794
|
await cmdUnpin(project, memoryId);
|
|
2963
3795
|
break;
|
|
2964
3796
|
}
|
|
3797
|
+
case "review": {
|
|
3798
|
+
const split = splitProjectFromVariadic(args);
|
|
3799
|
+
if (split.rest.length > 0) usage();
|
|
3800
|
+
const project = requireProject(split.project);
|
|
3801
|
+
await cmdReview(project);
|
|
3802
|
+
break;
|
|
3803
|
+
}
|
|
3804
|
+
case "edit": {
|
|
3805
|
+
const split = splitOptionalProject(args, 1);
|
|
3806
|
+
if (!split) usage();
|
|
3807
|
+
const project = requireProject(split.project);
|
|
3808
|
+
const [memoryId] = split.rest;
|
|
3809
|
+
await cmdEdit(project, memoryId);
|
|
3810
|
+
break;
|
|
3811
|
+
}
|
|
2965
3812
|
case "delete": {
|
|
2966
3813
|
const split = splitOptionalProject(args, 1);
|
|
2967
3814
|
if (!split) usage();
|
|
@@ -3002,6 +3849,29 @@ async function main() {
|
|
|
3002
3849
|
await cmdLink();
|
|
3003
3850
|
break;
|
|
3004
3851
|
}
|
|
3852
|
+
case "open": {
|
|
3853
|
+
const split = splitProjectFromVariadic(args);
|
|
3854
|
+
if (split.rest.length > 0) usage();
|
|
3855
|
+
const project = requireProject(split.project);
|
|
3856
|
+
await cmdOpen(project);
|
|
3857
|
+
break;
|
|
3858
|
+
}
|
|
3859
|
+
case "purge": {
|
|
3860
|
+
const allIndex = args.indexOf("--all");
|
|
3861
|
+
const isAll = allIndex !== -1;
|
|
3862
|
+
const withoutAllFlag = isAll ? [...args.slice(0, allIndex), ...args.slice(allIndex + 1)] : args;
|
|
3863
|
+
|
|
3864
|
+
const split = splitProjectFromVariadic(withoutAllFlag);
|
|
3865
|
+
if (split.rest.length > 0) usage();
|
|
3866
|
+
|
|
3867
|
+
if (isAll) {
|
|
3868
|
+
await cmdPurgeAll();
|
|
3869
|
+
} else {
|
|
3870
|
+
const project = requireProject(split.project);
|
|
3871
|
+
await cmdPurgeProject(project);
|
|
3872
|
+
}
|
|
3873
|
+
break;
|
|
3874
|
+
}
|
|
3005
3875
|
case "sync": {
|
|
3006
3876
|
if (args.length > 0) usage();
|
|
3007
3877
|
const project = requireProject(null);
|
|
@@ -3009,10 +3879,17 @@ async function main() {
|
|
|
3009
3879
|
break;
|
|
3010
3880
|
}
|
|
3011
3881
|
case "context": {
|
|
3012
|
-
const
|
|
3882
|
+
const hookIndex = args.indexOf("--hook");
|
|
3883
|
+
const hookMode = hookIndex !== -1;
|
|
3884
|
+
const withoutHookFlag = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
|
|
3885
|
+
|
|
3886
|
+
const split = splitProjectFromVariadic(withoutHookFlag);
|
|
3013
3887
|
const project = requireProject(split.project);
|
|
3014
|
-
|
|
3015
|
-
|
|
3888
|
+
// task is optional only in --hook mode (the PreCompact hook has no
|
|
3889
|
+
// user-typed task to pass) — interactive use still requires one.
|
|
3890
|
+
if (split.rest.length === 0 && !hookMode) usage();
|
|
3891
|
+
const task = split.rest.length > 0 ? split.rest.join(" ") : null;
|
|
3892
|
+
await cmdContext(project, task, hookMode);
|
|
3016
3893
|
break;
|
|
3017
3894
|
}
|
|
3018
3895
|
case "why": {
|
|
@@ -3031,6 +3908,13 @@ async function main() {
|
|
|
3031
3908
|
await cmdDoctor(fix);
|
|
3032
3909
|
break;
|
|
3033
3910
|
}
|
|
3911
|
+
case "status": {
|
|
3912
|
+
const split = splitProjectFromVariadic(args);
|
|
3913
|
+
if (split.rest.length > 0) usage();
|
|
3914
|
+
const project = requireProject(split.project);
|
|
3915
|
+
await cmdStatus(project);
|
|
3916
|
+
break;
|
|
3917
|
+
}
|
|
3034
3918
|
case "undo": {
|
|
3035
3919
|
const split = splitOptionalProject(args, 0);
|
|
3036
3920
|
if (!split) usage();
|