stackmem 1.0.4 → 1.0.6

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/ast-index.js CHANGED
@@ -190,11 +190,22 @@ export function analyzeCodebase(projectPath) {
190
190
  const insertClass = db.prepare("INSERT INTO classes (file_id, name) VALUES (?, ?)");
191
191
  const insertImport = db.prepare("INSERT INTO imports (file_id, source) VALUES (?, ?)");
192
192
  const files = walkProjectFiles(resolvedProjectPath);
193
+ let filesIndexed = 0;
193
194
  let totalFunctions = 0;
194
195
  let totalClasses = 0;
195
196
  const runAll = db.transaction((filePaths) => {
196
197
  for (const filePath of filePaths) {
197
- const extraction = extractFile(filePath, resolvedProjectPath);
198
+ let extraction;
199
+ try {
200
+ extraction = extractFile(filePath, resolvedProjectPath);
201
+ }
202
+ catch {
203
+ // Tree-sitter (or the file read) choked on this file — better-sqlite3
204
+ // transactions roll back entirely on a throw, so skipping here
205
+ // (rather than letting it propagate) is what keeps one bad file from
206
+ // wiping out every other file already processed in this run.
207
+ continue;
208
+ }
198
209
  const row = insertFile.get(extraction.relativePath, path.basename(extraction.relativePath), new Date().toISOString());
199
210
  const fileId = row.id;
200
211
  deleteChildren.run(fileId);
@@ -211,12 +222,13 @@ export function analyzeCodebase(projectPath) {
211
222
  for (const source of extraction.imports) {
212
223
  insertImport.run(fileId, source);
213
224
  }
225
+ filesIndexed++;
214
226
  }
215
227
  });
216
228
  runAll(files);
217
229
  db.close();
218
230
  return {
219
- files_indexed: files.length,
231
+ files_indexed: filesIndexed,
220
232
  functions: totalFunctions,
221
233
  classes: totalClasses,
222
234
  };
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { analyzeCodebase } from "./ast-index.js";
8
8
  import { getDeviceId } from "./device.js";
9
- import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, scoreMemoryRelevance, } from "./scoring.js";
9
+ import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, scoreMemoryRelevance, } from "./scoring.js";
10
10
  import { supabase } from "./storage.js";
11
11
  const CONTEXT_BUDGET_TOKENS = 2000;
12
12
  const COMPRESSION_THRESHOLD = 20;
@@ -29,16 +29,19 @@ function usage() {
29
29
  cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
30
30
  cm fix [project] <problem> <solution>
31
31
  cm analyze [project] <path>
32
- cm search [project] <query>
32
+ cm search [--project <name>] <query>
33
33
  cm resolve [project] <memory-id>
34
34
  cm delete [project] <memory-id>
35
35
  cm compress [project]
36
36
  cm init <project> <path>
37
- cm context [project] <task>
37
+ cm context [--project <name>] <task>
38
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
38
39
  cm help
39
40
 
40
41
  [project] is optional if a .stackmem file exists in the current
41
- directory (written by 'cm init'). Otherwise it must be given explicitly.`);
42
+ directory (written by 'cm init'). Otherwise it must be given explicitly.
43
+ search/context take --project instead of a positional project, since a
44
+ bare query/task can't otherwise be told apart from a project name.`);
42
45
  process.exit(1);
43
46
  }
