stackmem 1.0.5 → 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 +14 -2
- package/dist/cli.js +207 -16
- package/dist/index.js +75 -1
- package/dist/scoring.js +44 -0
- package/package.json +1 -1
- package/src/ast-index.ts +15 -2
- package/src/cli.ts +228 -15
- package/src/index.ts +87 -0
- package/src/scoring.ts +51 -0
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
|
-
|
|
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:
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
return { project, rest };
|
|
176
|
+
return { project: null, rest: args };
|
|
139
177
|
}
|
|
140
178
|
// --- cm start ----------------------------------------------------------
|
|
141
179
|
async function cmdStart(project) {
|
|
@@ -488,13 +526,17 @@ const LOW_QUALITY_PREFIXES = [
|
|
|
488
526
|
"tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
|
|
489
527
|
];
|
|
490
528
|
|
|
529
|
+
const SEMVER_RE = /^\\d+\\.\\d+\\.\\d+$/;
|
|
530
|
+
|
|
491
531
|
// Filters out lazy/placeholder commit messages ("wip", "fix", "m", "...")
|
|
492
|
-
// so only messages worth remembering
|
|
493
|
-
// first word, not a raw prefix
|
|
494
|
-
// to JWT" isn't rejected just
|
|
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".
|
|
495
536
|
function isQualityCommitMessage(message) {
|
|
496
537
|
const trimmed = message.trim();
|
|
497
|
-
if (trimmed.length
|
|
538
|
+
if (trimmed.length < 15) return false;
|
|
539
|
+
if (SEMVER_RE.test(trimmed)) return false;
|
|
498
540
|
if (!trimmed.includes(" ")) return false;
|
|
499
541
|
|
|
500
542
|
const stripped = trimmed.replace(/\\s/g, "");
|
|
@@ -867,6 +909,151 @@ async function cmdContext(project, task) {
|
|
|
867
909
|
lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
|
|
868
910
|
console.log(lines.join("\n"));
|
|
869
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
|
+
}
|
|
870
1057
|
// --- main --------------------------------------------------------------
|
|
871
1058
|
async function main() {
|
|
872
1059
|
const [, , command, ...args] = process.argv;
|
|
@@ -969,6 +1156,10 @@ async function main() {
|
|
|
969
1156
|
await cmdContext(project, split.rest.join(" "));
|
|
970
1157
|
break;
|
|
971
1158
|
}
|
|
1159
|
+
case "session-end": {
|
|
1160
|
+
await cmdSessionEndEntry(args);
|
|
1161
|
+
break;
|
|
1162
|
+
}
|
|
972
1163
|
case "help": {
|
|
973
1164
|
cmdHelp();
|
|
974
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
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
|
-
|
|
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:
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
return { project, rest };
|
|
208
|
+
return { project: null, rest: args };
|
|
163
209
|
}
|
|
164
210
|
|
|
165
211
|
// --- cm start ----------------------------------------------------------
|
|
@@ -578,13 +624,17 @@ const LOW_QUALITY_PREFIXES = [
|
|
|
578
624
|
"tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
|
|
579
625
|
];
|
|
580
626
|
|
|
627
|
+
const SEMVER_RE = /^\\d+\\.\\d+\\.\\d+$/;
|
|
628
|
+
|
|
581
629
|
// Filters out lazy/placeholder commit messages ("wip", "fix", "m", "...")
|
|
582
|
-
// so only messages worth remembering
|
|
583
|
-
// first word, not a raw prefix
|
|
584
|
-
// to JWT" isn't rejected just
|
|
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".
|
|
585
634
|
function isQualityCommitMessage(message) {
|
|
586
635
|
const trimmed = message.trim();
|
|
587
|
-
if (trimmed.length
|
|
636
|
+
if (trimmed.length < 15) return false;
|
|
637
|
+
if (SEMVER_RE.test(trimmed)) return false;
|
|
588
638
|
if (!trimmed.includes(" ")) return false;
|
|
589
639
|
|
|
590
640
|
const stripped = trimmed.replace(/\\s/g, "");
|
|
@@ -999,6 +1049,165 @@ async function cmdContext(project: string, task: string) {
|
|
|
999
1049
|
console.log(lines.join("\n"));
|
|
1000
1050
|
}
|
|
1001
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
|
+
|
|
1002
1211
|
// --- main --------------------------------------------------------------
|
|
1003
1212
|
|
|
1004
1213
|
async function main() {
|
|
@@ -1094,6 +1303,10 @@ async function main() {
|
|
|
1094
1303
|
await cmdContext(project, split.rest.join(" "));
|
|
1095
1304
|
break;
|
|
1096
1305
|
}
|
|
1306
|
+
case "session-end": {
|
|
1307
|
+
await cmdSessionEndEntry(args);
|
|
1308
|
+
break;
|
|
1309
|
+
}
|
|
1097
1310
|
case "help": {
|
|
1098
1311
|
cmdHelp();
|
|
1099
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[] {
|