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/dist/cli.js
CHANGED
|
@@ -234,6 +234,8 @@ async function cmdStart(project) {
|
|
|
234
234
|
const memories = data ?? [];
|
|
235
235
|
if (memories.length === 0) {
|
|
236
236
|
console.log(`No memories found for "${project}".`);
|
|
237
|
+
console.log("");
|
|
238
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
237
239
|
return;
|
|
238
240
|
}
|
|
239
241
|
const scoredMemories = memories
|
|
@@ -244,7 +246,26 @@ async function cmdStart(project) {
|
|
|
244
246
|
.sort((a, b) => b.decay_score - a.decay_score);
|
|
245
247
|
const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
|
|
246
248
|
const omittedCount = scoredMemories.length - budgetedMemories.length;
|
|
247
|
-
|
|
249
|
+
let tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
|
|
250
|
+
const { data: fixData, error: fixError } = await supabase
|
|
251
|
+
.from("execution_log")
|
|
252
|
+
.select("*")
|
|
253
|
+
.eq("project", project)
|
|
254
|
+
.eq("resolved", false)
|
|
255
|
+
.order("created_at", { ascending: false })
|
|
256
|
+
.limit(5);
|
|
257
|
+
if (fixError)
|
|
258
|
+
fail(`Failed to load execution log: ${fixError.message}`);
|
|
259
|
+
// Fixes share the same 2000-token budget as memories, which already
|
|
260
|
+
// consumed their portion above — fixes only get whatever room is left.
|
|
261
|
+
const budgetedFixes = [];
|
|
262
|
+
for (const fix of fixData ?? []) {
|
|
263
|
+
const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
|
|
264
|
+
if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS)
|
|
265
|
+
break;
|
|
266
|
+
budgetedFixes.push(fix);
|
|
267
|
+
tokensUsed += tokens;
|
|
268
|
+
}
|
|
248
269
|
const memoryIds = budgetedMemories.map((m) => m.id);
|
|
249
270
|
const linkedIdsByMemory = new Map();
|
|
250
271
|
const idList = memoryIds.join(",");
|
|
@@ -278,10 +299,24 @@ async function cmdStart(project) {
|
|
|
278
299
|
}
|
|
279
300
|
console.log("");
|
|
280
301
|
}
|
|
302
|
+
if (budgetedFixes.length > 0) {
|
|
303
|
+
console.log(`Past Fixes (${budgetedFixes.length})`);
|
|
304
|
+
console.log("-".repeat(14));
|
|
305
|
+
for (const fix of budgetedFixes) {
|
|
306
|
+
console.log(` Problem: ${fix.problem}`);
|
|
307
|
+
console.log(` Solution: ${fix.solution ?? "(pending)"}`);
|
|
308
|
+
console.log("");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
281
311
|
console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
|
|
282
312
|
if (omittedCount > 0) {
|
|
283
313
|
console.log(`${omittedCount} memories omitted (budget exceeded)`);
|
|
284
314
|
}
|
|
315
|
+
const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
|
|
316
|
+
if (!hasTechStackMemory) {
|
|
317
|
+
console.log("");
|
|
318
|
+
console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
|
|
319
|
+
}
|
|
285
320
|
const incrementResults = await Promise.all(budgetedMemories.map((memory) => supabase
|
|
286
321
|
.from("memories")
|
|
287
322
|
.update({ access_count: (memory.access_count ?? 0) + 1 })
|
|
@@ -1012,6 +1047,21 @@ function writeProjectFile(project, resolvedPath) {
|
|
|
1012
1047
|
const stackmemPath = nodePath.join(resolvedPath, STACKMEM_FILE);
|
|
1013
1048
|
nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
|
|
1014
1049
|
}
|
|
1050
|
+
// Shared by every seedMemories check below: skip the insert if this exact
|
|
1051
|
+
// content already exists (decideSaveAction's duplicate check) so re-running
|
|
1052
|
+
// `cm init` on an already-seeded project doesn't pile up repeats.
|
|
1053
|
+
async function trySeedMemory(project, existingMemories, type, content) {
|
|
1054
|
+
const decision = decideSaveAction(content, existingMemories);
|
|
1055
|
+
if (decision.action === "duplicate")
|
|
1056
|
+
return false;
|
|
1057
|
+
const { error } = await supabase.from("memories").insert({
|
|
1058
|
+
project,
|
|
1059
|
+
type,
|
|
1060
|
+
content,
|
|
1061
|
+
device_id: getDeviceId(),
|
|
1062
|
+
});
|
|
1063
|
+
return !error;
|
|
1064
|
+
}
|
|
1015
1065
|
// Scans the freshly-initialized project for a few cheap, high-signal facts
|
|
1016
1066
|
// and saves them as memories directly (bypassing cmdSave's duplicate/conflict
|
|
1017
1067
|
// prompt — there's nothing to conflict with yet, and init must stay
|
|
@@ -1036,17 +1086,8 @@ async function seedMemories(project, resolvedPath) {
|
|
|
1036
1086
|
];
|
|
1037
1087
|
if (depNames.length > 0) {
|
|
1038
1088
|
const content = `tech stack: ${depNames.join(", ")}`;
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
const { error } = await supabase.from("memories").insert({
|
|
1042
|
-
project,
|
|
1043
|
-
type: "discovery",
|
|
1044
|
-
content,
|
|
1045
|
-
device_id: getDeviceId(),
|
|
1046
|
-
});
|
|
1047
|
-
if (!error)
|
|
1048
|
-
seededCount++;
|
|
1049
|
-
}
|
|
1089
|
+
if (await trySeedMemory(project, existingMemories, "discovery", content))
|
|
1090
|
+
seededCount++;
|
|
1050
1091
|
}
|
|
1051
1092
|
}
|
|
1052
1093
|
catch {
|
|
@@ -1062,18 +1103,57 @@ async function seedMemories(project, resolvedPath) {
|
|
|
1062
1103
|
.map((line) => line.split("=")[0]);
|
|
1063
1104
|
if (varNames.length > 0) {
|
|
1064
1105
|
const content = `required env vars: ${varNames.join(", ")}`;
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1106
|
+
if (await trySeedMemory(project, existingMemories, "constraint", content))
|
|
1107
|
+
seededCount++;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const tsconfigPath = nodePath.join(resolvedPath, "tsconfig.json");
|
|
1111
|
+
if (nodeFs.existsSync(tsconfigPath)) {
|
|
1112
|
+
try {
|
|
1113
|
+
const tsconfig = JSON.parse(nodeFs.readFileSync(tsconfigPath, "utf-8"));
|
|
1114
|
+
const target = tsconfig.compilerOptions?.target;
|
|
1115
|
+
const moduleKind = tsconfig.compilerOptions?.module;
|
|
1116
|
+
if (target || moduleKind) {
|
|
1117
|
+
const content = `TypeScript config: target=${target ?? "unspecified"}, module=${moduleKind ?? "unspecified"}`;
|
|
1118
|
+
if (await trySeedMemory(project, existingMemories, "constraint", content))
|
|
1074
1119
|
seededCount++;
|
|
1075
1120
|
}
|
|
1076
1121
|
}
|
|
1122
|
+
catch {
|
|
1123
|
+
// Malformed tsconfig.json (or JSONC with comments) — skip.
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
const readmePath = nodePath.join(resolvedPath, "README.md");
|
|
1127
|
+
if (nodeFs.existsSync(readmePath)) {
|
|
1128
|
+
const words = nodeFs
|
|
1129
|
+
.readFileSync(readmePath, "utf-8")
|
|
1130
|
+
.split(/\s+/)
|
|
1131
|
+
.filter(Boolean)
|
|
1132
|
+
.slice(0, 100);
|
|
1133
|
+
if (words.length > 0) {
|
|
1134
|
+
const content = `project description: ${words.join(" ")}`;
|
|
1135
|
+
if (await trySeedMemory(project, existingMemories, "discovery", content))
|
|
1136
|
+
seededCount++;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const FRAMEWORK_FILE_INDICATORS = [
|
|
1140
|
+
{ file: "next.config.ts", type: "constraint", content: "framework: Next.js" },
|
|
1141
|
+
{ file: "next.config.js", type: "constraint", content: "framework: Next.js" },
|
|
1142
|
+
{ file: "vite.config.ts", type: "constraint", content: "framework: Vite" },
|
|
1143
|
+
{ file: "vite.config.js", type: "constraint", content: "framework: Vite" },
|
|
1144
|
+
{ file: "Dockerfile", type: "constraint", content: "containerized: Docker" },
|
|
1145
|
+
];
|
|
1146
|
+
for (const indicator of FRAMEWORK_FILE_INDICATORS) {
|
|
1147
|
+
if (!nodeFs.existsSync(nodePath.join(resolvedPath, indicator.file)))
|
|
1148
|
+
continue;
|
|
1149
|
+
if (await trySeedMemory(project, existingMemories, indicator.type, indicator.content))
|
|
1150
|
+
seededCount++;
|
|
1151
|
+
}
|
|
1152
|
+
const githubWorkflowsPath = nodePath.join(resolvedPath, ".github", "workflows");
|
|
1153
|
+
if (nodeFs.existsSync(githubWorkflowsPath)) {
|
|
1154
|
+
const content = "CI/CD: GitHub Actions";
|
|
1155
|
+
if (await trySeedMemory(project, existingMemories, "discovery", content))
|
|
1156
|
+
seededCount++;
|
|
1077
1157
|
}
|
|
1078
1158
|
analyzeCodebase(resolvedPath);
|
|
1079
1159
|
return seededCount;
|
package/dist/index.js
CHANGED
|
@@ -62,6 +62,20 @@ server.registerTool("session_start", {
|
|
|
62
62
|
...m,
|
|
63
63
|
linked_memory_ids: Array.from(linkedIdsByMemory.get(m.id) ?? []),
|
|
64
64
|
}));
|
|
65
|
+
const { data: fixData, error: fixError } = await supabase
|
|
66
|
+
.from("execution_log")
|
|
67
|
+
.select("*")
|
|
68
|
+
.eq("project", project)
|
|
69
|
+
.eq("resolved", false)
|
|
70
|
+
.order("created_at", { ascending: false })
|
|
71
|
+
.limit(5);
|
|
72
|
+
if (fixError) {
|
|
73
|
+
throw new Error(`Failed to load execution log: ${fixError.message}`);
|
|
74
|
+
}
|
|
75
|
+
const fixes = (fixData ?? []).map((fix) => ({
|
|
76
|
+
problem: fix.problem,
|
|
77
|
+
solution: fix.solution ?? "pending",
|
|
78
|
+
}));
|
|
65
79
|
if (budgetedMemories.length > 0) {
|
|
66
80
|
const incrementResults = await Promise.all(budgetedMemories.map((memory) => supabase
|
|
67
81
|
.from("memories")
|
|
@@ -75,10 +89,15 @@ server.registerTool("session_start", {
|
|
|
75
89
|
const budgetText = omittedCount > 0
|
|
76
90
|
? `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used, ${omittedCount} omitted for budget).`
|
|
77
91
|
: `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used).`;
|
|
92
|
+
const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
|
|
93
|
+
const techStackTip = hasTechStackMemory
|
|
94
|
+
? ""
|
|
95
|
+
: "\n\nTip: run 'cm init' to seed tech stack memories from your project files.";
|
|
78
96
|
return {
|
|
79
|
-
content: [{ type: "text", text: budgetText }],
|
|
97
|
+
content: [{ type: "text", text: budgetText + techStackTip }],
|
|
80
98
|
structuredContent: {
|
|
81
99
|
memories: memoriesWithLinks,
|
|
100
|
+
fixes,
|
|
82
101
|
context_budget: {
|
|
83
102
|
budget_tokens: CONTEXT_BUDGET_TOKENS,
|
|
84
103
|
tokens_used: tokensUsed,
|
package/dist/scoring.js
CHANGED
|
@@ -3,10 +3,31 @@ const STOP_WORDS = new Set([
|
|
|
3
3
|
"we", "use", "for", "all", "a", "the", "is", "has", "have", "are", "to",
|
|
4
4
|
"of", "in", "it", "this", "that", "with", "and", "or", "but",
|
|
5
5
|
]);
|
|
6
|
+
// Bumping this from 5 to 6 was considered (see the benchmark's duplicate-
|
|
7
|
+
// detection findings) but doesn't actually fix anything: "removed" is 7
|
|
8
|
+
// chars and "added"/"dependency" aren't the problem either. The real issue
|
|
9
|
+
// is entity-level, not length-based — see DEPENDENCY_TEMPLATE_RE below.
|
|
6
10
|
const MIN_ENTITY_LENGTH = 5;
|
|
11
|
+
// Hook-generated dependency-change templates ("removed dependency: X" /
|
|
12
|
+
// "added dependency: X") reduce to just {"removed"/"added", "dependency"}
|
|
13
|
+
// under generic word-based extraction below, since package names are often
|
|
14
|
+
// short (node:path, zod, ./device.js — all filtered by MIN_ENTITY_LENGTH).
|
|
15
|
+
// That made two DIFFERENT packages removed in different commits look like
|
|
16
|
+
// duplicates of each other. Special-cased here instead: for these
|
|
17
|
+
// templates, the package name itself is the only entity, so containment
|
|
18
|
+
// (and hybridScore) only agrees when the package name actually matches.
|
|
19
|
+
const DEPENDENCY_TEMPLATE_RE = /^(removed|added) dependency:/i;
|
|
20
|
+
const DEPENDENCY_PACKAGE_RE = /^(?:removed|added) dependency:\s*(.+)$/i;
|
|
21
|
+
function extractDependencyPackageName(content) {
|
|
22
|
+
const match = DEPENDENCY_PACKAGE_RE.exec(content.trim());
|
|
23
|
+
return match ? match[1].trim().toLowerCase() : null;
|
|
24
|
+
}
|
|
7
25
|
// Key entities: words longer than 4 characters that aren't stop words.
|
|
8
26
|
// Short/common words carry little identifying signal for a memory.
|
|
9
27
|
export function extractEntities(text) {
|
|
28
|
+
const packageName = extractDependencyPackageName(text);
|
|
29
|
+
if (packageName !== null)
|
|
30
|
+
return new Set([packageName]);
|
|
10
31
|
const entities = new Set();
|
|
11
32
|
for (const word of text.toLowerCase().split(/\W+/)) {
|
|
12
33
|
if (word.length < MIN_ENTITY_LENGTH || STOP_WORDS.has(word))
|
|
@@ -29,13 +50,58 @@ export function containmentScore(entitiesA, entitiesB) {
|
|
|
29
50
|
}
|
|
30
51
|
return shared / smallerSize;
|
|
31
52
|
}
|
|
53
|
+
function trigramSet(text) {
|
|
54
|
+
const normalized = text.toLowerCase();
|
|
55
|
+
if (normalized.length < 3) {
|
|
56
|
+
return normalized.length > 0 ? new Set([normalized]) : new Set();
|
|
57
|
+
}
|
|
58
|
+
const trigrams = new Set();
|
|
59
|
+
for (let i = 0; i <= normalized.length - 3; i++) {
|
|
60
|
+
trigrams.add(normalized.slice(i, i + 3));
|
|
61
|
+
}
|
|
62
|
+
return trigrams;
|
|
63
|
+
}
|
|
64
|
+
// Character-trigram Jaccard overlap: catches surface-level lexical
|
|
65
|
+
// similarity (typos, partial substrings, shared phrasing) that word-based
|
|
66
|
+
// containment misses entirely, since containment only ever compares whole
|
|
67
|
+
// extracted entities.
|
|
68
|
+
function ngramOverlap(a, b) {
|
|
69
|
+
const trigramsA = trigramSet(a);
|
|
70
|
+
const trigramsB = trigramSet(b);
|
|
71
|
+
const union = new Set([...trigramsA, ...trigramsB]);
|
|
72
|
+
if (union.size === 0)
|
|
73
|
+
return 0;
|
|
74
|
+
let shared = 0;
|
|
75
|
+
for (const trigram of trigramsA) {
|
|
76
|
+
if (trigramsB.has(trigram))
|
|
77
|
+
shared++;
|
|
78
|
+
}
|
|
79
|
+
return shared / union.size;
|
|
80
|
+
}
|
|
81
|
+
// Blends entity containment (structural: do the two texts share key
|
|
82
|
+
// extracted terms) with character-trigram overlap (lexical: do the two
|
|
83
|
+
// texts look alike as raw strings) so near-duplicates with slightly
|
|
84
|
+
// different wording still score high even when their extracted entities
|
|
85
|
+
// don't line up neatly, and vice versa.
|
|
86
|
+
//
|
|
87
|
+
// NOTE ON THRESHOLDS: DUPLICATE_THRESHOLD (0.85), CONTRADICTION_MIN (0.4),
|
|
88
|
+
// computeMemoryLinks' default (0.15), and CLUSTER_THRESHOLD (0.2) were all
|
|
89
|
+
// tuned against pure containmentScore. The 0.3 trigram component shifts
|
|
90
|
+
// scores upward for almost any pair sharing common substrings (whitespace,
|
|
91
|
+
// "ing", "the", etc.), so these thresholds may need recalibration —
|
|
92
|
+
// re-validate against src/benchmark.ts rather than assuming they still
|
|
93
|
+
// hold as-is.
|
|
94
|
+
export function hybridScore(a, b) {
|
|
95
|
+
const containment = containmentScore(extractEntities(a), extractEntities(b));
|
|
96
|
+
const overlap = ngramOverlap(a, b);
|
|
97
|
+
return containment * 0.7 + overlap * 0.3;
|
|
98
|
+
}
|
|
32
99
|
export function computeMemoryLinks(newMemoryId, newContent, existingMemories, threshold = 0.15) {
|
|
33
|
-
const newEntities = extractEntities(newContent);
|
|
34
100
|
return existingMemories
|
|
35
101
|
.map((existing) => ({
|
|
36
102
|
source_id: newMemoryId,
|
|
37
103
|
target_id: existing.id,
|
|
38
|
-
score:
|
|
104
|
+
score: hybridScore(newContent, existing.content),
|
|
39
105
|
}))
|
|
40
106
|
.filter((link) => link.score > threshold);
|
|
41
107
|
}
|
|
@@ -61,12 +127,15 @@ function containsNegationWord(text) {
|
|
|
61
127
|
// Scanning finds the best-scoring match in each band rather than stopping
|
|
62
128
|
// at the first hit, so the result doesn't depend on row order.
|
|
63
129
|
export function decideSaveAction(newContent, existingMemories) {
|
|
64
|
-
|
|
65
|
-
|
|
130
|
+
// Dependency-change templates are structural facts, not decisions —
|
|
131
|
+
// "removed"/"added" would otherwise trip containsNegationWord below, but
|
|
132
|
+
// there's nothing for them to supersede/contradict, so they're exempted
|
|
133
|
+
// up front and only ever fall through to the plain duplicate check.
|
|
134
|
+
const hasNegation = DEPENDENCY_TEMPLATE_RE.test(newContent) ? false : containsNegationWord(newContent);
|
|
66
135
|
let bestDuplicate = null;
|
|
67
136
|
let bestSupersede = null;
|
|
68
137
|
for (const existing of existingMemories) {
|
|
69
|
-
const score =
|
|
138
|
+
const score = hybridScore(newContent, existing.content);
|
|
70
139
|
if (score > DUPLICATE_THRESHOLD) {
|
|
71
140
|
if (hasNegation) {
|
|
72
141
|
if (!bestSupersede || score > bestSupersede.score) {
|
|
@@ -139,10 +208,9 @@ export function clusterMemories(memories) {
|
|
|
139
208
|
if (rootA !== rootB)
|
|
140
209
|
parent[rootA] = rootB;
|
|
141
210
|
}
|
|
142
|
-
const entities = memories.map((memory) => extractEntities(memory.content));
|
|
143
211
|
for (let i = 0; i < memories.length; i++) {
|
|
144
212
|
for (let j = i + 1; j < memories.length; j++) {
|
|
145
|
-
if (
|
|
213
|
+
if (hybridScore(memories[i].content, memories[j].content) > CLUSTER_THRESHOLD) {
|
|
146
214
|
union(i, j);
|
|
147
215
|
}
|
|
148
216
|
}
|
|
@@ -243,11 +311,12 @@ function splitWords(text) {
|
|
|
243
311
|
.split(/\s+/)
|
|
244
312
|
.filter((word) => word.length > 0 && !STOP_WORDS.has(word));
|
|
245
313
|
}
|
|
246
|
-
// Blends
|
|
247
|
-
// (surface-level phrasing match) so a memory that shares the task's
|
|
248
|
-
// wording scores well even if its extracted entities don't line up
|
|
314
|
+
// Blends hybrid structural/lexical overlap (see hybridScore) with raw word
|
|
315
|
+
// overlap (surface-level phrasing match) so a memory that shares the task's
|
|
316
|
+
// exact wording scores well even if its extracted entities don't line up
|
|
317
|
+
// neatly.
|
|
249
318
|
export function scoreMemoryRelevance(memoryContent, task) {
|
|
250
|
-
const
|
|
319
|
+
const structuralScore = hybridScore(memoryContent, task);
|
|
251
320
|
const memoryWords = new Set(splitWords(memoryContent));
|
|
252
321
|
const taskWords = splitWords(task);
|
|
253
322
|
let shared = 0;
|
|
@@ -256,6 +325,6 @@ export function scoreMemoryRelevance(memoryContent, task) {
|
|
|
256
325
|
shared++;
|
|
257
326
|
}
|
|
258
327
|
const wordOverlapRatio = taskWords.length === 0 ? 0 : shared / taskWords.length;
|
|
259
|
-
return
|
|
328
|
+
return structuralScore * 0.7 + wordOverlapRatio * 0.3;
|
|
260
329
|
}
|
|
261
330
|
// TODO: optimize the clustering algorithm
|
package/dist/tools.js
CHANGED