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/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
- const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
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
- const decision = decideSaveAction(content, existingMemories);
1040
- if (decision.action !== "duplicate") {
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
- const decision = decideSaveAction(content, existingMemories);
1066
- if (decision.action !== "duplicate") {
1067
- const { error } = await supabase.from("memories").insert({
1068
- project,
1069
- type: "constraint",
1070
- content,
1071
- device_id: getDeviceId(),
1072
- });
1073
- if (!error)
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: containmentScore(newEntities, extractEntities(existing.content)),
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
- const newEntities = extractEntities(newContent);
65
- const hasNegation = containsNegationWord(newContent);
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 = containmentScore(newEntities, extractEntities(existing.content));
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 (containmentScore(entities[i], entities[j]) > CLUSTER_THRESHOLD) {
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 entity containment (structural overlap) with raw word overlap
247
- // (surface-level phrasing match) so a memory that shares the task's exact
248
- // wording scores well even if its extracted entities don't line up neatly.
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 containment = containmentScore(extractEntities(memoryContent), extractEntities(task));
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 containment * 0.7 + wordOverlapRatio * 0.3;
328
+ return structuralScore * 0.7 + wordOverlapRatio * 0.3;
260
329
  }
261
330
  // TODO: optimize the clustering algorithm
package/dist/tools.js CHANGED
@@ -1 +1,2 @@
1
+ const test = 1;
1
2
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackmem",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",