44
47
  function cmdHelp() {
@@ -67,9 +70,10 @@ function cmdHelp() {
67
70
  Build an AST index (files, functions, classes) for the codebase
68
71
  at <path>.
69
72
 
70
- cm search [project] <query>
73
+ cm search [--project <name>] <query>
71
74
  Search saved memories and past fixes for a project matching
72
- <query>.
75
+ <query>. The whole query is always the full remaining text — use
76
+ --project to search a project other than the one in .stackmem.
73
77
 
74
78
  cm resolve [project] <memory-id>
75
79
  Mark a memory as resolved.
@@ -86,10 +90,22 @@ function cmdHelp() {
86
90
  the stackmem MCP server with Claude Code, write a CLAUDE.md and
87
91
  .stackmem file, and seed initial memories from the project scan.
88
92
 
89
- cm context [project] <task>
93
+ cm context [--project <name>] <task>
90
94
  Return only the top 5 memories most relevant to a specific
91
95
  task, ranked by a blend of task relevance and decay score,
92
- as a markdown block capped at a 2000 token budget.
96
+ as a markdown block capped at a 2000 token budget. Use
97
+ --project to target a project other than the one in .stackmem.
98
+
99
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
100
+ Capture what happened in a session and extract memories: each
101
+ error becomes an unsolved execution_log entry, each approach
102
+ containing "failed", "didn't work", "reverted", or "switched
103
+ away" becomes a rejection, a summary that passes the quality
104
+ filter becomes a decision, auth-related files become a
105
+ discovery, and any .ts/.js file triggers an AST re-index.
106
+ Accepts the same fields as JSON on stdin instead of flags
107
+ (project, summary, errors_encountered, files_touched,
108
+ approaches_tried) when stdin is piped rather than a terminal.
93
109
 
94
110
  cm help
95
111
  Show this help message.`);
@@ -107,7 +123,18 @@ function readProjectFromFile() {
107
123
  const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
108
124
  return content.length > 0 ? content : null;
109
125
  }
126
+ // Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
127
+ // muscle memory from `cm init <project> .` — "." (or "..", or anything
128
+ // path-shaped) is never a real project name, so an explicit arg this shape
129
+ // almost certainly means the caller meant to omit the project entirely.
130
+ function looksLikeAPath(value) {
131
+ return value === "." || value === ".." || value.includes("/") || value.includes("\\");
132
+ }
110
133
  function requireProject(explicit) {
134
+ if (explicit && looksLikeAPath(explicit)) {
135
+ console.error(`Warning: '${explicit}' is not a valid project name — reading from .stackmem instead.`);
136
+ explicit = null;
137
+ }
111
138
  if (explicit)
112
139
  return explicit;
113
140
  const fromFile = readProjectFromFile();
@@ -130,12 +157,23 @@ function splitOptionalProject(args, fixedArgCount) {
130
157
  // arg count can't disambiguate an omitted project from a multi-word query —
131
158
  // "cm search bug fix" is ambiguous by count alone. Use .stackmem's presence
132
159
  // instead: if it exists, nothing is positionally a project.
160
+ // Arg count can't disambiguate an omitted project from a multi-word query —
161
+ // "cm search bug fix" is ambiguous by count alone. Treating .stackmem's mere
162
+ // presence as "project omitted" (the previous approach) was worse: it
163
+ // silently swallowed an explicit project into the query text whenever a
164
+ // .stackmem file happened to exist in cwd, so "cm search otherproj auth bug"
165
+ // would search project X (from .stackmem) for the literal text "otherproj
166
+ // auth bug" with no error. Positional args are now always the full
167
+ // query/task; an explicit override uses --project <name> instead, which is
168
+ // never ambiguous and is never silently absorbed.
133
169
  function splitProjectFromVariadic(args) {
134
- if (readProjectFromFile() !== null) {
135
- return { project: null, rest: args };
170
+ const flagIndex = args.indexOf("--project");
171
+ if (flagIndex !== -1 && args[flagIndex + 1] !== undefined) {
172
+ const project = args[flagIndex + 1];
173
+ const rest = [...args.slice(0, flagIndex), ...args.slice(flagIndex + 2)];
174
+ return { project, rest };
136
175
  }
137
- const [project = null, ...rest] = args;
138
- return { project, rest };
176
+ return { project: null, rest: args };
139
177
  }
140
178
  // --- cm start ----------------------------------------------------------
141
179
  async function cmdStart(project) {
@@ -483,21 +521,64 @@ function isSchemaFile(filePath) {
483
521
  );
484
522
  }
485
523
 
524
+ const LOW_QUALITY_PREFIXES = [
525
+ "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
526
+ "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
527
+ ];
528
+
529
+ const SEMVER_RE = /^\\d+\\.\\d+\\.\\d+$/;
530
+
531
+ // Filters out lazy/placeholder commit messages ("wip", "fix", "m", "...")
532
+ // and bare version bumps ("1.0.4") so only messages worth remembering
533
+ // become decisions. Checked against the first word, not a raw prefix
534
+ // match, so a real message like "Migrate auth to JWT" isn't rejected just
535
+ // for starting with the letter "m".
536
+ function isQualityCommitMessage(message) {
537
+ const trimmed = message.trim();
538
+ if (trimmed.length < 15) return false;
539
+ if (SEMVER_RE.test(trimmed)) return false;
540
+ if (!trimmed.includes(" ")) return false;
541
+
542
+ const stripped = trimmed.replace(/\\s/g, "");
543
+ if (stripped.length > 0 && new Set(stripped).size === 1) return false;
544
+
545
+ const lowerTrimmed = trimmed.toLowerCase();
546
+ if (LOW_QUALITY_PREFIXES.includes(lowerTrimmed)) return false;
547
+
548
+ const firstWord = (lowerTrimmed.match(/^[a-z0-9]+/) || [""])[0];
549
+ if (LOW_QUALITY_PREFIXES.includes(firstWord)) return false;
550
+
551
+ return true;
552
+ }
553
+
486
554
  const savedItems = [];
487
555
 
488
556
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
489
- cmSave("decision", commitMessage);
490
- savedItems.push(\`decision: \${commitMessage}\`);
491
557
 
492
- const changedFiles = git(["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"])
558
+ const isFirstCommit = (() => {
559
+ try {
560
+ execFileSync("git", ["rev-parse", "HEAD~1"], { stdio: "pipe" });
561
+ return false;
562
+ } catch {
563
+ return true;
564
+ }
565
+ })();
566
+
567
+ const changedFiles = git(
568
+ isFirstCommit
569
+ ? ["diff-tree", "--root", "--no-commit-id", "-r", "--name-only", "HEAD"]
570
+ : ["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"]
571
+ )
493
572
  .split("\\n")
494
573
  .filter(Boolean);
495
574
 
496
575
  let diff = "";
497
- try {
498
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
499
- } catch {
500
- diff = "";
576
+ if (!isFirstCommit) {
577
+ try {
578
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
579
+ } catch {
580
+ diff = "";
581
+ }
501
582
  }
502
583
 
503
584
  const lines = diff.split("\\n");
@@ -505,7 +586,7 @@ let currentFile = null;
505
586
  let isNewFile = false;
506
587
  let inDependenciesSection = false;
507
588
 
508
- for (const line of lines) {
589
+ for (const line of isFirstCommit ? [] : lines) {
509
590
  if (line.startsWith("diff --git")) {
510
591
  isNewFile = false;
511
592
  inDependenciesSection = false;
@@ -555,10 +636,20 @@ for (const line of lines) {
555
636
  if (line.startsWith("-") && !line.startsWith("---")) {
556
637
  const codeLine = line.slice(1).trim();
557
638
 
558
- if (codeLine.startsWith("import") || codeLine.startsWith("require")) {
559
- const pathMatch = codeLine.match(/['"]([^'"]+)['"]/);
560
- const importPath = pathMatch ? pathMatch[1] : codeLine;
561
- const content = \`removed dependency: \${importPath}\`;
639
+ const importMatch = line.match(
640
+ /^-\\s*(import\\s+.*\\s+from\\s+['"]([^'"]+)['"]|import\\s+['"]([^'"]+)['"])/
641
+ );
642
+ const requireMatch = line.match(
643
+ /^-\\s*(?:const|let|var)\\s+\\S+\\s*=\\s*require\\(['"]([^'"]+)['"]\\)/
644
+ );
645
+ const removedModulePath = importMatch
646
+ ? importMatch[2] || importMatch[3]
647
+ : requireMatch
648
+ ? requireMatch[1]
649
+ : null;
650
+
651
+ if (removedModulePath) {
652
+ const content = \`removed dependency: \${removedModulePath}\`;
562
653
  cmSave("rejection", content);
563
654
  savedItems.push(\`rejection: \${content}\`);
564
655
  }
@@ -614,6 +705,14 @@ for (const line of lines) {
614
705
  }
615
706
  }
616
707
 
708
+ // Saved last, after every diff-detected memory, and only if it clears the
709
+ // quality filter — diff analysis always runs regardless of commit message.
710
+ if (isQualityCommitMessage(commitMessage)) {
711
+ const trimmedMessage = commitMessage.trim();
712
+ cmSave("decision", trimmedMessage);
713
+ savedItems.push(\`decision: \${trimmedMessage}\`);
714
+ }
715
+
617
716
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
618
717
  for (const item of savedItems) {
619
718
  console.log(\` - \${item}\`);
@@ -810,6 +909,151 @@ async function cmdContext(project, task) {
810
909
  lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
811
910
  console.log(lines.join("\n"));
812
911
  }
912
+ function splitCommaList(value) {
913
+ if (!value)
914
+ return [];
915
+ return value
916
+ .split(",")
917
+ .map((item) => item.trim())
918
+ .filter(Boolean);
919
+ }
920
+ // Flags only — stdin JSON is handled separately in cmdSessionEndEntry, since
921
+ // it carries its own full shape (arrays, not comma-joined strings).
922
+ function parseSessionEndFlags(args) {
923
+ let project = null;
924
+ let summary;
925
+ let errors = [];
926
+ let files = [];
927
+ let approaches = [];
928
+ let i = 0;
929
+ if (args[i] && !args[i].startsWith("--")) {
930
+ project = args[i];
931
+ i++;
932
+ }
933
+ for (; i < args.length; i++) {
934
+ const flag = args[i];
935
+ const value = args[i + 1];
936
+ if (flag === "--summary") {
937
+ summary = value;
938
+ i++;
939
+ }
940
+ else if (flag === "--errors") {
941
+ errors = splitCommaList(value);
942
+ i++;
943
+ }
944
+ else if (flag === "--files") {
945
+ files = splitCommaList(value);
946
+ i++;
947
+ }
948
+ else if (flag === "--approaches") {
949
+ approaches = splitCommaList(value);
950
+ i++;
951
+ }
952
+ }
953
+ return { project, summary, errors, files, approaches };
954
+ }
955
+ async function readStdinJson() {
956
+ const chunks = [];
957
+ for await (const chunk of process.stdin) {
958
+ chunks.push(chunk);
959
+ }
960
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
961
+ if (!raw)
962
+ return null;
963
+ try {
964
+ const parsed = JSON.parse(raw);
965
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
966
+ }
967
+ catch {
968
+ return null;
969
+ }
970
+ }
971
+ async function cmdSessionEnd(project, input) {
972
+ let memoriesSaved = 0;
973
+ let errorsLogged = 0;
974
+ for (const problem of input.errors) {
975
+ const { error } = await supabase.from("execution_log").insert({
976
+ project,
977
+ problem,
978
+ solution: null,
979
+ resolved: false,
980
+ device_id: getDeviceId(),
981
+ });
982
+ if (!error)
983
+ errorsLogged++;
984
+ }
985
+ for (const approach of input.approaches) {
986
+ if (!indicatesFailedApproach(approach))
987
+ continue;
988
+ const { error } = await supabase.from("memories").insert({
989
+ project,
990
+ type: "rejection",
991
+ content: approach,
992
+ device_id: getDeviceId(),
993
+ });
994
+ if (!error)
995
+ memoriesSaved++;
996
+ }
997
+ if (input.summary && isQualityMessage(input.summary)) {
998
+ const { error } = await supabase.from("memories").insert({
999
+ project,
1000
+ type: "decision",
1001
+ content: input.summary.trim(),
1002
+ device_id: getDeviceId(),
1003
+ });
1004
+ if (!error)
1005
+ memoriesSaved++;
1006
+ }
1007
+ const authFiles = input.files.filter(isAuthRelatedFile);
1008
+ if (authFiles.length > 0) {
1009
+ const { error } = await supabase.from("memories").insert({
1010
+ project,
1011
+ type: "discovery",
1012
+ content: `session touched auth files: ${authFiles.join(", ")}`,
1013
+ device_id: getDeviceId(),
1014
+ });
1015
+ if (!error)
1016
+ memoriesSaved++;
1017
+ }
1018
+ if (input.files.some(isCodeFile)) {
1019
+ analyzeCodebase(process.cwd());
1020
+ }
1021
+ console.log(`Session captured. ${memoriesSaved} memories saved.`);
1022
+ if (errorsLogged > 0) {
1023
+ console.log(`${errorsLogged} error(s) logged to execution_log.`);
1024
+ }
1025
+ }
1026
+ // Dispatches to JSON-from-stdin or flag parsing depending on how the
1027
+ // command was invoked — piped input (e.g. from a script) uses JSON; an
1028
+ // interactive terminal with no piped stdin uses flags.
1029
+ async function cmdSessionEndEntry(args) {
1030
+ let input;
1031
+ if (!process.stdin.isTTY) {
1032
+ const json = await readStdinJson();
1033
+ if (json) {
1034
+ input = {
1035
+ project: typeof json.project === "string" ? json.project : null,
1036
+ summary: typeof json.summary === "string" ? json.summary : undefined,
1037
+ errors: Array.isArray(json.errors_encountered) ? json.errors_encountered : [],
1038
+ files: Array.isArray(json.files_touched) ? json.files_touched : [],
1039
+ approaches: Array.isArray(json.approaches_tried) ? json.approaches_tried : [],
1040
+ };
1041
+ }
1042
+ else {
1043
+ input = parseSessionEndFlags(args);
1044
+ }
1045
+ }
1046
+ else {
1047
+ input = parseSessionEndFlags(args);
1048
+ }
1049
+ const project = requireProject(input.project);
1050
+ await cmdSessionEnd(project, {
1051
+ summary: input.summary,
1052
+ errors: input.errors,
1053
+ files: input.files,
1054
+ approaches: input.approaches,
1055
+ });
1056
+ }
813
1057
  // --- main --------------------------------------------------------------
814
1058
  async function main() {
815
1059
  const [, , command, ...args] = process.argv;
@@ -912,6 +1156,10 @@ async function main() {
912
1156
  await cmdContext(project, split.rest.join(" "));
913
1157
  break;
914
1158
  }
1159
+ case "session-end": {
1160
+ await cmdSessionEndEntry(args);
1161
+ break;
1162
+ }
915
1163
  case "help": {
916
1164
  cmdHelp();
917
1165
  break;
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import { analyzeCodebase, getFileSummary, findDependencies } from "./ast-index.js";
6
6
  import { getDeviceId } from "./device.js";
7
- import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, } from "./scoring.js";
7
+ import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, } from "./scoring.js";
8
8
  import { supabase } from "./storage.js";
9
9
  const CONTEXT_BUDGET_TOKENS = 2000;
10
10
  const COMPRESSION_THRESHOLD = 20;
@@ -312,6 +312,80 @@ server.registerTool("find_dependencies", {
312
312
  structuredContent: deps,
313
313
  };
314
314
  });
315
+ // --- session_end -----------------------------------------------------------
316
+ server.registerTool("session_end", {
317
+ title: "Session End",
318
+ description: "Capture what happened during a coding session and automatically extract memories: unsolved errors, failed approaches, a summary decision, auth-file touches, and an AST re-index.",
319
+ inputSchema: {
320
+ project: z.string().describe("Project identifier"),
321
+ files_touched: z.array(z.string()).describe("Files read or modified during the session"),
322
+ errors_encountered: z.array(z.string()).describe("Error messages encountered during the session"),
323
+ approaches_tried: z
324
+ .array(z.string())
325
+ .optional()
326
+ .describe("Things that were attempted during the session"),
327
+ summary: z.string().optional().describe("One sentence describing what was accomplished"),
328
+ },
329
+ }, async ({ project, files_touched, errors_encountered, approaches_tried, summary }) => {
330
+ let memoriesSaved = 0;
331
+ let errorsLogged = 0;
332
+ for (const problem of errors_encountered) {
333
+ const { error } = await supabase.from("execution_log").insert({
334
+ project,
335
+ problem,
336
+ solution: null,
337
+ resolved: false,
338
+ device_id: getDeviceId(),
339
+ });
340
+ if (!error)
341
+ errorsLogged++;
342
+ }
343
+ for (const approach of approaches_tried ?? []) {
344
+ if (!indicatesFailedApproach(approach))
345
+ continue;
346
+ const { error } = await supabase.from("memories").insert({
347
+ project,
348
+ type: "rejection",
349
+ content: approach,
350
+ device_id: getDeviceId(),
351
+ });
352
+ if (!error)
353
+ memoriesSaved++;
354
+ }
355
+ if (summary && isQualityMessage(summary)) {
356
+ const { error } = await supabase.from("memories").insert({
357
+ project,
358
+ type: "decision",
359
+ content: summary.trim(),
360
+ device_id: getDeviceId(),
361
+ });
362
+ if (!error)
363
+ memoriesSaved++;
364
+ }
365
+ const authFiles = files_touched.filter(isAuthRelatedFile);
366
+ if (authFiles.length > 0) {
367
+ const { error } = await supabase.from("memories").insert({
368
+ project,
369
+ type: "discovery",
370
+ content: `session touched auth files: ${authFiles.join(", ")}`,
371
+ device_id: getDeviceId(),
372
+ });
373
+ if (!error)
374
+ memoriesSaved++;
375
+ }
376
+ if (files_touched.some(isCodeFile)) {
377
+ analyzeCodebase(process.cwd());
378
+ }
379
+ const message = `Session captured. ${memoriesSaved} memories saved.`;
380
+ return {
381
+ content: [{ type: "text", text: message }],
382
+ structuredContent: {
383
+ memories_saved: memoriesSaved,
384
+ errors_logged: errorsLogged,
385
+ message,
386
+ },
387
+ };
388
+ });
315
389
  // --- transport -------------------------------------------------------------
316
390
  async function main() {
317
391
  const transport = new StdioServerTransport();
package/dist/scoring.js CHANGED
@@ -192,6 +192,50 @@ export function buildSummaryContent(cluster) {
192
192
  .join("; ");
193
193
  return `[${mostCommonType.toUpperCase()} cluster] ${topic}: ${items}`;
194
194
  }
195
+ // --- message quality filter ------------------------------------------------
196
+ const LOW_QUALITY_PREFIXES = [
197
+ "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
198
+ "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
199
+ ];
200
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
201
+ // Filters out lazy/placeholder messages ("wip", "fix", "m", "...") and bare
202
+ // version bumps ("1.0.4"). Checked against the first word (and the whole
203
+ // message), not a raw prefix match, so a real message like "Migrate auth to
204
+ // JWT" isn't rejected just for starting with the letter "m". The git hook
205
+ // keeps its own inline copy of this same logic since it runs as a standalone
206
+ // script with no access to this module — keep the two in sync by hand if
207
+ // this changes.
208
+ export function isQualityMessage(message) {
209
+ const trimmed = message.trim();
210
+ if (trimmed.length < 15)
211
+ return false;
212
+ if (SEMVER_RE.test(trimmed))
213
+ return false;
214
+ if (!trimmed.includes(" "))
215
+ return false;
216
+ const stripped = trimmed.replace(/\s/g, "");
217
+ if (stripped.length > 0 && new Set(stripped).size === 1)
218
+ return false;
219
+ const lowerTrimmed = trimmed.toLowerCase();
220
+ if (LOW_QUALITY_PREFIXES.includes(lowerTrimmed))
221
+ return false;
222
+ const firstWord = (lowerTrimmed.match(/^[a-z0-9]+/) || [""])[0];
223
+ if (LOW_QUALITY_PREFIXES.includes(firstWord))
224
+ return false;
225
+ return true;
226
+ }
227
+ // --- session-end classification ---------------------------------------------
228
+ const FAILURE_PHRASES = ["failed", "didn't work", "reverted", "switched away"];
229
+ export function indicatesFailedApproach(text) {
230
+ const lower = text.toLowerCase();
231
+ return FAILURE_PHRASES.some((phrase) => lower.includes(phrase));
232
+ }
233
+ export function isAuthRelatedFile(filePath) {
234
+ return /(auth|login|session|token)/i.test(filePath);
235
+ }
236
+ export function isCodeFile(filePath) {
237
+ return /\.(ts|js)$/i.test(filePath);
238
+ }
195
239
  // --- task relevance --------------------------------------------------------
196
240
  function splitWords(text) {
197
241
  return text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackmem",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/src/ast-index.ts CHANGED
@@ -217,12 +217,23 @@ export function analyzeCodebase(projectPath: string): AnalyzeCodebaseResult {
217
217
  const insertImport = db.prepare("INSERT INTO imports (file_id, source) VALUES (?, ?)");
218
218
 
219
219
  const files = walkProjectFiles(resolvedProjectPath);
220
+ let filesIndexed = 0;
220
221
  let totalFunctions = 0;
221
222
  let totalClasses = 0;
222
223
 
223
224
  const runAll = db.transaction((filePaths: string[]) => {
224
225
  for (const filePath of filePaths) {
225
- const extraction = extractFile(filePath, resolvedProjectPath);
226
+ let extraction: FileExtraction;
227
+ try {
228
+ extraction = extractFile(filePath, resolvedProjectPath);
229
+ } catch {
230
+ // Tree-sitter (or the file read) choked on this file — better-sqlite3
231
+ // transactions roll back entirely on a throw, so skipping here
232
+ // (rather than letting it propagate) is what keeps one bad file from
233
+ // wiping out every other file already processed in this run.
234
+ continue;
235
+ }
236
+
226
237
  const row = insertFile.get(
227
238
  extraction.relativePath,
228
239
  path.basename(extraction.relativePath),
@@ -245,6 +256,8 @@ export function analyzeCodebase(projectPath: string): AnalyzeCodebaseResult {
245
256
  for (const source of extraction.imports) {
246
257
  insertImport.run(fileId, source);
247
258
  }
259
+
260
+ filesIndexed++;
248
261
  }
249
262
  });
250
263
 
@@ -252,7 +265,7 @@ export function analyzeCodebase(projectPath: string): AnalyzeCodebaseResult {
252
265
  db.close();
253
266
 
254
267
  return {
255
- files_indexed: files.length,
268
+ files_indexed: filesIndexed,
256
269
  functions: totalFunctions,
257
270
  classes: totalClasses,
258
271
  };
package/src/cli.ts CHANGED
@@ -14,6 +14,10 @@ import {
14
14
  computeMemoryLinks,
15
15
  decideSaveAction,
16
16
  estimateTokens,
17
+ indicatesFailedApproach,
18
+ isAuthRelatedFile,
19
+ isCodeFile,
20
+ isQualityMessage,
17
21
  scoreMemoryRelevance,
18
22
  } from "./scoring.js";
19
23
  import { supabase } from "./storage.js";
@@ -43,16 +47,19 @@ function usage(): never {
43
47
  cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
44
48
  cm fix [project] <problem> <solution>
45
49
  cm analyze [project] <path>
46
- cm search [project] <query>
50
+ cm search [--project <name>] <query>
47
51
  cm resolve [project] <memory-id>
48
52
  cm delete [project] <memory-id>
49
53
  cm compress [project]
50
54
  cm init <project> <path>
51
- cm context [project] <task>
55
+ cm context [--project <name>] <task>
56
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
52
57
  cm help
53
58
 
54
59
  [project] is optional if a .stackmem file exists in the current
55
- directory (written by 'cm init'). Otherwise it must be given explicitly.`);
60
+ directory (written by 'cm init'). Otherwise it must be given explicitly.
61
+ search/context take --project instead of a positional project, since a
62
+ bare query/task can't otherwise be told apart from a project name.`);
56
63
  process.exit(1);
57
64
  }
58
65
 
@@ -82,9 +89,10 @@ function cmdHelp() {
82
89
  Build an AST index (files, functions, classes) for the codebase
83
90
  at <path>.
84
91
 
85
- cm search [project] <query>
92
+ cm search [--project <name>] <query>
86
93
  Search saved memories and past fixes for a project matching
87
- <query>.
94
+ <query>. The whole query is always the full remaining text — use
95
+ --project to search a project other than the one in .stackmem.
88
96
 
89
97
  cm resolve [project] <memory-id>
90
98
  Mark a memory as resolved.
@@ -101,10 +109,22 @@ function cmdHelp() {
101
109
  the stackmem MCP server with Claude Code, write a CLAUDE.md and
102
110
  .stackmem file, and seed initial memories from the project scan.
103
111
 
104
- cm context [project] <task>
112
+ cm context [--project <name>] <task>
105
113
  Return only the top 5 memories most relevant to a specific
106
114
  task, ranked by a blend of task relevance and decay score,
107
- as a markdown block capped at a 2000 token budget.
115
+ as a markdown block capped at a 2000 token budget. Use
116
+ --project to target a project other than the one in .stackmem.
117
+
118
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
119
+ Capture what happened in a session and extract memories: each
120
+ error becomes an unsolved execution_log entry, each approach
121
+ containing "failed", "didn't work", "reverted", or "switched
122
+ away" becomes a rejection, a summary that passes the quality
123
+ filter becomes a decision, auth-related files become a
124
+ discovery, and any .ts/.js file triggers an AST re-index.
125
+ Accepts the same fields as JSON on stdin instead of flags
126
+ (project, summary, errors_encountered, files_touched,
127
+ approaches_tried) when stdin is piped rather than a terminal.
108
128
 
109
129
  cm help
110
130
  Show this help message.`);
@@ -126,7 +146,22 @@ function readProjectFromFile(): string | null {
126
146
  return content.length > 0 ? content : null;
127
147
  }
128
148
 
149
+ // Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
150
+ // muscle memory from `cm init <project> .` — "." (or "..", or anything
151
+ // path-shaped) is never a real project name, so an explicit arg this shape
152
+ // almost certainly means the caller meant to omit the project entirely.
153
+ function looksLikeAPath(value: string): boolean {
154
+ return value === "." || value === ".." || value.includes("/") || value.includes("\\");
155
+ }
156
+
129
157
  function requireProject(explicit: string | null): string {
158
+ if (explicit && looksLikeAPath(explicit)) {
159
+ console.error(
160
+ `Warning: '${explicit}' is not a valid project name — reading from .stackmem instead.`
161
+ );
162
+ explicit = null;
163
+ }
164
+
130
165
  if (explicit) return explicit;
131
166
 
132
167
  const fromFile = readProjectFromFile();
@@ -154,12 +189,23 @@ function splitOptionalProject(
154
189
  // arg count can't disambiguate an omitted project from a multi-word query —
155
190
  // "cm search bug fix" is ambiguous by count alone. Use .stackmem's presence
156
191
  // instead: if it exists, nothing is positionally a project.
192
+ // Arg count can't disambiguate an omitted project from a multi-word query —
193
+ // "cm search bug fix" is ambiguous by count alone. Treating .stackmem's mere
194
+ // presence as "project omitted" (the previous approach) was worse: it
195
+ // silently swallowed an explicit project into the query text whenever a
196
+ // .stackmem file happened to exist in cwd, so "cm search otherproj auth bug"
197
+ // would search project X (from .stackmem) for the literal text "otherproj
198
+ // auth bug" with no error. Positional args are now always the full
199
+ // query/task; an explicit override uses --project <name> instead, which is
200
+ // never ambiguous and is never silently absorbed.
157
201
  function splitProjectFromVariadic(args: string[]): { project: string | null; rest: string[] } {
158
- if (readProjectFromFile() !== null) {
159
- return { project: null, rest: args };
202
+ const flagIndex = args.indexOf("--project");
203
+ if (flagIndex !== -1 && args[flagIndex + 1] !== undefined) {
204
+ const project = args[flagIndex + 1];
205
+ const rest = [...args.slice(0, flagIndex), ...args.slice(flagIndex + 2)];
206
+ return { project, rest };
160
207
  }
161
- const [project = null, ...rest] = args;
162
- return { project, rest };
208
+ return { project: null, rest: args };
163
209
  }
164
210
 
165
211
  // --- cm start ----------------------------------------------------------
@@ -573,21 +619,64 @@ function isSchemaFile(filePath) {
573
619
  );
574
620
  }
575
621
 
622
+ const LOW_QUALITY_PREFIXES = [
623
+ "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
624
+ "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
625
+ ];
626
+
627
+ const SEMVER_RE = /^\\d+\\.\\d+\\.\\d+$/;
628
+
629
+ // Filters out lazy/placeholder commit messages ("wip", "fix", "m", "...")
630
+ // and bare version bumps ("1.0.4") so only messages worth remembering
631
+ // become decisions. Checked against the first word, not a raw prefix
632
+ // match, so a real message like "Migrate auth to JWT" isn't rejected just
633
+ // for starting with the letter "m".
634
+ function isQualityCommitMessage(message) {
635
+ const trimmed = message.trim();
636
+ if (trimmed.length < 15) return false;
637
+ if (SEMVER_RE.test(trimmed)) return false;
638
+ if (!trimmed.includes(" ")) return false;
639
+
640
+ const stripped = trimmed.replace(/\\s/g, "");
641
+ if (stripped.length > 0 && new Set(stripped).size === 1) return false;
642
+
643
+ const lowerTrimmed = trimmed.toLowerCase();
644
+ if (LOW_QUALITY_PREFIXES.includes(lowerTrimmed)) return false;
645
+
646
+ const firstWord = (lowerTrimmed.match(/^[a-z0-9]+/) || [""])[0];
647
+ if (LOW_QUALITY_PREFIXES.includes(firstWord)) return false;
648
+
649
+ return true;
650
+ }
651
+
576
652
  const savedItems = [];
577
653
 
578
654
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
579
- cmSave("decision", commitMessage);
580
- savedItems.push(\`decision: \${commitMessage}\`);
581
655
 
582
- const changedFiles = git(["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"])
656
+ const isFirstCommit = (() => {
657
+ try {
658
+ execFileSync("git", ["rev-parse", "HEAD~1"], { stdio: "pipe" });
659
+ return false;
660
+ } catch {
661
+ return true;
662
+ }
663
+ })();
664
+
665
+ const changedFiles = git(
666
+ isFirstCommit
667
+ ? ["diff-tree", "--root", "--no-commit-id", "-r", "--name-only", "HEAD"]
668
+ : ["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"]
669
+ )
583
670
  .split("\\n")
584
671
  .filter(Boolean);
585
672
 
586
673
  let diff = "";
587
- try {
588
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
589
- } catch {
590
- diff = "";
674
+ if (!isFirstCommit) {
675
+ try {
676
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
677
+ } catch {
678
+ diff = "";
679
+ }
591
680
  }
592
681
 
593
682
  const lines = diff.split("\\n");
@@ -595,7 +684,7 @@ let currentFile = null;
595
684
  let isNewFile = false;
596
685
  let inDependenciesSection = false;
597
686
 
598
- for (const line of lines) {
687
+ for (const line of isFirstCommit ? [] : lines) {
599
688
  if (line.startsWith("diff --git")) {
600
689
  isNewFile = false;
601
690
  inDependenciesSection = false;
@@ -645,10 +734,20 @@ for (const line of lines) {
645
734
  if (line.startsWith("-") && !line.startsWith("---")) {
646
735
  const codeLine = line.slice(1).trim();
647
736
 
648
- if (codeLine.startsWith("import") || codeLine.startsWith("require")) {
649
- const pathMatch = codeLine.match(/['"]([^'"]+)['"]/);
650
- const importPath = pathMatch ? pathMatch[1] : codeLine;
651
- const content = \`removed dependency: \${importPath}\`;
737
+ const importMatch = line.match(
738
+ /^-\\s*(import\\s+.*\\s+from\\s+['"]([^'"]+)['"]|import\\s+['"]([^'"]+)['"])/
739
+ );
740
+ const requireMatch = line.match(
741
+ /^-\\s*(?:const|let|var)\\s+\\S+\\s*=\\s*require\\(['"]([^'"]+)['"]\\)/
742
+ );
743
+ const removedModulePath = importMatch
744
+ ? importMatch[2] || importMatch[3]
745
+ : requireMatch
746
+ ? requireMatch[1]
747
+ : null;
748
+
749
+ if (removedModulePath) {
750
+ const content = \`removed dependency: \${removedModulePath}\`;
652
751
  cmSave("rejection", content);
653
752
  savedItems.push(\`rejection: \${content}\`);
654
753
  }
@@ -704,6 +803,14 @@ for (const line of lines) {
704
803
  }
705
804
  }
706
805
 
806
+ // Saved last, after every diff-detected memory, and only if it clears the
807
+ // quality filter — diff analysis always runs regardless of commit message.
808
+ if (isQualityCommitMessage(commitMessage)) {
809
+ const trimmedMessage = commitMessage.trim();
810
+ cmSave("decision", trimmedMessage);
811
+ savedItems.push(\`decision: \${trimmedMessage}\`);
812
+ }
813
+
707
814
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
708
815
  for (const item of savedItems) {
709
816
  console.log(\` - \${item}\`);
@@ -942,6 +1049,165 @@ async function cmdContext(project: string, task: string) {
942
1049
  console.log(lines.join("\n"));
943
1050
  }
944
1051
 
1052
+ // --- cm session-end --------------------------------------------------------
1053
+
1054
+ interface SessionEndInput {
1055
+ project: string | null;
1056
+ summary?: string;
1057
+ errors: string[];
1058
+ files: string[];
1059
+ approaches: string[];
1060
+ }
1061
+
1062
+ function splitCommaList(value: string | undefined): string[] {
1063
+ if (!value) return [];
1064
+ return value
1065
+ .split(",")
1066
+ .map((item) => item.trim())
1067
+ .filter(Boolean);
1068
+ }
1069
+
1070
+ // Flags only — stdin JSON is handled separately in cmdSessionEndEntry, since
1071
+ // it carries its own full shape (arrays, not comma-joined strings).
1072
+ function parseSessionEndFlags(args: string[]): SessionEndInput {
1073
+ let project: string | null = null;
1074
+ let summary: string | undefined;
1075
+ let errors: string[] = [];
1076
+ let files: string[] = [];
1077
+ let approaches: string[] = [];
1078
+
1079
+ let i = 0;
1080
+ if (args[i] && !args[i].startsWith("--")) {
1081
+ project = args[i];
1082
+ i++;
1083
+ }
1084
+
1085
+ for (; i < args.length; i++) {
1086
+ const flag = args[i];
1087
+ const value = args[i + 1];
1088
+ if (flag === "--summary") {
1089
+ summary = value;
1090
+ i++;
1091
+ } else if (flag === "--errors") {
1092
+ errors = splitCommaList(value);
1093
+ i++;
1094
+ } else if (flag === "--files") {
1095
+ files = splitCommaList(value);
1096
+ i++;
1097
+ } else if (flag === "--approaches") {
1098
+ approaches = splitCommaList(value);
1099
+ i++;
1100
+ }
1101
+ }
1102
+
1103
+ return { project, summary, errors, files, approaches };
1104
+ }
1105
+
1106
+ async function readStdinJson(): Promise<Record<string, unknown> | null> {
1107
+ const chunks: Buffer[] = [];
1108
+ for await (const chunk of process.stdin) {
1109
+ chunks.push(chunk as Buffer);
1110
+ }
1111
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
1112
+ if (!raw) return null;
1113
+
1114
+ try {
1115
+ const parsed = JSON.parse(raw);
1116
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
1117
+ } catch {
1118
+ return null;
1119
+ }
1120
+ }
1121
+
1122
+ async function cmdSessionEnd(project: string, input: Omit<SessionEndInput, "project">) {
1123
+ let memoriesSaved = 0;
1124
+ let errorsLogged = 0;
1125
+
1126
+ for (const problem of input.errors) {
1127
+ const { error } = await supabase.from("execution_log").insert({
1128
+ project,
1129
+ problem,
1130
+ solution: null,
1131
+ resolved: false,
1132
+ device_id: getDeviceId(),
1133
+ });
1134
+ if (!error) errorsLogged++;
1135
+ }
1136
+
1137
+ for (const approach of input.approaches) {
1138
+ if (!indicatesFailedApproach(approach)) continue;
1139
+ const { error } = await supabase.from("memories").insert({
1140
+ project,
1141
+ type: "rejection",
1142
+ content: approach,
1143
+ device_id: getDeviceId(),
1144
+ });
1145
+ if (!error) memoriesSaved++;
1146
+ }
1147
+
1148
+ if (input.summary && isQualityMessage(input.summary)) {
1149
+ const { error } = await supabase.from("memories").insert({
1150
+ project,
1151
+ type: "decision",
1152
+ content: input.summary.trim(),
1153
+ device_id: getDeviceId(),
1154
+ });
1155
+ if (!error) memoriesSaved++;
1156
+ }
1157
+
1158
+ const authFiles = input.files.filter(isAuthRelatedFile);
1159
+ if (authFiles.length > 0) {
1160
+ const { error } = await supabase.from("memories").insert({
1161
+ project,
1162
+ type: "discovery",
1163
+ content: `session touched auth files: ${authFiles.join(", ")}`,
1164
+ device_id: getDeviceId(),
1165
+ });
1166
+ if (!error) memoriesSaved++;
1167
+ }
1168
+
1169
+ if (input.files.some(isCodeFile)) {
1170
+ analyzeCodebase(process.cwd());
1171
+ }
1172
+
1173
+ console.log(`Session captured. ${memoriesSaved} memories saved.`);
1174
+ if (errorsLogged > 0) {
1175
+ console.log(`${errorsLogged} error(s) logged to execution_log.`);
1176
+ }
1177
+ }
1178
+
1179
+ // Dispatches to JSON-from-stdin or flag parsing depending on how the
1180
+ // command was invoked — piped input (e.g. from a script) uses JSON; an
1181
+ // interactive terminal with no piped stdin uses flags.
1182
+ async function cmdSessionEndEntry(args: string[]) {
1183
+ let input: SessionEndInput;
1184
+
1185
+ if (!process.stdin.isTTY) {
1186
+ const json = await readStdinJson();
1187
+ if (json) {
1188
+ input = {
1189
+ project: typeof json.project === "string" ? json.project : null,
1190
+ summary: typeof json.summary === "string" ? json.summary : undefined,
1191
+ errors: Array.isArray(json.errors_encountered) ? json.errors_encountered : [],
1192
+ files: Array.isArray(json.files_touched) ? json.files_touched : [],
1193
+ approaches: Array.isArray(json.approaches_tried) ? json.approaches_tried : [],
1194
+ };
1195
+ } else {
1196
+ input = parseSessionEndFlags(args);
1197
+ }
1198
+ } else {
1199
+ input = parseSessionEndFlags(args);
1200
+ }
1201
+
1202
+ const project = requireProject(input.project);
1203
+ await cmdSessionEnd(project, {
1204
+ summary: input.summary,
1205
+ errors: input.errors,
1206
+ files: input.files,
1207
+ approaches: input.approaches,
1208
+ });
1209
+ }
1210
+
945
1211
  // --- main --------------------------------------------------------------
946
1212
 
947
1213
  async function main() {
@@ -1037,6 +1303,10 @@ async function main() {
1037
1303
  await cmdContext(project, split.rest.join(" "));
1038
1304
  break;
1039
1305
  }
1306
+ case "session-end": {
1307
+ await cmdSessionEndEntry(args);
1308
+ break;
1309
+ }
1040
1310
  case "help": {
1041
1311
  cmdHelp();
1042
1312
  break;
package/src/index.ts CHANGED
@@ -12,6 +12,10 @@ import {
12
12
  computeMemoryLinks,
13
13
  decideSaveAction,
14
14
  estimateTokens,
15
+ indicatesFailedApproach,
16
+ isAuthRelatedFile,
17
+ isCodeFile,
18
+ isQualityMessage,
15
19
  } from "./scoring.js";
16
20
  import { supabase } from "./storage.js";
17
21
 
@@ -422,6 +426,89 @@ server.registerTool(
422
426
  }
423
427
  );
424
428
 
429
+ // --- session_end -----------------------------------------------------------
430
+
431
+ server.registerTool(
432
+ "session_end",
433
+ {
434
+ title: "Session End",
435
+ description:
436
+ "Capture what happened during a coding session and automatically extract memories: unsolved errors, failed approaches, a summary decision, auth-file touches, and an AST re-index.",
437
+ inputSchema: {
438
+ project: z.string().describe("Project identifier"),
439
+ files_touched: z.array(z.string()).describe("Files read or modified during the session"),
440
+ errors_encountered: z.array(z.string()).describe("Error messages encountered during the session"),
441
+ approaches_tried: z
442
+ .array(z.string())
443
+ .optional()
444
+ .describe("Things that were attempted during the session"),
445
+ summary: z.string().optional().describe("One sentence describing what was accomplished"),
446
+ },
447
+ },
448
+ async ({ project, files_touched, errors_encountered, approaches_tried, summary }) => {
449
+ let memoriesSaved = 0;
450
+ let errorsLogged = 0;
451
+
452
+ for (const problem of errors_encountered) {
453
+ const { error } = await supabase.from("execution_log").insert({
454
+ project,
455
+ problem,
456
+ solution: null,
457
+ resolved: false,
458
+ device_id: getDeviceId(),
459
+ });
460
+ if (!error) errorsLogged++;
461
+ }
462
+
463
+ for (const approach of approaches_tried ?? []) {
464
+ if (!indicatesFailedApproach(approach)) continue;
465
+ const { error } = await supabase.from("memories").insert({
466
+ project,
467
+ type: "rejection",
468
+ content: approach,
469
+ device_id: getDeviceId(),
470
+ });
471
+ if (!error) memoriesSaved++;
472
+ }
473
+
474
+ if (summary && isQualityMessage(summary)) {
475
+ const { error } = await supabase.from("memories").insert({
476
+ project,
477
+ type: "decision",
478
+ content: summary.trim(),
479
+ device_id: getDeviceId(),
480
+ });
481
+ if (!error) memoriesSaved++;
482
+ }
483
+
484
+ const authFiles = files_touched.filter(isAuthRelatedFile);
485
+ if (authFiles.length > 0) {
486
+ const { error } = await supabase.from("memories").insert({
487
+ project,
488
+ type: "discovery",
489
+ content: `session touched auth files: ${authFiles.join(", ")}`,
490
+ device_id: getDeviceId(),
491
+ });
492
+ if (!error) memoriesSaved++;
493
+ }
494
+
495
+ if (files_touched.some(isCodeFile)) {
496
+ analyzeCodebase(process.cwd());
497
+ }
498
+
499
+ const message = `Session captured. ${memoriesSaved} memories saved.`;
500
+
501
+ return {
502
+ content: [{ type: "text", text: message }],
503
+ structuredContent: {
504
+ memories_saved: memoriesSaved,
505
+ errors_logged: errorsLogged,
506
+ message,
507
+ },
508
+ };
509
+ }
510
+ );
511
+
425
512
  // --- transport -------------------------------------------------------------
426
513
 
427
514
  async function main() {
package/src/scoring.ts CHANGED
@@ -247,6 +247,57 @@ export function buildSummaryContent(cluster: any[]): string {
247
247
  return `[${mostCommonType.toUpperCase()} cluster] ${topic}: ${items}`;
248
248
  }
249
249
 
250
+ // --- message quality filter ------------------------------------------------
251
+
252
+ const LOW_QUALITY_PREFIXES = [
253
+ "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
254
+ "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
255
+ ];
256
+
257
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
258
+
259
+ // Filters out lazy/placeholder messages ("wip", "fix", "m", "...") and bare
260
+ // version bumps ("1.0.4"). Checked against the first word (and the whole
261
+ // message), not a raw prefix match, so a real message like "Migrate auth to
262
+ // JWT" isn't rejected just for starting with the letter "m". The git hook
263
+ // keeps its own inline copy of this same logic since it runs as a standalone
264
+ // script with no access to this module — keep the two in sync by hand if
265
+ // this changes.
266
+ export function isQualityMessage(message: string): boolean {
267
+ const trimmed = message.trim();
268
+ if (trimmed.length < 15) return false;
269
+ if (SEMVER_RE.test(trimmed)) return false;
270
+ if (!trimmed.includes(" ")) return false;
271
+
272
+ const stripped = trimmed.replace(/\s/g, "");
273
+ if (stripped.length > 0 && new Set(stripped).size === 1) return false;
274
+
275
+ const lowerTrimmed = trimmed.toLowerCase();
276
+ if (LOW_QUALITY_PREFIXES.includes(lowerTrimmed)) return false;
277
+
278
+ const firstWord = (lowerTrimmed.match(/^[a-z0-9]+/) || [""])[0];
279
+ if (LOW_QUALITY_PREFIXES.includes(firstWord)) return false;
280
+
281
+ return true;
282
+ }
283
+
284
+ // --- session-end classification ---------------------------------------------
285
+
286
+ const FAILURE_PHRASES = ["failed", "didn't work", "reverted", "switched away"];
287
+
288
+ export function indicatesFailedApproach(text: string): boolean {
289
+ const lower = text.toLowerCase();
290
+ return FAILURE_PHRASES.some((phrase) => lower.includes(phrase));
291
+ }
292
+
293
+ export function isAuthRelatedFile(filePath: string): boolean {
294
+ return /(auth|login|session|token)/i.test(filePath);
295
+ }
296
+
297
+ export function isCodeFile(filePath: string): boolean {
298
+ return /\.(ts|js)$/i.test(filePath);
299
+ }
300
+
250
301
  // --- task relevance --------------------------------------------------------
251
302
 
252
303
  function splitWords(text: string): string[] {