stackmem 1.0.15 → 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 CHANGED
@@ -8,7 +8,7 @@ import { execSync } from "node:child_process";
8
8
  import nodeFs from "node:fs";
9
9
  import nodePath from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
- import { applyContextBudget, computeDecayScore, containmentScore, decideSaveAction, estimateTokens, extractEntities, scoreMemoryRelevance, } from "./scoring.js";
11
+ import { applyContextBudget, computeDecayScore, decideSaveAction, estimateTokens, extractEntities, hybridScore, scoreMemoryRelevance, } from "./scoring.js";
12
12
  import { supabase } from "./storage.js";
13
13
  const CONTEXT_BUDGET_TOKENS = 2000;
14
14
  const REPO_ROOT = nodePath.join(nodePath.dirname(fileURLToPath(import.meta.url)), "..");
@@ -256,7 +256,16 @@ function benchmarkDuplicateDetection(allMemories, unrelatedPairs) {
256
256
  // a harder, unverified case this benchmark doesn't cover.
257
257
  const CONTRADICTION_PREFIXES = ["switched from ", "no longer using "];
258
258
  function benchmarkContradictionDetection(allMemories, unrelatedPairs) {
259
- const baseSample = seededShuffle(allMemories, BENCHMARK_SEED + 2).slice(0, SAMPLE_SIZE / 2);
259
+ // Hook-generated templates (dependency changes, config/schema/TODO
260
+ // discoveries) are structural event records, not decisions — decideSaveAction
261
+ // deliberately exempts dependency templates from ever routing to
262
+ // "superseded" (see DEPENDENCY_TEMPLATE_RE in scoring.ts), and prepending
263
+ // "switched from X" to any of these templates isn't a phrase anyone would
264
+ // actually write. Sampling them here would just measure whether that
265
+ // exemption holds, not whether contradiction detection works — so they're
266
+ // excluded, keeping this test on genuine decision/discovery-style content.
267
+ const contradictableMemories = allMemories.filter((m) => !HOOK_TEMPLATE_PREFIXES.some((p) => m.content.startsWith(p.prefix)));
268
+ const baseSample = seededShuffle(contradictableMemories, BENCHMARK_SEED + 2).slice(0, SAMPLE_SIZE / 2);
260
269
  let tp = 0;
261
270
  let fn = 0;
262
271
  for (const memory of baseSample) {
@@ -294,6 +303,14 @@ function benchmarkContradictionDetection(allMemories, unrelatedPairs) {
294
303
  // exhaustively rather than randomly sampled — at this data size (a few
295
304
  // thousand possible pairs at most) exhaustive is cheap and gives a more
296
305
  // stable number than a small random draw would.
306
+ //
307
+ // CAVEAT: avgLinkScore below reads memory_links.score as stored — i.e.
308
+ // whatever computeMemoryLinks computed at save time. Rows saved before
309
+ // computeMemoryLinks switched from containmentScore to hybridScore still
310
+ // hold old-formula scores; only the baseline here is guaranteed to reflect
311
+ // the current formula (it's computed live). A mixed-vintage avgLinkScore
312
+ // vs. a current-formula baseline is still the most honest comparison
313
+ // available without backfilling every historical link.
297
314
  function benchmarkSemanticLinking(allMemories, allLinks) {
298
315
  const avgLinkScore = allLinks.length === 0 ? 0 : allLinks.reduce((sum, l) => sum + l.score, 0) / allLinks.length;
299
316
  const linkedPairKeys = new Set(allLinks.map((l) => pairKey(l.source_id, l.target_id)));
@@ -306,13 +323,12 @@ function benchmarkSemanticLinking(allMemories, allLinks) {
306
323
  let baselineSum = 0;
307
324
  let baselineCount = 0;
308
325
  for (const rows of byProject.values()) {
309
- const entitiesById = new Map(rows.map((m) => [m.id, extractEntities(m.content)]));
310
326
  for (let i = 0; i < rows.length; i++) {
311
327
  for (let j = i + 1; j < rows.length; j++) {
312
328
  const key = pairKey(rows[i].id, rows[j].id);
313
329
  if (linkedPairKeys.has(key))
314
330
  continue;
315
- baselineSum += containmentScore(entitiesById.get(rows[i].id), entitiesById.get(rows[j].id));
331
+ baselineSum += hybridScore(rows[i].content, rows[j].content);
316
332
  baselineCount++;
317
333
  }
318
334
  }
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
@@ -310,6 +312,11 @@ async function cmdStart(project) {
310
312
  if (omittedCount > 0) {
311
313
  console.log(`${omittedCount} memories omitted (budget exceeded)`);
312
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
+ }
313
320
  const incrementResults = await Promise.all(budgetedMemories.map((memory) => supabase
314
321
  .from("memories")
315
322
  .update({ access_count: (memory.access_count ?? 0) + 1 })
@@ -1040,6 +1047,21 @@ function writeProjectFile(project, resolvedPath) {
1040
1047
  const stackmemPath = nodePath.join(resolvedPath, STACKMEM_FILE);
1041
1048
  nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
1042
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
+ }
1043
1065
  // Scans the freshly-initialized project for a few cheap, high-signal facts
1044
1066
  // and saves them as memories directly (bypassing cmdSave's duplicate/conflict
1045
1067
  // prompt — there's nothing to conflict with yet, and init must stay
@@ -1064,17 +1086,8 @@ async function seedMemories(project, resolvedPath) {
1064
1086
  ];
1065
1087
  if (depNames.length > 0) {
1066
1088
  const content = `tech stack: ${depNames.join(", ")}`;
1067
- const decision = decideSaveAction(content, existingMemories);
1068
- if (decision.action !== "duplicate") {
1069
- const { error } = await supabase.from("memories").insert({
1070
- project,
1071
- type: "discovery",
1072
- content,
1073
- device_id: getDeviceId(),
1074
- });
1075
- if (!error)
1076
- seededCount++;
1077
- }
1089
+ if (await trySeedMemory(project, existingMemories, "discovery", content))
1090
+ seededCount++;
1078
1091
  }
1079
1092
  }
1080
1093
  catch {
@@ -1090,18 +1103,57 @@ async function seedMemories(project, resolvedPath) {
1090
1103
  .map((line) => line.split("=")[0]);
1091
1104
  if (varNames.length > 0) {
1092
1105
  const content = `required env vars: ${varNames.join(", ")}`;
1093
- const decision = decideSaveAction(content, existingMemories);
1094
- if (decision.action !== "duplicate") {
1095
- const { error } = await supabase.from("memories").insert({
1096
- project,
1097
- type: "constraint",
1098
- content,
1099
- device_id: getDeviceId(),
1100
- });
1101
- 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))
1102
1119
  seededCount++;
1103
1120
  }
1104
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++;
1105
1157
  }
1106
1158
  analyzeCodebase(resolvedPath);
1107
1159
  return seededCount;
package/dist/index.js CHANGED
@@ -89,8 +89,12 @@ server.registerTool("session_start", {
89
89
  const budgetText = omittedCount > 0
90
90
  ? `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used, ${omittedCount} omitted for budget).`
91
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.";
92
96
  return {
93
- content: [{ type: "text", text: budgetText }],
97
+ content: [{ type: "text", text: budgetText + techStackTip }],
94
98
  structuredContent: {
95
99
  memories: memoriesWithLinks,
96
100
  fixes,
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
  }
@@ -52,11 +118,6 @@ function containsNegationWord(text) {
52
118
  const lower = text.toLowerCase();
53
119
  return NEGATION_WORDS.some((word) => lower.includes(word));
54
120
  }
55
- // Hook-generated dependency-change templates (e.g. "removed dependency: X")
56
- // are structural facts, not decisions — "removed" trips the negation-word
57
- // check below, but there's nothing for them to supersede/contradict, so
58
- // they're exempted up front rather than ever entering the negation branch.
59
- const DEPENDENCY_TEMPLATE_RE = /^(removed|added) dependency:/i;
60
121
  // Decides whether a new memory is a near-duplicate of an existing one
61
122
  // (skip it), supersedes an existing one (resolve the old, insert the new),
62
123
  // or is genuinely new. A negation word (e.g. "switched", "no longer")
@@ -66,12 +127,15 @@ const DEPENDENCY_TEMPLATE_RE = /^(removed|added) dependency:/i;
66
127
  // Scanning finds the best-scoring match in each band rather than stopping
67
128
  // at the first hit, so the result doesn't depend on row order.
68
129
  export function decideSaveAction(newContent, existingMemories) {
69
- const newEntities = extractEntities(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.
70
134
  const hasNegation = DEPENDENCY_TEMPLATE_RE.test(newContent) ? false : containsNegationWord(newContent);
71
135
  let bestDuplicate = null;
72
136
  let bestSupersede = null;
73
137
  for (const existing of existingMemories) {
74
- const score = containmentScore(newEntities, extractEntities(existing.content));
138
+ const score = hybridScore(newContent, existing.content);
75
139
  if (score > DUPLICATE_THRESHOLD) {
76
140
  if (hasNegation) {
77
141
  if (!bestSupersede || score > bestSupersede.score) {
@@ -144,10 +208,9 @@ export function clusterMemories(memories) {
144
208
  if (rootA !== rootB)
145
209
  parent[rootA] = rootB;
146
210
  }
147
- const entities = memories.map((memory) => extractEntities(memory.content));
148
211
  for (let i = 0; i < memories.length; i++) {
149
212
  for (let j = i + 1; j < memories.length; j++) {
150
- if (containmentScore(entities[i], entities[j]) > CLUSTER_THRESHOLD) {
213
+ if (hybridScore(memories[i].content, memories[j].content) > CLUSTER_THRESHOLD) {
151
214
  union(i, j);
152
215
  }
153
216
  }
@@ -248,11 +311,12 @@ function splitWords(text) {
248
311
  .split(/\s+/)
249
312
  .filter((word) => word.length > 0 && !STOP_WORDS.has(word));
250
313
  }
251
- // Blends entity containment (structural overlap) with raw word overlap
252
- // (surface-level phrasing match) so a memory that shares the task's exact
253
- // 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.
254
318
  export function scoreMemoryRelevance(memoryContent, task) {
255
- const containment = containmentScore(extractEntities(memoryContent), extractEntities(task));
319
+ const structuralScore = hybridScore(memoryContent, task);
256
320
  const memoryWords = new Set(splitWords(memoryContent));
257
321
  const taskWords = splitWords(task);
258
322
  let shared = 0;
@@ -261,6 +325,6 @@ export function scoreMemoryRelevance(memoryContent, task) {
261
325
  shared++;
262
326
  }
263
327
  const wordOverlapRatio = taskWords.length === 0 ? 0 : shared / taskWords.length;
264
- return containment * 0.7 + wordOverlapRatio * 0.3;
328
+ return structuralScore * 0.7 + wordOverlapRatio * 0.3;
265
329
  }
266
330
  // TODO: optimize the clustering algorithm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackmem",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/src/benchmark.ts CHANGED
@@ -11,10 +11,10 @@ import { fileURLToPath } from "node:url";
11
11
  import {
12
12
  applyContextBudget,
13
13
  computeDecayScore,
14
- containmentScore,
15
14
  decideSaveAction,
16
15
  estimateTokens,
17
16
  extractEntities,
17
+ hybridScore,
18
18
  scoreMemoryRelevance,
19
19
  type ExistingMemory,
20
20
  } from "./scoring.js";
@@ -407,7 +407,18 @@ function benchmarkContradictionDetection(
407
407
  allMemories: MemoryRow[],
408
408
  unrelatedPairs: [MemoryRow, MemoryRow][]
409
409
  ): ContradictionDetectionResult {
410
- const baseSample = seededShuffle(allMemories, BENCHMARK_SEED + 2).slice(0, SAMPLE_SIZE / 2);
410
+ // Hook-generated templates (dependency changes, config/schema/TODO
411
+ // discoveries) are structural event records, not decisions — decideSaveAction
412
+ // deliberately exempts dependency templates from ever routing to
413
+ // "superseded" (see DEPENDENCY_TEMPLATE_RE in scoring.ts), and prepending
414
+ // "switched from X" to any of these templates isn't a phrase anyone would
415
+ // actually write. Sampling them here would just measure whether that
416
+ // exemption holds, not whether contradiction detection works — so they're
417
+ // excluded, keeping this test on genuine decision/discovery-style content.
418
+ const contradictableMemories = allMemories.filter(
419
+ (m) => !HOOK_TEMPLATE_PREFIXES.some((p) => m.content.startsWith(p.prefix))
420
+ );
421
+ const baseSample = seededShuffle(contradictableMemories, BENCHMARK_SEED + 2).slice(0, SAMPLE_SIZE / 2);
411
422
 
412
423
  let tp = 0;
413
424
  let fn = 0;
@@ -457,6 +468,14 @@ interface SemanticLinkingResult {
457
468
  // exhaustively rather than randomly sampled — at this data size (a few
458
469
  // thousand possible pairs at most) exhaustive is cheap and gives a more
459
470
  // stable number than a small random draw would.
471
+ //
472
+ // CAVEAT: avgLinkScore below reads memory_links.score as stored — i.e.
473
+ // whatever computeMemoryLinks computed at save time. Rows saved before
474
+ // computeMemoryLinks switched from containmentScore to hybridScore still
475
+ // hold old-formula scores; only the baseline here is guaranteed to reflect
476
+ // the current formula (it's computed live). A mixed-vintage avgLinkScore
477
+ // vs. a current-formula baseline is still the most honest comparison
478
+ // available without backfilling every historical link.
460
479
  function benchmarkSemanticLinking(allMemories: MemoryRow[], allLinks: MemoryLinkRow[]): SemanticLinkingResult {
461
480
  const avgLinkScore =
462
481
  allLinks.length === 0 ? 0 : allLinks.reduce((sum, l) => sum + l.score, 0) / allLinks.length;
@@ -472,12 +491,11 @@ function benchmarkSemanticLinking(allMemories: MemoryRow[], allLinks: MemoryLink
472
491
  let baselineSum = 0;
473
492
  let baselineCount = 0;
474
493
  for (const rows of byProject.values()) {
475
- const entitiesById = new Map(rows.map((m) => [m.id, extractEntities(m.content)]));
476
494
  for (let i = 0; i < rows.length; i++) {
477
495
  for (let j = i + 1; j < rows.length; j++) {
478
496
  const key = pairKey(rows[i].id, rows[j].id);
479
497
  if (linkedPairKeys.has(key)) continue;
480
- baselineSum += containmentScore(entitiesById.get(rows[i].id)!, entitiesById.get(rows[j].id)!);
498
+ baselineSum += hybridScore(rows[i].content, rows[j].content);
481
499
  baselineCount++;
482
500
  }
483
501
  }
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
 
@@ -358,6 +360,12 @@ async function cmdStart(project: string) {
358
360
  console.log(`${omittedCount} memories omitted (budget exceeded)`);
359
361
  }
360
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
+
361
369
  const incrementResults = await Promise.all(
362
370
  budgetedMemories.map((memory) =>
363
371
  supabase
@@ -1189,6 +1197,27 @@ function writeProjectFile(project: string, resolvedPath: string): void {
1189
1197
  nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
1190
1198
  }
1191
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
+
1192
1221
  // Scans the freshly-initialized project for a few cheap, high-signal facts
1193
1222
  // and saves them as memories directly (bypassing cmdSave's duplicate/conflict
1194
1223
  // prompt — there's nothing to conflict with yet, and init must stay
@@ -1216,16 +1245,7 @@ async function seedMemories(project: string, resolvedPath: string): Promise<numb
1216
1245
  ];
1217
1246
  if (depNames.length > 0) {
1218
1247
  const content = `tech stack: ${depNames.join(", ")}`;
1219
- const decision = decideSaveAction(content, existingMemories);
1220
- if (decision.action !== "duplicate") {
1221
- const { error } = await supabase.from("memories").insert({
1222
- project,
1223
- type: "discovery",
1224
- content,
1225
- device_id: getDeviceId(),
1226
- });
1227
- if (!error) seededCount++;
1228
- }
1248
+ if (await trySeedMemory(project, existingMemories, "discovery", content)) seededCount++;
1229
1249
  }
1230
1250
  } catch {
1231
1251
  // Malformed package.json — skip the tech-stack memory.
@@ -1242,19 +1262,56 @@ async function seedMemories(project: string, resolvedPath: string): Promise<numb
1242
1262
 
1243
1263
  if (varNames.length > 0) {
1244
1264
  const content = `required env vars: ${varNames.join(", ")}`;
1245
- const decision = decideSaveAction(content, existingMemories);
1246
- if (decision.action !== "duplicate") {
1247
- const { error } = await supabase.from("memories").insert({
1248
- project,
1249
- type: "constraint",
1250
- content,
1251
- device_id: getDeviceId(),
1252
- });
1253
- 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++;
1254
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++;
1255
1294
  }
1256
1295
  }
1257
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
+
1258
1315
  analyzeCodebase(resolvedPath);
1259
1316
 
1260
1317
  return seededCount;
package/src/index.ts CHANGED
@@ -129,8 +129,13 @@ server.registerTool(
129
129
  ? `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used, ${omittedCount} omitted for budget).`
130
130
  : `Loaded ${memories.length} memory row(s) for "${project}" (${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used).`;
131
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
+
132
137
  return {
133
- content: [{ type: "text", text: budgetText }],
138
+ content: [{ type: "text", text: budgetText + techStackTip }],
134
139
  structuredContent: {
135
140
  memories: memoriesWithLinks,
136
141
  fixes,
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
  }
@@ -79,12 +150,6 @@ function containsNegationWord(text: string): boolean {
79
150
  return NEGATION_WORDS.some((word) => lower.includes(word));
80
151
  }
81
152
 
82
- // Hook-generated dependency-change templates (e.g. "removed dependency: X")
83
- // are structural facts, not decisions — "removed" trips the negation-word
84
- // check below, but there's nothing for them to supersede/contradict, so
85
- // they're exempted up front rather than ever entering the negation branch.
86
- const DEPENDENCY_TEMPLATE_RE = /^(removed|added) dependency:/i;
87
-
88
153
  export type SaveAction =
89
154
  | { action: "duplicate"; matchedId: string }
90
155
  | { action: "superseded"; oldId: string }
@@ -102,14 +167,17 @@ export function decideSaveAction(
102
167
  newContent: string,
103
168
  existingMemories: ExistingMemory[]
104
169
  ): SaveAction {
105
- const newEntities = extractEntities(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.
106
174
  const hasNegation = DEPENDENCY_TEMPLATE_RE.test(newContent) ? false : containsNegationWord(newContent);
107
175
 
108
176
  let bestDuplicate: { id: string; score: number } | null = null;
109
177
  let bestSupersede: { id: string; score: number } | null = null;
110
178
 
111
179
  for (const existing of existingMemories) {
112
- const score = containmentScore(newEntities, extractEntities(existing.content));
180
+ const score = hybridScore(newContent, existing.content);
113
181
 
114
182
  if (score > DUPLICATE_THRESHOLD) {
115
183
  if (hasNegation) {
@@ -192,11 +260,9 @@ export function clusterMemories(memories: any[]): any[][] {
192
260
  if (rootA !== rootB) parent[rootA] = rootB;
193
261
  }
194
262
 
195
- const entities = memories.map((memory) => extractEntities(memory.content));
196
-
197
263
  for (let i = 0; i < memories.length; i++) {
198
264
  for (let j = i + 1; j < memories.length; j++) {
199
- if (containmentScore(entities[i], entities[j]) > CLUSTER_THRESHOLD) {
265
+ if (hybridScore(memories[i].content, memories[j].content) > CLUSTER_THRESHOLD) {
200
266
  union(i, j);
201
267
  }
202
268
  }
@@ -313,11 +379,12 @@ function splitWords(text: string): string[] {
313
379
  .filter((word) => word.length > 0 && !STOP_WORDS.has(word));
314
380
  }
315
381
 
316
- // Blends entity containment (structural overlap) with raw word overlap
317
- // (surface-level phrasing match) so a memory that shares the task's exact
318
- // 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.
319
386
  export function scoreMemoryRelevance(memoryContent: string, task: string): number {
320
- const containment = containmentScore(extractEntities(memoryContent), extractEntities(task));
387
+ const structuralScore = hybridScore(memoryContent, task);
321
388
 
322
389
  const memoryWords = new Set(splitWords(memoryContent));
323
390
  const taskWords = splitWords(task);
@@ -328,6 +395,6 @@ export function scoreMemoryRelevance(memoryContent: string, task: string): numbe
328
395
  }
329
396
  const wordOverlapRatio = taskWords.length === 0 ? 0 : shared / taskWords.length;
330
397
 
331
- return containment * 0.7 + wordOverlapRatio * 0.3;
398
+ return structuralScore * 0.7 + wordOverlapRatio * 0.3;
332
399
  }
333
400
  // TODO: optimize the clustering algorithm