stackmem 1.0.24 → 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 +658 -81
- package/dist/config-file.js +72 -0
- package/dist/config.js +3 -0
- package/dist/index.js +4 -3
- package/dist/scoring.js +34 -4
- package/dist/storage.js +85 -1
- package/package.json +4 -1
- package/src/cli.ts +750 -79
- package/src/config-file.ts +98 -0
- package/src/config.ts +4 -0
- package/src/index.ts +5 -2
- package/src/scoring.ts +44 -4
- package/src/storage.ts +108 -1
package/dist/cli.js
CHANGED
|
@@ -2,12 +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";
|
|
6
|
+
import { randomInt } from "node:crypto";
|
|
5
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
8
|
import { createInterface } from "node:readline/promises";
|
|
7
9
|
import { analyzeCodebase } from "./ast-index.js";
|
|
10
|
+
import { VAULT_URL } from "./config.js";
|
|
11
|
+
import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
|
|
8
12
|
import { getDeviceId } from "./device.js";
|
|
9
|
-
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";
|
|
10
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
|
+
}
|
|
11
24
|
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
12
25
|
const COMPRESSION_THRESHOLD = 20;
|
|
13
26
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
@@ -22,7 +35,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
|
|
|
22
35
|
const STACKMEM_HOOK_MARKER = "# stackmem-managed";
|
|
23
36
|
const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
|
|
24
37
|
const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
|
|
25
|
-
const
|
|
38
|
+
const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
|
|
26
39
|
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
|
|
27
40
|
const TYPE_LABELS = {
|
|
28
41
|
decision: "Decisions",
|
|
@@ -38,13 +51,17 @@ function usage() {
|
|
|
38
51
|
cm analyze [project] <path>
|
|
39
52
|
cm search [--project <name>] <query>
|
|
40
53
|
cm resolve [project] <memory-id>
|
|
54
|
+
cm pin [project] <memory-id>
|
|
55
|
+
cm unpin [project] <memory-id>
|
|
41
56
|
cm delete [project] <memory-id>
|
|
42
57
|
cm inspect [project] <memory-id>
|
|
43
58
|
cm compress [project]
|
|
44
|
-
cm init <project> <path>
|
|
59
|
+
cm init <project> <path> [--config]
|
|
45
60
|
cm uninstall [project] [path]
|
|
61
|
+
cm link
|
|
46
62
|
cm sync
|
|
47
63
|
cm context [--project <name>] <task>
|
|
64
|
+
cm why [--project <name>] <file>
|
|
48
65
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
49
66
|
cm doctor [--fix]
|
|
50
67
|
cm undo [project]
|
|
@@ -95,6 +112,14 @@ function cmdHelp() {
|
|
|
95
112
|
cm resolve [project] <memory-id>
|
|
96
113
|
Mark a memory as resolved.
|
|
97
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
|
+
|
|
98
123
|
cm delete [project] <memory-id>
|
|
99
124
|
Delete a memory and any memory_links referencing it.
|
|
100
125
|
|
|
@@ -109,7 +134,7 @@ function cmdHelp() {
|
|
|
109
134
|
Cluster related unresolved memories (needs 20+) and collapse
|
|
110
135
|
clusters of 3 or more into a single summary memory.
|
|
111
136
|
|
|
112
|
-
cm init <project> <path>
|
|
137
|
+
cm init <project> <path> [--config]
|
|
113
138
|
Install a git post-commit hook in the repo at <path>, register
|
|
114
139
|
the stackmem MCP server with Claude Code, write a CLAUDE.md,
|
|
115
140
|
.cursorrules, .windsurfrules, and .stackmem file, and seed
|
|
@@ -117,7 +142,12 @@ function cmdHelp() {
|
|
|
117
142
|
.windsurfrules are pre-filled with the project's current
|
|
118
143
|
memories (for Cursor and Windsurf) and skipped if they already
|
|
119
144
|
exist. The post-commit hook keeps them fresh afterward by
|
|
120
|
-
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.
|
|
121
151
|
|
|
122
152
|
cm uninstall [project] [path]
|
|
123
153
|
Remove everything 'cm init' set up at <path> (defaults to the
|
|
@@ -130,11 +160,19 @@ function cmdHelp() {
|
|
|
130
160
|
Also removes the stackmem entry from ~/.claude.json's mcpServers.
|
|
131
161
|
[project] defaults to the .stackmem file at <path> if omitted.
|
|
132
162
|
|
|
163
|
+
cm link
|
|
164
|
+
Connect this device to your GitHub account in the hosted vault.
|
|
165
|
+
Prints a 6-digit code, then waits for you to enter it at
|
|
166
|
+
stackmem-vault.vercel.app/link while signed in. The code is
|
|
167
|
+
single-use and expires after 10 minutes; polling stops when it
|
|
168
|
+
does. Links are per-device, so run this once on each machine.
|
|
169
|
+
|
|
133
170
|
cm sync
|
|
134
171
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
135
172
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
136
173
|
token budget) and its 5 most recent open execution_log fixes,
|
|
137
|
-
overwriting both files
|
|
174
|
+
overwriting both files on disk only (never git add/commit).
|
|
175
|
+
Project is read from .stackmem.
|
|
138
176
|
|
|
139
177
|
cm context [--project <name>] <task>
|
|
140
178
|
Return only the top 5 memories most relevant to a specific
|
|
@@ -142,6 +180,14 @@ function cmdHelp() {
|
|
|
142
180
|
as a markdown block capped at a 2000 token budget. Use
|
|
143
181
|
--project to target a project other than the one in .stackmem.
|
|
144
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
|
+
|
|
145
191
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
146
192
|
Capture what happened in a session and extract memories: each
|
|
147
193
|
error becomes an unsolved execution_log entry, each approach
|
|
@@ -247,7 +293,84 @@ function splitProjectFromVariadic(args) {
|
|
|
247
293
|
}
|
|
248
294
|
return { project: null, rest: args };
|
|
249
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
|
+
}
|
|
250
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
|
+
}
|
|
251
374
|
async function cmdStart(project) {
|
|
252
375
|
const { data, error } = await supabase
|
|
253
376
|
.from("memories")
|
|
@@ -263,13 +386,32 @@ async function cmdStart(project) {
|
|
|
263
386
|
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
264
387
|
return;
|
|
265
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;
|
|
266
398
|
const scoredMemories = memories
|
|
267
|
-
.map((memory) =>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
+
})
|
|
271
413
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
272
|
-
const budgetedMemories = applyContextBudget(scoredMemories,
|
|
414
|
+
const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
|
|
273
415
|
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
274
416
|
let tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
|
|
275
417
|
const { data: fixData, error: fixError } = await supabase
|
|
@@ -281,12 +423,12 @@ async function cmdStart(project) {
|
|
|
281
423
|
.limit(5);
|
|
282
424
|
if (fixError)
|
|
283
425
|
fail(`Failed to load execution log: ${fixError.message}`);
|
|
284
|
-
// Fixes share the same
|
|
285
|
-
//
|
|
426
|
+
// Fixes share the same token budget as memories, which already consumed
|
|
427
|
+
// their portion above — fixes only get whatever room is left.
|
|
286
428
|
const budgetedFixes = [];
|
|
287
429
|
for (const fix of fixData ?? []) {
|
|
288
430
|
const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
|
|
289
|
-
if (tokensUsed + tokens >
|
|
431
|
+
if (tokensUsed + tokens > tokenBudget)
|
|
290
432
|
break;
|
|
291
433
|
budgetedFixes.push(fix);
|
|
292
434
|
tokensUsed += tokens;
|
|
@@ -309,8 +451,22 @@ async function cmdStart(project) {
|
|
|
309
451
|
linkedIdsByMemory.get(link.target_id).add(link.source_id);
|
|
310
452
|
}
|
|
311
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
|
+
}
|
|
312
468
|
for (const type of MEMORY_TYPES) {
|
|
313
|
-
const group =
|
|
469
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
314
470
|
if (group.length === 0)
|
|
315
471
|
continue;
|
|
316
472
|
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
@@ -333,7 +489,7 @@ async function cmdStart(project) {
|
|
|
333
489
|
console.log("");
|
|
334
490
|
}
|
|
335
491
|
}
|
|
336
|
-
console.log(`Context budget: ${tokensUsed}/${
|
|
492
|
+
console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
|
|
337
493
|
if (omittedCount > 0) {
|
|
338
494
|
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
339
495
|
}
|
|
@@ -376,7 +532,7 @@ async function confirmSupersede(oldId, newContent) {
|
|
|
376
532
|
console.log("Invalid input — kept original memory.");
|
|
377
533
|
return false;
|
|
378
534
|
}
|
|
379
|
-
async function cmdSave(project, type, content, force = false) {
|
|
535
|
+
async function cmdSave(project, type, content, force = false, provenance = {}) {
|
|
380
536
|
if (!MEMORY_TYPES.includes(type)) {
|
|
381
537
|
fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
|
|
382
538
|
}
|
|
@@ -413,9 +569,24 @@ async function cmdSave(project, type, content, force = false) {
|
|
|
413
569
|
if (resolveError)
|
|
414
570
|
fail(`Failed to resolve superseded memory: ${resolveError.message}`);
|
|
415
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;
|
|
416
587
|
const { data: inserted, error } = await supabase
|
|
417
588
|
.from("memories")
|
|
418
|
-
.insert(
|
|
589
|
+
.insert(insertPayload)
|
|
419
590
|
.select("id")
|
|
420
591
|
.single();
|
|
421
592
|
if (error || !inserted)
|
|
@@ -445,6 +616,36 @@ async function cmdSave(project, type, content, force = false) {
|
|
|
445
616
|
await compressMemories(project);
|
|
446
617
|
}
|
|
447
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
|
+
}
|
|
448
649
|
// --- memory compression ----------------------------------------------------
|
|
449
650
|
async function compressMemories(project) {
|
|
450
651
|
const { data, error } = await supabase
|
|
@@ -573,6 +774,28 @@ async function cmdResolve(project, memoryId) {
|
|
|
573
774
|
}
|
|
574
775
|
console.log(`Resolved memory [${memoryId}]`);
|
|
575
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
|
+
}
|
|
576
799
|
// --- cm delete -------------------------------------------------------------
|
|
577
800
|
async function cmdDelete(project, memoryId) {
|
|
578
801
|
const { error: linksError } = await supabase
|
|
@@ -612,7 +835,8 @@ async function cmdInspect(project, memoryId) {
|
|
|
612
835
|
.single();
|
|
613
836
|
if (error || !memory)
|
|
614
837
|
fail(`Memory [${memoryId}] not found for "${project}".`);
|
|
615
|
-
const
|
|
838
|
+
const config = readStackmemConfig();
|
|
839
|
+
const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
|
|
616
840
|
const { data: links, error: linksError } = await supabase
|
|
617
841
|
.from("memory_links")
|
|
618
842
|
.select("source_id, target_id, score")
|
|
@@ -701,6 +925,65 @@ try {
|
|
|
701
925
|
|
|
702
926
|
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
703
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
|
+
|
|
704
987
|
const SECRET_PATTERNS = [
|
|
705
988
|
/AKIA[0-9A-Z]{16}/g,
|
|
706
989
|
/(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
|
|
@@ -757,10 +1040,43 @@ function git(args) {
|
|
|
757
1040
|
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
758
1041
|
}
|
|
759
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).
|
|
760
1066
|
function cmSave(type, content) {
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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}\`);
|
|
764
1080
|
}
|
|
765
1081
|
|
|
766
1082
|
function basename(filePath) {
|
|
@@ -787,6 +1103,29 @@ function isSchemaFile(filePath) {
|
|
|
787
1103
|
);
|
|
788
1104
|
}
|
|
789
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
|
+
|
|
790
1129
|
const LOW_QUALITY_PREFIXES = [
|
|
791
1130
|
"wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
|
|
792
1131
|
"tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
|
|
@@ -821,11 +1160,10 @@ const savedItems = [];
|
|
|
821
1160
|
|
|
822
1161
|
const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
823
1162
|
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
// 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.
|
|
829
1167
|
if (commitMessage.includes("[skip ci]")) {
|
|
830
1168
|
return;
|
|
831
1169
|
}
|
|
@@ -849,10 +1187,40 @@ const changedFiles = git(
|
|
|
849
1187
|
|
|
850
1188
|
let diff = "";
|
|
851
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();
|
|
852
1194
|
try {
|
|
853
|
-
|
|
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
|
+
}
|
|
854
1204
|
} catch {
|
|
855
|
-
|
|
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";
|
|
856
1224
|
}
|
|
857
1225
|
}
|
|
858
1226
|
|
|
@@ -879,17 +1247,14 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
879
1247
|
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
880
1248
|
const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
|
|
881
1249
|
cmSave("discovery", content);
|
|
882
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
883
1250
|
}
|
|
884
1251
|
if (isConfigFile(currentFile)) {
|
|
885
1252
|
const content = redactSecrets(\`new config: \${currentFile}\`);
|
|
886
1253
|
cmSave("constraint", content);
|
|
887
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
888
1254
|
}
|
|
889
1255
|
if (isSchemaFile(currentFile)) {
|
|
890
1256
|
const content = redactSecrets(\`schema change: \${currentFile}\`);
|
|
891
1257
|
cmSave("discovery", content);
|
|
892
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
893
1258
|
}
|
|
894
1259
|
}
|
|
895
1260
|
continue;
|
|
@@ -926,7 +1291,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
926
1291
|
if (removedModulePath) {
|
|
927
1292
|
const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
|
|
928
1293
|
cmSave("rejection", content);
|
|
929
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
930
1294
|
}
|
|
931
1295
|
|
|
932
1296
|
if (isPackageJson && inDependenciesSection) {
|
|
@@ -934,7 +1298,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
934
1298
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
935
1299
|
const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
|
|
936
1300
|
cmSave("rejection", content);
|
|
937
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
938
1301
|
}
|
|
939
1302
|
}
|
|
940
1303
|
continue;
|
|
@@ -950,7 +1313,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
950
1313
|
if (envMatch) {
|
|
951
1314
|
const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
|
|
952
1315
|
cmSave("constraint", content);
|
|
953
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
954
1316
|
}
|
|
955
1317
|
}
|
|
956
1318
|
}
|
|
@@ -960,7 +1322,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
960
1322
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
961
1323
|
const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
|
|
962
1324
|
cmSave("decision", content);
|
|
963
|
-
savedItems.push(\`decision: \${content}\`);
|
|
964
1325
|
}
|
|
965
1326
|
}
|
|
966
1327
|
|
|
@@ -968,14 +1329,12 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
968
1329
|
const truncated = codeLine.trim().slice(0, 80);
|
|
969
1330
|
const content = redactSecrets(\`database change: \${truncated}\`);
|
|
970
1331
|
cmSave("discovery", content);
|
|
971
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
972
1332
|
}
|
|
973
1333
|
|
|
974
1334
|
if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
|
|
975
1335
|
const truncated = codeLine.trim().slice(0, 100);
|
|
976
1336
|
const content = redactSecrets(\`open issue from commit: \${truncated}\`);
|
|
977
1337
|
cmSave("discovery", content);
|
|
978
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
979
1338
|
}
|
|
980
1339
|
}
|
|
981
1340
|
}
|
|
@@ -1004,19 +1363,16 @@ if (breakingMatch) {
|
|
|
1004
1363
|
const breakingContent = redactSecrets(breakingMatch[1].trim());
|
|
1005
1364
|
const content = \`BREAKING CHANGE: \${breakingContent}\`;
|
|
1006
1365
|
cmSave("constraint", content);
|
|
1007
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1008
1366
|
}
|
|
1009
1367
|
|
|
1010
1368
|
if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
|
|
1011
1369
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1012
1370
|
cmSave("decision", trimmedMessage);
|
|
1013
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1014
1371
|
} else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
|
|
1015
1372
|
// docs/style/test/ci/build/chore — skip entirely, nothing saved.
|
|
1016
1373
|
} else if (isQualityCommitMessage(commitMessage)) {
|
|
1017
1374
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1018
1375
|
cmSave("decision", trimmedMessage);
|
|
1019
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1020
1376
|
}
|
|
1021
1377
|
|
|
1022
1378
|
console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
|
|
@@ -1024,21 +1380,13 @@ for (const item of savedItems) {
|
|
|
1024
1380
|
console.log(\` - \${item}\`);
|
|
1025
1381
|
}
|
|
1026
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.
|
|
1027
1389
|
execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
|
|
1028
|
-
|
|
1029
|
-
// cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
|
|
1030
|
-
// the working directory dirty (breaking things like \`npm version patch\`
|
|
1031
|
-
// that require a clean tree). Commit the sync ourselves instead. --no-verify
|
|
1032
|
-
// skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
|
|
1033
|
-
// guard above, which is what actually stops this from recursing).
|
|
1034
|
-
try {
|
|
1035
|
-
execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
|
|
1036
|
-
execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
|
|
1037
|
-
stdio: "pipe",
|
|
1038
|
-
});
|
|
1039
|
-
} catch {
|
|
1040
|
-
// Nothing to commit (rules files unchanged) — fine, ignore.
|
|
1041
|
-
}
|
|
1042
1390
|
}
|
|
1043
1391
|
|
|
1044
1392
|
async function main() {
|
|
@@ -1063,14 +1411,24 @@ async function main() {
|
|
|
1063
1411
|
main().finally(() => process.exit(0));
|
|
1064
1412
|
`;
|
|
1065
1413
|
}
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
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
|
|
1068
1426
|
// preserved as a backup and chained to run before stackmem's own hook,
|
|
1069
1427
|
// rather than being clobbered — see `uninstallHook` for how it's restored.
|
|
1070
|
-
function installHook(hooksDir, hookLogicPath) {
|
|
1428
|
+
function installHook(hooksDir, hookLogicPath, hookName) {
|
|
1071
1429
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1072
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1073
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1430
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1431
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1074
1432
|
let chainedOriginal = false;
|
|
1075
1433
|
if (nodeFs.existsSync(hookPath)) {
|
|
1076
1434
|
const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
|
|
@@ -1086,21 +1444,21 @@ function installHook(hooksDir, hookLogicPath) {
|
|
|
1086
1444
|
}
|
|
1087
1445
|
}
|
|
1088
1446
|
const chainLine = chainedOriginal
|
|
1089
|
-
? `"$(dirname "$0")/${
|
|
1447
|
+
? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
|
|
1090
1448
|
: "";
|
|
1091
1449
|
nodeFs.writeFileSync(hookPath, `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`, "utf-8");
|
|
1092
1450
|
nodeFs.chmodSync(hookPath, 0o755);
|
|
1093
1451
|
if (chainedOriginal) {
|
|
1094
|
-
console.log(
|
|
1452
|
+
console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
|
|
1095
1453
|
}
|
|
1096
1454
|
}
|
|
1097
1455
|
// Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
|
|
1098
1456
|
// stackmem chained it after an existing hook, restores that original hook
|
|
1099
1457
|
// in its place. Returns a description of what happened, or null if there
|
|
1100
1458
|
// was no stackmem-managed hook to remove (nothing done).
|
|
1101
|
-
function uninstallHook(hooksDir) {
|
|
1102
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1103
|
-
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));
|
|
1104
1462
|
if (!nodeFs.existsSync(hookPath))
|
|
1105
1463
|
return null;
|
|
1106
1464
|
const content = nodeFs.readFileSync(hookPath, "utf-8");
|
|
@@ -1108,10 +1466,10 @@ function uninstallHook(hooksDir) {
|
|
|
1108
1466
|
return null;
|
|
1109
1467
|
if (nodeFs.existsSync(backupPath)) {
|
|
1110
1468
|
nodeFs.renameSync(backupPath, hookPath);
|
|
1111
|
-
return
|
|
1469
|
+
return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
|
|
1112
1470
|
}
|
|
1113
1471
|
nodeFs.unlinkSync(hookPath);
|
|
1114
|
-
return
|
|
1472
|
+
return `.git/hooks/${hookName}`;
|
|
1115
1473
|
}
|
|
1116
1474
|
// Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
|
|
1117
1475
|
// at a scratch file instead of the real ~/.claude.json.
|
|
@@ -1167,6 +1525,8 @@ dist/
|
|
|
1167
1525
|
coverage/
|
|
1168
1526
|
*-memories.json
|
|
1169
1527
|
*.db
|
|
1528
|
+
*cursorrules
|
|
1529
|
+
*windsurfrules
|
|
1170
1530
|
`;
|
|
1171
1531
|
// Returns true if the file was created, false if it already existed
|
|
1172
1532
|
// (and was left untouched).
|
|
@@ -1214,10 +1574,11 @@ async function generateRulesContent(project) {
|
|
|
1214
1574
|
.eq("resolved", false);
|
|
1215
1575
|
if (memoriesError)
|
|
1216
1576
|
fail(`Failed to load memories: ${memoriesError.message}`);
|
|
1577
|
+
const config = readStackmemConfig();
|
|
1217
1578
|
const scoredMemories = (memoryRows ?? [])
|
|
1218
1579
|
.map((memory) => ({
|
|
1219
1580
|
...memory,
|
|
1220
|
-
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),
|
|
1221
1582
|
}))
|
|
1222
1583
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
1223
1584
|
const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
|
|
@@ -1309,6 +1670,10 @@ export async function trySeedMemory(project, existingMemories, type, content, cl
|
|
|
1309
1670
|
type,
|
|
1310
1671
|
content,
|
|
1311
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",
|
|
1312
1677
|
});
|
|
1313
1678
|
return !error;
|
|
1314
1679
|
}
|
|
@@ -1417,7 +1782,17 @@ async function verifyBackendConnection(project) {
|
|
|
1417
1782
|
.eq("project", project);
|
|
1418
1783
|
return !error;
|
|
1419
1784
|
}
|
|
1420
|
-
|
|
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) {
|
|
1421
1796
|
const resolvedPath = nodePath.resolve(targetPath);
|
|
1422
1797
|
const gitDir = nodePath.join(resolvedPath, ".git");
|
|
1423
1798
|
if (!nodeFs.existsSync(gitDir)) {
|
|
@@ -1431,7 +1806,9 @@ async function cmdInit(project, targetPath) {
|
|
|
1431
1806
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1432
1807
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1433
1808
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1434
|
-
|
|
1809
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
1810
|
+
installHook(hooksDir, hookLogicPath, hookName);
|
|
1811
|
+
}
|
|
1435
1812
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
1436
1813
|
registerMcpServer(DIST_INDEX_PATH);
|
|
1437
1814
|
writeClaudeMd(project, resolvedPath);
|
|
@@ -1441,10 +1818,14 @@ async function cmdInit(project, targetPath) {
|
|
|
1441
1818
|
const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
|
|
1442
1819
|
const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
|
|
1443
1820
|
const backendOk = await verifyBackendConnection(project);
|
|
1821
|
+
const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
|
|
1444
1822
|
console.log("");
|
|
1445
1823
|
if (gitignoreCreated) {
|
|
1446
1824
|
console.log("✓ .gitignore created");
|
|
1447
1825
|
}
|
|
1826
|
+
if (writeConfig) {
|
|
1827
|
+
console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
|
|
1828
|
+
}
|
|
1448
1829
|
console.log("✓ Git hook installed");
|
|
1449
1830
|
console.log("✓ MCP server registered");
|
|
1450
1831
|
console.log("✓ CLAUDE.md written");
|
|
@@ -1472,6 +1853,73 @@ async function cmdSync(project) {
|
|
|
1472
1853
|
nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
|
|
1473
1854
|
console.log("Synced memories to .cursorrules and .windsurfrules");
|
|
1474
1855
|
}
|
|
1856
|
+
// --- cm link ---------------------------------------------------------------
|
|
1857
|
+
// Mirrors the link_codes.expires_at default in the migration
|
|
1858
|
+
// (supabase/migrations/001_user_devices_and_link_codes.sql in the vault repo).
|
|
1859
|
+
// If that interval changes, change this too — polling past expires_at just
|
|
1860
|
+
// burns requests against a row the API will refuse.
|
|
1861
|
+
const LINK_CODE_TTL_MS = 10 * 60 * 1000;
|
|
1862
|
+
const LINK_POLL_INTERVAL_MS = 3_000;
|
|
1863
|
+
const LINK_CODE_INSERT_ATTEMPTS = 5;
|
|
1864
|
+
const PG_UNIQUE_VIOLATION = "23505";
|
|
1865
|
+
function sleep(ms) {
|
|
1866
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1867
|
+
}
|
|
1868
|
+
// randomInt is a CSPRNG and uniform over the range. Math.random() would do
|
|
1869
|
+
// neither, and a link code is a short-lived credential for claiming this
|
|
1870
|
+
// device's memories — a guessable one is a hijack.
|
|
1871
|
+
function generateLinkCode() {
|
|
1872
|
+
return String(randomInt(0, 1_000_000)).padStart(6, "0");
|
|
1873
|
+
}
|
|
1874
|
+
async function cmdLink() {
|
|
1875
|
+
const deviceId = getDeviceId();
|
|
1876
|
+
// A 6-digit space collides rarely, but only unclaimed codes occupy it and
|
|
1877
|
+
// the primary key makes a collision a hard error rather than a silent
|
|
1878
|
+
// overwrite — so retry with a fresh code instead of failing the command.
|
|
1879
|
+
let code = null;
|
|
1880
|
+
for (let attempt = 0; attempt < LINK_CODE_INSERT_ATTEMPTS; attempt++) {
|
|
1881
|
+
const candidate = generateLinkCode();
|
|
1882
|
+
const { error } = await supabase
|
|
1883
|
+
.from("link_codes")
|
|
1884
|
+
.insert({ code: candidate, device_id: deviceId });
|
|
1885
|
+
if (!error) {
|
|
1886
|
+
code = candidate;
|
|
1887
|
+
break;
|
|
1888
|
+
}
|
|
1889
|
+
if (error.code !== PG_UNIQUE_VIOLATION) {
|
|
1890
|
+
fail(`Failed to create a link code: ${error.message}`);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
if (!code) {
|
|
1894
|
+
fail("Could not get an unused link code. Try again in a minute.");
|
|
1895
|
+
}
|
|
1896
|
+
console.log(`Go to ${VAULT_URL}/link and enter code: ${code}`);
|
|
1897
|
+
const deadline = Date.now() + LINK_CODE_TTL_MS;
|
|
1898
|
+
while (Date.now() < deadline) {
|
|
1899
|
+
await sleep(LINK_POLL_INTERVAL_MS);
|
|
1900
|
+
const { data, error } = await supabase
|
|
1901
|
+
.from("link_codes")
|
|
1902
|
+
.select("claimed_at")
|
|
1903
|
+
.eq("code", code)
|
|
1904
|
+
.maybeSingle();
|
|
1905
|
+
// A transient read failure shouldn't abandon a link the user may have
|
|
1906
|
+
// already completed in the browser — keep polling until the code expires.
|
|
1907
|
+
if (error)
|
|
1908
|
+
continue;
|
|
1909
|
+
// The row is gone: either housekeeping swept it or it was never visible
|
|
1910
|
+
// to this device. Either way there's nothing left to claim.
|
|
1911
|
+
if (!data)
|
|
1912
|
+
break;
|
|
1913
|
+
if (data.claimed_at) {
|
|
1914
|
+
console.log("Device linked to your GitHub account.");
|
|
1915
|
+
// Best-effort cleanup — the code is spent either way, and the API
|
|
1916
|
+
// rejects claimed codes, so a failure here is harmless.
|
|
1917
|
+
await supabase.from("link_codes").delete().eq("code", code);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
console.log("Link code expired. Run cm link again.");
|
|
1922
|
+
}
|
|
1475
1923
|
// --- cm context ------------------------------------------------------------
|
|
1476
1924
|
const CONTEXT_TYPE_ORDER = ["decision", "constraint", "rejection", "discovery"];
|
|
1477
1925
|
const CONTEXT_TOP_N = 5;
|
|
@@ -1488,9 +1936,11 @@ async function cmdContext(project, task) {
|
|
|
1488
1936
|
console.log(`No memories found for "${project}".`);
|
|
1489
1937
|
return;
|
|
1490
1938
|
}
|
|
1939
|
+
const config = readStackmemConfig();
|
|
1940
|
+
const tokenBudget = config.tokenBudget;
|
|
1491
1941
|
const scoredMemories = memories
|
|
1492
1942
|
.map((memory) => {
|
|
1493
|
-
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);
|
|
1494
1944
|
const relevanceScore = scoreMemoryRelevance(memory.content, task);
|
|
1495
1945
|
return {
|
|
1496
1946
|
...memory,
|
|
@@ -1499,7 +1949,7 @@ async function cmdContext(project, task) {
|
|
|
1499
1949
|
})
|
|
1500
1950
|
.sort((a, b) => b.combined_score - a.combined_score);
|
|
1501
1951
|
const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
|
|
1502
|
-
const budgetedMemories = applyContextBudget(topMemories,
|
|
1952
|
+
const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
|
|
1503
1953
|
const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
|
|
1504
1954
|
const lines = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
|
|
1505
1955
|
for (const type of CONTEXT_TYPE_ORDER) {
|
|
@@ -1512,9 +1962,62 @@ async function cmdContext(project, task) {
|
|
|
1512
1962
|
}
|
|
1513
1963
|
lines.push("");
|
|
1514
1964
|
}
|
|
1515
|
-
lines.push(`*(${tokensUsed}/${
|
|
1965
|
+
lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
|
|
1516
1966
|
console.log(lines.join("\n"));
|
|
1517
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
|
+
}
|
|
1518
2021
|
function splitCommaList(value) {
|
|
1519
2022
|
if (!value)
|
|
1520
2023
|
return [];
|
|
@@ -1751,7 +2254,9 @@ async function cmdDoctor(fix) {
|
|
|
1751
2254
|
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
1752
2255
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
1753
2256
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1754
|
-
|
|
2257
|
+
for (const name of MANAGED_HOOK_NAMES) {
|
|
2258
|
+
installHook(hooksDir, hookLogicPath, name);
|
|
2259
|
+
}
|
|
1755
2260
|
if (isHookExecutable(hookPath)) {
|
|
1756
2261
|
console.log("Fixed: git hook");
|
|
1757
2262
|
issues--;
|
|
@@ -1921,9 +2426,11 @@ function removeIfMarked(filePath, marker, label) {
|
|
|
1921
2426
|
async function cmdUninstall(project, targetPath) {
|
|
1922
2427
|
const gitDir = nodePath.join(targetPath, ".git");
|
|
1923
2428
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1924
|
-
const
|
|
1925
|
-
|
|
1926
|
-
|
|
2429
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
2430
|
+
const hookResult = uninstallHook(hooksDir, hookName);
|
|
2431
|
+
if (hookResult)
|
|
2432
|
+
console.log(`Removed ${hookResult}`);
|
|
2433
|
+
}
|
|
1927
2434
|
const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
|
|
1928
2435
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
1929
2436
|
if (nodeFs.existsSync(hookLogicPath)) {
|
|
@@ -1967,9 +2474,19 @@ async function main() {
|
|
|
1967
2474
|
case "save": {
|
|
1968
2475
|
const forceIndex = args.indexOf("--force");
|
|
1969
2476
|
const force = forceIndex !== -1;
|
|
1970
|
-
|
|
2477
|
+
let positional = force
|
|
1971
2478
|
? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
|
|
1972
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;
|
|
1973
2490
|
let explicitProject;
|
|
1974
2491
|
let type;
|
|
1975
2492
|
let rest;
|
|
@@ -1994,7 +2511,30 @@ async function main() {
|
|
|
1994
2511
|
if (!type || rest.length === 0)
|
|
1995
2512
|
usage();
|
|
1996
2513
|
const project = requireProject(explicitProject);
|
|
1997
|
-
|
|
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);
|
|
1998
2538
|
break;
|
|
1999
2539
|
}
|
|
2000
2540
|
case "fix": {
|
|
@@ -2032,6 +2572,24 @@ async function main() {
|
|
|
2032
2572
|
await cmdResolve(project, memoryId);
|
|
2033
2573
|
break;
|
|
2034
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
|
+
}
|
|
2035
2593
|
case "delete": {
|
|
2036
2594
|
const split = splitOptionalProject(args, 1);
|
|
2037
2595
|
if (!split)
|
|
@@ -2059,10 +2617,21 @@ async function main() {
|
|
|
2059
2617
|
break;
|
|
2060
2618
|
}
|
|
2061
2619
|
case "init": {
|
|
2062
|
-
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;
|
|
2063
2626
|
if (!project || !targetPath)
|
|
2064
2627
|
usage();
|
|
2065
|
-
await cmdInit(project, targetPath);
|
|
2628
|
+
await cmdInit(project, targetPath, writeConfig);
|
|
2629
|
+
break;
|
|
2630
|
+
}
|
|
2631
|
+
case "link": {
|
|
2632
|
+
if (args.length > 0)
|
|
2633
|
+
usage();
|
|
2634
|
+
await cmdLink();
|
|
2066
2635
|
break;
|
|
2067
2636
|
}
|
|
2068
2637
|
case "sync": {
|
|
@@ -2080,6 +2649,14 @@ async function main() {
|
|
|
2080
2649
|
await cmdContext(project, split.rest.join(" "));
|
|
2081
2650
|
break;
|
|
2082
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
|
+
}
|
|
2083
2660
|
case "session-end": {
|
|
2084
2661
|
await cmdSessionEndEntry(args);
|
|
2085
2662
|
break;
|