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/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
- const tokensUsed = budgetedMemories.reduce(
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
- const decision = decideSaveAction(content, existingMemories);
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
- const decision = decideSaveAction(content, existingMemories);
1216
- if (decision.action !== "duplicate") {
1217
- const { error } = await supabase.from("memories").insert({
1218
- project,
1219
- type: "constraint",
1220
- content,
1221
- device_id: getDeviceId(),
1222
- });
1223
- if (!error) seededCount++;
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: containmentScore(newEntities, extractEntities(existing.content)),
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
- const newEntities = extractEntities(newContent);
100
- const hasNegation = containsNegationWord(newContent);
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 = containmentScore(newEntities, extractEntities(existing.content));
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 (containmentScore(entities[i], entities[j]) > CLUSTER_THRESHOLD) {
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 entity containment (structural overlap) with raw word overlap
311
- // (surface-level phrasing match) so a memory that shares the task's exact
312
- // wording scores well even if its extracted entities don't line up neatly.
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 containment = containmentScore(extractEntities(memoryContent), extractEntities(task));
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 containment * 0.7 + wordOverlapRatio * 0.3;
398
+ return structuralScore * 0.7 + wordOverlapRatio * 0.3;
326
399
  }
327
400
  // TODO: optimize the clustering algorithm