stackmem 1.0.14 → 1.0.16
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/benchmark.js +568 -0
- package/dist/cli.js +101 -21
- package/dist/index.js +20 -1
- package/dist/scoring.js +81 -12
- package/dist/tools.js +1 -0
- package/package.json +1 -1
- package/src/benchmark.ts +816 -0
- package/src/cli.ts +107 -20
- package/src/index.ts +24 -1
- package/src/scoring.ts +86 -13
package/src/cli.ts
CHANGED
|
@@ -270,6 +270,8 @@ async function cmdStart(project: string) {
|
|
|
270
270
|
const memories = data ?? [];
|
|
271
271
|
if (memories.length === 0) {
|
|
272
272
|
console.log(`No memories found for "${project}".`);
|
|
273
|
+
console.log("");
|
|
274
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
273
275
|
return;
|
|
274
276
|
}
|
|
275
277
|
|
|
@@ -282,11 +284,31 @@ async function cmdStart(project: string) {
|
|
|
282
284
|
|
|
283
285
|
const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
|
|
284
286
|
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
285
|
-
|
|
287
|
+
let tokensUsed = budgetedMemories.reduce(
|
|
286
288
|
(sum, memory) => sum + estimateTokens(memory.content),
|
|
287
289
|
0
|
|
288
290
|
);
|
|
289
291
|
|
|
292
|
+
const { data: fixData, error: fixError } = await supabase
|
|
293
|
+
.from("execution_log")
|
|
294
|
+
.select("*")
|
|
295
|
+
.eq("project", project)
|
|
296
|
+
.eq("resolved", false)
|
|
297
|
+
.order("created_at", { ascending: false })
|
|
298
|
+
.limit(5);
|
|
299
|
+
|
|
300
|
+
if (fixError) fail(`Failed to load execution log: ${fixError.message}`);
|
|
301
|
+
|
|
302
|
+
// Fixes share the same 2000-token budget as memories, which already
|
|
303
|
+
// consumed their portion above — fixes only get whatever room is left.
|
|
304
|
+
const budgetedFixes: { problem: string; solution: string | null }[] = [];
|
|
305
|
+
for (const fix of fixData ?? []) {
|
|
306
|
+
const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
|
|
307
|
+
if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS) break;
|
|
308
|
+
budgetedFixes.push(fix);
|
|
309
|
+
tokensUsed += tokens;
|
|
310
|
+
}
|
|
311
|
+
|
|
290
312
|
const memoryIds = budgetedMemories.map((m) => m.id);
|
|
291
313
|
const linkedIdsByMemory = new Map<string, Set<string>>();
|
|
292
314
|
|
|
@@ -323,11 +345,27 @@ async function cmdStart(project: string) {
|
|
|
323
345
|
console.log("");
|
|
324
346
|
}
|
|
325
347
|
|
|
348
|
+
if (budgetedFixes.length > 0) {
|
|
349
|
+
console.log(`Past Fixes (${budgetedFixes.length})`);
|
|
350
|
+
console.log("-".repeat(14));
|
|
351
|
+
for (const fix of budgetedFixes) {
|
|
352
|
+
console.log(` Problem: ${fix.problem}`);
|
|
353
|
+
console.log(` Solution: ${fix.solution ?? "(pending)"}`);
|
|
354
|
+
console.log("");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
326
358
|
console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
|
|
327
359
|
if (omittedCount > 0) {
|
|
328
360
|
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
329
361
|
}
|
|
330
362
|
|
|
363
|
+
const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
|
|
364
|
+
if (!hasTechStackMemory) {
|
|
365
|
+
console.log("");
|
|
366
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
367
|
+
}
|
|
368
|
+
|
|
331
369
|
const incrementResults = await Promise.all(
|
|
332
370
|
budgetedMemories.map((memory) =>
|
|
333
371
|
supabase
|
|
@@ -1159,6 +1197,27 @@ function writeProjectFile(project: string, resolvedPath: string): void {
|
|
|
1159
1197
|
nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
|
|
1160
1198
|
}
|
|
1161
1199
|
|
|
1200
|
+
// Shared by every seedMemories check below: skip the insert if this exact
|
|
1201
|
+
// content already exists (decideSaveAction's duplicate check) so re-running
|
|
1202
|
+
// `cm init` on an already-seeded project doesn't pile up repeats.
|
|
1203
|
+
async function trySeedMemory(
|
|
1204
|
+
project: string,
|
|
1205
|
+
existingMemories: { id: string; content: string }[],
|
|
1206
|
+
type: MemoryType,
|
|
1207
|
+
content: string
|
|
1208
|
+
): Promise<boolean> {
|
|
1209
|
+
const decision = decideSaveAction(content, existingMemories);
|
|
1210
|
+
if (decision.action === "duplicate") return false;
|
|
1211
|
+
|
|
1212
|
+
const { error } = await supabase.from("memories").insert({
|
|
1213
|
+
project,
|
|
1214
|
+
type,
|
|
1215
|
+
content,
|
|
1216
|
+
device_id: getDeviceId(),
|
|
1217
|
+
});
|
|
1218
|
+
return !error;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1162
1221
|
// Scans the freshly-initialized project for a few cheap, high-signal facts
|
|
1163
1222
|
// and saves them as memories directly (bypassing cmdSave's duplicate/conflict
|
|
1164
1223
|
// prompt — there's nothing to conflict with yet, and init must stay
|
|
@@ -1186,16 +1245,7 @@ async function seedMemories(project: string, resolvedPath: string): Promise<numb
|
|
|
1186
1245
|
];
|
|
1187
1246
|
if (depNames.length > 0) {
|
|
1188
1247
|
const content = `tech stack: ${depNames.join(", ")}`;
|
|
1189
|
-
|
|
1190
|
-
if (decision.action !== "duplicate") {
|
|
1191
|
-
const { error } = await supabase.from("memories").insert({
|
|
1192
|
-
project,
|
|
1193
|
-
type: "discovery",
|
|
1194
|
-
content,
|
|
1195
|
-
device_id: getDeviceId(),
|
|
1196
|
-
});
|
|
1197
|
-
if (!error) seededCount++;
|
|
1198
|
-
}
|
|
1248
|
+
if (await trySeedMemory(project, existingMemories, "discovery", content)) seededCount++;
|
|
1199
1249
|
}
|
|
1200
1250
|
} catch {
|
|
1201
1251
|
// Malformed package.json — skip the tech-stack memory.
|
|
@@ -1212,19 +1262,56 @@ async function seedMemories(project: string, resolvedPath: string): Promise<numb
|
|
|
1212
1262
|
|
|
1213
1263
|
if (varNames.length > 0) {
|
|
1214
1264
|
const content = `required env vars: ${varNames.join(", ")}`;
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1265
|
+
if (await trySeedMemory(project, existingMemories, "constraint", content)) seededCount++;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const tsconfigPath = nodePath.join(resolvedPath, "tsconfig.json");
|
|
1270
|
+
if (nodeFs.existsSync(tsconfigPath)) {
|
|
1271
|
+
try {
|
|
1272
|
+
const tsconfig = JSON.parse(nodeFs.readFileSync(tsconfigPath, "utf-8"));
|
|
1273
|
+
const target = tsconfig.compilerOptions?.target;
|
|
1274
|
+
const moduleKind = tsconfig.compilerOptions?.module;
|
|
1275
|
+
if (target || moduleKind) {
|
|
1276
|
+
const content = `TypeScript config: target=${target ?? "unspecified"}, module=${moduleKind ?? "unspecified"}`;
|
|
1277
|
+
if (await trySeedMemory(project, existingMemories, "constraint", content)) seededCount++;
|
|
1224
1278
|
}
|
|
1279
|
+
} catch {
|
|
1280
|
+
// Malformed tsconfig.json (or JSONC with comments) — skip.
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const readmePath = nodePath.join(resolvedPath, "README.md");
|
|
1285
|
+
if (nodeFs.existsSync(readmePath)) {
|
|
1286
|
+
const words = nodeFs
|
|
1287
|
+
.readFileSync(readmePath, "utf-8")
|
|
1288
|
+
.split(/\s+/)
|
|
1289
|
+
.filter(Boolean)
|
|
1290
|
+
.slice(0, 100);
|
|
1291
|
+
if (words.length > 0) {
|
|
1292
|
+
const content = `project description: ${words.join(" ")}`;
|
|
1293
|
+
if (await trySeedMemory(project, existingMemories, "discovery", content)) seededCount++;
|
|
1225
1294
|
}
|
|
1226
1295
|
}
|
|
1227
1296
|
|
|
1297
|
+
const FRAMEWORK_FILE_INDICATORS: { file: string; type: MemoryType; content: string }[] = [
|
|
1298
|
+
{ file: "next.config.ts", type: "constraint", content: "framework: Next.js" },
|
|
1299
|
+
{ file: "next.config.js", type: "constraint", content: "framework: Next.js" },
|
|
1300
|
+
{ file: "vite.config.ts", type: "constraint", content: "framework: Vite" },
|
|
1301
|
+
{ file: "vite.config.js", type: "constraint", content: "framework: Vite" },
|
|
1302
|
+
{ file: "Dockerfile", type: "constraint", content: "containerized: Docker" },
|
|
1303
|
+
];
|
|
1304
|
+
for (const indicator of FRAMEWORK_FILE_INDICATORS) {
|
|
1305
|
+
if (!nodeFs.existsSync(nodePath.join(resolvedPath, indicator.file))) continue;
|
|
1306
|
+
if (await trySeedMemory(project, existingMemories, indicator.type, indicator.content)) seededCount++;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const githubWorkflowsPath = nodePath.join(resolvedPath, ".github", "workflows");
|
|
1310
|
+
if (nodeFs.existsSync(githubWorkflowsPath)) {
|
|
1311
|
+
const content = "CI/CD: GitHub Actions";
|
|
1312
|
+
if (await trySeedMemory(project, existingMemories, "discovery", content)) seededCount++;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1228
1315
|
analyzeCodebase(resolvedPath);
|
|
1229
1316
|
|
|
1230
1317
|
return seededCount;
|
package/src/index.ts
CHANGED
|
@@ -92,6 +92,23 @@ server.registerTool(
|
|
|
92
92
|
linked_memory_ids: Array.from(linkedIdsByMemory.get(m.id) ?? []),
|
|
93
93
|
}));
|
|
94
94
|
|
|
95
|
+
const { data: fixData, error: fixError } = await supabase
|
|
96
|
+
.from("execution_log")
|
|
97
|
+
.select("*")
|
|
98
|
+
.eq("project", project)
|
|
99
|
+
.eq("resolved", false)
|
|
100
|
+
.order("created_at", { ascending: false })
|
|
101
|
+
.limit(5);
|
|
102
|
+
|
|
103
|
+
if (fixError) {
|
|
104
|
+
throw new Error(`Failed to load execution log: ${fixError.message}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const fixes = (fixData ?? []).map((fix) => ({
|
|
108
|
+
problem: fix.problem,
|
|
109
|
+
solution: fix.solution ?? "pending",
|
|
110
|
+
}));
|
|
111
|
+
|
|
95
112
|
if (budgetedMemories.length > 0) {
|
|
96
113
|
const incrementResults = await Promise.all(
|
|
97
114
|
budgetedMemories.map((memory) =>
|
|
@@ -112,10 +129,16 @@ server.registerTool(
|
|
|
112
129
|
? `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used, ${omittedCount} omitted for budget).`
|
|
113
130
|
: `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used).`;
|
|
114
131
|
|
|
132
|
+
const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
|
|
133
|
+
const techStackTip = hasTechStackMemory
|
|
134
|
+
? ""
|
|
135
|
+
: "\n\nTip: run 'cm init' to seed tech stack memories from your project files.";
|
|
136
|
+
|
|
115
137
|
return {
|
|
116
|
-
content: [{ type: "text", text: budgetText }],
|
|
138
|
+
content: [{ type: "text", text: budgetText + techStackTip }],
|
|
117
139
|
structuredContent: {
|
|
118
140
|
memories: memoriesWithLinks,
|
|
141
|
+
fixes,
|
|
119
142
|
context_budget: {
|
|
120
143
|
budget_tokens: CONTEXT_BUDGET_TOKENS,
|
|
121
144
|
tokens_used: tokensUsed,
|
package/src/scoring.ts
CHANGED
|
@@ -5,11 +5,34 @@ const STOP_WORDS = new Set([
|
|
|
5
5
|
"of", "in", "it", "this", "that", "with", "and", "or", "but",
|
|
6
6
|
]);
|
|
7
7
|
|
|
8
|
+
// Bumping this from 5 to 6 was considered (see the benchmark's duplicate-
|
|
9
|
+
// detection findings) but doesn't actually fix anything: "removed" is 7
|
|
10
|
+
// chars and "added"/"dependency" aren't the problem either. The real issue
|
|
11
|
+
// is entity-level, not length-based — see DEPENDENCY_TEMPLATE_RE below.
|
|
8
12
|
const MIN_ENTITY_LENGTH = 5;
|
|
9
13
|
|
|
14
|
+
// Hook-generated dependency-change templates ("removed dependency: X" /
|
|
15
|
+
// "added dependency: X") reduce to just {"removed"/"added", "dependency"}
|
|
16
|
+
// under generic word-based extraction below, since package names are often
|
|
17
|
+
// short (node:path, zod, ./device.js — all filtered by MIN_ENTITY_LENGTH).
|
|
18
|
+
// That made two DIFFERENT packages removed in different commits look like
|
|
19
|
+
// duplicates of each other. Special-cased here instead: for these
|
|
20
|
+
// templates, the package name itself is the only entity, so containment
|
|
21
|
+
// (and hybridScore) only agrees when the package name actually matches.
|
|
22
|
+
const DEPENDENCY_TEMPLATE_RE = /^(removed|added) dependency:/i;
|
|
23
|
+
const DEPENDENCY_PACKAGE_RE = /^(?:removed|added) dependency:\s*(.+)$/i;
|
|
24
|
+
|
|
25
|
+
function extractDependencyPackageName(content: string): string | null {
|
|
26
|
+
const match = DEPENDENCY_PACKAGE_RE.exec(content.trim());
|
|
27
|
+
return match ? match[1].trim().toLowerCase() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
// Key entities: words longer than 4 characters that aren't stop words.
|
|
11
31
|
// Short/common words carry little identifying signal for a memory.
|
|
12
32
|
export function extractEntities(text: string): Set<string> {
|
|
33
|
+
const packageName = extractDependencyPackageName(text);
|
|
34
|
+
if (packageName !== null) return new Set([packageName]);
|
|
35
|
+
|
|
13
36
|
const entities = new Set<string>();
|
|
14
37
|
for (const word of text.toLowerCase().split(/\W+/)) {
|
|
15
38
|
if (word.length < MIN_ENTITY_LENGTH || STOP_WORDS.has(word)) continue;
|
|
@@ -33,6 +56,55 @@ export function containmentScore(entitiesA: Set<string>, entitiesB: Set<string>)
|
|
|
33
56
|
return shared / smallerSize;
|
|
34
57
|
}
|
|
35
58
|
|
|
59
|
+
function trigramSet(text: string): Set<string> {
|
|
60
|
+
const normalized = text.toLowerCase();
|
|
61
|
+
if (normalized.length < 3) {
|
|
62
|
+
return normalized.length > 0 ? new Set([normalized]) : new Set();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const trigrams = new Set<string>();
|
|
66
|
+
for (let i = 0; i <= normalized.length - 3; i++) {
|
|
67
|
+
trigrams.add(normalized.slice(i, i + 3));
|
|
68
|
+
}
|
|
69
|
+
return trigrams;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Character-trigram Jaccard overlap: catches surface-level lexical
|
|
73
|
+
// similarity (typos, partial substrings, shared phrasing) that word-based
|
|
74
|
+
// containment misses entirely, since containment only ever compares whole
|
|
75
|
+
// extracted entities.
|
|
76
|
+
function ngramOverlap(a: string, b: string): number {
|
|
77
|
+
const trigramsA = trigramSet(a);
|
|
78
|
+
const trigramsB = trigramSet(b);
|
|
79
|
+
const union = new Set([...trigramsA, ...trigramsB]);
|
|
80
|
+
if (union.size === 0) return 0;
|
|
81
|
+
|
|
82
|
+
let shared = 0;
|
|
83
|
+
for (const trigram of trigramsA) {
|
|
84
|
+
if (trigramsB.has(trigram)) shared++;
|
|
85
|
+
}
|
|
86
|
+
return shared / union.size;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Blends entity containment (structural: do the two texts share key
|
|
90
|
+
// extracted terms) with character-trigram overlap (lexical: do the two
|
|
91
|
+
// texts look alike as raw strings) so near-duplicates with slightly
|
|
92
|
+
// different wording still score high even when their extracted entities
|
|
93
|
+
// don't line up neatly, and vice versa.
|
|
94
|
+
//
|
|
95
|
+
// NOTE ON THRESHOLDS: DUPLICATE_THRESHOLD (0.85), CONTRADICTION_MIN (0.4),
|
|
96
|
+
// computeMemoryLinks' default (0.15), and CLUSTER_THRESHOLD (0.2) were all
|
|
97
|
+
// tuned against pure containmentScore. The 0.3 trigram component shifts
|
|
98
|
+
// scores upward for almost any pair sharing common substrings (whitespace,
|
|
99
|
+
// "ing", "the", etc.), so these thresholds may need recalibration —
|
|
100
|
+
// re-validate against src/benchmark.ts rather than assuming they still
|
|
101
|
+
// hold as-is.
|
|
102
|
+
export function hybridScore(a: string, b: string): number {
|
|
103
|
+
const containment = containmentScore(extractEntities(a), extractEntities(b));
|
|
104
|
+
const overlap = ngramOverlap(a, b);
|
|
105
|
+
return containment * 0.7 + overlap * 0.3;
|
|
106
|
+
}
|
|
107
|
+
|
|
36
108
|
// --- memory links ------------------------------------------------------
|
|
37
109
|
|
|
38
110
|
export interface ExistingMemory {
|
|
@@ -52,12 +124,11 @@ export function computeMemoryLinks(
|
|
|
52
124
|
existingMemories: ExistingMemory[],
|
|
53
125
|
threshold = 0.15
|
|
54
126
|
): MemoryLink[] {
|
|
55
|
-
const newEntities = extractEntities(newContent);
|
|
56
127
|
return existingMemories
|
|
57
128
|
.map((existing) => ({
|
|
58
129
|
source_id: newMemoryId,
|
|
59
130
|
target_id: existing.id,
|
|
60
|
-
score:
|
|
131
|
+
score: hybridScore(newContent, existing.content),
|
|
61
132
|
}))
|
|
62
133
|
.filter((link) => link.score > threshold);
|
|
63
134
|
}
|
|
@@ -96,14 +167,17 @@ export function decideSaveAction(
|
|
|
96
167
|
newContent: string,
|
|
97
168
|
existingMemories: ExistingMemory[]
|
|
98
169
|
): SaveAction {
|
|
99
|
-
|
|
100
|
-
|
|
170
|
+
// Dependency-change templates are structural facts, not decisions —
|
|
171
|
+
// "removed"/"added" would otherwise trip containsNegationWord below, but
|
|
172
|
+
// there's nothing for them to supersede/contradict, so they're exempted
|
|
173
|
+
// up front and only ever fall through to the plain duplicate check.
|
|
174
|
+
const hasNegation = DEPENDENCY_TEMPLATE_RE.test(newContent) ? false : containsNegationWord(newContent);
|
|
101
175
|
|
|
102
176
|
let bestDuplicate: { id: string; score: number } | null = null;
|
|
103
177
|
let bestSupersede: { id: string; score: number } | null = null;
|
|
104
178
|
|
|
105
179
|
for (const existing of existingMemories) {
|
|
106
|
-
const score =
|
|
180
|
+
const score = hybridScore(newContent, existing.content);
|
|
107
181
|
|
|
108
182
|
if (score > DUPLICATE_THRESHOLD) {
|
|
109
183
|
if (hasNegation) {
|
|
@@ -186,11 +260,9 @@ export function clusterMemories(memories: any[]): any[][] {
|
|
|
186
260
|
if (rootA !== rootB) parent[rootA] = rootB;
|
|
187
261
|
}
|
|
188
262
|
|
|
189
|
-
const entities = memories.map((memory) => extractEntities(memory.content));
|
|
190
|
-
|
|
191
263
|
for (let i = 0; i < memories.length; i++) {
|
|
192
264
|
for (let j = i + 1; j < memories.length; j++) {
|
|
193
|
-
if (
|
|
265
|
+
if (hybridScore(memories[i].content, memories[j].content) > CLUSTER_THRESHOLD) {
|
|
194
266
|
union(i, j);
|
|
195
267
|
}
|
|
196
268
|
}
|
|
@@ -307,11 +379,12 @@ function splitWords(text: string): string[] {
|
|
|
307
379
|
.filter((word) => word.length > 0 && !STOP_WORDS.has(word));
|
|
308
380
|
}
|
|
309
381
|
|
|
310
|
-
// Blends
|
|
311
|
-
// (surface-level phrasing match) so a memory that shares the task's
|
|
312
|
-
// wording scores well even if its extracted entities don't line up
|
|
382
|
+
// Blends hybrid structural/lexical overlap (see hybridScore) with raw word
|
|
383
|
+
// overlap (surface-level phrasing match) so a memory that shares the task's
|
|
384
|
+
// exact wording scores well even if its extracted entities don't line up
|
|
385
|
+
// neatly.
|
|
313
386
|
export function scoreMemoryRelevance(memoryContent: string, task: string): number {
|
|
314
|
-
const
|
|
387
|
+
const structuralScore = hybridScore(memoryContent, task);
|
|
315
388
|
|
|
316
389
|
const memoryWords = new Set(splitWords(memoryContent));
|
|
317
390
|
const taskWords = splitWords(task);
|
|
@@ -322,6 +395,6 @@ export function scoreMemoryRelevance(memoryContent: string, task: string): numbe
|
|
|
322
395
|
}
|
|
323
396
|
const wordOverlapRatio = taskWords.length === 0 ? 0 : shared / taskWords.length;
|
|
324
397
|
|
|
325
|
-
return
|
|
398
|
+
return structuralScore * 0.7 + wordOverlapRatio * 0.3;
|
|
326
399
|
}
|
|
327
400
|
// TODO: optimize the clustering algorithm
|