stackmem 1.0.0
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/README.md +223 -0
- package/dist/ast-index.js +272 -0
- package/dist/cli.js +718 -0
- package/dist/config.js +2 -0
- package/dist/device.js +30 -0
- package/dist/index.js +324 -0
- package/dist/scoring.js +217 -0
- package/dist/storage.js +53 -0
- package/dist/test-scoring.js +7 -0
- package/dist/tools.js +1 -0
- package/package.json +34 -0
- package/src/ast-index.ts +349 -0
- package/src/cli.ts +814 -0
- package/src/config.ts +3 -0
- package/src/device.ts +34 -0
- package/src/index.ts +436 -0
- package/src/scoring.ts +276 -0
- package/src/storage.ts +57 -0
- package/src/test-scoring.ts +11 -0
- package/src/tools.ts +0 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import nodeFs from "node:fs";
|
|
3
|
+
import nodePath from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { analyzeCodebase } from "./ast-index.js";
|
|
7
|
+
import { getDeviceId } from "./device.js";
|
|
8
|
+
import {
|
|
9
|
+
applyContextBudget,
|
|
10
|
+
buildSummaryContent,
|
|
11
|
+
clusterMemories,
|
|
12
|
+
computeDecayScore,
|
|
13
|
+
computeMemoryLinks,
|
|
14
|
+
decideSaveAction,
|
|
15
|
+
estimateTokens,
|
|
16
|
+
scoreMemoryRelevance,
|
|
17
|
+
} from "./scoring.js";
|
|
18
|
+
import { supabase } from "./storage.js";
|
|
19
|
+
|
|
20
|
+
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
21
|
+
const COMPRESSION_THRESHOLD = 20;
|
|
22
|
+
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
23
|
+
// The hook must always shell out to the compiled CLI, never `tsx src/cli.ts`
|
|
24
|
+
// directly — tsx's esbuild transform fails when invoked from inside a git
|
|
25
|
+
// hook's stripped-down shell environment.
|
|
26
|
+
const DIST_CLI_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "cli.js");
|
|
27
|
+
|
|
28
|
+
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
|
|
29
|
+
type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
30
|
+
|
|
31
|
+
const TYPE_LABELS: Record<MemoryType, string> = {
|
|
32
|
+
decision: "Decisions",
|
|
33
|
+
constraint: "Constraints",
|
|
34
|
+
discovery: "Discoveries",
|
|
35
|
+
rejection: "Rejections",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function usage(): never {
|
|
39
|
+
console.error(`Usage:
|
|
40
|
+
cm start <project>
|
|
41
|
+
cm save <project> <type> <content> [--force] (type: decision|rejection|constraint|discovery)
|
|
42
|
+
cm fix <project> <problem> <solution>
|
|
43
|
+
cm analyze <project> <path>
|
|
44
|
+
cm search <project> <query>
|
|
45
|
+
cm resolve <project> <memory-id>
|
|
46
|
+
cm delete <project> <memory-id>
|
|
47
|
+
cm compress <project>
|
|
48
|
+
cm init <project> <path>
|
|
49
|
+
cm context <project> <task>
|
|
50
|
+
cm help`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cmdHelp() {
|
|
55
|
+
console.log(`cm — coding memory CLI
|
|
56
|
+
|
|
57
|
+
cm start <project>
|
|
58
|
+
Load all unresolved memories for a project, sorted by decay
|
|
59
|
+
score and capped at a 2000 token context budget, grouped by
|
|
60
|
+
type (decisions, constraints, discoveries, rejections), with
|
|
61
|
+
linked memory ids shown under each entry.
|
|
62
|
+
|
|
63
|
+
cm save <project> <type> <content> [--force]
|
|
64
|
+
Save a new memory. <type> is one of: decision, rejection,
|
|
65
|
+
constraint, discovery. Automatically links to related existing
|
|
66
|
+
memories. If the new memory contradicts an existing one, prompts
|
|
67
|
+
to keep the old memory or replace it — pass --force to skip the
|
|
68
|
+
prompt and always replace.
|
|
69
|
+
|
|
70
|
+
cm fix <project> <problem> <solution>
|
|
71
|
+
Record a problem and its solution for future reference.
|
|
72
|
+
|
|
73
|
+
cm analyze <project> <path>
|
|
74
|
+
Build an AST index (files, functions, classes) for the codebase
|
|
75
|
+
at <path>.
|
|
76
|
+
|
|
77
|
+
cm search <project> <query>
|
|
78
|
+
Search saved memories and past fixes for a project matching
|
|
79
|
+
<query>.
|
|
80
|
+
|
|
81
|
+
cm resolve <project> <memory-id>
|
|
82
|
+
Mark a memory as resolved.
|
|
83
|
+
|
|
84
|
+
cm delete <project> <memory-id>
|
|
85
|
+
Delete a memory and any memory_links referencing it.
|
|
86
|
+
|
|
87
|
+
cm compress <project>
|
|
88
|
+
Cluster related unresolved memories (needs 20+) and collapse
|
|
89
|
+
clusters of 3 or more into a single summary memory.
|
|
90
|
+
|
|
91
|
+
cm init <project> <path>
|
|
92
|
+
Install a git post-commit hook in the repo at <path> that
|
|
93
|
+
records the commit message and any implicit memories (removed
|
|
94
|
+
imports, new env vars, new auth-related files) after every
|
|
95
|
+
commit.
|
|
96
|
+
|
|
97
|
+
cm context <project> <task>
|
|
98
|
+
Return only the top 5 memories most relevant to a specific
|
|
99
|
+
task, ranked by a blend of task relevance and decay score,
|
|
100
|
+
as a markdown block capped at a 2000 token budget.
|
|
101
|
+
|
|
102
|
+
cm help
|
|
103
|
+
Show this help message.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fail(message: string): never {
|
|
107
|
+
console.error(`Error: ${message}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- cm start ----------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async function cmdStart(project: string) {
|
|
114
|
+
const { data, error } = await supabase
|
|
115
|
+
.from("memories")
|
|
116
|
+
.select("*")
|
|
117
|
+
.eq("project", project)
|
|
118
|
+
.eq("resolved", false);
|
|
119
|
+
|
|
120
|
+
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
121
|
+
|
|
122
|
+
const memories = data ?? [];
|
|
123
|
+
if (memories.length === 0) {
|
|
124
|
+
console.log(`No memories found for "${project}".`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const scoredMemories = memories
|
|
129
|
+
.map((memory) => ({
|
|
130
|
+
...memory,
|
|
131
|
+
decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
|
|
132
|
+
}))
|
|
133
|
+
.sort((a, b) => b.decay_score - a.decay_score);
|
|
134
|
+
|
|
135
|
+
const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
|
|
136
|
+
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
137
|
+
const tokensUsed = budgetedMemories.reduce(
|
|
138
|
+
(sum, memory) => sum + estimateTokens(memory.content),
|
|
139
|
+
0
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const memoryIds = budgetedMemories.map((m) => m.id);
|
|
143
|
+
const linkedIdsByMemory = new Map<string, Set<string>>();
|
|
144
|
+
|
|
145
|
+
const idList = memoryIds.join(",");
|
|
146
|
+
const { data: links, error: linksError } = await supabase
|
|
147
|
+
.from("memory_links")
|
|
148
|
+
.select("source_id, target_id")
|
|
149
|
+
.or(`source_id.in.(${idList}),target_id.in.(${idList})`);
|
|
150
|
+
|
|
151
|
+
if (linksError) fail(`Failed to load memory links: ${linksError.message}`);
|
|
152
|
+
|
|
153
|
+
for (const link of links ?? []) {
|
|
154
|
+
if (!linkedIdsByMemory.has(link.source_id)) linkedIdsByMemory.set(link.source_id, new Set());
|
|
155
|
+
if (!linkedIdsByMemory.has(link.target_id)) linkedIdsByMemory.set(link.target_id, new Set());
|
|
156
|
+
linkedIdsByMemory.get(link.source_id)!.add(link.target_id);
|
|
157
|
+
linkedIdsByMemory.get(link.target_id)!.add(link.source_id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
|
|
161
|
+
|
|
162
|
+
for (const type of MEMORY_TYPES) {
|
|
163
|
+
const group = budgetedMemories.filter((m) => m.type === type);
|
|
164
|
+
if (group.length === 0) continue;
|
|
165
|
+
|
|
166
|
+
console.log(`${TYPE_LABELS[type]} (${group.length})`);
|
|
167
|
+
console.log("-".repeat(TYPE_LABELS[type].length + 4));
|
|
168
|
+
for (const memory of group) {
|
|
169
|
+
console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
|
|
170
|
+
const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
|
|
171
|
+
if (linked.length > 0) {
|
|
172
|
+
console.log(` linked: ${linked.join(", ")}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
console.log("");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
|
|
179
|
+
if (omittedCount > 0) {
|
|
180
|
+
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const incrementResults = await Promise.all(
|
|
184
|
+
budgetedMemories.map((memory) =>
|
|
185
|
+
supabase
|
|
186
|
+
.from("memories")
|
|
187
|
+
.update({ access_count: (memory.access_count ?? 0) + 1 })
|
|
188
|
+
.eq("id", memory.id)
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
const incrementError = incrementResults.find((result) => result.error)?.error;
|
|
192
|
+
if (incrementError) fail(`Failed to update access counts: ${incrementError.message}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- cm save -------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
// Prompts on stdin before superseding — cm save runs interactively, unlike
|
|
198
|
+
// the MCP save_memory tool, which must supersede automatically since it has
|
|
199
|
+
// no user attached to ask.
|
|
200
|
+
async function confirmSupersede(oldId: string, newContent: string): Promise<boolean> {
|
|
201
|
+
const { data: oldMemory, error } = await supabase
|
|
202
|
+
.from("memories")
|
|
203
|
+
.select("content")
|
|
204
|
+
.eq("id", oldId)
|
|
205
|
+
.single();
|
|
206
|
+
|
|
207
|
+
if (error || !oldMemory) fail(`Failed to load superseded memory: ${error?.message ?? "not found"}`);
|
|
208
|
+
|
|
209
|
+
console.log(`Conflict detected:\n Old: ${oldMemory.content}\n New: ${newContent}\n`);
|
|
210
|
+
|
|
211
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
212
|
+
const answer = (await rl.question("Keep old or replace? (k/r): ")).trim().toLowerCase();
|
|
213
|
+
rl.close();
|
|
214
|
+
|
|
215
|
+
if (answer === "k") {
|
|
216
|
+
console.log("Kept original memory.");
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
if (answer === "r") {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
console.log("Invalid input — kept original memory.");
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function cmdSave(project: string, type: string, content: string, force = false) {
|
|
227
|
+
if (!MEMORY_TYPES.includes(type as MemoryType)) {
|
|
228
|
+
fail(`Invalid type "${type}". Must be one of: ${MEMORY_TYPES.join(", ")}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
const { data: existingRows, error: existingError } = await supabase
|
|
233
|
+
.from("memories")
|
|
234
|
+
.select("id, content")
|
|
235
|
+
.eq("project", project)
|
|
236
|
+
.eq("resolved", false);
|
|
237
|
+
|
|
238
|
+
if (existingError) fail(`Failed to load memories: ${existingError.message}`);
|
|
239
|
+
|
|
240
|
+
const existingMemories = existingRows ?? [];
|
|
241
|
+
const decision = decideSaveAction(content, existingMemories);
|
|
242
|
+
|
|
243
|
+
if (decision.action === "duplicate") {
|
|
244
|
+
console.log("Duplicate memory detected — skipping.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (decision.action === "superseded" && !force) {
|
|
249
|
+
const shouldSupersede = await confirmSupersede(decision.oldId, content);
|
|
250
|
+
if (!shouldSupersede) return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (decision.action === "superseded") {
|
|
254
|
+
const { error: resolveError } = await supabase
|
|
255
|
+
.from("memories")
|
|
256
|
+
.update({ resolved: true })
|
|
257
|
+
.eq("id", decision.oldId);
|
|
258
|
+
|
|
259
|
+
if (resolveError) fail(`Failed to resolve superseded memory: ${resolveError.message}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { data: inserted, error } = await supabase
|
|
263
|
+
.from("memories")
|
|
264
|
+
.insert({ project, type, content, device_id: getDeviceId() })
|
|
265
|
+
.select("id")
|
|
266
|
+
.single();
|
|
267
|
+
|
|
268
|
+
if (error || !inserted) fail(`Failed to save memory: ${error?.message ?? "no row returned"}`);
|
|
269
|
+
|
|
270
|
+
const newMemoryId = inserted.id;
|
|
271
|
+
const links = computeMemoryLinks(newMemoryId, content, existingMemories);
|
|
272
|
+
|
|
273
|
+
if (links.length > 0) {
|
|
274
|
+
const { error: linkError } = await supabase.from("memory_links").insert(links);
|
|
275
|
+
if (linkError) fail(`Failed to save memory links: ${linkError.message}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (decision.action === "superseded") {
|
|
279
|
+
console.log(`Replaced with new memory [${newMemoryId}].`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
console.log(`Saved ${type} [${newMemoryId}] for "${project}".`);
|
|
283
|
+
if (links.length > 0) {
|
|
284
|
+
console.log(`Linked to ${links.length} existing memory(ies): ${links.map((l) => l.target_id).join(", ")}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { count: unresolvedCount, error: countError } = await supabase
|
|
288
|
+
.from("memories")
|
|
289
|
+
.select("id", { count: "exact", head: true })
|
|
290
|
+
.eq("project", project)
|
|
291
|
+
.eq("resolved", false);
|
|
292
|
+
|
|
293
|
+
if (countError) fail(`Failed to count memories: ${countError.message}`);
|
|
294
|
+
|
|
295
|
+
if ((unresolvedCount ?? 0) >= COMPRESSION_THRESHOLD) {
|
|
296
|
+
await compressMemories(project);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- memory compression ----------------------------------------------------
|
|
301
|
+
|
|
302
|
+
async function compressMemories(
|
|
303
|
+
project: string
|
|
304
|
+
): Promise<{ totalUnresolved: number; clustersCompressed: number }> {
|
|
305
|
+
const { data, error } = await supabase
|
|
306
|
+
.from("memories")
|
|
307
|
+
.select("*")
|
|
308
|
+
.eq("project", project)
|
|
309
|
+
.eq("resolved", false);
|
|
310
|
+
|
|
311
|
+
if (error) fail(`Failed to load memories for compression: ${error.message}`);
|
|
312
|
+
|
|
313
|
+
const memories = data ?? [];
|
|
314
|
+
if (memories.length < COMPRESSION_THRESHOLD) {
|
|
315
|
+
return { totalUnresolved: memories.length, clustersCompressed: 0 };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const clusters = clusterMemories(memories);
|
|
319
|
+
let clustersCompressed = 0;
|
|
320
|
+
|
|
321
|
+
for (const cluster of clusters) {
|
|
322
|
+
if (cluster.length < 3) continue;
|
|
323
|
+
|
|
324
|
+
const summaryContent = buildSummaryContent(cluster);
|
|
325
|
+
const { error: insertError } = await supabase
|
|
326
|
+
.from("memories")
|
|
327
|
+
.insert({ project, type: "discovery", content: summaryContent, device_id: getDeviceId() });
|
|
328
|
+
|
|
329
|
+
if (insertError) fail(`Failed to insert summary memory: ${insertError.message}`);
|
|
330
|
+
|
|
331
|
+
const idsToResolve = cluster.map((memory) => memory.id);
|
|
332
|
+
const { error: resolveError } = await supabase
|
|
333
|
+
.from("memories")
|
|
334
|
+
.update({ resolved: true })
|
|
335
|
+
.in("id", idsToResolve);
|
|
336
|
+
|
|
337
|
+
if (resolveError) fail(`Failed to resolve compressed memories: ${resolveError.message}`);
|
|
338
|
+
|
|
339
|
+
console.log(`Compressed ${cluster.length} memories into 1 summary`);
|
|
340
|
+
clustersCompressed++;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log(`Compression complete. ${clustersCompressed} clusters compressed.`);
|
|
344
|
+
return { totalUnresolved: memories.length, clustersCompressed };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- cm compress -------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
async function cmdCompress(project: string) {
|
|
350
|
+
const result = await compressMemories(project);
|
|
351
|
+
if (result.totalUnresolved < COMPRESSION_THRESHOLD) {
|
|
352
|
+
console.log(
|
|
353
|
+
`Not enough memories to compress (need ${COMPRESSION_THRESHOLD}+, have ${result.totalUnresolved}).`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// --- cm fix --------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
async function cmdFix(project: string, problem: string, solution: string) {
|
|
361
|
+
const { error } = await supabase
|
|
362
|
+
.from("execution_log")
|
|
363
|
+
.insert({ project, problem, solution, resolved: false, device_id: getDeviceId() });
|
|
364
|
+
|
|
365
|
+
if (error) fail(`Failed to record fix: ${error.message}`);
|
|
366
|
+
|
|
367
|
+
console.log(`Fix recorded for "${project}".`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- cm analyze ------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
async function cmdAnalyze(project: string, path: string) {
|
|
373
|
+
void project;
|
|
374
|
+
const summary = analyzeCodebase(path);
|
|
375
|
+
console.log(`Indexed ${summary.files_indexed} file(s):`);
|
|
376
|
+
console.log(` functions: ${summary.functions}`);
|
|
377
|
+
console.log(` classes: ${summary.classes}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// --- cm search -------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
async function cmdSearch(project: string, query: string) {
|
|
383
|
+
const { data: memories, error: memoriesError } = await supabase
|
|
384
|
+
.from("memories")
|
|
385
|
+
.select("*")
|
|
386
|
+
.eq("project", project)
|
|
387
|
+
.ilike("content", `%${query}%`);
|
|
388
|
+
|
|
389
|
+
if (memoriesError) fail(`Failed to search memories: ${memoriesError.message}`);
|
|
390
|
+
|
|
391
|
+
const { data: fixes, error: fixesError } = await supabase
|
|
392
|
+
.from("execution_log")
|
|
393
|
+
.select("*")
|
|
394
|
+
.eq("project", project)
|
|
395
|
+
.or(`problem.ilike.%${query}%,solution.ilike.%${query}%`);
|
|
396
|
+
|
|
397
|
+
if (fixesError) fail(`Failed to search past fixes: ${fixesError.message}`);
|
|
398
|
+
|
|
399
|
+
const memoryResults = memories ?? [];
|
|
400
|
+
const fixResults = fixes ?? [];
|
|
401
|
+
|
|
402
|
+
if (memoryResults.length === 0 && fixResults.length === 0) {
|
|
403
|
+
console.log("No results found");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
console.log(`Memories (${memoryResults.length})`);
|
|
408
|
+
console.log("-".repeat(11));
|
|
409
|
+
if (memoryResults.length === 0) {
|
|
410
|
+
console.log("No results found");
|
|
411
|
+
} else {
|
|
412
|
+
for (const memory of memoryResults) {
|
|
413
|
+
console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
console.log("");
|
|
417
|
+
|
|
418
|
+
console.log(`Past Fixes (${fixResults.length})`);
|
|
419
|
+
console.log("-".repeat(14));
|
|
420
|
+
if (fixResults.length === 0) {
|
|
421
|
+
console.log("No results found");
|
|
422
|
+
} else {
|
|
423
|
+
for (const fix of fixResults) {
|
|
424
|
+
console.log(` Problem: ${fix.problem}`);
|
|
425
|
+
console.log(` Solution: ${fix.solution}`);
|
|
426
|
+
console.log("");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// --- cm resolve ------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
async function cmdResolve(project: string, memoryId: string) {
|
|
434
|
+
const { error } = await supabase
|
|
435
|
+
.from("memories")
|
|
436
|
+
.update({ resolved: true })
|
|
437
|
+
.eq("project", project)
|
|
438
|
+
.eq("id", memoryId);
|
|
439
|
+
|
|
440
|
+
if (error) fail(`Failed to resolve memory: ${error.message}`);
|
|
441
|
+
|
|
442
|
+
console.log(`Resolved memory [${memoryId}]`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// --- cm delete -------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
async function cmdDelete(project: string, memoryId: string) {
|
|
448
|
+
const { error: linksError } = await supabase
|
|
449
|
+
.from("memory_links")
|
|
450
|
+
.delete()
|
|
451
|
+
.or(`source_id.eq.${memoryId},target_id.eq.${memoryId}`);
|
|
452
|
+
|
|
453
|
+
if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
|
|
454
|
+
|
|
455
|
+
const { error } = await supabase
|
|
456
|
+
.from("memories")
|
|
457
|
+
.delete()
|
|
458
|
+
.eq("project", project)
|
|
459
|
+
.eq("id", memoryId);
|
|
460
|
+
|
|
461
|
+
if (error) fail(`Failed to delete memory: ${error.message}`);
|
|
462
|
+
|
|
463
|
+
console.log(`Deleted memory [${memoryId}]`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// --- cm init -------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
// The hook logic lives in its own .mjs file (not directly in
|
|
469
|
+
// .git/hooks/post-commit) so its module type is unambiguous — an
|
|
470
|
+
// extensionless file run by git would otherwise have its CommonJS/ESM
|
|
471
|
+
// interpretation depend on the target repo's own package.json.
|
|
472
|
+
function buildHookLogicScript(project: string, cliPath: string): string {
|
|
473
|
+
return `#!/usr/bin/env node
|
|
474
|
+
import { execFileSync } from "node:child_process";
|
|
475
|
+
|
|
476
|
+
const PROJECT = ${JSON.stringify(project)};
|
|
477
|
+
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
478
|
+
|
|
479
|
+
function git(args) {
|
|
480
|
+
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function cmSave(type, content) {
|
|
484
|
+
execFileSync("node", [CLI_PATH, "save", PROJECT, type, content], {
|
|
485
|
+
stdio: "inherit",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function basename(filePath) {
|
|
490
|
+
return filePath.split("/").pop() ?? filePath;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function isConfigFile(filePath) {
|
|
494
|
+
const base = basename(filePath);
|
|
495
|
+
return (
|
|
496
|
+
/\\.config\\.(ts|js)$/.test(base) ||
|
|
497
|
+
base.startsWith("docker-compose") ||
|
|
498
|
+
base === "Dockerfile" ||
|
|
499
|
+
base === "nginx.conf" ||
|
|
500
|
+
base.startsWith(".eslintrc") ||
|
|
501
|
+
base.startsWith(".prettierrc")
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function isSchemaFile(filePath) {
|
|
506
|
+
return (
|
|
507
|
+
/migration/i.test(filePath) ||
|
|
508
|
+
filePath.includes("schema.prisma") ||
|
|
509
|
+
filePath.includes("schema.sql")
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const savedItems = [];
|
|
514
|
+
|
|
515
|
+
const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
516
|
+
cmSave("decision", commitMessage);
|
|
517
|
+
savedItems.push(\`decision: \${commitMessage}\`);
|
|
518
|
+
|
|
519
|
+
const changedFiles = git(["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"])
|
|
520
|
+
.split("\\n")
|
|
521
|
+
.filter(Boolean);
|
|
522
|
+
|
|
523
|
+
let diff = "";
|
|
524
|
+
try {
|
|
525
|
+
diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
|
|
526
|
+
} catch {
|
|
527
|
+
diff = "";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const lines = diff.split("\\n");
|
|
531
|
+
let currentFile = null;
|
|
532
|
+
let isNewFile = false;
|
|
533
|
+
let inDependenciesSection = false;
|
|
534
|
+
|
|
535
|
+
for (const line of lines) {
|
|
536
|
+
if (line.startsWith("diff --git")) {
|
|
537
|
+
isNewFile = false;
|
|
538
|
+
inDependenciesSection = false;
|
|
539
|
+
}
|
|
540
|
+
if (line.startsWith("new file mode")) {
|
|
541
|
+
isNewFile = true;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (line.startsWith("+++ ")) {
|
|
545
|
+
let filePath = line.slice(4);
|
|
546
|
+
if (filePath.startsWith("b/")) filePath = filePath.slice(2);
|
|
547
|
+
currentFile = filePath;
|
|
548
|
+
|
|
549
|
+
if (isNewFile) {
|
|
550
|
+
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
551
|
+
const content = \`new auth-related file: \${currentFile}\`;
|
|
552
|
+
cmSave("discovery", content);
|
|
553
|
+
savedItems.push(\`discovery: \${content}\`);
|
|
554
|
+
}
|
|
555
|
+
if (isConfigFile(currentFile)) {
|
|
556
|
+
const content = \`new config: \${currentFile}\`;
|
|
557
|
+
cmSave("constraint", content);
|
|
558
|
+
savedItems.push(\`constraint: \${content}\`);
|
|
559
|
+
}
|
|
560
|
+
if (isSchemaFile(currentFile)) {
|
|
561
|
+
const content = \`schema change: \${currentFile}\`;
|
|
562
|
+
cmSave("discovery", content);
|
|
563
|
+
savedItems.push(\`discovery: \${content}\`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const isPackageJson = currentFile != null && basename(currentFile) === "package.json";
|
|
570
|
+
if (isPackageJson) {
|
|
571
|
+
const rawLine =
|
|
572
|
+
line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")
|
|
573
|
+
? line.slice(1)
|
|
574
|
+
: line;
|
|
575
|
+
if (/"(dependencies|devDependencies)"\\s*:\\s*\\{/.test(rawLine)) {
|
|
576
|
+
inDependenciesSection = true;
|
|
577
|
+
} else if (inDependenciesSection && /^\\s*\\}/.test(rawLine)) {
|
|
578
|
+
inDependenciesSection = false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
583
|
+
const codeLine = line.slice(1).trim();
|
|
584
|
+
|
|
585
|
+
if (codeLine.startsWith("import") || codeLine.startsWith("require")) {
|
|
586
|
+
const pathMatch = codeLine.match(/['"]([^'"]+)['"]/);
|
|
587
|
+
const importPath = pathMatch ? pathMatch[1] : codeLine;
|
|
588
|
+
const content = \`removed dependency: \${importPath}\`;
|
|
589
|
+
cmSave("rejection", content);
|
|
590
|
+
savedItems.push(\`rejection: \${content}\`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (isPackageJson && inDependenciesSection) {
|
|
594
|
+
const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
|
|
595
|
+
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
596
|
+
const content = \`removed dependency: \${pkgMatch[1]}\`;
|
|
597
|
+
cmSave("rejection", content);
|
|
598
|
+
savedItems.push(\`rejection: \${content}\`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
605
|
+
const codeLine = line.slice(1);
|
|
606
|
+
|
|
607
|
+
if (currentFile) {
|
|
608
|
+
const base = basename(currentFile);
|
|
609
|
+
if (base === ".env" || base === ".env.example") {
|
|
610
|
+
const envMatch = codeLine.match(/([A-Z_]{3,})=/);
|
|
611
|
+
if (envMatch) {
|
|
612
|
+
const content = \`new env var required: \${envMatch[1]}\`;
|
|
613
|
+
cmSave("constraint", content);
|
|
614
|
+
savedItems.push(\`constraint: \${content}\`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (isPackageJson && inDependenciesSection) {
|
|
620
|
+
const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
|
|
621
|
+
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
622
|
+
const content = \`added dependency: \${pkgMatch[1]}\`;
|
|
623
|
+
cmSave("decision", content);
|
|
624
|
+
savedItems.push(\`decision: \${content}\`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (currentFile && isSchemaFile(currentFile) && /CREATE TABLE|ALTER TABLE|DROP TABLE/i.test(codeLine)) {
|
|
629
|
+
const truncated = codeLine.trim().slice(0, 80);
|
|
630
|
+
const content = \`database change: \${truncated}\`;
|
|
631
|
+
cmSave("discovery", content);
|
|
632
|
+
savedItems.push(\`discovery: \${content}\`);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (/TODO:|FIXME:|HACK:|XXX:/.test(codeLine)) {
|
|
636
|
+
const truncated = codeLine.trim().slice(0, 100);
|
|
637
|
+
const content = \`open issue from commit: \${truncated}\`;
|
|
638
|
+
cmSave("discovery", content);
|
|
639
|
+
savedItems.push(\`discovery: \${content}\`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
|
|
645
|
+
for (const item of savedItems) {
|
|
646
|
+
console.log(\` - \${item}\`);
|
|
647
|
+
}
|
|
648
|
+
`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function cmdInit(project: string, targetPath: string) {
|
|
652
|
+
const resolvedPath = nodePath.resolve(targetPath);
|
|
653
|
+
const gitDir = nodePath.join(resolvedPath, ".git");
|
|
654
|
+
|
|
655
|
+
if (!nodeFs.existsSync(gitDir)) {
|
|
656
|
+
console.error(`No git repo found at ${targetPath}`);
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const codingMemoryDir = nodePath.join(resolvedPath, ".coding-memory");
|
|
661
|
+
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
662
|
+
|
|
663
|
+
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
664
|
+
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(project, DIST_CLI_PATH), "utf-8");
|
|
665
|
+
|
|
666
|
+
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
667
|
+
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
668
|
+
|
|
669
|
+
const hookPath = nodePath.join(hooksDir, "post-commit");
|
|
670
|
+
nodeFs.writeFileSync(hookPath, `#!/bin/sh\nexec node "${hookLogicPath}" "$@"\n`, "utf-8");
|
|
671
|
+
nodeFs.chmodSync(hookPath, 0o755);
|
|
672
|
+
|
|
673
|
+
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// --- cm context ------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
|
|
679
|
+
const CONTEXT_TOP_N = 5;
|
|
680
|
+
|
|
681
|
+
async function cmdContext(project: string, task: string) {
|
|
682
|
+
const { data, error } = await supabase
|
|
683
|
+
.from("memories")
|
|
684
|
+
.select("*")
|
|
685
|
+
.eq("project", project)
|
|
686
|
+
.eq("resolved", false);
|
|
687
|
+
|
|
688
|
+
if (error) fail(`Failed to load memories: ${error.message}`);
|
|
689
|
+
|
|
690
|
+
const memories = data ?? [];
|
|
691
|
+
if (memories.length === 0) {
|
|
692
|
+
console.log(`No memories found for "${project}".`);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const scoredMemories = memories
|
|
697
|
+
.map((memory) => {
|
|
698
|
+
const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
|
|
699
|
+
const relevanceScore = scoreMemoryRelevance(memory.content, task);
|
|
700
|
+
return {
|
|
701
|
+
...memory,
|
|
702
|
+
combined_score: relevanceScore * 0.6 + decayScore * 0.4,
|
|
703
|
+
};
|
|
704
|
+
})
|
|
705
|
+
.sort((a, b) => b.combined_score - a.combined_score);
|
|
706
|
+
|
|
707
|
+
const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
|
|
708
|
+
const budgetedMemories = applyContextBudget(topMemories, CONTEXT_BUDGET_TOKENS);
|
|
709
|
+
const tokensUsed = budgetedMemories.reduce(
|
|
710
|
+
(sum, memory) => sum + estimateTokens(memory.content),
|
|
711
|
+
0
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const lines: string[] = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
|
|
715
|
+
|
|
716
|
+
for (const type of CONTEXT_TYPE_ORDER) {
|
|
717
|
+
const group = budgetedMemories.filter((memory) => memory.type === type);
|
|
718
|
+
if (group.length === 0) continue;
|
|
719
|
+
|
|
720
|
+
lines.push(`**${TYPE_LABELS[type]}**`);
|
|
721
|
+
for (const memory of group) {
|
|
722
|
+
lines.push(`- ${memory.content}`);
|
|
723
|
+
}
|
|
724
|
+
lines.push("");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
|
|
728
|
+
|
|
729
|
+
console.log(lines.join("\n"));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// --- main --------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
async function main() {
|
|
735
|
+
const [, , command, ...args] = process.argv;
|
|
736
|
+
|
|
737
|
+
switch (command) {
|
|
738
|
+
case "start": {
|
|
739
|
+
const [project] = args;
|
|
740
|
+
if (!project) usage();
|
|
741
|
+
await cmdStart(project);
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
case "save": {
|
|
745
|
+
const forceIndex = args.indexOf("--force");
|
|
746
|
+
const force = forceIndex !== -1;
|
|
747
|
+
const positional = force
|
|
748
|
+
? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
|
|
749
|
+
: args;
|
|
750
|
+
const [project, type, ...rest] = positional;
|
|
751
|
+
if (!project || !type || rest.length === 0) usage();
|
|
752
|
+
await cmdSave(project, type, rest.join(" "), force);
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
case "fix": {
|
|
756
|
+
const [project, problem, solution] = args;
|
|
757
|
+
if (!project || !problem || !solution) usage();
|
|
758
|
+
await cmdFix(project, problem, solution);
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
case "analyze": {
|
|
762
|
+
const [project, path] = args;
|
|
763
|
+
if (!project || !path) usage();
|
|
764
|
+
await cmdAnalyze(project, path);
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
case "search": {
|
|
768
|
+
const [project, ...rest] = args;
|
|
769
|
+
if (!project || rest.length === 0) usage();
|
|
770
|
+
await cmdSearch(project, rest.join(" "));
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
case "resolve": {
|
|
774
|
+
const [project, memoryId] = args;
|
|
775
|
+
if (!project || !memoryId) usage();
|
|
776
|
+
await cmdResolve(project, memoryId);
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
case "delete": {
|
|
780
|
+
const [project, memoryId] = args;
|
|
781
|
+
if (!project || !memoryId) usage();
|
|
782
|
+
await cmdDelete(project, memoryId);
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
case "compress": {
|
|
786
|
+
const [project] = args;
|
|
787
|
+
if (!project) usage();
|
|
788
|
+
await cmdCompress(project);
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
case "init": {
|
|
792
|
+
const [project, targetPath] = args;
|
|
793
|
+
if (!project || !targetPath) usage();
|
|
794
|
+
await cmdInit(project, targetPath);
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
case "context": {
|
|
798
|
+
const [project, ...rest] = args;
|
|
799
|
+
if (!project || rest.length === 0) usage();
|
|
800
|
+
await cmdContext(project, rest.join(" "));
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
case "help": {
|
|
804
|
+
cmdHelp();
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
default:
|
|
808
|
+
usage();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
main().catch((error) => {
|
|
813
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
814
|
+
});
|