stackmem 1.0.5 → 1.0.7

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/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.5",
3
+ "version": "1.0.7",
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,23 @@ 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>]
57
+ cm doctor
58
+ cm undo [project]
59
+ cm export [filename]
60
+ cm import [project] <filename>
52
61
  cm help
53
62
 
54
63
  [project] is optional if a .stackmem file exists in the current
55
- directory (written by 'cm init'). Otherwise it must be given explicitly.`);
64
+ directory (written by 'cm init'). Otherwise it must be given explicitly.
65
+ search/context take --project instead of a positional project, since a
66
+ bare query/task can't otherwise be told apart from a project name.`);
56
67
  process.exit(1);
57
68
  }
58
69
 
@@ -82,9 +93,10 @@ function cmdHelp() {
82
93
  Build an AST index (files, functions, classes) for the codebase
83
94
  at <path>.
84
95
 
85
- cm search [project] <query>
96
+ cm search [--project <name>] <query>
86
97
  Search saved memories and past fixes for a project matching
87
- <query>.
98
+ <query>. The whole query is always the full remaining text — use
99
+ --project to search a project other than the one in .stackmem.
88
100
 
89
101
  cm resolve [project] <memory-id>
90
102
  Mark a memory as resolved.
@@ -101,10 +113,42 @@ function cmdHelp() {
101
113
  the stackmem MCP server with Claude Code, write a CLAUDE.md and
102
114
  .stackmem file, and seed initial memories from the project scan.
103
115
 
104
- cm context [project] <task>
116
+ cm context [--project <name>] <task>
105
117
  Return only the top 5 memories most relevant to a specific
106
118
  task, ranked by a blend of task relevance and decay score,
107
- as a markdown block capped at a 2000 token budget.
119
+ as a markdown block capped at a 2000 token budget. Use
120
+ --project to target a project other than the one in .stackmem.
121
+
122
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
123
+ Capture what happened in a session and extract memories: each
124
+ error becomes an unsolved execution_log entry, each approach
125
+ containing "failed", "didn't work", "reverted", or "switched
126
+ away" becomes a rejection, a summary that passes the quality
127
+ filter becomes a decision, auth-related files become a
128
+ discovery, and any .ts/.js file triggers an AST re-index.
129
+ Accepts the same fields as JSON on stdin instead of flags
130
+ (project, summary, errors_encountered, files_touched,
131
+ approaches_tried) when stdin is piped rather than a terminal.
132
+
133
+ cm doctor
134
+ Check every integration point in the current directory — backend
135
+ connection, MCP registration, .stackmem, git hook, and memory
136
+ count — and report what's working and what's broken.
137
+
138
+ cm undo [project]
139
+ Delete the most recently saved unresolved memory (and any
140
+ memory_links referencing it) for a project.
141
+
142
+ cm export [filename]
143
+ Export all unresolved memories, execution_log entries, and
144
+ memory_links for a project to a JSON file. Defaults to
145
+ <project>-memories.json.
146
+
147
+ cm import [project] <filename>
148
+ Import memories and execution_log entries from a JSON file
149
+ produced by 'cm export'. Inserted under the current device_id
150
+ and project; memory_links are skipped and get rebuilt by
151
+ semantic linking the next time cm save runs.
108
152
 
109
153
  cm help
110
154
  Show this help message.`);
@@ -126,7 +170,22 @@ function readProjectFromFile(): string | null {
126
170
  return content.length > 0 ? content : null;
127
171
  }
128
172
 
173
+ // Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
174
+ // muscle memory from `cm init <project> .` — "." (or "..", or anything
175
+ // path-shaped) is never a real project name, so an explicit arg this shape
176
+ // almost certainly means the caller meant to omit the project entirely.
177
+ function looksLikeAPath(value: string): boolean {
178
+ return value === "." || value === ".." || value.includes("/") || value.includes("\\");
179
+ }
180
+
129
181
  function requireProject(explicit: string | null): string {
182
+ if (explicit && looksLikeAPath(explicit)) {
183
+ console.error(
184
+ `Warning: '${explicit}' is not a valid project name — reading from .stackmem instead.`
185
+ );
186
+ explicit = null;
187
+ }
188
+
130
189
  if (explicit) return explicit;
131
190
 
132
191
  const fromFile = readProjectFromFile();
@@ -154,12 +213,23 @@ function splitOptionalProject(
154
213
  // arg count can't disambiguate an omitted project from a multi-word query —
155
214
  // "cm search bug fix" is ambiguous by count alone. Use .stackmem's presence
156
215
  // instead: if it exists, nothing is positionally a project.
216
+ // Arg count can't disambiguate an omitted project from a multi-word query —
217
+ // "cm search bug fix" is ambiguous by count alone. Treating .stackmem's mere
218
+ // presence as "project omitted" (the previous approach) was worse: it
219
+ // silently swallowed an explicit project into the query text whenever a
220
+ // .stackmem file happened to exist in cwd, so "cm search otherproj auth bug"
221
+ // would search project X (from .stackmem) for the literal text "otherproj
222
+ // auth bug" with no error. Positional args are now always the full
223
+ // query/task; an explicit override uses --project <name> instead, which is
224
+ // never ambiguous and is never silently absorbed.
157
225
  function splitProjectFromVariadic(args: string[]): { project: string | null; rest: string[] } {
158
- if (readProjectFromFile() !== null) {
159
- return { project: null, rest: args };
226
+ const flagIndex = args.indexOf("--project");
227
+ if (flagIndex !== -1 && args[flagIndex + 1] !== undefined) {
228
+ const project = args[flagIndex + 1];
229
+ const rest = [...args.slice(0, flagIndex), ...args.slice(flagIndex + 2)];
230
+ return { project, rest };
160
231
  }
161
- const [project = null, ...rest] = args;
162
- return { project, rest };
232
+ return { project: null, rest: args };
163
233
  }
164
234
 
165
235
  // --- cm start ----------------------------------------------------------
@@ -578,13 +648,17 @@ const LOW_QUALITY_PREFIXES = [
578
648
  "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
579
649
  ];
580
650
 
651
+ const SEMVER_RE = /^\\d+\\.\\d+\\.\\d+$/;
652
+
581
653
  // Filters out lazy/placeholder commit messages ("wip", "fix", "m", "...")
582
- // so only messages worth remembering become decisions. Checked against the
583
- // first word, not a raw prefix match, so a real message like "Migrate auth
584
- // to JWT" isn't rejected just for starting with the letter "m".
654
+ // and bare version bumps ("1.0.4") so only messages worth remembering
655
+ // become decisions. Checked against the first word, not a raw prefix
656
+ // match, so a real message like "Migrate auth to JWT" isn't rejected just
657
+ // for starting with the letter "m".
585
658
  function isQualityCommitMessage(message) {
586
659
  const trimmed = message.trim();
587
- if (trimmed.length <= 20) return false;
660
+ if (trimmed.length < 15) return false;
661
+ if (SEMVER_RE.test(trimmed)) return false;
588
662
  if (!trimmed.includes(" ")) return false;
589
663
 
590
664
  const stripped = trimmed.replace(/\\s/g, "");
@@ -934,7 +1008,7 @@ async function cmdInit(project: string, targetPath: string) {
934
1008
  `stackmem is live. Open Claude Code and ask "what do you know about this project?" to verify.`
935
1009
  );
936
1010
  } else {
937
- console.log("✗ Backend connection failed — run 'cm doctor' (once we build it)");
1011
+ console.log("✗ Backend connection failed — run 'cm doctor'");
938
1012
  }
939
1013
 
940
1014
  // Written last and deliberately: if a commit (and hence the post-commit
@@ -999,6 +1073,382 @@ async function cmdContext(project: string, task: string) {
999
1073
  console.log(lines.join("\n"));
1000
1074
  }
1001
1075
 
1076
+ // --- cm session-end --------------------------------------------------------
1077
+
1078
+ interface SessionEndInput {
1079
+ project: string | null;
1080
+ summary?: string;
1081
+ errors: string[];
1082
+ files: string[];
1083
+ approaches: string[];
1084
+ }
1085
+
1086
+ function splitCommaList(value: string | undefined): string[] {
1087
+ if (!value) return [];
1088
+ return value
1089
+ .split(",")
1090
+ .map((item) => item.trim())
1091
+ .filter(Boolean);
1092
+ }
1093
+
1094
+ // Flags only — stdin JSON is handled separately in cmdSessionEndEntry, since
1095
+ // it carries its own full shape (arrays, not comma-joined strings).
1096
+ function parseSessionEndFlags(args: string[]): SessionEndInput {
1097
+ let project: string | null = null;
1098
+ let summary: string | undefined;
1099
+ let errors: string[] = [];
1100
+ let files: string[] = [];
1101
+ let approaches: string[] = [];
1102
+
1103
+ let i = 0;
1104
+ if (args[i] && !args[i].startsWith("--")) {
1105
+ project = args[i];
1106
+ i++;
1107
+ }
1108
+
1109
+ for (; i < args.length; i++) {
1110
+ const flag = args[i];
1111
+ const value = args[i + 1];
1112
+ if (flag === "--summary") {
1113
+ summary = value;
1114
+ i++;
1115
+ } else if (flag === "--errors") {
1116
+ errors = splitCommaList(value);
1117
+ i++;
1118
+ } else if (flag === "--files") {
1119
+ files = splitCommaList(value);
1120
+ i++;
1121
+ } else if (flag === "--approaches") {
1122
+ approaches = splitCommaList(value);
1123
+ i++;
1124
+ }
1125
+ }
1126
+
1127
+ return { project, summary, errors, files, approaches };
1128
+ }
1129
+
1130
+ async function readStdinJson(): Promise<Record<string, unknown> | null> {
1131
+ const chunks: Buffer[] = [];
1132
+ for await (const chunk of process.stdin) {
1133
+ chunks.push(chunk as Buffer);
1134
+ }
1135
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
1136
+ if (!raw) return null;
1137
+
1138
+ try {
1139
+ const parsed = JSON.parse(raw);
1140
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
1141
+ } catch {
1142
+ return null;
1143
+ }
1144
+ }
1145
+
1146
+ async function cmdSessionEnd(project: string, input: Omit<SessionEndInput, "project">) {
1147
+ let memoriesSaved = 0;
1148
+ let errorsLogged = 0;
1149
+
1150
+ for (const problem of input.errors) {
1151
+ const { error } = await supabase.from("execution_log").insert({
1152
+ project,
1153
+ problem,
1154
+ solution: null,
1155
+ resolved: false,
1156
+ device_id: getDeviceId(),
1157
+ });
1158
+ if (!error) errorsLogged++;
1159
+ }
1160
+
1161
+ for (const approach of input.approaches) {
1162
+ if (!indicatesFailedApproach(approach)) continue;
1163
+ const { error } = await supabase.from("memories").insert({
1164
+ project,
1165
+ type: "rejection",
1166
+ content: approach,
1167
+ device_id: getDeviceId(),
1168
+ });
1169
+ if (!error) memoriesSaved++;
1170
+ }
1171
+
1172
+ if (input.summary && isQualityMessage(input.summary)) {
1173
+ const { error } = await supabase.from("memories").insert({
1174
+ project,
1175
+ type: "decision",
1176
+ content: input.summary.trim(),
1177
+ device_id: getDeviceId(),
1178
+ });
1179
+ if (!error) memoriesSaved++;
1180
+ }
1181
+
1182
+ const authFiles = input.files.filter(isAuthRelatedFile);
1183
+ if (authFiles.length > 0) {
1184
+ const { error } = await supabase.from("memories").insert({
1185
+ project,
1186
+ type: "discovery",
1187
+ content: `session touched auth files: ${authFiles.join(", ")}`,
1188
+ device_id: getDeviceId(),
1189
+ });
1190
+ if (!error) memoriesSaved++;
1191
+ }
1192
+
1193
+ if (input.files.some(isCodeFile)) {
1194
+ analyzeCodebase(process.cwd());
1195
+ }
1196
+
1197
+ console.log(`Session captured. ${memoriesSaved} memories saved.`);
1198
+ if (errorsLogged > 0) {
1199
+ console.log(`${errorsLogged} error(s) logged to execution_log.`);
1200
+ }
1201
+ }
1202
+
1203
+ // Dispatches to JSON-from-stdin or flag parsing depending on how the
1204
+ // command was invoked — piped input (e.g. from a script) uses JSON; an
1205
+ // interactive terminal with no piped stdin uses flags.
1206
+ async function cmdSessionEndEntry(args: string[]) {
1207
+ let input: SessionEndInput;
1208
+
1209
+ if (!process.stdin.isTTY) {
1210
+ const json = await readStdinJson();
1211
+ if (json) {
1212
+ input = {
1213
+ project: typeof json.project === "string" ? json.project : null,
1214
+ summary: typeof json.summary === "string" ? json.summary : undefined,
1215
+ errors: Array.isArray(json.errors_encountered) ? json.errors_encountered : [],
1216
+ files: Array.isArray(json.files_touched) ? json.files_touched : [],
1217
+ approaches: Array.isArray(json.approaches_tried) ? json.approaches_tried : [],
1218
+ };
1219
+ } else {
1220
+ input = parseSessionEndFlags(args);
1221
+ }
1222
+ } else {
1223
+ input = parseSessionEndFlags(args);
1224
+ }
1225
+
1226
+ const project = requireProject(input.project);
1227
+ await cmdSessionEnd(project, {
1228
+ summary: input.summary,
1229
+ errors: input.errors,
1230
+ files: input.files,
1231
+ approaches: input.approaches,
1232
+ });
1233
+ }
1234
+
1235
+ // --- cm doctor ---------------------------------------------------------
1236
+
1237
+ async function cmdDoctor() {
1238
+ let issues = 0;
1239
+ const project = readProjectFromFile();
1240
+
1241
+ // 1. Backend connection
1242
+ const backendOk = await verifyBackendConnection(project ?? "");
1243
+ if (backendOk) {
1244
+ console.log("✓ Backend connection verified");
1245
+ } else {
1246
+ console.log("✗ Backend connection failed — check your network");
1247
+ issues++;
1248
+ }
1249
+
1250
+ // 2. MCP server registration
1251
+ const claudeConfigPath = nodePath.join(nodeOs.homedir(), ".claude.json");
1252
+ let mcpRegistered = false;
1253
+ if (nodeFs.existsSync(claudeConfigPath)) {
1254
+ try {
1255
+ const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
1256
+ mcpRegistered = Boolean(config?.mcpServers?.stackmem);
1257
+ } catch {
1258
+ mcpRegistered = false;
1259
+ }
1260
+ }
1261
+ if (mcpRegistered) {
1262
+ console.log("✓ MCP server registered with Claude Code");
1263
+ } else {
1264
+ console.log("✗ MCP server not registered — run 'cm init <project> .'");
1265
+ issues++;
1266
+ }
1267
+
1268
+ // 3. Project file
1269
+ if (project) {
1270
+ console.log(`✓ Project file found: ${project}`);
1271
+ } else {
1272
+ console.log("✗ No .stackmem found — run 'cm init <project> .'");
1273
+ issues++;
1274
+ }
1275
+
1276
+ // 4. Git hook
1277
+ const gitDir = nodePath.join(process.cwd(), ".git");
1278
+ if (!nodeFs.existsSync(gitDir)) {
1279
+ console.log("✗ No git repo found in current directory");
1280
+ issues++;
1281
+ } else {
1282
+ const hookPath = nodePath.join(gitDir, "hooks", "post-commit");
1283
+ let hookExecutable = false;
1284
+ if (nodeFs.existsSync(hookPath)) {
1285
+ try {
1286
+ nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
1287
+ hookExecutable = true;
1288
+ } catch {
1289
+ hookExecutable = false;
1290
+ }
1291
+ }
1292
+ if (hookExecutable) {
1293
+ console.log("✓ Git hook installed");
1294
+ } else {
1295
+ console.log("✗ Git hook not installed — run 'cm init <project> .'");
1296
+ issues++;
1297
+ }
1298
+ }
1299
+
1300
+ // 5. Memory count
1301
+ if (project) {
1302
+ const { count, error } = await supabase
1303
+ .from("memories")
1304
+ .select("id", { count: "exact", head: true })
1305
+ .eq("project", project)
1306
+ .eq("resolved", false);
1307
+
1308
+ if (!error && (count ?? 0) > 0) {
1309
+ console.log(`✓ ${count} memories stored for "${project}"`);
1310
+ } else {
1311
+ console.log(`✗ No memories yet — make a commit to start capturing`);
1312
+ issues++;
1313
+ }
1314
+ } else {
1315
+ console.log(`✗ No memories yet — make a commit to start capturing`);
1316
+ issues++;
1317
+ }
1318
+
1319
+ console.log("");
1320
+ if (issues === 0) {
1321
+ console.log("All systems operational.");
1322
+ } else {
1323
+ console.log(`${issues} issue(s) found. See above.`);
1324
+ }
1325
+ }
1326
+
1327
+ // --- cm undo -------------------------------------------------------------
1328
+
1329
+ async function cmdUndo(project: string) {
1330
+ const { data, error } = await supabase
1331
+ .from("memories")
1332
+ .select("*")
1333
+ .eq("project", project)
1334
+ .eq("resolved", false)
1335
+ .order("created_at", { ascending: false })
1336
+ .limit(1);
1337
+
1338
+ if (error) fail(`Failed to load most recent memory: ${error.message}`);
1339
+
1340
+ const memory = (data ?? [])[0];
1341
+ if (!memory) {
1342
+ console.log("Nothing to undo.");
1343
+ return;
1344
+ }
1345
+
1346
+ const { error: linksError } = await supabase
1347
+ .from("memory_links")
1348
+ .delete()
1349
+ .or(`source_id.eq.${memory.id},target_id.eq.${memory.id}`);
1350
+
1351
+ if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
1352
+
1353
+ const { error: deleteError } = await supabase.from("memories").delete().eq("id", memory.id);
1354
+
1355
+ if (deleteError) fail(`Failed to delete memory: ${deleteError.message}`);
1356
+
1357
+ console.log(`Undone: [${memory.type}] ${memory.content}`);
1358
+ }
1359
+
1360
+ // --- cm export -------------------------------------------------------------
1361
+
1362
+ async function cmdExport(project: string, filename?: string) {
1363
+ const { data: memories, error: memoriesError } = await supabase
1364
+ .from("memories")
1365
+ .select("*")
1366
+ .eq("project", project)
1367
+ .eq("resolved", false);
1368
+
1369
+ if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
1370
+
1371
+ const { data: executionLog, error: executionLogError } = await supabase
1372
+ .from("execution_log")
1373
+ .select("*")
1374
+ .eq("project", project);
1375
+
1376
+ if (executionLogError) fail(`Failed to load execution log: ${executionLogError.message}`);
1377
+
1378
+ const memoryResults = memories ?? [];
1379
+ const memoryIds = memoryResults.map((m) => m.id);
1380
+
1381
+ let memoryLinks: unknown[] = [];
1382
+ if (memoryIds.length > 0) {
1383
+ const idList = memoryIds.join(",");
1384
+ const { data: links, error: linksError } = await supabase
1385
+ .from("memory_links")
1386
+ .select("*")
1387
+ .or(`source_id.in.(${idList}),target_id.in.(${idList})`);
1388
+
1389
+ if (linksError) fail(`Failed to load memory links: ${linksError.message}`);
1390
+ memoryLinks = links ?? [];
1391
+ }
1392
+
1393
+ const exportData = {
1394
+ project,
1395
+ exported_at: new Date().toISOString(),
1396
+ memories: memoryResults,
1397
+ execution_log: executionLog ?? [],
1398
+ memory_links: memoryLinks,
1399
+ };
1400
+
1401
+ const outputFilename = filename ?? `${project}-memories.json`;
1402
+ nodeFs.writeFileSync(outputFilename, JSON.stringify(exportData, null, 2), "utf-8");
1403
+
1404
+ console.log(`Exported ${memoryResults.length} memories to ${outputFilename}`);
1405
+ }
1406
+
1407
+ // --- cm import -------------------------------------------------------------
1408
+
1409
+ async function cmdImport(project: string, filename: string) {
1410
+ if (!nodeFs.existsSync(filename)) {
1411
+ console.log(`File not found: ${filename}`);
1412
+ return;
1413
+ }
1414
+
1415
+ let parsed: any;
1416
+ try {
1417
+ parsed = JSON.parse(nodeFs.readFileSync(filename, "utf-8"));
1418
+ } catch (error) {
1419
+ fail(`Failed to parse ${filename}: ${error instanceof Error ? error.message : String(error)}`);
1420
+ }
1421
+
1422
+ const memories = Array.isArray(parsed.memories) ? parsed.memories : [];
1423
+ const executionLogEntries = Array.isArray(parsed.execution_log) ? parsed.execution_log : [];
1424
+
1425
+ // memory_links are intentionally skipped — they get rebuilt by semantic
1426
+ // linking the next time cm save runs against the imported memories.
1427
+
1428
+ let importedCount = 0;
1429
+ for (const memory of memories) {
1430
+ const { error } = await supabase.from("memories").insert({
1431
+ project,
1432
+ type: memory.type,
1433
+ content: memory.content,
1434
+ device_id: getDeviceId(),
1435
+ });
1436
+ if (!error) importedCount++;
1437
+ }
1438
+
1439
+ for (const entry of executionLogEntries) {
1440
+ await supabase.from("execution_log").insert({
1441
+ project,
1442
+ problem: entry.problem,
1443
+ solution: entry.solution ?? null,
1444
+ resolved: entry.resolved ?? false,
1445
+ device_id: getDeviceId(),
1446
+ });
1447
+ }
1448
+
1449
+ console.log(`Imported ${importedCount} memories from ${filename}`);
1450
+ }
1451
+
1002
1452
  // --- main --------------------------------------------------------------
1003
1453
 
1004
1454
  async function main() {
@@ -1094,6 +1544,36 @@ async function main() {
1094
1544
  await cmdContext(project, split.rest.join(" "));
1095
1545
  break;
1096
1546
  }
1547
+ case "session-end": {
1548
+ await cmdSessionEndEntry(args);
1549
+ break;
1550
+ }
1551
+ case "doctor": {
1552
+ await cmdDoctor();
1553
+ break;
1554
+ }
1555
+ case "undo": {
1556
+ const split = splitOptionalProject(args, 0);
1557
+ if (!split) usage();
1558
+ const project = requireProject(split.project);
1559
+ await cmdUndo(project);
1560
+ break;
1561
+ }
1562
+ case "export": {
1563
+ if (args.length > 1) usage();
1564
+ const [filename] = args;
1565
+ const project = requireProject(null);
1566
+ await cmdExport(project, filename);
1567
+ break;
1568
+ }
1569
+ case "import": {
1570
+ const split = splitOptionalProject(args, 1);
1571
+ if (!split) usage();
1572
+ const project = requireProject(split.project);
1573
+ const [filename] = split.rest;
1574
+ await cmdImport(project, filename);
1575
+ break;
1576
+ }
1097
1577
  case "help": {
1098
1578
  cmdHelp();
1099
1579
  break;