stackmem 1.0.25 → 1.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +575 -81
- package/dist/config-file.js +72 -0
- package/dist/index.js +4 -3
- package/dist/scoring.js +34 -4
- package/dist/storage.js +79 -1
- package/package.json +4 -1
- package/src/cli.ts +653 -79
- package/src/config-file.ts +98 -0
- package/src/index.ts +5 -2
- package/src/scoring.ts +44 -4
- package/src/storage.ts +101 -1
package/src/cli.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import nodeFs from "node:fs";
|
|
3
3
|
import nodeOs from "node:os";
|
|
4
4
|
import nodePath from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
5
6
|
import { randomInt } from "node:crypto";
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
8
|
import { createInterface } from "node:readline/promises";
|
|
8
9
|
import { analyzeCodebase } from "./ast-index.js";
|
|
9
10
|
import { VAULT_URL } from "./config.js";
|
|
11
|
+
import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
|
|
10
12
|
import { getDeviceId } from "./device.js";
|
|
11
13
|
import {
|
|
12
14
|
applyContextBudget,
|
|
@@ -20,12 +22,26 @@ import {
|
|
|
20
22
|
indicatesFailedApproach,
|
|
21
23
|
isAuthRelatedFile,
|
|
22
24
|
isCodeFile,
|
|
25
|
+
hasTokenOverlap,
|
|
23
26
|
isQualityMessage,
|
|
24
27
|
redactSecrets,
|
|
25
28
|
scoreMemoryRelevance,
|
|
29
|
+
tokenize,
|
|
26
30
|
} from "./scoring.js";
|
|
27
31
|
import { supabase } from "./storage.js";
|
|
28
32
|
|
|
33
|
+
// Checked as early as ESM import hoisting allows — before any command
|
|
34
|
+
// dispatch, config loading, or network call — so an unsupported Node
|
|
35
|
+
// version fails with a clear message instead of an obscure syntax or
|
|
36
|
+
// API error from deeper in the dependency chain.
|
|
37
|
+
const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
|
|
38
|
+
if (NODE_MAJOR_VERSION < 18) {
|
|
39
|
+
console.error(
|
|
40
|
+
`Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
30
46
|
const COMPRESSION_THRESHOLD = 20;
|
|
31
47
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
@@ -41,7 +57,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
|
|
|
41
57
|
const STACKMEM_HOOK_MARKER = "# stackmem-managed";
|
|
42
58
|
const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
|
|
43
59
|
const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
|
|
44
|
-
const
|
|
60
|
+
const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
|
|
45
61
|
|
|
46
62
|
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
|
|
47
63
|
type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
@@ -61,14 +77,17 @@ function usage(): never {
|
|
|
61
77
|
cm analyze [project] <path>
|
|
62
78
|
cm search [--project <name>] <query>
|
|
63
79
|
cm resolve [project] <memory-id>
|
|
80
|
+
cm pin [project] <memory-id>
|
|
81
|
+
cm unpin [project] <memory-id>
|
|
64
82
|
cm delete [project] <memory-id>
|
|
65
83
|
cm inspect [project] <memory-id>
|
|
66
84
|
cm compress [project]
|
|
67
|
-
cm init <project> <path>
|
|
85
|
+
cm init <project> <path> [--config]
|
|
68
86
|
cm uninstall [project] [path]
|
|
69
87
|
cm link
|
|
70
88
|
cm sync
|
|
71
89
|
cm context [--project <name>] <task>
|
|
90
|
+
cm why [--project <name>] <file>
|
|
72
91
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
73
92
|
cm doctor [--fix]
|
|
74
93
|
cm undo [project]
|
|
@@ -120,6 +139,14 @@ function cmdHelp() {
|
|
|
120
139
|
cm resolve [project] <memory-id>
|
|
121
140
|
Mark a memory as resolved.
|
|
122
141
|
|
|
142
|
+
cm pin [project] <memory-id>
|
|
143
|
+
Pin a memory: it scores 999 in decay ranking (always sorts
|
|
144
|
+
first, never decays) and shows in its own "Pinned" section at
|
|
145
|
+
the top of 'cm start', ahead of its normal type group.
|
|
146
|
+
|
|
147
|
+
cm unpin [project] <memory-id>
|
|
148
|
+
Un-pin a memory, returning it to normal decay-based ranking.
|
|
149
|
+
|
|
123
150
|
cm delete [project] <memory-id>
|
|
124
151
|
Delete a memory and any memory_links referencing it.
|
|
125
152
|
|
|
@@ -134,7 +161,7 @@ function cmdHelp() {
|
|
|
134
161
|
Cluster related unresolved memories (needs 20+) and collapse
|
|
135
162
|
clusters of 3 or more into a single summary memory.
|
|
136
163
|
|
|
137
|
-
cm init <project> <path>
|
|
164
|
+
cm init <project> <path> [--config]
|
|
138
165
|
Install a git post-commit hook in the repo at <path>, register
|
|
139
166
|
the stackmem MCP server with Claude Code, write a CLAUDE.md,
|
|
140
167
|
.cursorrules, .windsurfrules, and .stackmem file, and seed
|
|
@@ -142,7 +169,12 @@ function cmdHelp() {
|
|
|
142
169
|
.windsurfrules are pre-filled with the project's current
|
|
143
170
|
memories (for Cursor and Windsurf) and skipped if they already
|
|
144
171
|
exist. The post-commit hook keeps them fresh afterward by
|
|
145
|
-
running 'cm sync' on every commit
|
|
172
|
+
running 'cm sync' on every commit — on disk only, never
|
|
173
|
+
committed, so they don't double your commit count or conflict
|
|
174
|
+
across branches. Both files are added to .gitignore. With
|
|
175
|
+
--config, also writes a default .stackmem.json (ignore patterns,
|
|
176
|
+
tokenBudget, decayLambda, captureTypes, minQualityScore — see
|
|
177
|
+
src/config-file.ts) if one doesn't already exist.
|
|
146
178
|
|
|
147
179
|
cm uninstall [project] [path]
|
|
148
180
|
Remove everything 'cm init' set up at <path> (defaults to the
|
|
@@ -166,7 +198,8 @@ function cmdHelp() {
|
|
|
166
198
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
167
199
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
168
200
|
token budget) and its 5 most recent open execution_log fixes,
|
|
169
|
-
overwriting both files
|
|
201
|
+
overwriting both files on disk only (never git add/commit).
|
|
202
|
+
Project is read from .stackmem.
|
|
170
203
|
|
|
171
204
|
cm context [--project <name>] <task>
|
|
172
205
|
Return only the top 5 memories most relevant to a specific
|
|
@@ -174,6 +207,14 @@ function cmdHelp() {
|
|
|
174
207
|
as a markdown block capped at a 2000 token budget. Use
|
|
175
208
|
--project to target a project other than the one in .stackmem.
|
|
176
209
|
|
|
210
|
+
cm why [--project <name>] <file>
|
|
211
|
+
Show memories tied to a file: matched by Item 6's file_paths
|
|
212
|
+
column (hook-captured commits that touched it) plus a plain
|
|
213
|
+
content search for the file's path or basename, ranked by decay
|
|
214
|
+
score and grouped by type. Prints a guidance message instead if
|
|
215
|
+
nothing matches. Use --project to target a project other than
|
|
216
|
+
the one in .stackmem.
|
|
217
|
+
|
|
177
218
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
178
219
|
Capture what happened in a session and extract memories: each
|
|
179
220
|
error becomes an unsolved execution_log entry, each approach
|
|
@@ -293,8 +334,85 @@ function splitProjectFromVariadic(args: string[]): { project: string | null; res
|
|
|
293
334
|
return { project: null, rest: args };
|
|
294
335
|
}
|
|
295
336
|
|
|
337
|
+
// Generic "--flag value" extractor for internal/provenance flags (not
|
|
338
|
+
// meant to be remembered by end users — only the hook and MCP server pass
|
|
339
|
+
// these). Unlike splitProjectFromVariadic's --project, these don't need
|
|
340
|
+
// special positional disambiguation, so one generic helper covers all of
|
|
341
|
+
// them: --commit-sha, --author, --source, --confidence, --files.
|
|
342
|
+
function extractValueFlag(args: string[], flag: string): { value: string | null; rest: string[] } {
|
|
343
|
+
const idx = args.indexOf(flag);
|
|
344
|
+
if (idx === -1 || args[idx + 1] === undefined) return { value: null, rest: args };
|
|
345
|
+
return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
|
|
346
|
+
}
|
|
347
|
+
|
|
296
348
|
// --- cm start ----------------------------------------------------------
|
|
297
349
|
|
|
350
|
+
const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
|
|
351
|
+
const BRANCH_NAME_BOOST = 1.2;
|
|
352
|
+
const RECENT_FILES_BOOST = 1.3;
|
|
353
|
+
const PR_TITLE_BOOST = 1.2;
|
|
354
|
+
|
|
355
|
+
// null means "signal unavailable" (git/gh failed, detached HEAD, not
|
|
356
|
+
// enough history, gh not installed, no open PR, etc.) — every one of
|
|
357
|
+
// these is a soft skip, never an error, per the task spec.
|
|
358
|
+
function getBranchNameTokens(): Set<string> | null {
|
|
359
|
+
try {
|
|
360
|
+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
361
|
+
encoding: "utf-8",
|
|
362
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
363
|
+
}).trim();
|
|
364
|
+
if (!branch || branch === "HEAD") return null; // detached HEAD
|
|
365
|
+
const tokens = tokenize(branch);
|
|
366
|
+
return tokens.size > 0 ? tokens : null;
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function getRecentlyTouchedFiles(): Set<string> | null {
|
|
373
|
+
try {
|
|
374
|
+
const output = execFileSync(
|
|
375
|
+
"git",
|
|
376
|
+
["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"],
|
|
377
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
|
|
378
|
+
);
|
|
379
|
+
const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
380
|
+
return files.length > 0 ? new Set(files) : null;
|
|
381
|
+
} catch {
|
|
382
|
+
// Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
|
|
383
|
+
// not a git repo at all — skip gracefully.
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function getOpenPrTitleTokens(): Set<string> | null {
|
|
389
|
+
try {
|
|
390
|
+
const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
|
|
391
|
+
encoding: "utf-8",
|
|
392
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
393
|
+
}).trim();
|
|
394
|
+
if (!title) return null;
|
|
395
|
+
const tokens = tokenize(title);
|
|
396
|
+
return tokens.size > 0 ? tokens : null;
|
|
397
|
+
} catch {
|
|
398
|
+
// gh not installed, not authenticated, or no open PR for this branch.
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function fileListOverlaps(filePaths: unknown, recentFiles: Set<string>): boolean {
|
|
404
|
+
if (!Array.isArray(filePaths)) return false;
|
|
405
|
+
return filePaths.some((candidate) => {
|
|
406
|
+
if (typeof candidate !== "string") return false;
|
|
407
|
+
if (recentFiles.has(candidate)) return true;
|
|
408
|
+
const base = candidate.split("/").pop();
|
|
409
|
+
for (const recent of recentFiles) {
|
|
410
|
+
if (recent.split("/").pop() === base) return true;
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
298
416
|
async function cmdStart(project: string) {
|
|
299
417
|
const { data, error } = await supabase
|
|
300
418
|
.from("memories")
|
|
@@ -312,14 +430,42 @@ async function cmdStart(project: string) {
|
|
|
312
430
|
return;
|
|
313
431
|
}
|
|
314
432
|
|
|
433
|
+
// Task-aware re-ranking: boost memories relevant to what's actively
|
|
434
|
+
// being worked on, multiplicatively on top of the base decay score.
|
|
435
|
+
// Every signal degrades to null (no boost, not an error) if the
|
|
436
|
+
// repo/branch/gh state it needs isn't available.
|
|
437
|
+
const branchTokens = getBranchNameTokens();
|
|
438
|
+
const recentFiles = getRecentlyTouchedFiles();
|
|
439
|
+
const prTitleTokens = getOpenPrTitleTokens();
|
|
440
|
+
|
|
441
|
+
const config = readStackmemConfig();
|
|
442
|
+
const tokenBudget = config.tokenBudget;
|
|
443
|
+
|
|
315
444
|
const scoredMemories = memories
|
|
316
|
-
.map((memory) =>
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
445
|
+
.map((memory) => {
|
|
446
|
+
let decayScore = computeDecayScore(
|
|
447
|
+
new Date(memory.created_at),
|
|
448
|
+
memory.access_count ?? 0,
|
|
449
|
+
memory.pinned ?? false,
|
|
450
|
+
config.decayLambda
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const contentTokens = tokenize(memory.content);
|
|
454
|
+
if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
|
|
455
|
+
decayScore *= BRANCH_NAME_BOOST;
|
|
456
|
+
}
|
|
457
|
+
if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
|
|
458
|
+
decayScore *= RECENT_FILES_BOOST;
|
|
459
|
+
}
|
|
460
|
+
if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
|
|
461
|
+
decayScore *= PR_TITLE_BOOST;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { ...memory, decay_score: decayScore };
|
|
465
|
+
})
|
|
320
466
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
321
467
|
|
|
322
|
-
const budgetedMemories = applyContextBudget(scoredMemories,
|
|
468
|
+
const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
|
|
323
469
|
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
324
470
|
let tokensUsed = budgetedMemories.reduce(
|
|
325
471
|
(sum, memory) => sum + estimateTokens(memory.content),
|
|
@@ -336,12 +482,12 @@ async function cmdStart(project: string) {
|
|
|
336
482
|
|
|
337
483
|
if (fixError) fail(`Failed to load execution log: ${fixError.message}`);
|
|
338
484
|
|
|
339
|
-
// Fixes share the same
|
|
340
|
-
//
|
|
485
|
+
// Fixes share the same token budget as memories, which already consumed
|
|
486
|
+
// their portion above — fixes only get whatever room is left.
|
|
341
487
|
const budgetedFixes: { problem: string; solution: string | null }[] = [];
|
|
342
488
|
for (const fix of fixData ?? []) {
|
|
343
489
|
const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
|
|
344
|
-
if (tokensUsed + tokens >
|
|
490
|
+
if (tokensUsed + tokens > tokenBudget) break;
|
|
345
491
|
budgetedFixes.push(fix);
|
|
346
492
|
tokensUsed += tokens;
|
|
347
493
|
}
|
|
@@ -366,8 +512,24 @@ async function cmdStart(project: string) {
|
|
|
366
512
|
|
|
367
513
|
console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
|
|
368
514
|
|
|
515
|
+
const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
|
|
516
|
+
const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
|
|
517
|
+
|
|
518
|
+
if (pinnedMemories.length > 0) {
|
|
519
|
+
console.log(`Pinned (${pinnedMemories.length})`);
|
|
520
|
+
console.log("-".repeat(11));
|
|
521
|
+
for (const memory of pinnedMemories) {
|
|
522
|
+
console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
|
|
523
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
524
|
+
if (linked.length > 0) {
|
|
525
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
console.log("");
|
|
529
|
+
}
|
|
530
|
+
|
|
369
531
|
for (const type of MEMORY_TYPES) {
|
|
370
|
-
const group =
|
|
532
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
371
533
|
if (group.length === 0) continue;
|
|
372
534
|
|
|
373
535
|
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
@@ -392,7 +554,7 @@ async function cmdStart(project: string) {
|
|
|
392
554
|
}
|
|
393
555
|
}
|
|
394
556
|
|
|
395
|
-
console.log(`Context budget: ${tokensUsed}/${
|
|
557
|
+
console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
|
|
396
558
|
if (omittedCount > 0) {
|
|
397
559
|
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
398
560
|
}
|
|
@@ -446,7 +608,26 @@ async function confirmSupersede(oldId: string, newContent: string): Promise<bool
|
|
|
446
608
|
return false;
|
|
447
609
|
}
|
|
448
610
|
|
|
449
|
-
|
|
611
|
+
// Provenance metadata attached to a memory at save time. Manual `cm save`
|
|
612
|
+
// calls (a human at the keyboard) leave this at its defaults — source
|
|
613
|
+
// "manual", confidence 1.0, no commit/file/author info. The hook passes
|
|
614
|
+
// commitSha/filePaths/author and source "hook" with a lower confidence;
|
|
615
|
+
// MCP tool saves pass source "mcp".
|
|
616
|
+
interface SaveProvenance {
|
|
617
|
+
commitSha?: string;
|
|
618
|
+
filePaths?: string[];
|
|
619
|
+
author?: string;
|
|
620
|
+
source?: "hook" | "manual" | "mcp";
|
|
621
|
+
confidence?: number;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function cmdSave(
|
|
625
|
+
project: string,
|
|
626
|
+
type: string,
|
|
627
|
+
content: string,
|
|
628
|
+
force = false,
|
|
629
|
+
provenance: SaveProvenance = {}
|
|
630
|
+
) {
|
|
450
631
|
if (!MEMORY_TYPES.includes(type as MemoryType)) {
|
|
451
632
|
fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
|
|
452
633
|
}
|
|
@@ -492,9 +673,22 @@ async function cmdSave(project: string, type: string, content: string, force = f
|
|
|
492
673
|
if (resolveError) fail(`Failed to resolve superseded memory: ${resolveError.message}`);
|
|
493
674
|
}
|
|
494
675
|
|
|
676
|
+
const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
|
|
677
|
+
const insertPayload: Record<string, unknown> = {
|
|
678
|
+
project,
|
|
679
|
+
type,
|
|
680
|
+
content,
|
|
681
|
+
device_id: getDeviceId(),
|
|
682
|
+
source,
|
|
683
|
+
confidence,
|
|
684
|
+
};
|
|
685
|
+
if (commitSha) insertPayload.commit_sha = commitSha;
|
|
686
|
+
if (filePaths && filePaths.length > 0) insertPayload.file_paths = filePaths;
|
|
687
|
+
if (author) insertPayload.author = author;
|
|
688
|
+
|
|
495
689
|
const { data: inserted, error } = await supabase
|
|
496
690
|
.from("memories")
|
|
497
|
-
.insert(
|
|
691
|
+
.insert(insertPayload)
|
|
498
692
|
.select("id")
|
|
499
693
|
.single();
|
|
500
694
|
|
|
@@ -530,6 +724,40 @@ async function cmdSave(project: string, type: string, content: string, force = f
|
|
|
530
724
|
}
|
|
531
725
|
}
|
|
532
726
|
|
|
727
|
+
// --- cm check-commit (internal, used by the hook) --------------------------
|
|
728
|
+
|
|
729
|
+
// Not a user-facing command (no usage()/help entry) — the hook calls this
|
|
730
|
+
// once per invocation, before doing any diff analysis, to decide whether
|
|
731
|
+
// this commit_sha has already been captured. post-commit and post-rewrite
|
|
732
|
+
// both run the exact same capture logic against current HEAD, so without
|
|
733
|
+
// this guard, a commit that fires both hooks (e.g. `git commit --amend`,
|
|
734
|
+
// which triggers post-commit for the new commit AND post-rewrite for the
|
|
735
|
+
// rewrite) would be captured twice. Checked once per invocation — not
|
|
736
|
+
// inside each individual cmSave call — because a single commit normally
|
|
737
|
+
// produces several distinct memories; a per-save check would see the
|
|
738
|
+
// first save's own commit_sha and wrongly skip the rest.
|
|
739
|
+
async function cmdCheckCommit(project: string, commitSha: string): Promise<void> {
|
|
740
|
+
const { data, error } = await supabase
|
|
741
|
+
.from("memories")
|
|
742
|
+
.select("id")
|
|
743
|
+
.eq("project", project)
|
|
744
|
+
.eq("commit_sha", commitSha)
|
|
745
|
+
.limit(1);
|
|
746
|
+
|
|
747
|
+
if (error) {
|
|
748
|
+
// Fail open: an error here (including "column does not exist" if the
|
|
749
|
+
// commit_sha migration hasn't landed yet) must not look identical to
|
|
750
|
+
// "duplicate, skip" below — that would silently suppress every hook
|
|
751
|
+
// capture with no log trail. Exit 0 so the normal save path runs;
|
|
752
|
+
// if commit_sha genuinely isn't a column, the save itself fails
|
|
753
|
+
// there instead, where the hook's outer error handler logs it.
|
|
754
|
+
console.error(`check-commit: ${error.message}`);
|
|
755
|
+
process.exit(0);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
process.exit(data && data.length > 0 ? 1 : 0);
|
|
759
|
+
}
|
|
760
|
+
|
|
533
761
|
// --- memory compression ----------------------------------------------------
|
|
534
762
|
|
|
535
763
|
async function compressMemories(
|
|
@@ -688,6 +916,34 @@ async function cmdResolve(project: string, memoryId: string) {
|
|
|
688
916
|
console.log(`Resolved memory [${memoryId}]`);
|
|
689
917
|
}
|
|
690
918
|
|
|
919
|
+
// --- cm pin / cm unpin -------------------------------------------------
|
|
920
|
+
|
|
921
|
+
async function setPinned(project: string, memoryId: string, pinned: boolean): Promise<void> {
|
|
922
|
+
const { data, error } = await supabase
|
|
923
|
+
.from("memories")
|
|
924
|
+
.update({ pinned })
|
|
925
|
+
.eq("project", project)
|
|
926
|
+
.eq("id", memoryId)
|
|
927
|
+
.select("id");
|
|
928
|
+
|
|
929
|
+
if (error) fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
|
|
930
|
+
|
|
931
|
+
if (!data || data.length === 0) {
|
|
932
|
+
console.log(`No memory found with ID [${memoryId}]`);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function cmdPin(project: string, memoryId: string): Promise<void> {
|
|
940
|
+
await setPinned(project, memoryId, true);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function cmdUnpin(project: string, memoryId: string): Promise<void> {
|
|
944
|
+
await setPinned(project, memoryId, false);
|
|
945
|
+
}
|
|
946
|
+
|
|
691
947
|
// --- cm delete -------------------------------------------------------------
|
|
692
948
|
|
|
693
949
|
async function cmdDelete(project: string, memoryId: string) {
|
|
@@ -735,7 +991,13 @@ async function cmdInspect(project: string, memoryId: string) {
|
|
|
735
991
|
|
|
736
992
|
if (error || !memory) fail(`Memory [${memoryId}] not found for "${project}".`);
|
|
737
993
|
|
|
738
|
-
const
|
|
994
|
+
const config = readStackmemConfig();
|
|
995
|
+
const decayScore = computeDecayScore(
|
|
996
|
+
new Date(memory.created_at),
|
|
997
|
+
memory.access_count ?? 0,
|
|
998
|
+
memory.pinned ?? false,
|
|
999
|
+
config.decayLambda
|
|
1000
|
+
);
|
|
739
1001
|
|
|
740
1002
|
const { data: links, error: linksError } = await supabase
|
|
741
1003
|
.from("memory_links")
|
|
@@ -843,6 +1105,65 @@ try {
|
|
|
843
1105
|
|
|
844
1106
|
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
845
1107
|
|
|
1108
|
+
// Inlined copy of src/config-file.ts's defaults/merge logic — this script
|
|
1109
|
+
// runs standalone with no import access to that module.
|
|
1110
|
+
const DEFAULT_STACKMEM_CONFIG = {
|
|
1111
|
+
ignore: [],
|
|
1112
|
+
tokenBudget: 2000,
|
|
1113
|
+
decayLambda: 0.05,
|
|
1114
|
+
captureTypes: ["decision", "constraint", "discovery", "rejection"],
|
|
1115
|
+
minQualityScore: 0.3,
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
function isStringArray(value) {
|
|
1119
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function readStackmemConfig() {
|
|
1123
|
+
const configPath = join(process.cwd(), ".stackmem.json");
|
|
1124
|
+
try {
|
|
1125
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1126
|
+
return {
|
|
1127
|
+
ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
|
|
1128
|
+
tokenBudget:
|
|
1129
|
+
typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
|
|
1130
|
+
? raw.tokenBudget
|
|
1131
|
+
: DEFAULT_STACKMEM_CONFIG.tokenBudget,
|
|
1132
|
+
decayLambda:
|
|
1133
|
+
typeof raw.decayLambda === "number" && raw.decayLambda > 0
|
|
1134
|
+
? raw.decayLambda
|
|
1135
|
+
: DEFAULT_STACKMEM_CONFIG.decayLambda,
|
|
1136
|
+
captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
|
|
1137
|
+
minQualityScore:
|
|
1138
|
+
typeof raw.minQualityScore === "number"
|
|
1139
|
+
? raw.minQualityScore
|
|
1140
|
+
: DEFAULT_STACKMEM_CONFIG.minQualityScore,
|
|
1141
|
+
};
|
|
1142
|
+
} catch {
|
|
1143
|
+
return { ...DEFAULT_STACKMEM_CONFIG };
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function matchesIgnorePattern(filePath, pattern) {
|
|
1148
|
+
const base = filePath.split("/").pop() ?? filePath;
|
|
1149
|
+
|
|
1150
|
+
if (pattern.endsWith("/")) {
|
|
1151
|
+
const dirName = pattern.slice(0, -1);
|
|
1152
|
+
return filePath.split("/").includes(dirName);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (pattern.includes("*")) {
|
|
1156
|
+
const regex = new RegExp(
|
|
1157
|
+
"^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
|
|
1158
|
+
);
|
|
1159
|
+
return regex.test(base) || regex.test(filePath);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return filePath === pattern || base === pattern;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const STACKMEM_CONFIG = readStackmemConfig();
|
|
1166
|
+
|
|
846
1167
|
const SECRET_PATTERNS = [
|
|
847
1168
|
/AKIA[0-9A-Z]{16}/g,
|
|
848
1169
|
/(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
|
|
@@ -899,10 +1220,43 @@ function git(args) {
|
|
|
899
1220
|
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
900
1221
|
}
|
|
901
1222
|
|
|
1223
|
+
const CURRENT_SHA = git(["rev-parse", "HEAD"]);
|
|
1224
|
+
const CURRENT_AUTHOR = (() => {
|
|
1225
|
+
try {
|
|
1226
|
+
return git(["log", "--format=%ae", "-1", "HEAD"]);
|
|
1227
|
+
} catch {
|
|
1228
|
+
return "";
|
|
1229
|
+
}
|
|
1230
|
+
})();
|
|
1231
|
+
|
|
1232
|
+
// post-commit and post-rewrite both run this exact script against current
|
|
1233
|
+
// HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
|
|
1234
|
+
// would otherwise capture the same commit twice. Checked once up front,
|
|
1235
|
+
// not per-save — see cmdCheckCommit in src/cli.ts for why.
|
|
1236
|
+
try {
|
|
1237
|
+
execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
|
|
1238
|
+
} catch {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// file_paths is populated with every file the commit touched (not just
|
|
1243
|
+
// the filtered diffableFiles below) — "cm why <file>" should still find
|
|
1244
|
+
// a commit's decision/discovery even if that specific file's own diff
|
|
1245
|
+
// content was skipped as noise (a lockfile, say).
|
|
902
1246
|
function cmSave(type, content) {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1247
|
+
if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
|
|
1248
|
+
|
|
1249
|
+
const args = [
|
|
1250
|
+
CLI_PATH, "save", PROJECT, type, content, "--force",
|
|
1251
|
+
"--commit-sha", CURRENT_SHA,
|
|
1252
|
+
"--source", "hook",
|
|
1253
|
+
"--confidence", "0.8",
|
|
1254
|
+
];
|
|
1255
|
+
if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
|
|
1256
|
+
if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
|
|
1257
|
+
|
|
1258
|
+
execFileSync("node", args, { stdio: "inherit" });
|
|
1259
|
+
savedItems.push(\`\${type}: \${content}\`);
|
|
906
1260
|
}
|
|
907
1261
|
|
|
908
1262
|
function basename(filePath) {
|
|
@@ -929,6 +1283,29 @@ function isSchemaFile(filePath) {
|
|
|
929
1283
|
);
|
|
930
1284
|
}
|
|
931
1285
|
|
|
1286
|
+
// Noise that's never worth diffing: lockfiles, build output, vendored/
|
|
1287
|
+
// generated code, and timestamp-prefixed SQL migrations (their content is
|
|
1288
|
+
// a mechanical dump, not a human decision). Checked against path segments
|
|
1289
|
+
// (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
|
|
1290
|
+
// just a top-level "dist/...".
|
|
1291
|
+
const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
|
|
1292
|
+
const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
|
|
1293
|
+
const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
|
|
1294
|
+
const DIFF_MAX_BYTES = 50 * 1024;
|
|
1295
|
+
|
|
1296
|
+
function shouldSkipDiffFile(filePath) {
|
|
1297
|
+
const base = basename(filePath);
|
|
1298
|
+
if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
|
|
1299
|
+
if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
|
|
1300
|
+
if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
|
|
1301
|
+
if (base.endsWith(".map")) return true;
|
|
1302
|
+
if (base.endsWith(".pb.go")) return true;
|
|
1303
|
+
if (/\\.generated\\./.test(base)) return true;
|
|
1304
|
+
if (MIGRATION_FILE_RE.test(base)) return true;
|
|
1305
|
+
if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
932
1309
|
const LOW_QUALITY_PREFIXES = [
|
|
933
1310
|
"wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
|
|
934
1311
|
"tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
|
|
@@ -963,11 +1340,10 @@ const savedItems = [];
|
|
|
963
1340
|
|
|
964
1341
|
const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
965
1342
|
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
// rules-file content and triggers another sync commit, indefinitely.
|
|
1343
|
+
// Kept as a general escape hatch for other automation (CI bots, scripts)
|
|
1344
|
+
// that tags its own commits this way and shouldn't be captured as a
|
|
1345
|
+
// memory — cm sync no longer self-commits, so this isn't load-bearing for
|
|
1346
|
+
// that anymore, but other tools' commits still deserve the same skip.
|
|
971
1347
|
if (commitMessage.includes("[skip ci]")) {
|
|
972
1348
|
return;
|
|
973
1349
|
}
|
|
@@ -991,10 +1367,40 @@ const changedFiles = git(
|
|
|
991
1367
|
|
|
992
1368
|
let diff = "";
|
|
993
1369
|
if (!isFirstCommit) {
|
|
1370
|
+
// --numstat marks a binary file's added/deleted columns as "-" — that's
|
|
1371
|
+
// the cheapest way to identify them without fetching their (useless,
|
|
1372
|
+
// and potentially huge) diff content at all.
|
|
1373
|
+
let binaryFiles = new Set();
|
|
994
1374
|
try {
|
|
995
|
-
|
|
1375
|
+
const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
|
|
1376
|
+
encoding: "utf-8",
|
|
1377
|
+
});
|
|
1378
|
+
for (const line of numstat.split("\\n")) {
|
|
1379
|
+
const parts = line.split("\\t");
|
|
1380
|
+
if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
|
|
1381
|
+
binaryFiles.add(parts[2]);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
996
1384
|
} catch {
|
|
997
|
-
|
|
1385
|
+
// Nothing to compare against — leave binaryFiles empty.
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const diffableFiles = changedFiles.filter(
|
|
1389
|
+
(file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
|
|
1390
|
+
);
|
|
1391
|
+
|
|
1392
|
+
if (diffableFiles.length > 0) {
|
|
1393
|
+
try {
|
|
1394
|
+
diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
|
|
1395
|
+
encoding: "utf-8",
|
|
1396
|
+
});
|
|
1397
|
+
} catch {
|
|
1398
|
+
diff = "";
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (diff.length > DIFF_MAX_BYTES) {
|
|
1403
|
+
diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
|
|
998
1404
|
}
|
|
999
1405
|
}
|
|
1000
1406
|
|
|
@@ -1021,17 +1427,14 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1021
1427
|
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
1022
1428
|
const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
|
|
1023
1429
|
cmSave("discovery", content);
|
|
1024
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1025
1430
|
}
|
|
1026
1431
|
if (isConfigFile(currentFile)) {
|
|
1027
1432
|
const content = redactSecrets(\`new config: \${currentFile}\`);
|
|
1028
1433
|
cmSave("constraint", content);
|
|
1029
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1030
1434
|
}
|
|
1031
1435
|
if (isSchemaFile(currentFile)) {
|
|
1032
1436
|
const content = redactSecrets(\`schema change: \${currentFile}\`);
|
|
1033
1437
|
cmSave("discovery", content);
|
|
1034
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1035
1438
|
}
|
|
1036
1439
|
}
|
|
1037
1440
|
continue;
|
|
@@ -1068,7 +1471,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1068
1471
|
if (removedModulePath) {
|
|
1069
1472
|
const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
|
|
1070
1473
|
cmSave("rejection", content);
|
|
1071
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
1072
1474
|
}
|
|
1073
1475
|
|
|
1074
1476
|
if (isPackageJson && inDependenciesSection) {
|
|
@@ -1076,7 +1478,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1076
1478
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
1077
1479
|
const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
|
|
1078
1480
|
cmSave("rejection", content);
|
|
1079
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
1080
1481
|
}
|
|
1081
1482
|
}
|
|
1082
1483
|
continue;
|
|
@@ -1092,7 +1493,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1092
1493
|
if (envMatch) {
|
|
1093
1494
|
const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
|
|
1094
1495
|
cmSave("constraint", content);
|
|
1095
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1096
1496
|
}
|
|
1097
1497
|
}
|
|
1098
1498
|
}
|
|
@@ -1102,7 +1502,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1102
1502
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
1103
1503
|
const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
|
|
1104
1504
|
cmSave("decision", content);
|
|
1105
|
-
savedItems.push(\`decision: \${content}\`);
|
|
1106
1505
|
}
|
|
1107
1506
|
}
|
|
1108
1507
|
|
|
@@ -1110,14 +1509,12 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1110
1509
|
const truncated = codeLine.trim().slice(0, 80);
|
|
1111
1510
|
const content = redactSecrets(\`database change: \${truncated}\`);
|
|
1112
1511
|
cmSave("discovery", content);
|
|
1113
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1114
1512
|
}
|
|
1115
1513
|
|
|
1116
1514
|
if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
|
|
1117
1515
|
const truncated = codeLine.trim().slice(0, 100);
|
|
1118
1516
|
const content = redactSecrets(\`open issue from commit: \${truncated}\`);
|
|
1119
1517
|
cmSave("discovery", content);
|
|
1120
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1121
1518
|
}
|
|
1122
1519
|
}
|
|
1123
1520
|
}
|
|
@@ -1146,19 +1543,16 @@ if (breakingMatch) {
|
|
|
1146
1543
|
const breakingContent = redactSecrets(breakingMatch[1].trim());
|
|
1147
1544
|
const content = \`BREAKING CHANGE: \${breakingContent}\`;
|
|
1148
1545
|
cmSave("constraint", content);
|
|
1149
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1150
1546
|
}
|
|
1151
1547
|
|
|
1152
1548
|
if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
|
|
1153
1549
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1154
1550
|
cmSave("decision", trimmedMessage);
|
|
1155
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1156
1551
|
} else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
|
|
1157
1552
|
// docs/style/test/ci/build/chore — skip entirely, nothing saved.
|
|
1158
1553
|
} else if (isQualityCommitMessage(commitMessage)) {
|
|
1159
1554
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1160
1555
|
cmSave("decision", trimmedMessage);
|
|
1161
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1162
1556
|
}
|
|
1163
1557
|
|
|
1164
1558
|
console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
|
|
@@ -1166,21 +1560,13 @@ for (const item of savedItems) {
|
|
|
1166
1560
|
console.log(\` - \${item}\`);
|
|
1167
1561
|
}
|
|
1168
1562
|
|
|
1563
|
+
// Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
|
|
1564
|
+
// Editors read these files straight off disk, and they're gitignored by
|
|
1565
|
+
// default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
|
|
1566
|
+
// sync with git history; committing them here used to double the commit
|
|
1567
|
+
// count on every real commit and produced constant rules-file merge
|
|
1568
|
+
// conflicts on teams.
|
|
1169
1569
|
execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
|
|
1170
|
-
|
|
1171
|
-
// cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
|
|
1172
|
-
// the working directory dirty (breaking things like \`npm version patch\`
|
|
1173
|
-
// that require a clean tree). Commit the sync ourselves instead. --no-verify
|
|
1174
|
-
// skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
|
|
1175
|
-
// guard above, which is what actually stops this from recursing).
|
|
1176
|
-
try {
|
|
1177
|
-
execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
|
|
1178
|
-
execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
|
|
1179
|
-
stdio: "pipe",
|
|
1180
|
-
});
|
|
1181
|
-
} catch {
|
|
1182
|
-
// Nothing to commit (rules files unchanged) — fine, ignore.
|
|
1183
|
-
}
|
|
1184
1570
|
}
|
|
1185
1571
|
|
|
1186
1572
|
async function main() {
|
|
@@ -1206,15 +1592,27 @@ main().finally(() => process.exit(0));
|
|
|
1206
1592
|
`;
|
|
1207
1593
|
}
|
|
1208
1594
|
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1595
|
+
// Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
|
|
1596
|
+
// `git rebase` (neither of which is a plain commit, so post-commit alone
|
|
1597
|
+
// misses them) — it runs the exact same capture script as post-commit,
|
|
1598
|
+
// against current HEAD, with the check-commit idempotency guard in the
|
|
1599
|
+
// script itself preventing a double-capture when a single operation
|
|
1600
|
+
// (e.g. --amend) fires both hooks for the same resulting commit.
|
|
1601
|
+
const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"] as const;
|
|
1602
|
+
|
|
1603
|
+
function hookBackupFilename(hookName: string): string {
|
|
1604
|
+
return `${hookName}${HOOK_BACKUP_SUFFIX}`;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
|
|
1608
|
+
// hook of this name already exists and wasn't written by stackmem, it's
|
|
1211
1609
|
// preserved as a backup and chained to run before stackmem's own hook,
|
|
1212
1610
|
// rather than being clobbered — see `uninstallHook` for how it's restored.
|
|
1213
|
-
function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
1611
|
+
function installHook(hooksDir: string, hookLogicPath: string, hookName: string): void {
|
|
1214
1612
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1215
1613
|
|
|
1216
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1217
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1614
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1615
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1218
1616
|
|
|
1219
1617
|
let chainedOriginal = false;
|
|
1220
1618
|
if (nodeFs.existsSync(hookPath)) {
|
|
@@ -1231,7 +1629,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1231
1629
|
}
|
|
1232
1630
|
|
|
1233
1631
|
const chainLine = chainedOriginal
|
|
1234
|
-
? `"$(dirname "$0")/${
|
|
1632
|
+
? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
|
|
1235
1633
|
: "";
|
|
1236
1634
|
|
|
1237
1635
|
nodeFs.writeFileSync(
|
|
@@ -1242,7 +1640,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1242
1640
|
nodeFs.chmodSync(hookPath, 0o755);
|
|
1243
1641
|
|
|
1244
1642
|
if (chainedOriginal) {
|
|
1245
|
-
console.log(
|
|
1643
|
+
console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
|
|
1246
1644
|
}
|
|
1247
1645
|
}
|
|
1248
1646
|
|
|
@@ -1250,9 +1648,9 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1250
1648
|
// stackmem chained it after an existing hook, restores that original hook
|
|
1251
1649
|
// in its place. Returns a description of what happened, or null if there
|
|
1252
1650
|
// was no stackmem-managed hook to remove (nothing done).
|
|
1253
|
-
function uninstallHook(hooksDir: string): string | null {
|
|
1254
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1255
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1651
|
+
function uninstallHook(hooksDir: string, hookName: string): string | null {
|
|
1652
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1653
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1256
1654
|
|
|
1257
1655
|
if (!nodeFs.existsSync(hookPath)) return null;
|
|
1258
1656
|
|
|
@@ -1261,11 +1659,11 @@ function uninstallHook(hooksDir: string): string | null {
|
|
|
1261
1659
|
|
|
1262
1660
|
if (nodeFs.existsSync(backupPath)) {
|
|
1263
1661
|
nodeFs.renameSync(backupPath, hookPath);
|
|
1264
|
-
return
|
|
1662
|
+
return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
|
|
1265
1663
|
}
|
|
1266
1664
|
|
|
1267
1665
|
nodeFs.unlinkSync(hookPath);
|
|
1268
|
-
return
|
|
1666
|
+
return `.git/hooks/${hookName}`;
|
|
1269
1667
|
}
|
|
1270
1668
|
|
|
1271
1669
|
// Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
|
|
@@ -1328,6 +1726,8 @@ dist/
|
|
|
1328
1726
|
coverage/
|
|
1329
1727
|
*-memories.json
|
|
1330
1728
|
*.db
|
|
1729
|
+
*cursorrules
|
|
1730
|
+
*windsurfrules
|
|
1331
1731
|
`;
|
|
1332
1732
|
|
|
1333
1733
|
// Returns true if the file was created, false if it already existed
|
|
@@ -1385,10 +1785,16 @@ async function generateRulesContent(project: string): Promise<string> {
|
|
|
1385
1785
|
|
|
1386
1786
|
if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
|
|
1387
1787
|
|
|
1788
|
+
const config = readStackmemConfig();
|
|
1388
1789
|
const scoredMemories = (memoryRows ?? [])
|
|
1389
1790
|
.map((memory) => ({
|
|
1390
1791
|
...memory,
|
|
1391
|
-
decay_score: computeDecayScore(
|
|
1792
|
+
decay_score: computeDecayScore(
|
|
1793
|
+
new Date(memory.created_at),
|
|
1794
|
+
memory.access_count ?? 0,
|
|
1795
|
+
memory.pinned ?? false,
|
|
1796
|
+
config.decayLambda
|
|
1797
|
+
),
|
|
1392
1798
|
}))
|
|
1393
1799
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
1394
1800
|
|
|
@@ -1507,6 +1913,10 @@ export async function trySeedMemory(
|
|
|
1507
1913
|
type,
|
|
1508
1914
|
content,
|
|
1509
1915
|
device_id: getDeviceId(),
|
|
1916
|
+
// Seeded by `cm init` scanning project files — an explicit user-run
|
|
1917
|
+
// command, not an auto-captured commit, so it's "manual" like cm save,
|
|
1918
|
+
// not the memories.source column's default of "hook".
|
|
1919
|
+
source: "manual",
|
|
1510
1920
|
});
|
|
1511
1921
|
return !error;
|
|
1512
1922
|
}
|
|
@@ -1621,7 +2031,20 @@ async function verifyBackendConnection(project: string): Promise<boolean> {
|
|
|
1621
2031
|
return !error;
|
|
1622
2032
|
}
|
|
1623
2033
|
|
|
1624
|
-
|
|
2034
|
+
// Returns true if the file was created, false if it already existed
|
|
2035
|
+
// (and was left untouched).
|
|
2036
|
+
function writeStackmemConfigFile(resolvedPath: string): boolean {
|
|
2037
|
+
const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
|
|
2038
|
+
|
|
2039
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2040
|
+
return false;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
|
|
2044
|
+
return true;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
async function cmdInit(project: string, targetPath: string, writeConfig = false): Promise<void> {
|
|
1625
2048
|
const resolvedPath = nodePath.resolve(targetPath);
|
|
1626
2049
|
const gitDir = nodePath.join(resolvedPath, ".git");
|
|
1627
2050
|
|
|
@@ -1641,7 +2064,9 @@ async function cmdInit(project: string, targetPath: string) {
|
|
|
1641
2064
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1642
2065
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1643
2066
|
|
|
1644
|
-
|
|
2067
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
2068
|
+
installHook(hooksDir, hookLogicPath, hookName);
|
|
2069
|
+
}
|
|
1645
2070
|
|
|
1646
2071
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
1647
2072
|
|
|
@@ -1657,10 +2082,15 @@ async function cmdInit(project: string, targetPath: string) {
|
|
|
1657
2082
|
|
|
1658
2083
|
const backendOk = await verifyBackendConnection(project);
|
|
1659
2084
|
|
|
2085
|
+
const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
|
|
2086
|
+
|
|
1660
2087
|
console.log("");
|
|
1661
2088
|
if (gitignoreCreated) {
|
|
1662
2089
|
console.log("✓ .gitignore created");
|
|
1663
2090
|
}
|
|
2091
|
+
if (writeConfig) {
|
|
2092
|
+
console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
|
|
2093
|
+
}
|
|
1664
2094
|
console.log("✓ Git hook installed");
|
|
1665
2095
|
console.log("✓ MCP server registered");
|
|
1666
2096
|
console.log("✓ CLAUDE.md written");
|
|
@@ -1801,9 +2231,17 @@ async function cmdContext(project: string, task: string) {
|
|
|
1801
2231
|
return;
|
|
1802
2232
|
}
|
|
1803
2233
|
|
|
2234
|
+
const config = readStackmemConfig();
|
|
2235
|
+
const tokenBudget = config.tokenBudget;
|
|
2236
|
+
|
|
1804
2237
|
const scoredMemories = memories
|
|
1805
2238
|
.map((memory) => {
|
|
1806
|
-
const decayScore = computeDecayScore(
|
|
2239
|
+
const decayScore = computeDecayScore(
|
|
2240
|
+
new Date(memory.created_at),
|
|
2241
|
+
memory.access_count ?? 0,
|
|
2242
|
+
memory.pinned ?? false,
|
|
2243
|
+
config.decayLambda
|
|
2244
|
+
);
|
|
1807
2245
|
const relevanceScore = scoreMemoryRelevance(memory.content, task);
|
|
1808
2246
|
return {
|
|
1809
2247
|
...memory,
|
|
@@ -1813,7 +2251,7 @@ async function cmdContext(project: string, task: string) {
|
|
|
1813
2251
|
.sort((a, b) => b.combined_score - a.combined_score);
|
|
1814
2252
|
|
|
1815
2253
|
const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
|
|
1816
|
-
const budgetedMemories = applyContextBudget(topMemories,
|
|
2254
|
+
const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
|
|
1817
2255
|
const tokensUsed = budgetedMemories.reduce(
|
|
1818
2256
|
(sum, memory) => sum + estimateTokens(memory.content),
|
|
1819
2257
|
0
|
|
@@ -1832,11 +2270,82 @@ async function cmdContext(project: string, task: string) {
|
|
|
1832
2270
|
lines.push("");
|
|
1833
2271
|
}
|
|
1834
2272
|
|
|
1835
|
-
lines.push(`*(${tokensUsed}/${
|
|
2273
|
+
lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
|
|
1836
2274
|
|
|
1837
2275
|
console.log(lines.join("\n"));
|
|
1838
2276
|
}
|
|
1839
2277
|
|
|
2278
|
+
// --- cm why --------------------------------------------------------------
|
|
2279
|
+
|
|
2280
|
+
function fileMatchesPaths(filePaths: unknown, file: string, baseName: string): boolean {
|
|
2281
|
+
if (!Array.isArray(filePaths)) return false;
|
|
2282
|
+
return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
function fileMentionedInContent(content: string, file: string, baseName: string): boolean {
|
|
2286
|
+
const lower = content.toLowerCase();
|
|
2287
|
+
return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
async function cmdWhy(project: string, file: string) {
|
|
2291
|
+
const baseName = file.split("/").pop() ?? file;
|
|
2292
|
+
|
|
2293
|
+
const { data, error } = await supabase
|
|
2294
|
+
.from("memories")
|
|
2295
|
+
.select("*")
|
|
2296
|
+
.eq("project", project)
|
|
2297
|
+
.eq("resolved", false);
|
|
2298
|
+
|
|
2299
|
+
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
2300
|
+
|
|
2301
|
+
const memories = data ?? [];
|
|
2302
|
+
|
|
2303
|
+
// file_paths (structured, from Item 6) and a content substring search
|
|
2304
|
+
// are combined into one match set rather than one gating the other as
|
|
2305
|
+
// a strict fallback — most memories predate file_paths existing at all
|
|
2306
|
+
// (manual saves and seeds never set it), so content search is the
|
|
2307
|
+
// primary signal for everything except recent hook-captured commits,
|
|
2308
|
+
// not a rare last resort.
|
|
2309
|
+
const matched = memories.filter(
|
|
2310
|
+
(memory) =>
|
|
2311
|
+
fileMatchesPaths(memory.file_paths, file, baseName) ||
|
|
2312
|
+
fileMentionedInContent(memory.content, file, baseName)
|
|
2313
|
+
);
|
|
2314
|
+
|
|
2315
|
+
if (matched.length === 0) {
|
|
2316
|
+
console.log(`No memories found for ${file}.`);
|
|
2317
|
+
console.log("Make some commits touching this file to build up context.");
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
const config = readStackmemConfig();
|
|
2322
|
+
const scoredMemories = matched
|
|
2323
|
+
.map((memory) => ({
|
|
2324
|
+
...memory,
|
|
2325
|
+
decay_score: computeDecayScore(
|
|
2326
|
+
new Date(memory.created_at),
|
|
2327
|
+
memory.access_count ?? 0,
|
|
2328
|
+
memory.pinned ?? false,
|
|
2329
|
+
config.decayLambda
|
|
2330
|
+
),
|
|
2331
|
+
}))
|
|
2332
|
+
.sort((a, b) => b.decay_score - a.decay_score);
|
|
2333
|
+
|
|
2334
|
+
console.log(`Decisions and constraints tied to ${file}:\n`);
|
|
2335
|
+
|
|
2336
|
+
for (const type of MEMORY_TYPES) {
|
|
2337
|
+
const group = scoredMemories.filter((memory) => memory.type === type);
|
|
2338
|
+
if (group.length === 0) continue;
|
|
2339
|
+
|
|
2340
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
2341
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
2342
|
+
for (const memory of group) {
|
|
2343
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
2344
|
+
}
|
|
2345
|
+
console.log("");
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
1840
2349
|
// --- cm session-end --------------------------------------------------------
|
|
1841
2350
|
|
|
1842
2351
|
interface SessionEndInput {
|
|
@@ -2087,7 +2596,9 @@ async function cmdDoctor(fix: boolean) {
|
|
|
2087
2596
|
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
2088
2597
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
2089
2598
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
2090
|
-
|
|
2599
|
+
for (const name of MANAGED_HOOK_NAMES) {
|
|
2600
|
+
installHook(hooksDir, hookLogicPath, name);
|
|
2601
|
+
}
|
|
2091
2602
|
|
|
2092
2603
|
if (isHookExecutable(hookPath)) {
|
|
2093
2604
|
console.log("Fixed: git hook");
|
|
@@ -2284,8 +2795,10 @@ async function cmdUninstall(project: string, targetPath: string) {
|
|
|
2284
2795
|
const gitDir = nodePath.join(targetPath, ".git");
|
|
2285
2796
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
2286
2797
|
|
|
2287
|
-
const
|
|
2288
|
-
|
|
2798
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
2799
|
+
const hookResult = uninstallHook(hooksDir, hookName);
|
|
2800
|
+
if (hookResult) console.log(`Removed ${hookResult}`);
|
|
2801
|
+
}
|
|
2289
2802
|
|
|
2290
2803
|
const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
|
|
2291
2804
|
|
|
@@ -2339,10 +2852,21 @@ async function main() {
|
|
|
2339
2852
|
case "save": {
|
|
2340
2853
|
const forceIndex = args.indexOf("--force");
|
|
2341
2854
|
const force = forceIndex !== -1;
|
|
2342
|
-
|
|
2855
|
+
let positional = force
|
|
2343
2856
|
? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
|
|
2344
2857
|
: args;
|
|
2345
2858
|
|
|
2859
|
+
const commitShaFlag = extractValueFlag(positional, "--commit-sha");
|
|
2860
|
+
positional = commitShaFlag.rest;
|
|
2861
|
+
const authorFlag = extractValueFlag(positional, "--author");
|
|
2862
|
+
positional = authorFlag.rest;
|
|
2863
|
+
const sourceFlag = extractValueFlag(positional, "--source");
|
|
2864
|
+
positional = sourceFlag.rest;
|
|
2865
|
+
const confidenceFlag = extractValueFlag(positional, "--confidence");
|
|
2866
|
+
positional = confidenceFlag.rest;
|
|
2867
|
+
const filesFlag = extractValueFlag(positional, "--files");
|
|
2868
|
+
positional = filesFlag.rest;
|
|
2869
|
+
|
|
2346
2870
|
let explicitProject: string | null;
|
|
2347
2871
|
let type: string | undefined;
|
|
2348
2872
|
let rest: string[];
|
|
@@ -2367,7 +2891,28 @@ async function main() {
|
|
|
2367
2891
|
|
|
2368
2892
|
if (!type || rest.length === 0) usage();
|
|
2369
2893
|
const project = requireProject(explicitProject);
|
|
2370
|
-
|
|
2894
|
+
|
|
2895
|
+
const provenance: SaveProvenance = {};
|
|
2896
|
+
if (commitShaFlag.value) provenance.commitSha = commitShaFlag.value;
|
|
2897
|
+
if (authorFlag.value) provenance.author = authorFlag.value;
|
|
2898
|
+
if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
|
|
2899
|
+
provenance.source = sourceFlag.value;
|
|
2900
|
+
}
|
|
2901
|
+
if (confidenceFlag.value) {
|
|
2902
|
+
const parsed = Number(confidenceFlag.value);
|
|
2903
|
+
if (!Number.isNaN(parsed)) provenance.confidence = parsed;
|
|
2904
|
+
}
|
|
2905
|
+
if (filesFlag.value) {
|
|
2906
|
+
provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
await cmdSave(project, type, rest.join(" "), force, provenance);
|
|
2910
|
+
break;
|
|
2911
|
+
}
|
|
2912
|
+
case "check-commit": {
|
|
2913
|
+
const [project, commitSha] = args;
|
|
2914
|
+
if (!project || !commitSha) usage();
|
|
2915
|
+
await cmdCheckCommit(project, commitSha);
|
|
2371
2916
|
break;
|
|
2372
2917
|
}
|
|
2373
2918
|
case "fix": {
|
|
@@ -2401,6 +2946,22 @@ async function main() {
|
|
|
2401
2946
|
await cmdResolve(project, memoryId);
|
|
2402
2947
|
break;
|
|
2403
2948
|
}
|
|
2949
|
+
case "pin": {
|
|
2950
|
+
const split = splitOptionalProject(args, 1);
|
|
2951
|
+
if (!split) usage();
|
|
2952
|
+
const project = requireProject(split.project);
|
|
2953
|
+
const [memoryId] = split.rest;
|
|
2954
|
+
await cmdPin(project, memoryId);
|
|
2955
|
+
break;
|
|
2956
|
+
}
|
|
2957
|
+
case "unpin": {
|
|
2958
|
+
const split = splitOptionalProject(args, 1);
|
|
2959
|
+
if (!split) usage();
|
|
2960
|
+
const project = requireProject(split.project);
|
|
2961
|
+
const [memoryId] = split.rest;
|
|
2962
|
+
await cmdUnpin(project, memoryId);
|
|
2963
|
+
break;
|
|
2964
|
+
}
|
|
2404
2965
|
case "delete": {
|
|
2405
2966
|
const split = splitOptionalProject(args, 1);
|
|
2406
2967
|
if (!split) usage();
|
|
@@ -2425,9 +2986,15 @@ async function main() {
|
|
|
2425
2986
|
break;
|
|
2426
2987
|
}
|
|
2427
2988
|
case "init": {
|
|
2428
|
-
const
|
|
2989
|
+
const configIndex = args.indexOf("--config");
|
|
2990
|
+
const writeConfig = configIndex !== -1;
|
|
2991
|
+
const positional = writeConfig
|
|
2992
|
+
? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
|
|
2993
|
+
: args;
|
|
2994
|
+
|
|
2995
|
+
const [project, targetPath] = positional;
|
|
2429
2996
|
if (!project || !targetPath) usage();
|
|
2430
|
-
await cmdInit(project, targetPath);
|
|
2997
|
+
await cmdInit(project, targetPath, writeConfig);
|
|
2431
2998
|
break;
|
|
2432
2999
|
}
|
|
2433
3000
|
case "link": {
|
|
@@ -2448,6 +3015,13 @@ async function main() {
|
|
|
2448
3015
|
await cmdContext(project, split.rest.join(" "));
|
|
2449
3016
|
break;
|
|
2450
3017
|
}
|
|
3018
|
+
case "why": {
|
|
3019
|
+
const split = splitProjectFromVariadic(args);
|
|
3020
|
+
const project = requireProject(split.project);
|
|
3021
|
+
if (split.rest.length === 0) usage();
|
|
3022
|
+
await cmdWhy(project, split.rest.join(" "));
|
|
3023
|
+
break;
|
|
3024
|
+
}
|
|
2451
3025
|
case "session-end": {
|
|
2452
3026
|
await cmdSessionEndEntry(args);
|
|
2453
3027
|
break;
|