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/dist/cli.js
CHANGED
|
@@ -2,14 +2,25 @@
|
|
|
2
2
|
import nodeFs from "node:fs";
|
|
3
3
|
import nodeOs from "node:os";
|
|
4
4
|
import nodePath from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
5
6
|
import { randomInt } from "node:crypto";
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
8
|
import { createInterface } from "node:readline/promises";
|
|
8
9
|
import { analyzeCodebase } from "./ast-index.js";
|
|
9
10
|
import { VAULT_URL } from "./config.js";
|
|
11
|
+
import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
|
|
10
12
|
import { getDeviceId } from "./device.js";
|
|
11
|
-
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, scoreMemoryRelevance, } from "./scoring.js";
|
|
13
|
+
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, hasTokenOverlap, isQualityMessage, redactSecrets, scoreMemoryRelevance, tokenize, } from "./scoring.js";
|
|
12
14
|
import { supabase } from "./storage.js";
|
|
15
|
+
// Checked as early as ESM import hoisting allows — before any command
|
|
16
|
+
// dispatch, config loading, or network call — so an unsupported Node
|
|
17
|
+
// version fails with a clear message instead of an obscure syntax or
|
|
18
|
+
// API error from deeper in the dependency chain.
|
|
19
|
+
const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
|
|
20
|
+
if (NODE_MAJOR_VERSION < 18) {
|
|
21
|
+
console.error(`Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
13
24
|
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
14
25
|
const COMPRESSION_THRESHOLD = 20;
|
|
15
26
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
@@ -24,7 +35,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
|
|
|
24
35
|
const STACKMEM_HOOK_MARKER = "# stackmem-managed";
|
|
25
36
|
const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
|
|
26
37
|
const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
|
|
27
|
-
const
|
|
38
|
+
const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
|
|
28
39
|
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
|
|
29
40
|
const TYPE_LABELS = {
|
|
30
41
|
decision: "Decisions",
|
|
@@ -40,14 +51,17 @@ function usage() {
|
|
|
40
51
|
cm analyze [project] <path>
|
|
41
52
|
cm search [--project <name>] <query>
|
|
42
53
|
cm resolve [project] <memory-id>
|
|
54
|
+
cm pin [project] <memory-id>
|
|
55
|
+
cm unpin [project] <memory-id>
|
|
43
56
|
cm delete [project] <memory-id>
|
|
44
57
|
cm inspect [project] <memory-id>
|
|
45
58
|
cm compress [project]
|
|
46
|
-
cm init <project> <path>
|
|
59
|
+
cm init <project> <path> [--config]
|
|
47
60
|
cm uninstall [project] [path]
|
|
48
61
|
cm link
|
|
49
62
|
cm sync
|
|
50
63
|
cm context [--project <name>] <task>
|
|
64
|
+
cm why [--project <name>] <file>
|
|
51
65
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
52
66
|
cm doctor [--fix]
|
|
53
67
|
cm undo [project]
|
|
@@ -98,6 +112,14 @@ function cmdHelp() {
|
|
|
98
112
|
cm resolve [project] <memory-id>
|
|
99
113
|
Mark a memory as resolved.
|
|
100
114
|
|
|
115
|
+
cm pin [project] <memory-id>
|
|
116
|
+
Pin a memory: it scores 999 in decay ranking (always sorts
|
|
117
|
+
first, never decays) and shows in its own "Pinned" section at
|
|
118
|
+
the top of 'cm start', ahead of its normal type group.
|
|
119
|
+
|
|
120
|
+
cm unpin [project] <memory-id>
|
|
121
|
+
Un-pin a memory, returning it to normal decay-based ranking.
|
|
122
|
+
|
|
101
123
|
cm delete [project] <memory-id>
|
|
102
124
|
Delete a memory and any memory_links referencing it.
|
|
103
125
|
|
|
@@ -112,7 +134,7 @@ function cmdHelp() {
|
|
|
112
134
|
Cluster related unresolved memories (needs 20+) and collapse
|
|
113
135
|
clusters of 3 or more into a single summary memory.
|
|
114
136
|
|
|
115
|
-
cm init <project> <path>
|
|
137
|
+
cm init <project> <path> [--config]
|
|
116
138
|
Install a git post-commit hook in the repo at <path>, register
|
|
117
139
|
the stackmem MCP server with Claude Code, write a CLAUDE.md,
|
|
118
140
|
.cursorrules, .windsurfrules, and .stackmem file, and seed
|
|
@@ -120,7 +142,12 @@ function cmdHelp() {
|
|
|
120
142
|
.windsurfrules are pre-filled with the project's current
|
|
121
143
|
memories (for Cursor and Windsurf) and skipped if they already
|
|
122
144
|
exist. The post-commit hook keeps them fresh afterward by
|
|
123
|
-
running 'cm sync' on every commit
|
|
145
|
+
running 'cm sync' on every commit — on disk only, never
|
|
146
|
+
committed, so they don't double your commit count or conflict
|
|
147
|
+
across branches. Both files are added to .gitignore. With
|
|
148
|
+
--config, also writes a default .stackmem.json (ignore patterns,
|
|
149
|
+
tokenBudget, decayLambda, captureTypes, minQualityScore — see
|
|
150
|
+
src/config-file.ts) if one doesn't already exist.
|
|
124
151
|
|
|
125
152
|
cm uninstall [project] [path]
|
|
126
153
|
Remove everything 'cm init' set up at <path> (defaults to the
|
|
@@ -144,7 +171,8 @@ function cmdHelp() {
|
|
|
144
171
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
145
172
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
146
173
|
token budget) and its 5 most recent open execution_log fixes,
|
|
147
|
-
overwriting both files
|
|
174
|
+
overwriting both files on disk only (never git add/commit).
|
|
175
|
+
Project is read from .stackmem.
|
|
148
176
|
|
|
149
177
|
cm context [--project <name>] <task>
|
|
150
178
|
Return only the top 5 memories most relevant to a specific
|
|
@@ -152,6 +180,14 @@ function cmdHelp() {
|
|
|
152
180
|
as a markdown block capped at a 2000 token budget. Use
|
|
153
181
|
--project to target a project other than the one in .stackmem.
|
|
154
182
|
|
|
183
|
+
cm why [--project <name>] <file>
|
|
184
|
+
Show memories tied to a file: matched by Item 6's file_paths
|
|
185
|
+
column (hook-captured commits that touched it) plus a plain
|
|
186
|
+
content search for the file's path or basename, ranked by decay
|
|
187
|
+
score and grouped by type. Prints a guidance message instead if
|
|
188
|
+
nothing matches. Use --project to target a project other than
|
|
189
|
+
the one in .stackmem.
|
|
190
|
+
|
|
155
191
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
156
192
|
Capture what happened in a session and extract memories: each
|
|
157
193
|
error becomes an unsolved execution_log entry, each approach
|
|
@@ -257,7 +293,84 @@ function splitProjectFromVariadic(args) {
|
|
|
257
293
|
}
|
|
258
294
|
return { project: null, rest: args };
|
|
259
295
|
}
|
|
296
|
+
// Generic "--flag value" extractor for internal/provenance flags (not
|
|
297
|
+
// meant to be remembered by end users — only the hook and MCP server pass
|
|
298
|
+
// these). Unlike splitProjectFromVariadic's --project, these don't need
|
|
299
|
+
// special positional disambiguation, so one generic helper covers all of
|
|
300
|
+
// them: --commit-sha, --author, --source, --confidence, --files.
|
|
301
|
+
function extractValueFlag(args, flag) {
|
|
302
|
+
const idx = args.indexOf(flag);
|
|
303
|
+
if (idx === -1 || args[idx + 1] === undefined)
|
|
304
|
+
return { value: null, rest: args };
|
|
305
|
+
return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
|
|
306
|
+
}
|
|
260
307
|
// --- cm start ----------------------------------------------------------
|
|
308
|
+
const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
|
|
309
|
+
const BRANCH_NAME_BOOST = 1.2;
|
|
310
|
+
const RECENT_FILES_BOOST = 1.3;
|
|
311
|
+
const PR_TITLE_BOOST = 1.2;
|
|
312
|
+
// null means "signal unavailable" (git/gh failed, detached HEAD, not
|
|
313
|
+
// enough history, gh not installed, no open PR, etc.) — every one of
|
|
314
|
+
// these is a soft skip, never an error, per the task spec.
|
|
315
|
+
function getBranchNameTokens() {
|
|
316
|
+
try {
|
|
317
|
+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
318
|
+
encoding: "utf-8",
|
|
319
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
320
|
+
}).trim();
|
|
321
|
+
if (!branch || branch === "HEAD")
|
|
322
|
+
return null; // detached HEAD
|
|
323
|
+
const tokens = tokenize(branch);
|
|
324
|
+
return tokens.size > 0 ? tokens : null;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function getRecentlyTouchedFiles() {
|
|
331
|
+
try {
|
|
332
|
+
const output = execFileSync("git", ["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
333
|
+
const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
334
|
+
return files.length > 0 ? new Set(files) : null;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
|
|
338
|
+
// not a git repo at all — skip gracefully.
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function getOpenPrTitleTokens() {
|
|
343
|
+
try {
|
|
344
|
+
const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
|
|
345
|
+
encoding: "utf-8",
|
|
346
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
347
|
+
}).trim();
|
|
348
|
+
if (!title)
|
|
349
|
+
return null;
|
|
350
|
+
const tokens = tokenize(title);
|
|
351
|
+
return tokens.size > 0 ? tokens : null;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// gh not installed, not authenticated, or no open PR for this branch.
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function fileListOverlaps(filePaths, recentFiles) {
|
|
359
|
+
if (!Array.isArray(filePaths))
|
|
360
|
+
return false;
|
|
361
|
+
return filePaths.some((candidate) => {
|
|
362
|
+
if (typeof candidate !== "string")
|
|
363
|
+
return false;
|
|
364
|
+
if (recentFiles.has(candidate))
|
|
365
|
+
return true;
|
|
366
|
+
const base = candidate.split("/").pop();
|
|
367
|
+
for (const recent of recentFiles) {
|
|
368
|
+
if (recent.split("/").pop() === base)
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
261
374
|
async function cmdStart(project) {
|
|
262
375
|
const { data, error } = await supabase
|
|
263
376
|
.from("memories")
|
|
@@ -273,13 +386,32 @@ async function cmdStart(project) {
|
|
|
273
386
|
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
274
387
|
return;
|
|
275
388
|
}
|
|
389
|
+
// Task-aware re-ranking: boost memories relevant to what's actively
|
|
390
|
+
// being worked on, multiplicatively on top of the base decay score.
|
|
391
|
+
// Every signal degrades to null (no boost, not an error) if the
|
|
392
|
+
// repo/branch/gh state it needs isn't available.
|
|
393
|
+
const branchTokens = getBranchNameTokens();
|
|
394
|
+
const recentFiles = getRecentlyTouchedFiles();
|
|
395
|
+
const prTitleTokens = getOpenPrTitleTokens();
|
|
396
|
+
const config = readStackmemConfig();
|
|
397
|
+
const tokenBudget = config.tokenBudget;
|
|
276
398
|
const scoredMemories = memories
|
|
277
|
-
.map((memory) =>
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
399
|
+
.map((memory) => {
|
|
400
|
+
let decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
|
|
401
|
+
const contentTokens = tokenize(memory.content);
|
|
402
|
+
if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
|
|
403
|
+
decayScore *= BRANCH_NAME_BOOST;
|
|
404
|
+
}
|
|
405
|
+
if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
|
|
406
|
+
decayScore *= RECENT_FILES_BOOST;
|
|
407
|
+
}
|
|
408
|
+
if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
|
|
409
|
+
decayScore *= PR_TITLE_BOOST;
|
|
410
|
+
}
|
|
411
|
+
return { ...memory, decay_score: decayScore };
|
|
412
|
+
})
|
|
281
413
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
282
|
-
const budgetedMemories = applyContextBudget(scoredMemories,
|
|
414
|
+
const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
|
|
283
415
|
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
284
416
|
let tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
|
|
285
417
|
const { data: fixData, error: fixError } = await supabase
|
|
@@ -291,12 +423,12 @@ async function cmdStart(project) {
|
|
|
291
423
|
.limit(5);
|
|
292
424
|
if (fixError)
|
|
293
425
|
fail(`Failed to load execution log: ${fixError.message}`);
|
|
294
|
-
// Fixes share the same
|
|
295
|
-
//
|
|
426
|
+
// Fixes share the same token budget as memories, which already consumed
|
|
427
|
+
// their portion above — fixes only get whatever room is left.
|
|
296
428
|
const budgetedFixes = [];
|
|
297
429
|
for (const fix of fixData ?? []) {
|
|
298
430
|
const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
|
|
299
|
-
if (tokensUsed + tokens >
|
|
431
|
+
if (tokensUsed + tokens > tokenBudget)
|
|
300
432
|
break;
|
|
301
433
|
budgetedFixes.push(fix);
|
|
302
434
|
tokensUsed += tokens;
|
|
@@ -319,8 +451,22 @@ async function cmdStart(project) {
|
|
|
319
451
|
linkedIdsByMemory.get(link.target_id).add(link.source_id);
|
|
320
452
|
}
|
|
321
453
|
console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
|
|
454
|
+
const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
|
|
455
|
+
const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
|
|
456
|
+
if (pinnedMemories.length > 0) {
|
|
457
|
+
console.log(`Pinned (${pinnedMemories.length})`);
|
|
458
|
+
console.log("-".repeat(11));
|
|
459
|
+
for (const memory of pinnedMemories) {
|
|
460
|
+
console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
|
|
461
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
462
|
+
if (linked.length > 0) {
|
|
463
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
console.log("");
|
|
467
|
+
}
|
|
322
468
|
for (const type of MEMORY_TYPES) {
|
|
323
|
-
const group =
|
|
469
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
324
470
|
if (group.length === 0)
|
|
325
471
|
continue;
|
|
326
472
|
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
@@ -343,7 +489,7 @@ async function cmdStart(project) {
|
|
|
343
489
|
console.log("");
|
|
344
490
|
}
|
|
345
491
|
}
|
|
346
|
-
console.log(`Context budget: ${tokensUsed}/${
|
|
492
|
+
console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
|
|
347
493
|
if (omittedCount > 0) {
|
|
348
494
|
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
349
495
|
}
|
|
@@ -386,7 +532,7 @@ async function confirmSupersede(oldId, newContent) {
|
|
|
386
532
|
console.log("Invalid input — kept original memory.");
|
|
387
533
|
return false;
|
|
388
534
|
}
|
|
389
|
-
async function cmdSave(project, type, content, force = false) {
|
|
535
|
+
async function cmdSave(project, type, content, force = false, provenance = {}) {
|
|
390
536
|
if (!MEMORY_TYPES.includes(type)) {
|
|
391
537
|
fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
|
|
392
538
|
}
|
|
@@ -423,9 +569,24 @@ async function cmdSave(project, type, content, force = false) {
|
|
|
423
569
|
if (resolveError)
|
|
424
570
|
fail(`Failed to resolve superseded memory: ${resolveError.message}`);
|
|
425
571
|
}
|
|
572
|
+
const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
|
|
573
|
+
const insertPayload = {
|
|
574
|
+
project,
|
|
575
|
+
type,
|
|
576
|
+
content,
|
|
577
|
+
device_id: getDeviceId(),
|
|
578
|
+
source,
|
|
579
|
+
confidence,
|
|
580
|
+
};
|
|
581
|
+
if (commitSha)
|
|
582
|
+
insertPayload.commit_sha = commitSha;
|
|
583
|
+
if (filePaths && filePaths.length > 0)
|
|
584
|
+
insertPayload.file_paths = filePaths;
|
|
585
|
+
if (author)
|
|
586
|
+
insertPayload.author = author;
|
|
426
587
|
const { data: inserted, error } = await supabase
|
|
427
588
|
.from("memories")
|
|
428
|
-
.insert(
|
|
589
|
+
.insert(insertPayload)
|
|
429
590
|
.select("id")
|
|
430
591
|
.single();
|
|
431
592
|
if (error || !inserted)
|
|
@@ -455,6 +616,36 @@ async function cmdSave(project, type, content, force = false) {
|
|
|
455
616
|
await compressMemories(project);
|
|
456
617
|
}
|
|
457
618
|
}
|
|
619
|
+
// --- cm check-commit (internal, used by the hook) --------------------------
|
|
620
|
+
// Not a user-facing command (no usage()/help entry) — the hook calls this
|
|
621
|
+
// once per invocation, before doing any diff analysis, to decide whether
|
|
622
|
+
// this commit_sha has already been captured. post-commit and post-rewrite
|
|
623
|
+
// both run the exact same capture logic against current HEAD, so without
|
|
624
|
+
// this guard, a commit that fires both hooks (e.g. `git commit --amend`,
|
|
625
|
+
// which triggers post-commit for the new commit AND post-rewrite for the
|
|
626
|
+
// rewrite) would be captured twice. Checked once per invocation — not
|
|
627
|
+
// inside each individual cmSave call — because a single commit normally
|
|
628
|
+
// produces several distinct memories; a per-save check would see the
|
|
629
|
+
// first save's own commit_sha and wrongly skip the rest.
|
|
630
|
+
async function cmdCheckCommit(project, commitSha) {
|
|
631
|
+
const { data, error } = await supabase
|
|
632
|
+
.from("memories")
|
|
633
|
+
.select("id")
|
|
634
|
+
.eq("project", project)
|
|
635
|
+
.eq("commit_sha", commitSha)
|
|
636
|
+
.limit(1);
|
|
637
|
+
if (error) {
|
|
638
|
+
// Fail open: an error here (including "column does not exist" if the
|
|
639
|
+
// commit_sha migration hasn't landed yet) must not look identical to
|
|
640
|
+
// "duplicate, skip" below — that would silently suppress every hook
|
|
641
|
+
// capture with no log trail. Exit 0 so the normal save path runs;
|
|
642
|
+
// if commit_sha genuinely isn't a column, the save itself fails
|
|
643
|
+
// there instead, where the hook's outer error handler logs it.
|
|
644
|
+
console.error(`check-commit: ${error.message}`);
|
|
645
|
+
process.exit(0);
|
|
646
|
+
}
|
|
647
|
+
process.exit(data && data.length > 0 ? 1 : 0);
|
|
648
|
+
}
|
|
458
649
|
// --- memory compression ----------------------------------------------------
|
|
459
650
|
async function compressMemories(project) {
|
|
460
651
|
const { data, error } = await supabase
|
|
@@ -583,6 +774,28 @@ async function cmdResolve(project, memoryId) {
|
|
|
583
774
|
}
|
|
584
775
|
console.log(`Resolved memory [${memoryId}]`);
|
|
585
776
|
}
|
|
777
|
+
// --- cm pin / cm unpin -------------------------------------------------
|
|
778
|
+
async function setPinned(project, memoryId, pinned) {
|
|
779
|
+
const { data, error } = await supabase
|
|
780
|
+
.from("memories")
|
|
781
|
+
.update({ pinned })
|
|
782
|
+
.eq("project", project)
|
|
783
|
+
.eq("id", memoryId)
|
|
784
|
+
.select("id");
|
|
785
|
+
if (error)
|
|
786
|
+
fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
|
|
787
|
+
if (!data || data.length === 0) {
|
|
788
|
+
console.log(`No memory found with ID [${memoryId}]`);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
|
|
792
|
+
}
|
|
793
|
+
async function cmdPin(project, memoryId) {
|
|
794
|
+
await setPinned(project, memoryId, true);
|
|
795
|
+
}
|
|
796
|
+
async function cmdUnpin(project, memoryId) {
|
|
797
|
+
await setPinned(project, memoryId, false);
|
|
798
|
+
}
|
|
586
799
|
// --- cm delete -------------------------------------------------------------
|
|
587
800
|
async function cmdDelete(project, memoryId) {
|
|
588
801
|
const { error: linksError } = await supabase
|
|
@@ -622,7 +835,8 @@ async function cmdInspect(project, memoryId) {
|
|
|
622
835
|
.single();
|
|
623
836
|
if (error || !memory)
|
|
624
837
|
fail(`Memory [${memoryId}] not found for "${project}".`);
|
|
625
|
-
const
|
|
838
|
+
const config = readStackmemConfig();
|
|
839
|
+
const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
|
|
626
840
|
const { data: links, error: linksError } = await supabase
|
|
627
841
|
.from("memory_links")
|
|
628
842
|
.select("source_id, target_id, score")
|
|
@@ -711,6 +925,65 @@ try {
|
|
|
711
925
|
|
|
712
926
|
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
713
927
|
|
|
928
|
+
// Inlined copy of src/config-file.ts's defaults/merge logic — this script
|
|
929
|
+
// runs standalone with no import access to that module.
|
|
930
|
+
const DEFAULT_STACKMEM_CONFIG = {
|
|
931
|
+
ignore: [],
|
|
932
|
+
tokenBudget: 2000,
|
|
933
|
+
decayLambda: 0.05,
|
|
934
|
+
captureTypes: ["decision", "constraint", "discovery", "rejection"],
|
|
935
|
+
minQualityScore: 0.3,
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
function isStringArray(value) {
|
|
939
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function readStackmemConfig() {
|
|
943
|
+
const configPath = join(process.cwd(), ".stackmem.json");
|
|
944
|
+
try {
|
|
945
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
946
|
+
return {
|
|
947
|
+
ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
|
|
948
|
+
tokenBudget:
|
|
949
|
+
typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
|
|
950
|
+
? raw.tokenBudget
|
|
951
|
+
: DEFAULT_STACKMEM_CONFIG.tokenBudget,
|
|
952
|
+
decayLambda:
|
|
953
|
+
typeof raw.decayLambda === "number" && raw.decayLambda > 0
|
|
954
|
+
? raw.decayLambda
|
|
955
|
+
: DEFAULT_STACKMEM_CONFIG.decayLambda,
|
|
956
|
+
captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
|
|
957
|
+
minQualityScore:
|
|
958
|
+
typeof raw.minQualityScore === "number"
|
|
959
|
+
? raw.minQualityScore
|
|
960
|
+
: DEFAULT_STACKMEM_CONFIG.minQualityScore,
|
|
961
|
+
};
|
|
962
|
+
} catch {
|
|
963
|
+
return { ...DEFAULT_STACKMEM_CONFIG };
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function matchesIgnorePattern(filePath, pattern) {
|
|
968
|
+
const base = filePath.split("/").pop() ?? filePath;
|
|
969
|
+
|
|
970
|
+
if (pattern.endsWith("/")) {
|
|
971
|
+
const dirName = pattern.slice(0, -1);
|
|
972
|
+
return filePath.split("/").includes(dirName);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (pattern.includes("*")) {
|
|
976
|
+
const regex = new RegExp(
|
|
977
|
+
"^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
|
|
978
|
+
);
|
|
979
|
+
return regex.test(base) || regex.test(filePath);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return filePath === pattern || base === pattern;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const STACKMEM_CONFIG = readStackmemConfig();
|
|
986
|
+
|
|
714
987
|
const SECRET_PATTERNS = [
|
|
715
988
|
/AKIA[0-9A-Z]{16}/g,
|
|
716
989
|
/(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
|
|
@@ -767,10 +1040,43 @@ function git(args) {
|
|
|
767
1040
|
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
768
1041
|
}
|
|
769
1042
|
|
|
1043
|
+
const CURRENT_SHA = git(["rev-parse", "HEAD"]);
|
|
1044
|
+
const CURRENT_AUTHOR = (() => {
|
|
1045
|
+
try {
|
|
1046
|
+
return git(["log", "--format=%ae", "-1", "HEAD"]);
|
|
1047
|
+
} catch {
|
|
1048
|
+
return "";
|
|
1049
|
+
}
|
|
1050
|
+
})();
|
|
1051
|
+
|
|
1052
|
+
// post-commit and post-rewrite both run this exact script against current
|
|
1053
|
+
// HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
|
|
1054
|
+
// would otherwise capture the same commit twice. Checked once up front,
|
|
1055
|
+
// not per-save — see cmdCheckCommit in src/cli.ts for why.
|
|
1056
|
+
try {
|
|
1057
|
+
execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
|
|
1058
|
+
} catch {
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// file_paths is populated with every file the commit touched (not just
|
|
1063
|
+
// the filtered diffableFiles below) — "cm why <file>" should still find
|
|
1064
|
+
// a commit's decision/discovery even if that specific file's own diff
|
|
1065
|
+
// content was skipped as noise (a lockfile, say).
|
|
770
1066
|
function cmSave(type, content) {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1067
|
+
if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
|
|
1068
|
+
|
|
1069
|
+
const args = [
|
|
1070
|
+
CLI_PATH, "save", PROJECT, type, content, "--force",
|
|
1071
|
+
"--commit-sha", CURRENT_SHA,
|
|
1072
|
+
"--source", "hook",
|
|
1073
|
+
"--confidence", "0.8",
|
|
1074
|
+
];
|
|
1075
|
+
if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
|
|
1076
|
+
if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
|
|
1077
|
+
|
|
1078
|
+
execFileSync("node", args, { stdio: "inherit" });
|
|
1079
|
+
savedItems.push(\`\${type}: \${content}\`);
|
|
774
1080
|
}
|
|
775
1081
|
|
|
776
1082
|
function basename(filePath) {
|
|
@@ -797,6 +1103,29 @@ function isSchemaFile(filePath) {
|
|
|
797
1103
|
);
|
|
798
1104
|
}
|
|
799
1105
|
|
|
1106
|
+
// Noise that's never worth diffing: lockfiles, build output, vendored/
|
|
1107
|
+
// generated code, and timestamp-prefixed SQL migrations (their content is
|
|
1108
|
+
// a mechanical dump, not a human decision). Checked against path segments
|
|
1109
|
+
// (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
|
|
1110
|
+
// just a top-level "dist/...".
|
|
1111
|
+
const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
|
|
1112
|
+
const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
|
|
1113
|
+
const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
|
|
1114
|
+
const DIFF_MAX_BYTES = 50 * 1024;
|
|
1115
|
+
|
|
1116
|
+
function shouldSkipDiffFile(filePath) {
|
|
1117
|
+
const base = basename(filePath);
|
|
1118
|
+
if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
|
|
1119
|
+
if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
|
|
1120
|
+
if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
|
|
1121
|
+
if (base.endsWith(".map")) return true;
|
|
1122
|
+
if (base.endsWith(".pb.go")) return true;
|
|
1123
|
+
if (/\\.generated\\./.test(base)) return true;
|
|
1124
|
+
if (MIGRATION_FILE_RE.test(base)) return true;
|
|
1125
|
+
if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
800
1129
|
const LOW_QUALITY_PREFIXES = [
|
|
801
1130
|
"wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
|
|
802
1131
|
"tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
|
|
@@ -831,11 +1160,10 @@ const savedItems = [];
|
|
|
831
1160
|
|
|
832
1161
|
const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
833
1162
|
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
// rules-file content and triggers another sync commit, indefinitely.
|
|
1163
|
+
// Kept as a general escape hatch for other automation (CI bots, scripts)
|
|
1164
|
+
// that tags its own commits this way and shouldn't be captured as a
|
|
1165
|
+
// memory — cm sync no longer self-commits, so this isn't load-bearing for
|
|
1166
|
+
// that anymore, but other tools' commits still deserve the same skip.
|
|
839
1167
|
if (commitMessage.includes("[skip ci]")) {
|
|
840
1168
|
return;
|
|
841
1169
|
}
|
|
@@ -859,10 +1187,40 @@ const changedFiles = git(
|
|
|
859
1187
|
|
|
860
1188
|
let diff = "";
|
|
861
1189
|
if (!isFirstCommit) {
|
|
1190
|
+
// --numstat marks a binary file's added/deleted columns as "-" — that's
|
|
1191
|
+
// the cheapest way to identify them without fetching their (useless,
|
|
1192
|
+
// and potentially huge) diff content at all.
|
|
1193
|
+
let binaryFiles = new Set();
|
|
862
1194
|
try {
|
|
863
|
-
|
|
1195
|
+
const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
|
|
1196
|
+
encoding: "utf-8",
|
|
1197
|
+
});
|
|
1198
|
+
for (const line of numstat.split("\\n")) {
|
|
1199
|
+
const parts = line.split("\\t");
|
|
1200
|
+
if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
|
|
1201
|
+
binaryFiles.add(parts[2]);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
864
1204
|
} catch {
|
|
865
|
-
|
|
1205
|
+
// Nothing to compare against — leave binaryFiles empty.
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const diffableFiles = changedFiles.filter(
|
|
1209
|
+
(file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
if (diffableFiles.length > 0) {
|
|
1213
|
+
try {
|
|
1214
|
+
diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
|
|
1215
|
+
encoding: "utf-8",
|
|
1216
|
+
});
|
|
1217
|
+
} catch {
|
|
1218
|
+
diff = "";
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (diff.length > DIFF_MAX_BYTES) {
|
|
1223
|
+
diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
|
|
866
1224
|
}
|
|
867
1225
|
}
|
|
868
1226
|
|
|
@@ -889,17 +1247,14 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
889
1247
|
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
890
1248
|
const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
|
|
891
1249
|
cmSave("discovery", content);
|
|
892
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
893
1250
|
}
|
|
894
1251
|
if (isConfigFile(currentFile)) {
|
|
895
1252
|
const content = redactSecrets(\`new config: \${currentFile}\`);
|
|
896
1253
|
cmSave("constraint", content);
|
|
897
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
898
1254
|
}
|
|
899
1255
|
if (isSchemaFile(currentFile)) {
|
|
900
1256
|
const content = redactSecrets(\`schema change: \${currentFile}\`);
|
|
901
1257
|
cmSave("discovery", content);
|
|
902
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
903
1258
|
}
|
|
904
1259
|
}
|
|
905
1260
|
continue;
|
|
@@ -936,7 +1291,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
936
1291
|
if (removedModulePath) {
|
|
937
1292
|
const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
|
|
938
1293
|
cmSave("rejection", content);
|
|
939
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
940
1294
|
}
|
|
941
1295
|
|
|
942
1296
|
if (isPackageJson && inDependenciesSection) {
|
|
@@ -944,7 +1298,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
944
1298
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
945
1299
|
const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
|
|
946
1300
|
cmSave("rejection", content);
|
|
947
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
948
1301
|
}
|
|
949
1302
|
}
|
|
950
1303
|
continue;
|
|
@@ -960,7 +1313,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
960
1313
|
if (envMatch) {
|
|
961
1314
|
const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
|
|
962
1315
|
cmSave("constraint", content);
|
|
963
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
964
1316
|
}
|
|
965
1317
|
}
|
|
966
1318
|
}
|
|
@@ -970,7 +1322,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
970
1322
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
971
1323
|
const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
|
|
972
1324
|
cmSave("decision", content);
|
|
973
|
-
savedItems.push(\`decision: \${content}\`);
|
|
974
1325
|
}
|
|
975
1326
|
}
|
|
976
1327
|
|
|
@@ -978,14 +1329,12 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
978
1329
|
const truncated = codeLine.trim().slice(0, 80);
|
|
979
1330
|
const content = redactSecrets(\`database change: \${truncated}\`);
|
|
980
1331
|
cmSave("discovery", content);
|
|
981
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
982
1332
|
}
|
|
983
1333
|
|
|
984
1334
|
if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
|
|
985
1335
|
const truncated = codeLine.trim().slice(0, 100);
|
|
986
1336
|
const content = redactSecrets(\`open issue from commit: \${truncated}\`);
|
|
987
1337
|
cmSave("discovery", content);
|
|
988
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
989
1338
|
}
|
|
990
1339
|
}
|
|
991
1340
|
}
|
|
@@ -1014,19 +1363,16 @@ if (breakingMatch) {
|
|
|
1014
1363
|
const breakingContent = redactSecrets(breakingMatch[1].trim());
|
|
1015
1364
|
const content = \`BREAKING CHANGE: \${breakingContent}\`;
|
|
1016
1365
|
cmSave("constraint", content);
|
|
1017
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1018
1366
|
}
|
|
1019
1367
|
|
|
1020
1368
|
if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
|
|
1021
1369
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1022
1370
|
cmSave("decision", trimmedMessage);
|
|
1023
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1024
1371
|
} else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
|
|
1025
1372
|
// docs/style/test/ci/build/chore — skip entirely, nothing saved.
|
|
1026
1373
|
} else if (isQualityCommitMessage(commitMessage)) {
|
|
1027
1374
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1028
1375
|
cmSave("decision", trimmedMessage);
|
|
1029
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1030
1376
|
}
|
|
1031
1377
|
|
|
1032
1378
|
console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
|
|
@@ -1034,21 +1380,13 @@ for (const item of savedItems) {
|
|
|
1034
1380
|
console.log(\` - \${item}\`);
|
|
1035
1381
|
}
|
|
1036
1382
|
|
|
1383
|
+
// Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
|
|
1384
|
+
// Editors read these files straight off disk, and they're gitignored by
|
|
1385
|
+
// default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
|
|
1386
|
+
// sync with git history; committing them here used to double the commit
|
|
1387
|
+
// count on every real commit and produced constant rules-file merge
|
|
1388
|
+
// conflicts on teams.
|
|
1037
1389
|
execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
|
|
1038
|
-
|
|
1039
|
-
// cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
|
|
1040
|
-
// the working directory dirty (breaking things like \`npm version patch\`
|
|
1041
|
-
// that require a clean tree). Commit the sync ourselves instead. --no-verify
|
|
1042
|
-
// skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
|
|
1043
|
-
// guard above, which is what actually stops this from recursing).
|
|
1044
|
-
try {
|
|
1045
|
-
execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
|
|
1046
|
-
execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
|
|
1047
|
-
stdio: "pipe",
|
|
1048
|
-
});
|
|
1049
|
-
} catch {
|
|
1050
|
-
// Nothing to commit (rules files unchanged) — fine, ignore.
|
|
1051
|
-
}
|
|
1052
1390
|
}
|
|
1053
1391
|
|
|
1054
1392
|
async function main() {
|
|
@@ -1073,14 +1411,24 @@ async function main() {
|
|
|
1073
1411
|
main().finally(() => process.exit(0));
|
|
1074
1412
|
`;
|
|
1075
1413
|
}
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1414
|
+
// Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
|
|
1415
|
+
// `git rebase` (neither of which is a plain commit, so post-commit alone
|
|
1416
|
+
// misses them) — it runs the exact same capture script as post-commit,
|
|
1417
|
+
// against current HEAD, with the check-commit idempotency guard in the
|
|
1418
|
+
// script itself preventing a double-capture when a single operation
|
|
1419
|
+
// (e.g. --amend) fires both hooks for the same resulting commit.
|
|
1420
|
+
const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"];
|
|
1421
|
+
function hookBackupFilename(hookName) {
|
|
1422
|
+
return `${hookName}${HOOK_BACKUP_SUFFIX}`;
|
|
1423
|
+
}
|
|
1424
|
+
// Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
|
|
1425
|
+
// hook of this name already exists and wasn't written by stackmem, it's
|
|
1078
1426
|
// preserved as a backup and chained to run before stackmem's own hook,
|
|
1079
1427
|
// rather than being clobbered — see `uninstallHook` for how it's restored.
|
|
1080
|
-
function installHook(hooksDir, hookLogicPath) {
|
|
1428
|
+
function installHook(hooksDir, hookLogicPath, hookName) {
|
|
1081
1429
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1082
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1083
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1430
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1431
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1084
1432
|
let chainedOriginal = false;
|
|
1085
1433
|
if (nodeFs.existsSync(hookPath)) {
|
|
1086
1434
|
const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
|
|
@@ -1096,21 +1444,21 @@ function installHook(hooksDir, hookLogicPath) {
|
|
|
1096
1444
|
}
|
|
1097
1445
|
}
|
|
1098
1446
|
const chainLine = chainedOriginal
|
|
1099
|
-
? `"$(dirname "$0")/${
|
|
1447
|
+
? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
|
|
1100
1448
|
: "";
|
|
1101
1449
|
nodeFs.writeFileSync(hookPath, `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`, "utf-8");
|
|
1102
1450
|
nodeFs.chmodSync(hookPath, 0o755);
|
|
1103
1451
|
if (chainedOriginal) {
|
|
1104
|
-
console.log(
|
|
1452
|
+
console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
|
|
1105
1453
|
}
|
|
1106
1454
|
}
|
|
1107
1455
|
// Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
|
|
1108
1456
|
// stackmem chained it after an existing hook, restores that original hook
|
|
1109
1457
|
// in its place. Returns a description of what happened, or null if there
|
|
1110
1458
|
// was no stackmem-managed hook to remove (nothing done).
|
|
1111
|
-
function uninstallHook(hooksDir) {
|
|
1112
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1113
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1459
|
+
function uninstallHook(hooksDir, hookName) {
|
|
1460
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1461
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1114
1462
|
if (!nodeFs.existsSync(hookPath))
|
|
1115
1463
|
return null;
|
|
1116
1464
|
const content = nodeFs.readFileSync(hookPath, "utf-8");
|
|
@@ -1118,10 +1466,10 @@ function uninstallHook(hooksDir) {
|
|
|
1118
1466
|
return null;
|
|
1119
1467
|
if (nodeFs.existsSync(backupPath)) {
|
|
1120
1468
|
nodeFs.renameSync(backupPath, hookPath);
|
|
1121
|
-
return
|
|
1469
|
+
return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
|
|
1122
1470
|
}
|
|
1123
1471
|
nodeFs.unlinkSync(hookPath);
|
|
1124
|
-
return
|
|
1472
|
+
return `.git/hooks/${hookName}`;
|
|
1125
1473
|
}
|
|
1126
1474
|
// Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
|
|
1127
1475
|
// at a scratch file instead of the real ~/.claude.json.
|
|
@@ -1177,6 +1525,8 @@ dist/
|
|
|
1177
1525
|
coverage/
|
|
1178
1526
|
*-memories.json
|
|
1179
1527
|
*.db
|
|
1528
|
+
*cursorrules
|
|
1529
|
+
*windsurfrules
|
|
1180
1530
|
`;
|
|
1181
1531
|
// Returns true if the file was created, false if it already existed
|
|
1182
1532
|
// (and was left untouched).
|
|
@@ -1224,10 +1574,11 @@ async function generateRulesContent(project) {
|
|
|
1224
1574
|
.eq("resolved", false);
|
|
1225
1575
|
if (memoriesError)
|
|
1226
1576
|
fail(`Failed to load memories: ${memoriesError.message}`);
|
|
1577
|
+
const config = readStackmemConfig();
|
|
1227
1578
|
const scoredMemories = (memoryRows ?? [])
|
|
1228
1579
|
.map((memory) => ({
|
|
1229
1580
|
...memory,
|
|
1230
|
-
decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
|
|
1581
|
+
decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
|
|
1231
1582
|
}))
|
|
1232
1583
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
1233
1584
|
const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
|
|
@@ -1319,6 +1670,10 @@ export async function trySeedMemory(project, existingMemories, type, content, cl
|
|
|
1319
1670
|
type,
|
|
1320
1671
|
content,
|
|
1321
1672
|
device_id: getDeviceId(),
|
|
1673
|
+
// Seeded by `cm init` scanning project files — an explicit user-run
|
|
1674
|
+
// command, not an auto-captured commit, so it's "manual" like cm save,
|
|
1675
|
+
// not the memories.source column's default of "hook".
|
|
1676
|
+
source: "manual",
|
|
1322
1677
|
});
|
|
1323
1678
|
return !error;
|
|
1324
1679
|
}
|
|
@@ -1427,7 +1782,17 @@ async function verifyBackendConnection(project) {
|
|
|
1427
1782
|
.eq("project", project);
|
|
1428
1783
|
return !error;
|
|
1429
1784
|
}
|
|
1430
|
-
|
|
1785
|
+
// Returns true if the file was created, false if it already existed
|
|
1786
|
+
// (and was left untouched).
|
|
1787
|
+
function writeStackmemConfigFile(resolvedPath) {
|
|
1788
|
+
const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
|
|
1789
|
+
if (nodeFs.existsSync(configPath)) {
|
|
1790
|
+
return false;
|
|
1791
|
+
}
|
|
1792
|
+
nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
|
|
1793
|
+
return true;
|
|
1794
|
+
}
|
|
1795
|
+
async function cmdInit(project, targetPath, writeConfig = false) {
|
|
1431
1796
|
const resolvedPath = nodePath.resolve(targetPath);
|
|
1432
1797
|
const gitDir = nodePath.join(resolvedPath, ".git");
|
|
1433
1798
|
if (!nodeFs.existsSync(gitDir)) {
|
|
@@ -1441,7 +1806,9 @@ async function cmdInit(project, targetPath) {
|
|
|
1441
1806
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1442
1807
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1443
1808
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1444
|
-
|
|
1809
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
1810
|
+
installHook(hooksDir, hookLogicPath, hookName);
|
|
1811
|
+
}
|
|
1445
1812
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
1446
1813
|
registerMcpServer(DIST_INDEX_PATH);
|
|
1447
1814
|
writeClaudeMd(project, resolvedPath);
|
|
@@ -1451,10 +1818,14 @@ async function cmdInit(project, targetPath) {
|
|
|
1451
1818
|
const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
|
|
1452
1819
|
const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
|
|
1453
1820
|
const backendOk = await verifyBackendConnection(project);
|
|
1821
|
+
const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
|
|
1454
1822
|
console.log("");
|
|
1455
1823
|
if (gitignoreCreated) {
|
|
1456
1824
|
console.log("✓ .gitignore created");
|
|
1457
1825
|
}
|
|
1826
|
+
if (writeConfig) {
|
|
1827
|
+
console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
|
|
1828
|
+
}
|
|
1458
1829
|
console.log("✓ Git hook installed");
|
|
1459
1830
|
console.log("✓ MCP server registered");
|
|
1460
1831
|
console.log("✓ CLAUDE.md written");
|
|
@@ -1565,9 +1936,11 @@ async function cmdContext(project, task) {
|
|
|
1565
1936
|
console.log(`No memories found for "${project}".`);
|
|
1566
1937
|
return;
|
|
1567
1938
|
}
|
|
1939
|
+
const config = readStackmemConfig();
|
|
1940
|
+
const tokenBudget = config.tokenBudget;
|
|
1568
1941
|
const scoredMemories = memories
|
|
1569
1942
|
.map((memory) => {
|
|
1570
|
-
const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
|
|
1943
|
+
const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
|
|
1571
1944
|
const relevanceScore = scoreMemoryRelevance(memory.content, task);
|
|
1572
1945
|
return {
|
|
1573
1946
|
...memory,
|
|
@@ -1576,7 +1949,7 @@ async function cmdContext(project, task) {
|
|
|
1576
1949
|
})
|
|
1577
1950
|
.sort((a, b) => b.combined_score - a.combined_score);
|
|
1578
1951
|
const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
|
|
1579
|
-
const budgetedMemories = applyContextBudget(topMemories,
|
|
1952
|
+
const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
|
|
1580
1953
|
const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
|
|
1581
1954
|
const lines = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
|
|
1582
1955
|
for (const type of CONTEXT_TYPE_ORDER) {
|
|
@@ -1589,9 +1962,62 @@ async function cmdContext(project, task) {
|
|
|
1589
1962
|
}
|
|
1590
1963
|
lines.push("");
|
|
1591
1964
|
}
|
|
1592
|
-
lines.push(`*(${tokensUsed}/${
|
|
1965
|
+
lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
|
|
1593
1966
|
console.log(lines.join("\n"));
|
|
1594
1967
|
}
|
|
1968
|
+
// --- cm why --------------------------------------------------------------
|
|
1969
|
+
function fileMatchesPaths(filePaths, file, baseName) {
|
|
1970
|
+
if (!Array.isArray(filePaths))
|
|
1971
|
+
return false;
|
|
1972
|
+
return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
|
|
1973
|
+
}
|
|
1974
|
+
function fileMentionedInContent(content, file, baseName) {
|
|
1975
|
+
const lower = content.toLowerCase();
|
|
1976
|
+
return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
|
|
1977
|
+
}
|
|
1978
|
+
async function cmdWhy(project, file) {
|
|
1979
|
+
const baseName = file.split("/").pop() ?? file;
|
|
1980
|
+
const { data, error } = await supabase
|
|
1981
|
+
.from("memories")
|
|
1982
|
+
.select("*")
|
|
1983
|
+
.eq("project", project)
|
|
1984
|
+
.eq("resolved", false);
|
|
1985
|
+
if (error)
|
|
1986
|
+
fail(`Failed to load memories: ${error.message}`);
|
|
1987
|
+
const memories = data ?? [];
|
|
1988
|
+
// file_paths (structured, from Item 6) and a content substring search
|
|
1989
|
+
// are combined into one match set rather than one gating the other as
|
|
1990
|
+
// a strict fallback — most memories predate file_paths existing at all
|
|
1991
|
+
// (manual saves and seeds never set it), so content search is the
|
|
1992
|
+
// primary signal for everything except recent hook-captured commits,
|
|
1993
|
+
// not a rare last resort.
|
|
1994
|
+
const matched = memories.filter((memory) => fileMatchesPaths(memory.file_paths, file, baseName) ||
|
|
1995
|
+
fileMentionedInContent(memory.content, file, baseName));
|
|
1996
|
+
if (matched.length === 0) {
|
|
1997
|
+
console.log(`No memories found for ${file}.`);
|
|
1998
|
+
console.log("Make some commits touching this file to build up context.");
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
const config = readStackmemConfig();
|
|
2002
|
+
const scoredMemories = matched
|
|
2003
|
+
.map((memory) => ({
|
|
2004
|
+
...memory,
|
|
2005
|
+
decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
|
|
2006
|
+
}))
|
|
2007
|
+
.sort((a, b) => b.decay_score - a.decay_score);
|
|
2008
|
+
console.log(`Decisions and constraints tied to ${file}:\n`);
|
|
2009
|
+
for (const type of MEMORY_TYPES) {
|
|
2010
|
+
const group = scoredMemories.filter((memory) => memory.type === type);
|
|
2011
|
+
if (group.length === 0)
|
|
2012
|
+
continue;
|
|
2013
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
2014
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
2015
|
+
for (const memory of group) {
|
|
2016
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
2017
|
+
}
|
|
2018
|
+
console.log("");
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
1595
2021
|
function splitCommaList(value) {
|
|
1596
2022
|
if (!value)
|
|
1597
2023
|
return [];
|
|
@@ -1828,7 +2254,9 @@ async function cmdDoctor(fix) {
|
|
|
1828
2254
|
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
1829
2255
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
1830
2256
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1831
|
-
|
|
2257
|
+
for (const name of MANAGED_HOOK_NAMES) {
|
|
2258
|
+
installHook(hooksDir, hookLogicPath, name);
|
|
2259
|
+
}
|
|
1832
2260
|
if (isHookExecutable(hookPath)) {
|
|
1833
2261
|
console.log("Fixed: git hook");
|
|
1834
2262
|
issues--;
|
|
@@ -1998,9 +2426,11 @@ function removeIfMarked(filePath, marker, label) {
|
|
|
1998
2426
|
async function cmdUninstall(project, targetPath) {
|
|
1999
2427
|
const gitDir = nodePath.join(targetPath, ".git");
|
|
2000
2428
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
2001
|
-
const
|
|
2002
|
-
|
|
2003
|
-
|
|
2429
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
2430
|
+
const hookResult = uninstallHook(hooksDir, hookName);
|
|
2431
|
+
if (hookResult)
|
|
2432
|
+
console.log(`Removed ${hookResult}`);
|
|
2433
|
+
}
|
|
2004
2434
|
const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
|
|
2005
2435
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
2006
2436
|
if (nodeFs.existsSync(hookLogicPath)) {
|
|
@@ -2044,9 +2474,19 @@ async function main() {
|
|
|
2044
2474
|
case "save": {
|
|
2045
2475
|
const forceIndex = args.indexOf("--force");
|
|
2046
2476
|
const force = forceIndex !== -1;
|
|
2047
|
-
|
|
2477
|
+
let positional = force
|
|
2048
2478
|
? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
|
|
2049
2479
|
: args;
|
|
2480
|
+
const commitShaFlag = extractValueFlag(positional, "--commit-sha");
|
|
2481
|
+
positional = commitShaFlag.rest;
|
|
2482
|
+
const authorFlag = extractValueFlag(positional, "--author");
|
|
2483
|
+
positional = authorFlag.rest;
|
|
2484
|
+
const sourceFlag = extractValueFlag(positional, "--source");
|
|
2485
|
+
positional = sourceFlag.rest;
|
|
2486
|
+
const confidenceFlag = extractValueFlag(positional, "--confidence");
|
|
2487
|
+
positional = confidenceFlag.rest;
|
|
2488
|
+
const filesFlag = extractValueFlag(positional, "--files");
|
|
2489
|
+
positional = filesFlag.rest;
|
|
2050
2490
|
let explicitProject;
|
|
2051
2491
|
let type;
|
|
2052
2492
|
let rest;
|
|
@@ -2071,7 +2511,30 @@ async function main() {
|
|
|
2071
2511
|
if (!type || rest.length === 0)
|
|
2072
2512
|
usage();
|
|
2073
2513
|
const project = requireProject(explicitProject);
|
|
2074
|
-
|
|
2514
|
+
const provenance = {};
|
|
2515
|
+
if (commitShaFlag.value)
|
|
2516
|
+
provenance.commitSha = commitShaFlag.value;
|
|
2517
|
+
if (authorFlag.value)
|
|
2518
|
+
provenance.author = authorFlag.value;
|
|
2519
|
+
if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
|
|
2520
|
+
provenance.source = sourceFlag.value;
|
|
2521
|
+
}
|
|
2522
|
+
if (confidenceFlag.value) {
|
|
2523
|
+
const parsed = Number(confidenceFlag.value);
|
|
2524
|
+
if (!Number.isNaN(parsed))
|
|
2525
|
+
provenance.confidence = parsed;
|
|
2526
|
+
}
|
|
2527
|
+
if (filesFlag.value) {
|
|
2528
|
+
provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
|
|
2529
|
+
}
|
|
2530
|
+
await cmdSave(project, type, rest.join(" "), force, provenance);
|
|
2531
|
+
break;
|
|
2532
|
+
}
|
|
2533
|
+
case "check-commit": {
|
|
2534
|
+
const [project, commitSha] = args;
|
|
2535
|
+
if (!project || !commitSha)
|
|
2536
|
+
usage();
|
|
2537
|
+
await cmdCheckCommit(project, commitSha);
|
|
2075
2538
|
break;
|
|
2076
2539
|
}
|
|
2077
2540
|
case "fix": {
|
|
@@ -2109,6 +2572,24 @@ async function main() {
|
|
|
2109
2572
|
await cmdResolve(project, memoryId);
|
|
2110
2573
|
break;
|
|
2111
2574
|
}
|
|
2575
|
+
case "pin": {
|
|
2576
|
+
const split = splitOptionalProject(args, 1);
|
|
2577
|
+
if (!split)
|
|
2578
|
+
usage();
|
|
2579
|
+
const project = requireProject(split.project);
|
|
2580
|
+
const [memoryId] = split.rest;
|
|
2581
|
+
await cmdPin(project, memoryId);
|
|
2582
|
+
break;
|
|
2583
|
+
}
|
|
2584
|
+
case "unpin": {
|
|
2585
|
+
const split = splitOptionalProject(args, 1);
|
|
2586
|
+
if (!split)
|
|
2587
|
+
usage();
|
|
2588
|
+
const project = requireProject(split.project);
|
|
2589
|
+
const [memoryId] = split.rest;
|
|
2590
|
+
await cmdUnpin(project, memoryId);
|
|
2591
|
+
break;
|
|
2592
|
+
}
|
|
2112
2593
|
case "delete": {
|
|
2113
2594
|
const split = splitOptionalProject(args, 1);
|
|
2114
2595
|
if (!split)
|
|
@@ -2136,10 +2617,15 @@ async function main() {
|
|
|
2136
2617
|
break;
|
|
2137
2618
|
}
|
|
2138
2619
|
case "init": {
|
|
2139
|
-
const
|
|
2620
|
+
const configIndex = args.indexOf("--config");
|
|
2621
|
+
const writeConfig = configIndex !== -1;
|
|
2622
|
+
const positional = writeConfig
|
|
2623
|
+
? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
|
|
2624
|
+
: args;
|
|
2625
|
+
const [project, targetPath] = positional;
|
|
2140
2626
|
if (!project || !targetPath)
|
|
2141
2627
|
usage();
|
|
2142
|
-
await cmdInit(project, targetPath);
|
|
2628
|
+
await cmdInit(project, targetPath, writeConfig);
|
|
2143
2629
|
break;
|
|
2144
2630
|
}
|
|
2145
2631
|
case "link": {
|
|
@@ -2163,6 +2649,14 @@ async function main() {
|
|
|
2163
2649
|
await cmdContext(project, split.rest.join(" "));
|
|
2164
2650
|
break;
|
|
2165
2651
|
}
|
|
2652
|
+
case "why": {
|
|
2653
|
+
const split = splitProjectFromVariadic(args);
|
|
2654
|
+
const project = requireProject(split.project);
|
|
2655
|
+
if (split.rest.length === 0)
|
|
2656
|
+
usage();
|
|
2657
|
+
await cmdWhy(project, split.rest.join(" "));
|
|
2658
|
+
break;
|
|
2659
|
+
}
|
|
2166
2660
|
case "session-end": {
|
|
2167
2661
|
await cmdSessionEndEntry(args);
|
|
2168
2662
|
break;
|