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/src/cli.ts
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
import nodeFs from "node:fs";
|
|
3
3
|
import nodeOs from "node:os";
|
|
4
4
|
import nodePath from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
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
13
|
import {
|
|
10
14
|
applyContextBudget,
|
|
@@ -18,12 +22,26 @@ import {
|
|
|
18
22
|
indicatesFailedApproach,
|
|
19
23
|
isAuthRelatedFile,
|
|
20
24
|
isCodeFile,
|
|
25
|
+
hasTokenOverlap,
|
|
21
26
|
isQualityMessage,
|
|
22
27
|
redactSecrets,
|
|
23
28
|
scoreMemoryRelevance,
|
|
29
|
+
tokenize,
|
|
24
30
|
} from "./scoring.js";
|
|
25
31
|
import { supabase } from "./storage.js";
|
|
26
32
|
|
|
33
|
+
// Checked as early as ESM import hoisting allows — before any command
|
|
34
|
+
// dispatch, config loading, or network call — so an unsupported Node
|
|
35
|
+
// version fails with a clear message instead of an obscure syntax or
|
|
36
|
+
// API error from deeper in the dependency chain.
|
|
37
|
+
const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
|
|
38
|
+
if (NODE_MAJOR_VERSION < 18) {
|
|
39
|
+
console.error(
|
|
40
|
+
`Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
28
46
|
const COMPRESSION_THRESHOLD = 20;
|
|
29
47
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
@@ -39,7 +57,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
|
|
|
39
57
|
const STACKMEM_HOOK_MARKER = "# stackmem-managed";
|
|
40
58
|
const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
|
|
41
59
|
const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
|
|
42
|
-
const
|
|
60
|
+
const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
|
|
43
61
|
|
|
44
62
|
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
|
|
45
63
|
type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
@@ -59,13 +77,17 @@ function usage(): never {
|
|
|
59
77
|
cm analyze [project] <path>
|
|
60
78
|
cm search [--project <name>] <query>
|
|
61
79
|
cm resolve [project] <memory-id>
|
|
80
|
+
cm pin [project] <memory-id>
|
|
81
|
+
cm unpin [project] <memory-id>
|
|
62
82
|
cm delete [project] <memory-id>
|
|
63
83
|
cm inspect [project] <memory-id>
|
|
64
84
|
cm compress [project]
|
|
65
|
-
cm init <project> <path>
|
|
85
|
+
cm init <project> <path> [--config]
|
|
66
86
|
cm uninstall [project] [path]
|
|
87
|
+
cm link
|
|
67
88
|
cm sync
|
|
68
89
|
cm context [--project <name>] <task>
|
|
90
|
+
cm why [--project <name>] <file>
|
|
69
91
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
70
92
|
cm doctor [--fix]
|
|
71
93
|
cm undo [project]
|
|
@@ -117,6 +139,14 @@ function cmdHelp() {
|
|
|
117
139
|
cm resolve [project] <memory-id>
|
|
118
140
|
Mark a memory as resolved.
|
|
119
141
|
|
|
142
|
+
cm pin [project] <memory-id>
|
|
143
|
+
Pin a memory: it scores 999 in decay ranking (always sorts
|
|
144
|
+
first, never decays) and shows in its own "Pinned" section at
|
|
145
|
+
the top of 'cm start', ahead of its normal type group.
|
|
146
|
+
|
|
147
|
+
cm unpin [project] <memory-id>
|
|
148
|
+
Un-pin a memory, returning it to normal decay-based ranking.
|
|
149
|
+
|
|
120
150
|
cm delete [project] <memory-id>
|
|
121
151
|
Delete a memory and any memory_links referencing it.
|
|
122
152
|
|
|
@@ -131,7 +161,7 @@ function cmdHelp() {
|
|
|
131
161
|
Cluster related unresolved memories (needs 20+) and collapse
|
|
132
162
|
clusters of 3 or more into a single summary memory.
|
|
133
163
|
|
|
134
|
-
cm init <project> <path>
|
|
164
|
+
cm init <project> <path> [--config]
|
|
135
165
|
Install a git post-commit hook in the repo at <path>, register
|
|
136
166
|
the stackmem MCP server with Claude Code, write a CLAUDE.md,
|
|
137
167
|
.cursorrules, .windsurfrules, and .stackmem file, and seed
|
|
@@ -139,7 +169,12 @@ function cmdHelp() {
|
|
|
139
169
|
.windsurfrules are pre-filled with the project's current
|
|
140
170
|
memories (for Cursor and Windsurf) and skipped if they already
|
|
141
171
|
exist. The post-commit hook keeps them fresh afterward by
|
|
142
|
-
running 'cm sync' on every commit
|
|
172
|
+
running 'cm sync' on every commit — on disk only, never
|
|
173
|
+
committed, so they don't double your commit count or conflict
|
|
174
|
+
across branches. Both files are added to .gitignore. With
|
|
175
|
+
--config, also writes a default .stackmem.json (ignore patterns,
|
|
176
|
+
tokenBudget, decayLambda, captureTypes, minQualityScore — see
|
|
177
|
+
src/config-file.ts) if one doesn't already exist.
|
|
143
178
|
|
|
144
179
|
cm uninstall [project] [path]
|
|
145
180
|
Remove everything 'cm init' set up at <path> (defaults to the
|
|
@@ -152,11 +187,19 @@ function cmdHelp() {
|
|
|
152
187
|
Also removes the stackmem entry from ~/.claude.json's mcpServers.
|
|
153
188
|
[project] defaults to the .stackmem file at <path> if omitted.
|
|
154
189
|
|
|
190
|
+
cm link
|
|
191
|
+
Connect this device to your GitHub account in the hosted vault.
|
|
192
|
+
Prints a 6-digit code, then waits for you to enter it at
|
|
193
|
+
stackmem-vault.vercel.app/link while signed in. The code is
|
|
194
|
+
single-use and expires after 10 minutes; polling stops when it
|
|
195
|
+
does. Links are per-device, so run this once on each machine.
|
|
196
|
+
|
|
155
197
|
cm sync
|
|
156
198
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
157
199
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
158
200
|
token budget) and its 5 most recent open execution_log fixes,
|
|
159
|
-
overwriting both files
|
|
201
|
+
overwriting both files on disk only (never git add/commit).
|
|
202
|
+
Project is read from .stackmem.
|
|
160
203
|
|
|
161
204
|
cm context [--project <name>] <task>
|
|
162
205
|
Return only the top 5 memories most relevant to a specific
|
|
@@ -164,6 +207,14 @@ function cmdHelp() {
|
|
|
164
207
|
as a markdown block capped at a 2000 token budget. Use
|
|
165
208
|
--project to target a project other than the one in .stackmem.
|
|
166
209
|
|
|
210
|
+
cm why [--project <name>] <file>
|
|
211
|
+
Show memories tied to a file: matched by Item 6's file_paths
|
|
212
|
+
column (hook-captured commits that touched it) plus a plain
|
|
213
|
+
content search for the file's path or basename, ranked by decay
|
|
214
|
+
score and grouped by type. Prints a guidance message instead if
|
|
215
|
+
nothing matches. Use --project to target a project other than
|
|
216
|
+
the one in .stackmem.
|
|
217
|
+
|
|
167
218
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
168
219
|
Capture what happened in a session and extract memories: each
|
|
169
220
|
error becomes an unsolved execution_log entry, each approach
|
|
@@ -283,8 +334,85 @@ function splitProjectFromVariadic(args: string[]): { project: string | null; res
|
|
|
283
334
|
return { project: null, rest: args };
|
|
284
335
|
}
|
|
285
336
|
|
|
337
|
+
// Generic "--flag value" extractor for internal/provenance flags (not
|
|
338
|
+
// meant to be remembered by end users — only the hook and MCP server pass
|
|
339
|
+
// these). Unlike splitProjectFromVariadic's --project, these don't need
|
|
340
|
+
// special positional disambiguation, so one generic helper covers all of
|
|
341
|
+
// them: --commit-sha, --author, --source, --confidence, --files.
|
|
342
|
+
function extractValueFlag(args: string[], flag: string): { value: string | null; rest: string[] } {
|
|
343
|
+
const idx = args.indexOf(flag);
|
|
344
|
+
if (idx === -1 || args[idx + 1] === undefined) return { value: null, rest: args };
|
|
345
|
+
return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
|
|
346
|
+
}
|
|
347
|
+
|
|
286
348
|
// --- cm start ----------------------------------------------------------
|
|
287
349
|
|
|
350
|
+
const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
|
|
351
|
+
const BRANCH_NAME_BOOST = 1.2;
|
|
352
|
+
const RECENT_FILES_BOOST = 1.3;
|
|
353
|
+
const PR_TITLE_BOOST = 1.2;
|
|
354
|
+
|
|
355
|
+
// null means "signal unavailable" (git/gh failed, detached HEAD, not
|
|
356
|
+
// enough history, gh not installed, no open PR, etc.) — every one of
|
|
357
|
+
// these is a soft skip, never an error, per the task spec.
|
|
358
|
+
function getBranchNameTokens(): Set<string> | null {
|
|
359
|
+
try {
|
|
360
|
+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
361
|
+
encoding: "utf-8",
|
|
362
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
363
|
+
}).trim();
|
|
364
|
+
if (!branch || branch === "HEAD") return null; // detached HEAD
|
|
365
|
+
const tokens = tokenize(branch);
|
|
366
|
+
return tokens.size > 0 ? tokens : null;
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function getRecentlyTouchedFiles(): Set<string> | null {
|
|
373
|
+
try {
|
|
374
|
+
const output = execFileSync(
|
|
375
|
+
"git",
|
|
376
|
+
["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"],
|
|
377
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
|
|
378
|
+
);
|
|
379
|
+
const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
380
|
+
return files.length > 0 ? new Set(files) : null;
|
|
381
|
+
} catch {
|
|
382
|
+
// Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
|
|
383
|
+
// not a git repo at all — skip gracefully.
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function getOpenPrTitleTokens(): Set<string> | null {
|
|
389
|
+
try {
|
|
390
|
+
const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
|
|
391
|
+
encoding: "utf-8",
|
|
392
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
393
|
+
}).trim();
|
|
394
|
+
if (!title) return null;
|
|
395
|
+
const tokens = tokenize(title);
|
|
396
|
+
return tokens.size > 0 ? tokens : null;
|
|
397
|
+
} catch {
|
|
398
|
+
// gh not installed, not authenticated, or no open PR for this branch.
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function fileListOverlaps(filePaths: unknown, recentFiles: Set<string>): boolean {
|
|
404
|
+
if (!Array.isArray(filePaths)) return false;
|
|
405
|
+
return filePaths.some((candidate) => {
|
|
406
|
+
if (typeof candidate !== "string") return false;
|
|
407
|
+
if (recentFiles.has(candidate)) return true;
|
|
408
|
+
const base = candidate.split("/").pop();
|
|
409
|
+
for (const recent of recentFiles) {
|
|
410
|
+
if (recent.split("/").pop() === base) return true;
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
288
416
|
async function cmdStart(project: string) {
|
|
289
417
|
const { data, error } = await supabase
|
|
290
418
|
.from("memories")
|
|
@@ -302,14 +430,42 @@ async function cmdStart(project: string) {
|
|
|
302
430
|
return;
|
|
303
431
|
}
|
|
304
432
|
|
|
433
|
+
// Task-aware re-ranking: boost memories relevant to what's actively
|
|
434
|
+
// being worked on, multiplicatively on top of the base decay score.
|
|
435
|
+
// Every signal degrades to null (no boost, not an error) if the
|
|
436
|
+
// repo/branch/gh state it needs isn't available.
|
|
437
|
+
const branchTokens = getBranchNameTokens();
|
|
438
|
+
const recentFiles = getRecentlyTouchedFiles();
|
|
439
|
+
const prTitleTokens = getOpenPrTitleTokens();
|
|
440
|
+
|
|
441
|
+
const config = readStackmemConfig();
|
|
442
|
+
const tokenBudget = config.tokenBudget;
|
|
443
|
+
|
|
305
444
|
const scoredMemories = memories
|
|
306
|
-
.map((memory) =>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
445
|
+
.map((memory) => {
|
|
446
|
+
let decayScore = computeDecayScore(
|
|
447
|
+
new Date(memory.created_at),
|
|
448
|
+
memory.access_count ?? 0,
|
|
449
|
+
memory.pinned ?? false,
|
|
450
|
+
config.decayLambda
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const contentTokens = tokenize(memory.content);
|
|
454
|
+
if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
|
|
455
|
+
decayScore *= BRANCH_NAME_BOOST;
|
|
456
|
+
}
|
|
457
|
+
if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
|
|
458
|
+
decayScore *= RECENT_FILES_BOOST;
|
|
459
|
+
}
|
|
460
|
+
if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
|
|
461
|
+
decayScore *= PR_TITLE_BOOST;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { ...memory, decay_score: decayScore };
|
|
465
|
+
})
|
|
310
466
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
311
467
|
|
|
312
|
-
const budgetedMemories = applyContextBudget(scoredMemories,
|
|
468
|
+
const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
|
|
313
469
|
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
314
470
|
let tokensUsed = budgetedMemories.reduce(
|
|
315
471
|
(sum, memory) => sum + estimateTokens(memory.content),
|
|
@@ -326,12 +482,12 @@ async function cmdStart(project: string) {
|
|
|
326
482
|
|
|
327
483
|
if (fixError) fail(`Failed to load execution log: ${fixError.message}`);
|
|
328
484
|
|
|
329
|
-
// Fixes share the same
|
|
330
|
-
//
|
|
485
|
+
// Fixes share the same token budget as memories, which already consumed
|
|
486
|
+
// their portion above — fixes only get whatever room is left.
|
|
331
487
|
const budgetedFixes: { problem: string; solution: string | null }[] = [];
|
|
332
488
|
for (const fix of fixData ?? []) {
|
|
333
489
|
const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
|
|
334
|
-
if (tokensUsed + tokens >
|
|
490
|
+
if (tokensUsed + tokens > tokenBudget) break;
|
|
335
491
|
budgetedFixes.push(fix);
|
|
336
492
|
tokensUsed += tokens;
|
|
337
493
|
}
|
|
@@ -356,8 +512,24 @@ async function cmdStart(project: string) {
|
|
|
356
512
|
|
|
357
513
|
console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
|
|
358
514
|
|
|
515
|
+
const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
|
|
516
|
+
const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
|
|
517
|
+
|
|
518
|
+
if (pinnedMemories.length > 0) {
|
|
519
|
+
console.log(`Pinned (${pinnedMemories.length})`);
|
|
520
|
+
console.log("-".repeat(11));
|
|
521
|
+
for (const memory of pinnedMemories) {
|
|
522
|
+
console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
|
|
523
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
524
|
+
if (linked.length > 0) {
|
|
525
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
console.log("");
|
|
529
|
+
}
|
|
530
|
+
|
|
359
531
|
for (const type of MEMORY_TYPES) {
|
|
360
|
-
const group =
|
|
532
|
+
const group = unpinnedMemories.filter((m) => m.type === type);
|
|
361
533
|
if (group.length === 0) continue;
|
|
362
534
|
|
|
363
535
|
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
@@ -382,7 +554,7 @@ async function cmdStart(project: string) {
|
|
|
382
554
|
}
|
|
383
555
|
}
|
|
384
556
|
|
|
385
|
-
console.log(`Context budget: ${tokensUsed}/${
|
|
557
|
+
console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
|
|
386
558
|
if (omittedCount > 0) {
|
|
387
559
|
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
388
560
|
}
|
|
@@ -436,7 +608,26 @@ async function confirmSupersede(oldId: string, newContent: string): Promise<bool
|
|
|
436
608
|
return false;
|
|
437
609
|
}
|
|
438
610
|
|
|
439
|
-
|
|
611
|
+
// Provenance metadata attached to a memory at save time. Manual `cm save`
|
|
612
|
+
// calls (a human at the keyboard) leave this at its defaults — source
|
|
613
|
+
// "manual", confidence 1.0, no commit/file/author info. The hook passes
|
|
614
|
+
// commitSha/filePaths/author and source "hook" with a lower confidence;
|
|
615
|
+
// MCP tool saves pass source "mcp".
|
|
616
|
+
interface SaveProvenance {
|
|
617
|
+
commitSha?: string;
|
|
618
|
+
filePaths?: string[];
|
|
619
|
+
author?: string;
|
|
620
|
+
source?: "hook" | "manual" | "mcp";
|
|
621
|
+
confidence?: number;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function cmdSave(
|
|
625
|
+
project: string,
|
|
626
|
+
type: string,
|
|
627
|
+
content: string,
|
|
628
|
+
force = false,
|
|
629
|
+
provenance: SaveProvenance = {}
|
|
630
|
+
) {
|
|
440
631
|
if (!MEMORY_TYPES.includes(type as MemoryType)) {
|
|
441
632
|
fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
|
|
442
633
|
}
|
|
@@ -482,9 +673,22 @@ async function cmdSave(project: string, type: string, content: string, force = f
|
|
|
482
673
|
if (resolveError) fail(`Failed to resolve superseded memory: ${resolveError.message}`);
|
|
483
674
|
}
|
|
484
675
|
|
|
676
|
+
const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
|
|
677
|
+
const insertPayload: Record<string, unknown> = {
|
|
678
|
+
project,
|
|
679
|
+
type,
|
|
680
|
+
content,
|
|
681
|
+
device_id: getDeviceId(),
|
|
682
|
+
source,
|
|
683
|
+
confidence,
|
|
684
|
+
};
|
|
685
|
+
if (commitSha) insertPayload.commit_sha = commitSha;
|
|
686
|
+
if (filePaths && filePaths.length > 0) insertPayload.file_paths = filePaths;
|
|
687
|
+
if (author) insertPayload.author = author;
|
|
688
|
+
|
|
485
689
|
const { data: inserted, error } = await supabase
|
|
486
690
|
.from("memories")
|
|
487
|
-
.insert(
|
|
691
|
+
.insert(insertPayload)
|
|
488
692
|
.select("id")
|
|
489
693
|
.single();
|
|
490
694
|
|
|
@@ -520,6 +724,40 @@ async function cmdSave(project: string, type: string, content: string, force = f
|
|
|
520
724
|
}
|
|
521
725
|
}
|
|
522
726
|
|
|
727
|
+
// --- cm check-commit (internal, used by the hook) --------------------------
|
|
728
|
+
|
|
729
|
+
// Not a user-facing command (no usage()/help entry) — the hook calls this
|
|
730
|
+
// once per invocation, before doing any diff analysis, to decide whether
|
|
731
|
+
// this commit_sha has already been captured. post-commit and post-rewrite
|
|
732
|
+
// both run the exact same capture logic against current HEAD, so without
|
|
733
|
+
// this guard, a commit that fires both hooks (e.g. `git commit --amend`,
|
|
734
|
+
// which triggers post-commit for the new commit AND post-rewrite for the
|
|
735
|
+
// rewrite) would be captured twice. Checked once per invocation — not
|
|
736
|
+
// inside each individual cmSave call — because a single commit normally
|
|
737
|
+
// produces several distinct memories; a per-save check would see the
|
|
738
|
+
// first save's own commit_sha and wrongly skip the rest.
|
|
739
|
+
async function cmdCheckCommit(project: string, commitSha: string): Promise<void> {
|
|
740
|
+
const { data, error } = await supabase
|
|
741
|
+
.from("memories")
|
|
742
|
+
.select("id")
|
|
743
|
+
.eq("project", project)
|
|
744
|
+
.eq("commit_sha", commitSha)
|
|
745
|
+
.limit(1);
|
|
746
|
+
|
|
747
|
+
if (error) {
|
|
748
|
+
// Fail open: an error here (including "column does not exist" if the
|
|
749
|
+
// commit_sha migration hasn't landed yet) must not look identical to
|
|
750
|
+
// "duplicate, skip" below — that would silently suppress every hook
|
|
751
|
+
// capture with no log trail. Exit 0 so the normal save path runs;
|
|
752
|
+
// if commit_sha genuinely isn't a column, the save itself fails
|
|
753
|
+
// there instead, where the hook's outer error handler logs it.
|
|
754
|
+
console.error(`check-commit: ${error.message}`);
|
|
755
|
+
process.exit(0);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
process.exit(data && data.length > 0 ? 1 : 0);
|
|
759
|
+
}
|
|
760
|
+
|
|
523
761
|
// --- memory compression ----------------------------------------------------
|
|
524
762
|
|
|
525
763
|
async function compressMemories(
|
|
@@ -678,6 +916,34 @@ async function cmdResolve(project: string, memoryId: string) {
|
|
|
678
916
|
console.log(`Resolved memory [${memoryId}]`);
|
|
679
917
|
}
|
|
680
918
|
|
|
919
|
+
// --- cm pin / cm unpin -------------------------------------------------
|
|
920
|
+
|
|
921
|
+
async function setPinned(project: string, memoryId: string, pinned: boolean): Promise<void> {
|
|
922
|
+
const { data, error } = await supabase
|
|
923
|
+
.from("memories")
|
|
924
|
+
.update({ pinned })
|
|
925
|
+
.eq("project", project)
|
|
926
|
+
.eq("id", memoryId)
|
|
927
|
+
.select("id");
|
|
928
|
+
|
|
929
|
+
if (error) fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
|
|
930
|
+
|
|
931
|
+
if (!data || data.length === 0) {
|
|
932
|
+
console.log(`No memory found with ID [${memoryId}]`);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function cmdPin(project: string, memoryId: string): Promise<void> {
|
|
940
|
+
await setPinned(project, memoryId, true);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function cmdUnpin(project: string, memoryId: string): Promise<void> {
|
|
944
|
+
await setPinned(project, memoryId, false);
|
|
945
|
+
}
|
|
946
|
+
|
|
681
947
|
// --- cm delete -------------------------------------------------------------
|
|
682
948
|
|
|
683
949
|
async function cmdDelete(project: string, memoryId: string) {
|
|
@@ -725,7 +991,13 @@ async function cmdInspect(project: string, memoryId: string) {
|
|
|
725
991
|
|
|
726
992
|
if (error || !memory) fail(`Memory [${memoryId}] not found for "${project}".`);
|
|
727
993
|
|
|
728
|
-
const
|
|
994
|
+
const config = readStackmemConfig();
|
|
995
|
+
const decayScore = computeDecayScore(
|
|
996
|
+
new Date(memory.created_at),
|
|
997
|
+
memory.access_count ?? 0,
|
|
998
|
+
memory.pinned ?? false,
|
|
999
|
+
config.decayLambda
|
|
1000
|
+
);
|
|
729
1001
|
|
|
730
1002
|
const { data: links, error: linksError } = await supabase
|
|
731
1003
|
.from("memory_links")
|
|
@@ -833,6 +1105,65 @@ try {
|
|
|
833
1105
|
|
|
834
1106
|
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
835
1107
|
|
|
1108
|
+
// Inlined copy of src/config-file.ts's defaults/merge logic — this script
|
|
1109
|
+
// runs standalone with no import access to that module.
|
|
1110
|
+
const DEFAULT_STACKMEM_CONFIG = {
|
|
1111
|
+
ignore: [],
|
|
1112
|
+
tokenBudget: 2000,
|
|
1113
|
+
decayLambda: 0.05,
|
|
1114
|
+
captureTypes: ["decision", "constraint", "discovery", "rejection"],
|
|
1115
|
+
minQualityScore: 0.3,
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
function isStringArray(value) {
|
|
1119
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function readStackmemConfig() {
|
|
1123
|
+
const configPath = join(process.cwd(), ".stackmem.json");
|
|
1124
|
+
try {
|
|
1125
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1126
|
+
return {
|
|
1127
|
+
ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
|
|
1128
|
+
tokenBudget:
|
|
1129
|
+
typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
|
|
1130
|
+
? raw.tokenBudget
|
|
1131
|
+
: DEFAULT_STACKMEM_CONFIG.tokenBudget,
|
|
1132
|
+
decayLambda:
|
|
1133
|
+
typeof raw.decayLambda === "number" && raw.decayLambda > 0
|
|
1134
|
+
? raw.decayLambda
|
|
1135
|
+
: DEFAULT_STACKMEM_CONFIG.decayLambda,
|
|
1136
|
+
captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
|
|
1137
|
+
minQualityScore:
|
|
1138
|
+
typeof raw.minQualityScore === "number"
|
|
1139
|
+
? raw.minQualityScore
|
|
1140
|
+
: DEFAULT_STACKMEM_CONFIG.minQualityScore,
|
|
1141
|
+
};
|
|
1142
|
+
} catch {
|
|
1143
|
+
return { ...DEFAULT_STACKMEM_CONFIG };
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function matchesIgnorePattern(filePath, pattern) {
|
|
1148
|
+
const base = filePath.split("/").pop() ?? filePath;
|
|
1149
|
+
|
|
1150
|
+
if (pattern.endsWith("/")) {
|
|
1151
|
+
const dirName = pattern.slice(0, -1);
|
|
1152
|
+
return filePath.split("/").includes(dirName);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (pattern.includes("*")) {
|
|
1156
|
+
const regex = new RegExp(
|
|
1157
|
+
"^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
|
|
1158
|
+
);
|
|
1159
|
+
return regex.test(base) || regex.test(filePath);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return filePath === pattern || base === pattern;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const STACKMEM_CONFIG = readStackmemConfig();
|
|
1166
|
+
|
|
836
1167
|
const SECRET_PATTERNS = [
|
|
837
1168
|
/AKIA[0-9A-Z]{16}/g,
|
|
838
1169
|
/(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
|
|
@@ -889,10 +1220,43 @@ function git(args) {
|
|
|
889
1220
|
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
890
1221
|
}
|
|
891
1222
|
|
|
1223
|
+
const CURRENT_SHA = git(["rev-parse", "HEAD"]);
|
|
1224
|
+
const CURRENT_AUTHOR = (() => {
|
|
1225
|
+
try {
|
|
1226
|
+
return git(["log", "--format=%ae", "-1", "HEAD"]);
|
|
1227
|
+
} catch {
|
|
1228
|
+
return "";
|
|
1229
|
+
}
|
|
1230
|
+
})();
|
|
1231
|
+
|
|
1232
|
+
// post-commit and post-rewrite both run this exact script against current
|
|
1233
|
+
// HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
|
|
1234
|
+
// would otherwise capture the same commit twice. Checked once up front,
|
|
1235
|
+
// not per-save — see cmdCheckCommit in src/cli.ts for why.
|
|
1236
|
+
try {
|
|
1237
|
+
execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
|
|
1238
|
+
} catch {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// file_paths is populated with every file the commit touched (not just
|
|
1243
|
+
// the filtered diffableFiles below) — "cm why <file>" should still find
|
|
1244
|
+
// a commit's decision/discovery even if that specific file's own diff
|
|
1245
|
+
// content was skipped as noise (a lockfile, say).
|
|
892
1246
|
function cmSave(type, content) {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1247
|
+
if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
|
|
1248
|
+
|
|
1249
|
+
const args = [
|
|
1250
|
+
CLI_PATH, "save", PROJECT, type, content, "--force",
|
|
1251
|
+
"--commit-sha", CURRENT_SHA,
|
|
1252
|
+
"--source", "hook",
|
|
1253
|
+
"--confidence", "0.8",
|
|
1254
|
+
];
|
|
1255
|
+
if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
|
|
1256
|
+
if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
|
|
1257
|
+
|
|
1258
|
+
execFileSync("node", args, { stdio: "inherit" });
|
|
1259
|
+
savedItems.push(\`\${type}: \${content}\`);
|
|
896
1260
|
}
|
|
897
1261
|
|
|
898
1262
|
function basename(filePath) {
|
|
@@ -919,6 +1283,29 @@ function isSchemaFile(filePath) {
|
|
|
919
1283
|
);
|
|
920
1284
|
}
|
|
921
1285
|
|
|
1286
|
+
// Noise that's never worth diffing: lockfiles, build output, vendored/
|
|
1287
|
+
// generated code, and timestamp-prefixed SQL migrations (their content is
|
|
1288
|
+
// a mechanical dump, not a human decision). Checked against path segments
|
|
1289
|
+
// (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
|
|
1290
|
+
// just a top-level "dist/...".
|
|
1291
|
+
const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
|
|
1292
|
+
const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
|
|
1293
|
+
const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
|
|
1294
|
+
const DIFF_MAX_BYTES = 50 * 1024;
|
|
1295
|
+
|
|
1296
|
+
function shouldSkipDiffFile(filePath) {
|
|
1297
|
+
const base = basename(filePath);
|
|
1298
|
+
if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
|
|
1299
|
+
if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
|
|
1300
|
+
if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
|
|
1301
|
+
if (base.endsWith(".map")) return true;
|
|
1302
|
+
if (base.endsWith(".pb.go")) return true;
|
|
1303
|
+
if (/\\.generated\\./.test(base)) return true;
|
|
1304
|
+
if (MIGRATION_FILE_RE.test(base)) return true;
|
|
1305
|
+
if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
922
1309
|
const LOW_QUALITY_PREFIXES = [
|
|
923
1310
|
"wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
|
|
924
1311
|
"tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
|
|
@@ -953,11 +1340,10 @@ const savedItems = [];
|
|
|
953
1340
|
|
|
954
1341
|
const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
955
1342
|
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
// rules-file content and triggers another sync commit, indefinitely.
|
|
1343
|
+
// Kept as a general escape hatch for other automation (CI bots, scripts)
|
|
1344
|
+
// that tags its own commits this way and shouldn't be captured as a
|
|
1345
|
+
// memory — cm sync no longer self-commits, so this isn't load-bearing for
|
|
1346
|
+
// that anymore, but other tools' commits still deserve the same skip.
|
|
961
1347
|
if (commitMessage.includes("[skip ci]")) {
|
|
962
1348
|
return;
|
|
963
1349
|
}
|
|
@@ -981,10 +1367,40 @@ const changedFiles = git(
|
|
|
981
1367
|
|
|
982
1368
|
let diff = "";
|
|
983
1369
|
if (!isFirstCommit) {
|
|
1370
|
+
// --numstat marks a binary file's added/deleted columns as "-" — that's
|
|
1371
|
+
// the cheapest way to identify them without fetching their (useless,
|
|
1372
|
+
// and potentially huge) diff content at all.
|
|
1373
|
+
let binaryFiles = new Set();
|
|
984
1374
|
try {
|
|
985
|
-
|
|
1375
|
+
const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
|
|
1376
|
+
encoding: "utf-8",
|
|
1377
|
+
});
|
|
1378
|
+
for (const line of numstat.split("\\n")) {
|
|
1379
|
+
const parts = line.split("\\t");
|
|
1380
|
+
if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
|
|
1381
|
+
binaryFiles.add(parts[2]);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
986
1384
|
} catch {
|
|
987
|
-
|
|
1385
|
+
// Nothing to compare against — leave binaryFiles empty.
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const diffableFiles = changedFiles.filter(
|
|
1389
|
+
(file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
|
|
1390
|
+
);
|
|
1391
|
+
|
|
1392
|
+
if (diffableFiles.length > 0) {
|
|
1393
|
+
try {
|
|
1394
|
+
diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
|
|
1395
|
+
encoding: "utf-8",
|
|
1396
|
+
});
|
|
1397
|
+
} catch {
|
|
1398
|
+
diff = "";
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (diff.length > DIFF_MAX_BYTES) {
|
|
1403
|
+
diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
|
|
988
1404
|
}
|
|
989
1405
|
}
|
|
990
1406
|
|
|
@@ -1011,17 +1427,14 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1011
1427
|
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
1012
1428
|
const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
|
|
1013
1429
|
cmSave("discovery", content);
|
|
1014
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1015
1430
|
}
|
|
1016
1431
|
if (isConfigFile(currentFile)) {
|
|
1017
1432
|
const content = redactSecrets(\`new config: \${currentFile}\`);
|
|
1018
1433
|
cmSave("constraint", content);
|
|
1019
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1020
1434
|
}
|
|
1021
1435
|
if (isSchemaFile(currentFile)) {
|
|
1022
1436
|
const content = redactSecrets(\`schema change: \${currentFile}\`);
|
|
1023
1437
|
cmSave("discovery", content);
|
|
1024
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1025
1438
|
}
|
|
1026
1439
|
}
|
|
1027
1440
|
continue;
|
|
@@ -1058,7 +1471,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1058
1471
|
if (removedModulePath) {
|
|
1059
1472
|
const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
|
|
1060
1473
|
cmSave("rejection", content);
|
|
1061
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
1062
1474
|
}
|
|
1063
1475
|
|
|
1064
1476
|
if (isPackageJson && inDependenciesSection) {
|
|
@@ -1066,7 +1478,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1066
1478
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
1067
1479
|
const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
|
|
1068
1480
|
cmSave("rejection", content);
|
|
1069
|
-
savedItems.push(\`rejection: \${content}\`);
|
|
1070
1481
|
}
|
|
1071
1482
|
}
|
|
1072
1483
|
continue;
|
|
@@ -1082,7 +1493,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1082
1493
|
if (envMatch) {
|
|
1083
1494
|
const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
|
|
1084
1495
|
cmSave("constraint", content);
|
|
1085
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1086
1496
|
}
|
|
1087
1497
|
}
|
|
1088
1498
|
}
|
|
@@ -1092,7 +1502,6 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1092
1502
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
1093
1503
|
const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
|
|
1094
1504
|
cmSave("decision", content);
|
|
1095
|
-
savedItems.push(\`decision: \${content}\`);
|
|
1096
1505
|
}
|
|
1097
1506
|
}
|
|
1098
1507
|
|
|
@@ -1100,14 +1509,12 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1100
1509
|
const truncated = codeLine.trim().slice(0, 80);
|
|
1101
1510
|
const content = redactSecrets(\`database change: \${truncated}\`);
|
|
1102
1511
|
cmSave("discovery", content);
|
|
1103
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1104
1512
|
}
|
|
1105
1513
|
|
|
1106
1514
|
if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
|
|
1107
1515
|
const truncated = codeLine.trim().slice(0, 100);
|
|
1108
1516
|
const content = redactSecrets(\`open issue from commit: \${truncated}\`);
|
|
1109
1517
|
cmSave("discovery", content);
|
|
1110
|
-
savedItems.push(\`discovery: \${content}\`);
|
|
1111
1518
|
}
|
|
1112
1519
|
}
|
|
1113
1520
|
}
|
|
@@ -1136,19 +1543,16 @@ if (breakingMatch) {
|
|
|
1136
1543
|
const breakingContent = redactSecrets(breakingMatch[1].trim());
|
|
1137
1544
|
const content = \`BREAKING CHANGE: \${breakingContent}\`;
|
|
1138
1545
|
cmSave("constraint", content);
|
|
1139
|
-
savedItems.push(\`constraint: \${content}\`);
|
|
1140
1546
|
}
|
|
1141
1547
|
|
|
1142
1548
|
if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
|
|
1143
1549
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1144
1550
|
cmSave("decision", trimmedMessage);
|
|
1145
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1146
1551
|
} else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
|
|
1147
1552
|
// docs/style/test/ci/build/chore — skip entirely, nothing saved.
|
|
1148
1553
|
} else if (isQualityCommitMessage(commitMessage)) {
|
|
1149
1554
|
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1150
1555
|
cmSave("decision", trimmedMessage);
|
|
1151
|
-
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1152
1556
|
}
|
|
1153
1557
|
|
|
1154
1558
|
console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
|
|
@@ -1156,21 +1560,13 @@ for (const item of savedItems) {
|
|
|
1156
1560
|
console.log(\` - \${item}\`);
|
|
1157
1561
|
}
|
|
1158
1562
|
|
|
1563
|
+
// Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
|
|
1564
|
+
// Editors read these files straight off disk, and they're gitignored by
|
|
1565
|
+
// default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
|
|
1566
|
+
// sync with git history; committing them here used to double the commit
|
|
1567
|
+
// count on every real commit and produced constant rules-file merge
|
|
1568
|
+
// conflicts on teams.
|
|
1159
1569
|
execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
|
|
1160
|
-
|
|
1161
|
-
// cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
|
|
1162
|
-
// the working directory dirty (breaking things like \`npm version patch\`
|
|
1163
|
-
// that require a clean tree). Commit the sync ourselves instead. --no-verify
|
|
1164
|
-
// skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
|
|
1165
|
-
// guard above, which is what actually stops this from recursing).
|
|
1166
|
-
try {
|
|
1167
|
-
execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
|
|
1168
|
-
execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
|
|
1169
|
-
stdio: "pipe",
|
|
1170
|
-
});
|
|
1171
|
-
} catch {
|
|
1172
|
-
// Nothing to commit (rules files unchanged) — fine, ignore.
|
|
1173
|
-
}
|
|
1174
1570
|
}
|
|
1175
1571
|
|
|
1176
1572
|
async function main() {
|
|
@@ -1196,15 +1592,27 @@ main().finally(() => process.exit(0));
|
|
|
1196
1592
|
`;
|
|
1197
1593
|
}
|
|
1198
1594
|
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1595
|
+
// Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
|
|
1596
|
+
// `git rebase` (neither of which is a plain commit, so post-commit alone
|
|
1597
|
+
// misses them) — it runs the exact same capture script as post-commit,
|
|
1598
|
+
// against current HEAD, with the check-commit idempotency guard in the
|
|
1599
|
+
// script itself preventing a double-capture when a single operation
|
|
1600
|
+
// (e.g. --amend) fires both hooks for the same resulting commit.
|
|
1601
|
+
const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"] as const;
|
|
1602
|
+
|
|
1603
|
+
function hookBackupFilename(hookName: string): string {
|
|
1604
|
+
return `${hookName}${HOOK_BACKUP_SUFFIX}`;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
|
|
1608
|
+
// hook of this name already exists and wasn't written by stackmem, it's
|
|
1201
1609
|
// preserved as a backup and chained to run before stackmem's own hook,
|
|
1202
1610
|
// rather than being clobbered — see `uninstallHook` for how it's restored.
|
|
1203
|
-
function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
1611
|
+
function installHook(hooksDir: string, hookLogicPath: string, hookName: string): void {
|
|
1204
1612
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1205
1613
|
|
|
1206
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1207
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1614
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1615
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1208
1616
|
|
|
1209
1617
|
let chainedOriginal = false;
|
|
1210
1618
|
if (nodeFs.existsSync(hookPath)) {
|
|
@@ -1221,7 +1629,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1221
1629
|
}
|
|
1222
1630
|
|
|
1223
1631
|
const chainLine = chainedOriginal
|
|
1224
|
-
? `"$(dirname "$0")/${
|
|
1632
|
+
? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
|
|
1225
1633
|
: "";
|
|
1226
1634
|
|
|
1227
1635
|
nodeFs.writeFileSync(
|
|
@@ -1232,7 +1640,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1232
1640
|
nodeFs.chmodSync(hookPath, 0o755);
|
|
1233
1641
|
|
|
1234
1642
|
if (chainedOriginal) {
|
|
1235
|
-
console.log(
|
|
1643
|
+
console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
|
|
1236
1644
|
}
|
|
1237
1645
|
}
|
|
1238
1646
|
|
|
@@ -1240,9 +1648,9 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
|
1240
1648
|
// stackmem chained it after an existing hook, restores that original hook
|
|
1241
1649
|
// in its place. Returns a description of what happened, or null if there
|
|
1242
1650
|
// was no stackmem-managed hook to remove (nothing done).
|
|
1243
|
-
function uninstallHook(hooksDir: string): string | null {
|
|
1244
|
-
const hookPath = nodePath.join(hooksDir,
|
|
1245
|
-
const backupPath = nodePath.join(hooksDir,
|
|
1651
|
+
function uninstallHook(hooksDir: string, hookName: string): string | null {
|
|
1652
|
+
const hookPath = nodePath.join(hooksDir, hookName);
|
|
1653
|
+
const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
|
|
1246
1654
|
|
|
1247
1655
|
if (!nodeFs.existsSync(hookPath)) return null;
|
|
1248
1656
|
|
|
@@ -1251,11 +1659,11 @@ function uninstallHook(hooksDir: string): string | null {
|
|
|
1251
1659
|
|
|
1252
1660
|
if (nodeFs.existsSync(backupPath)) {
|
|
1253
1661
|
nodeFs.renameSync(backupPath, hookPath);
|
|
1254
|
-
return
|
|
1662
|
+
return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
|
|
1255
1663
|
}
|
|
1256
1664
|
|
|
1257
1665
|
nodeFs.unlinkSync(hookPath);
|
|
1258
|
-
return
|
|
1666
|
+
return `.git/hooks/${hookName}`;
|
|
1259
1667
|
}
|
|
1260
1668
|
|
|
1261
1669
|
// Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
|
|
@@ -1318,6 +1726,8 @@ dist/
|
|
|
1318
1726
|
coverage/
|
|
1319
1727
|
*-memories.json
|
|
1320
1728
|
*.db
|
|
1729
|
+
*cursorrules
|
|
1730
|
+
*windsurfrules
|
|
1321
1731
|
`;
|
|
1322
1732
|
|
|
1323
1733
|
// Returns true if the file was created, false if it already existed
|
|
@@ -1375,10 +1785,16 @@ async function generateRulesContent(project: string): Promise<string> {
|
|
|
1375
1785
|
|
|
1376
1786
|
if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
|
|
1377
1787
|
|
|
1788
|
+
const config = readStackmemConfig();
|
|
1378
1789
|
const scoredMemories = (memoryRows ?? [])
|
|
1379
1790
|
.map((memory) => ({
|
|
1380
1791
|
...memory,
|
|
1381
|
-
decay_score: computeDecayScore(
|
|
1792
|
+
decay_score: computeDecayScore(
|
|
1793
|
+
new Date(memory.created_at),
|
|
1794
|
+
memory.access_count ?? 0,
|
|
1795
|
+
memory.pinned ?? false,
|
|
1796
|
+
config.decayLambda
|
|
1797
|
+
),
|
|
1382
1798
|
}))
|
|
1383
1799
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
1384
1800
|
|
|
@@ -1497,6 +1913,10 @@ export async function trySeedMemory(
|
|
|
1497
1913
|
type,
|
|
1498
1914
|
content,
|
|
1499
1915
|
device_id: getDeviceId(),
|
|
1916
|
+
// Seeded by `cm init` scanning project files — an explicit user-run
|
|
1917
|
+
// command, not an auto-captured commit, so it's "manual" like cm save,
|
|
1918
|
+
// not the memories.source column's default of "hook".
|
|
1919
|
+
source: "manual",
|
|
1500
1920
|
});
|
|
1501
1921
|
return !error;
|
|
1502
1922
|
}
|
|
@@ -1611,7 +2031,20 @@ async function verifyBackendConnection(project: string): Promise<boolean> {
|
|
|
1611
2031
|
return !error;
|
|
1612
2032
|
}
|
|
1613
2033
|
|
|
1614
|
-
|
|
2034
|
+
// Returns true if the file was created, false if it already existed
|
|
2035
|
+
// (and was left untouched).
|
|
2036
|
+
function writeStackmemConfigFile(resolvedPath: string): boolean {
|
|
2037
|
+
const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
|
|
2038
|
+
|
|
2039
|
+
if (nodeFs.existsSync(configPath)) {
|
|
2040
|
+
return false;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
|
|
2044
|
+
return true;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
async function cmdInit(project: string, targetPath: string, writeConfig = false): Promise<void> {
|
|
1615
2048
|
const resolvedPath = nodePath.resolve(targetPath);
|
|
1616
2049
|
const gitDir = nodePath.join(resolvedPath, ".git");
|
|
1617
2050
|
|
|
@@ -1631,7 +2064,9 @@ async function cmdInit(project: string, targetPath: string) {
|
|
|
1631
2064
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1632
2065
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1633
2066
|
|
|
1634
|
-
|
|
2067
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
2068
|
+
installHook(hooksDir, hookLogicPath, hookName);
|
|
2069
|
+
}
|
|
1635
2070
|
|
|
1636
2071
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
1637
2072
|
|
|
@@ -1647,10 +2082,15 @@ async function cmdInit(project: string, targetPath: string) {
|
|
|
1647
2082
|
|
|
1648
2083
|
const backendOk = await verifyBackendConnection(project);
|
|
1649
2084
|
|
|
2085
|
+
const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
|
|
2086
|
+
|
|
1650
2087
|
console.log("");
|
|
1651
2088
|
if (gitignoreCreated) {
|
|
1652
2089
|
console.log("✓ .gitignore created");
|
|
1653
2090
|
}
|
|
2091
|
+
if (writeConfig) {
|
|
2092
|
+
console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
|
|
2093
|
+
}
|
|
1654
2094
|
console.log("✓ Git hook installed");
|
|
1655
2095
|
console.log("✓ MCP server registered");
|
|
1656
2096
|
console.log("✓ CLAUDE.md written");
|
|
@@ -1689,6 +2129,88 @@ async function cmdSync(project: string) {
|
|
|
1689
2129
|
console.log("Synced memories to .cursorrules and .windsurfrules");
|
|
1690
2130
|
}
|
|
1691
2131
|
|
|
2132
|
+
// --- cm link ---------------------------------------------------------------
|
|
2133
|
+
|
|
2134
|
+
// Mirrors the link_codes.expires_at default in the migration
|
|
2135
|
+
// (supabase/migrations/001_user_devices_and_link_codes.sql in the vault repo).
|
|
2136
|
+
// If that interval changes, change this too — polling past expires_at just
|
|
2137
|
+
// burns requests against a row the API will refuse.
|
|
2138
|
+
const LINK_CODE_TTL_MS = 10 * 60 * 1000;
|
|
2139
|
+
const LINK_POLL_INTERVAL_MS = 3_000;
|
|
2140
|
+
const LINK_CODE_INSERT_ATTEMPTS = 5;
|
|
2141
|
+
const PG_UNIQUE_VIOLATION = "23505";
|
|
2142
|
+
|
|
2143
|
+
function sleep(ms: number): Promise<void> {
|
|
2144
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// randomInt is a CSPRNG and uniform over the range. Math.random() would do
|
|
2148
|
+
// neither, and a link code is a short-lived credential for claiming this
|
|
2149
|
+
// device's memories — a guessable one is a hijack.
|
|
2150
|
+
function generateLinkCode(): string {
|
|
2151
|
+
return String(randomInt(0, 1_000_000)).padStart(6, "0");
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
async function cmdLink() {
|
|
2155
|
+
const deviceId = getDeviceId();
|
|
2156
|
+
|
|
2157
|
+
// A 6-digit space collides rarely, but only unclaimed codes occupy it and
|
|
2158
|
+
// the primary key makes a collision a hard error rather than a silent
|
|
2159
|
+
// overwrite — so retry with a fresh code instead of failing the command.
|
|
2160
|
+
let code: string | null = null;
|
|
2161
|
+
for (let attempt = 0; attempt < LINK_CODE_INSERT_ATTEMPTS; attempt++) {
|
|
2162
|
+
const candidate = generateLinkCode();
|
|
2163
|
+
const { error } = await supabase
|
|
2164
|
+
.from("link_codes")
|
|
2165
|
+
.insert({ code: candidate, device_id: deviceId });
|
|
2166
|
+
|
|
2167
|
+
if (!error) {
|
|
2168
|
+
code = candidate;
|
|
2169
|
+
break;
|
|
2170
|
+
}
|
|
2171
|
+
if (error.code !== PG_UNIQUE_VIOLATION) {
|
|
2172
|
+
fail(`Failed to create a link code: ${error.message}`);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
if (!code) {
|
|
2177
|
+
fail("Could not get an unused link code. Try again in a minute.");
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
console.log(`Go to ${VAULT_URL}/link and enter code: ${code}`);
|
|
2181
|
+
|
|
2182
|
+
const deadline = Date.now() + LINK_CODE_TTL_MS;
|
|
2183
|
+
|
|
2184
|
+
while (Date.now() < deadline) {
|
|
2185
|
+
await sleep(LINK_POLL_INTERVAL_MS);
|
|
2186
|
+
|
|
2187
|
+
const { data, error } = await supabase
|
|
2188
|
+
.from("link_codes")
|
|
2189
|
+
.select("claimed_at")
|
|
2190
|
+
.eq("code", code)
|
|
2191
|
+
.maybeSingle();
|
|
2192
|
+
|
|
2193
|
+
// A transient read failure shouldn't abandon a link the user may have
|
|
2194
|
+
// already completed in the browser — keep polling until the code expires.
|
|
2195
|
+
if (error) continue;
|
|
2196
|
+
|
|
2197
|
+
// The row is gone: either housekeeping swept it or it was never visible
|
|
2198
|
+
// to this device. Either way there's nothing left to claim.
|
|
2199
|
+
if (!data) break;
|
|
2200
|
+
|
|
2201
|
+
if (data.claimed_at) {
|
|
2202
|
+
console.log("Device linked to your GitHub account.");
|
|
2203
|
+
|
|
2204
|
+
// Best-effort cleanup — the code is spent either way, and the API
|
|
2205
|
+
// rejects claimed codes, so a failure here is harmless.
|
|
2206
|
+
await supabase.from("link_codes").delete().eq("code", code);
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
console.log("Link code expired. Run cm link again.");
|
|
2212
|
+
}
|
|
2213
|
+
|
|
1692
2214
|
// --- cm context ------------------------------------------------------------
|
|
1693
2215
|
|
|
1694
2216
|
const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
|
|
@@ -1709,9 +2231,17 @@ async function cmdContext(project: string, task: string) {
|
|
|
1709
2231
|
return;
|
|
1710
2232
|
}
|
|
1711
2233
|
|
|
2234
|
+
const config = readStackmemConfig();
|
|
2235
|
+
const tokenBudget = config.tokenBudget;
|
|
2236
|
+
|
|
1712
2237
|
const scoredMemories = memories
|
|
1713
2238
|
.map((memory) => {
|
|
1714
|
-
const decayScore = computeDecayScore(
|
|
2239
|
+
const decayScore = computeDecayScore(
|
|
2240
|
+
new Date(memory.created_at),
|
|
2241
|
+
memory.access_count ?? 0,
|
|
2242
|
+
memory.pinned ?? false,
|
|
2243
|
+
config.decayLambda
|
|
2244
|
+
);
|
|
1715
2245
|
const relevanceScore = scoreMemoryRelevance(memory.content, task);
|
|
1716
2246
|
return {
|
|
1717
2247
|
...memory,
|
|
@@ -1721,7 +2251,7 @@ async function cmdContext(project: string, task: string) {
|
|
|
1721
2251
|
.sort((a, b) => b.combined_score - a.combined_score);
|
|
1722
2252
|
|
|
1723
2253
|
const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
|
|
1724
|
-
const budgetedMemories = applyContextBudget(topMemories,
|
|
2254
|
+
const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
|
|
1725
2255
|
const tokensUsed = budgetedMemories.reduce(
|
|
1726
2256
|
(sum, memory) => sum + estimateTokens(memory.content),
|
|
1727
2257
|
0
|
|
@@ -1740,11 +2270,82 @@ async function cmdContext(project: string, task: string) {
|
|
|
1740
2270
|
lines.push("");
|
|
1741
2271
|
}
|
|
1742
2272
|
|
|
1743
|
-
lines.push(`*(${tokensUsed}/${
|
|
2273
|
+
lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
|
|
1744
2274
|
|
|
1745
2275
|
console.log(lines.join("\n"));
|
|
1746
2276
|
}
|
|
1747
2277
|
|
|
2278
|
+
// --- cm why --------------------------------------------------------------
|
|
2279
|
+
|
|
2280
|
+
function fileMatchesPaths(filePaths: unknown, file: string, baseName: string): boolean {
|
|
2281
|
+
if (!Array.isArray(filePaths)) return false;
|
|
2282
|
+
return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
function fileMentionedInContent(content: string, file: string, baseName: string): boolean {
|
|
2286
|
+
const lower = content.toLowerCase();
|
|
2287
|
+
return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
async function cmdWhy(project: string, file: string) {
|
|
2291
|
+
const baseName = file.split("/").pop() ?? file;
|
|
2292
|
+
|
|
2293
|
+
const { data, error } = await supabase
|
|
2294
|
+
.from("memories")
|
|
2295
|
+
.select("*")
|
|
2296
|
+
.eq("project", project)
|
|
2297
|
+
.eq("resolved", false);
|
|
2298
|
+
|
|
2299
|
+
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
2300
|
+
|
|
2301
|
+
const memories = data ?? [];
|
|
2302
|
+
|
|
2303
|
+
// file_paths (structured, from Item 6) and a content substring search
|
|
2304
|
+
// are combined into one match set rather than one gating the other as
|
|
2305
|
+
// a strict fallback — most memories predate file_paths existing at all
|
|
2306
|
+
// (manual saves and seeds never set it), so content search is the
|
|
2307
|
+
// primary signal for everything except recent hook-captured commits,
|
|
2308
|
+
// not a rare last resort.
|
|
2309
|
+
const matched = memories.filter(
|
|
2310
|
+
(memory) =>
|
|
2311
|
+
fileMatchesPaths(memory.file_paths, file, baseName) ||
|
|
2312
|
+
fileMentionedInContent(memory.content, file, baseName)
|
|
2313
|
+
);
|
|
2314
|
+
|
|
2315
|
+
if (matched.length === 0) {
|
|
2316
|
+
console.log(`No memories found for ${file}.`);
|
|
2317
|
+
console.log("Make some commits touching this file to build up context.");
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
const config = readStackmemConfig();
|
|
2322
|
+
const scoredMemories = matched
|
|
2323
|
+
.map((memory) => ({
|
|
2324
|
+
...memory,
|
|
2325
|
+
decay_score: computeDecayScore(
|
|
2326
|
+
new Date(memory.created_at),
|
|
2327
|
+
memory.access_count ?? 0,
|
|
2328
|
+
memory.pinned ?? false,
|
|
2329
|
+
config.decayLambda
|
|
2330
|
+
),
|
|
2331
|
+
}))
|
|
2332
|
+
.sort((a, b) => b.decay_score - a.decay_score);
|
|
2333
|
+
|
|
2334
|
+
console.log(`Decisions and constraints tied to ${file}:\n`);
|
|
2335
|
+
|
|
2336
|
+
for (const type of MEMORY_TYPES) {
|
|
2337
|
+
const group = scoredMemories.filter((memory) => memory.type === type);
|
|
2338
|
+
if (group.length === 0) continue;
|
|
2339
|
+
|
|
2340
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
2341
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
2342
|
+
for (const memory of group) {
|
|
2343
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
2344
|
+
}
|
|
2345
|
+
console.log("");
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
1748
2349
|
// --- cm session-end --------------------------------------------------------
|
|
1749
2350
|
|
|
1750
2351
|
interface SessionEndInput {
|
|
@@ -1995,7 +2596,9 @@ async function cmdDoctor(fix: boolean) {
|
|
|
1995
2596
|
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
1996
2597
|
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
1997
2598
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1998
|
-
|
|
2599
|
+
for (const name of MANAGED_HOOK_NAMES) {
|
|
2600
|
+
installHook(hooksDir, hookLogicPath, name);
|
|
2601
|
+
}
|
|
1999
2602
|
|
|
2000
2603
|
if (isHookExecutable(hookPath)) {
|
|
2001
2604
|
console.log("Fixed: git hook");
|
|
@@ -2192,8 +2795,10 @@ async function cmdUninstall(project: string, targetPath: string) {
|
|
|
2192
2795
|
const gitDir = nodePath.join(targetPath, ".git");
|
|
2193
2796
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
2194
2797
|
|
|
2195
|
-
const
|
|
2196
|
-
|
|
2798
|
+
for (const hookName of MANAGED_HOOK_NAMES) {
|
|
2799
|
+
const hookResult = uninstallHook(hooksDir, hookName);
|
|
2800
|
+
if (hookResult) console.log(`Removed ${hookResult}`);
|
|
2801
|
+
}
|
|
2197
2802
|
|
|
2198
2803
|
const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
|
|
2199
2804
|
|
|
@@ -2247,10 +2852,21 @@ async function main() {
|
|
|
2247
2852
|
case "save": {
|
|
2248
2853
|
const forceIndex = args.indexOf("--force");
|
|
2249
2854
|
const force = forceIndex !== -1;
|
|
2250
|
-
|
|
2855
|
+
let positional = force
|
|
2251
2856
|
? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
|
|
2252
2857
|
: args;
|
|
2253
2858
|
|
|
2859
|
+
const commitShaFlag = extractValueFlag(positional, "--commit-sha");
|
|
2860
|
+
positional = commitShaFlag.rest;
|
|
2861
|
+
const authorFlag = extractValueFlag(positional, "--author");
|
|
2862
|
+
positional = authorFlag.rest;
|
|
2863
|
+
const sourceFlag = extractValueFlag(positional, "--source");
|
|
2864
|
+
positional = sourceFlag.rest;
|
|
2865
|
+
const confidenceFlag = extractValueFlag(positional, "--confidence");
|
|
2866
|
+
positional = confidenceFlag.rest;
|
|
2867
|
+
const filesFlag = extractValueFlag(positional, "--files");
|
|
2868
|
+
positional = filesFlag.rest;
|
|
2869
|
+
|
|
2254
2870
|
let explicitProject: string | null;
|
|
2255
2871
|
let type: string | undefined;
|
|
2256
2872
|
let rest: string[];
|
|
@@ -2275,7 +2891,28 @@ async function main() {
|
|
|
2275
2891
|
|
|
2276
2892
|
if (!type || rest.length === 0) usage();
|
|
2277
2893
|
const project = requireProject(explicitProject);
|
|
2278
|
-
|
|
2894
|
+
|
|
2895
|
+
const provenance: SaveProvenance = {};
|
|
2896
|
+
if (commitShaFlag.value) provenance.commitSha = commitShaFlag.value;
|
|
2897
|
+
if (authorFlag.value) provenance.author = authorFlag.value;
|
|
2898
|
+
if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
|
|
2899
|
+
provenance.source = sourceFlag.value;
|
|
2900
|
+
}
|
|
2901
|
+
if (confidenceFlag.value) {
|
|
2902
|
+
const parsed = Number(confidenceFlag.value);
|
|
2903
|
+
if (!Number.isNaN(parsed)) provenance.confidence = parsed;
|
|
2904
|
+
}
|
|
2905
|
+
if (filesFlag.value) {
|
|
2906
|
+
provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
await cmdSave(project, type, rest.join(" "), force, provenance);
|
|
2910
|
+
break;
|
|
2911
|
+
}
|
|
2912
|
+
case "check-commit": {
|
|
2913
|
+
const [project, commitSha] = args;
|
|
2914
|
+
if (!project || !commitSha) usage();
|
|
2915
|
+
await cmdCheckCommit(project, commitSha);
|
|
2279
2916
|
break;
|
|
2280
2917
|
}
|
|
2281
2918
|
case "fix": {
|
|
@@ -2309,6 +2946,22 @@ async function main() {
|
|
|
2309
2946
|
await cmdResolve(project, memoryId);
|
|
2310
2947
|
break;
|
|
2311
2948
|
}
|
|
2949
|
+
case "pin": {
|
|
2950
|
+
const split = splitOptionalProject(args, 1);
|
|
2951
|
+
if (!split) usage();
|
|
2952
|
+
const project = requireProject(split.project);
|
|
2953
|
+
const [memoryId] = split.rest;
|
|
2954
|
+
await cmdPin(project, memoryId);
|
|
2955
|
+
break;
|
|
2956
|
+
}
|
|
2957
|
+
case "unpin": {
|
|
2958
|
+
const split = splitOptionalProject(args, 1);
|
|
2959
|
+
if (!split) usage();
|
|
2960
|
+
const project = requireProject(split.project);
|
|
2961
|
+
const [memoryId] = split.rest;
|
|
2962
|
+
await cmdUnpin(project, memoryId);
|
|
2963
|
+
break;
|
|
2964
|
+
}
|
|
2312
2965
|
case "delete": {
|
|
2313
2966
|
const split = splitOptionalProject(args, 1);
|
|
2314
2967
|
if (!split) usage();
|
|
@@ -2333,9 +2986,20 @@ async function main() {
|
|
|
2333
2986
|
break;
|
|
2334
2987
|
}
|
|
2335
2988
|
case "init": {
|
|
2336
|
-
const
|
|
2989
|
+
const configIndex = args.indexOf("--config");
|
|
2990
|
+
const writeConfig = configIndex !== -1;
|
|
2991
|
+
const positional = writeConfig
|
|
2992
|
+
? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
|
|
2993
|
+
: args;
|
|
2994
|
+
|
|
2995
|
+
const [project, targetPath] = positional;
|
|
2337
2996
|
if (!project || !targetPath) usage();
|
|
2338
|
-
await cmdInit(project, targetPath);
|
|
2997
|
+
await cmdInit(project, targetPath, writeConfig);
|
|
2998
|
+
break;
|
|
2999
|
+
}
|
|
3000
|
+
case "link": {
|
|
3001
|
+
if (args.length > 0) usage();
|
|
3002
|
+
await cmdLink();
|
|
2339
3003
|
break;
|
|
2340
3004
|
}
|
|
2341
3005
|
case "sync": {
|
|
@@ -2351,6 +3015,13 @@ async function main() {
|
|
|
2351
3015
|
await cmdContext(project, split.rest.join(" "));
|
|
2352
3016
|
break;
|
|
2353
3017
|
}
|
|
3018
|
+
case "why": {
|
|
3019
|
+
const split = splitProjectFromVariadic(args);
|
|
3020
|
+
const project = requireProject(split.project);
|
|
3021
|
+
if (split.rest.length === 0) usage();
|
|
3022
|
+
await cmdWhy(project, split.rest.join(" "));
|
|
3023
|
+
break;
|
|
3024
|
+
}
|
|
2354
3025
|
case "session-end": {
|
|
2355
3026
|
await cmdSessionEndEntry(args);
|
|
2356
3027
|
break;
|