mulmoclaude 2.4.0 → 2.4.1

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.
Files changed (34) hide show
  1. package/client/assets/{index-ijQN8Kty.js → index-CkmW0yWc.js} +11 -11
  2. package/client/assets/{marp-DbrRRh1n.js → marp-OXXg-yyV.js} +1 -1
  3. package/client/index.html +1 -1
  4. package/package.json +2 -2
  5. package/server/agent/index.ts +5 -3
  6. package/server/agent/prompt.ts +6 -3
  7. package/server/agent/promptWorkspacePaths.ts +12 -0
  8. package/server/build/mcp-server.mjs +604 -604
  9. package/server/index.ts +7 -22
  10. package/server/prompts/system/system.md +1 -2
  11. package/server/workspace/journal/index.ts +3 -0
  12. package/server/workspace/journal/memoryExtractor.ts +6 -0
  13. package/server/workspace/memory/legacy-fold.ts +34 -0
  14. package/server/workspace/memory/legacy-migration-gate.ts +27 -0
  15. package/server/workspace/memory/llm-classifier.ts +4 -22
  16. package/server/workspace/memory/migrate.ts +52 -21
  17. package/server/workspace/memory/run.ts +32 -28
  18. package/server/workspace/memory/seed-topic-layout-run.ts +50 -0
  19. package/server/workspace/memory/seed-topic-layout.ts +34 -0
  20. package/server/workspace/memory/startup.ts +29 -0
  21. package/server/workspace/memory/topic-migrate.ts +78 -9
  22. package/server/workspace/memory/topic-run.ts +14 -9
  23. package/server/workspace/memory/topic-swap.ts +4 -6
  24. package/server/workspace/workspace.ts +4 -7
  25. package/src/config/systemFileDescriptors.ts +1 -1
  26. package/src/lang/de.ts +5 -4
  27. package/src/lang/en.ts +5 -4
  28. package/src/lang/es.ts +5 -4
  29. package/src/lang/fr.ts +5 -4
  30. package/src/lang/ja.ts +6 -4
  31. package/src/lang/ko.ts +6 -4
  32. package/src/lang/pt-BR.ts +5 -4
  33. package/src/lang/zh.ts +6 -4
  34. package/server/workspace/billing-migration.ts +0 -69
package/server/index.ts CHANGED
@@ -84,7 +84,6 @@ import { announceShadowedEnv, SHADOWED_ENV_KEYS_VAR } from "./system/shadowedEnv
84
84
  import { announceOptionalDeps } from "./system/announceOptionalDeps.js";
85
85
  import { announceGeminiKey } from "./system/announceGeminiKey.js";
86
86
  import { currentLaunchRouteFacts } from "./system/geminiKeyGuidance.js";
87
- import { migrateLegacyBillingPresets } from "./workspace/billing-migration.js";
88
87
  import { APP_VERSION } from "./system/appVersion.js";
89
88
  import { createChatService } from "@mulmobridge/chat-service";
90
89
  import { readSessionJsonl, readSessionMeta } from "./utils/files/session-io.js";
@@ -109,8 +108,7 @@ import { preflightUserServers, logPreflightResult } from "./agent/mcpPreflight.j
109
108
  import { loadMcpConfig, loadSettings } from "./system/config.js";
110
109
  import { getVoiceInputStatus, stopWhisperSidecar, warmupVoiceInput } from "./system/whisper/index.js";
111
110
  import { initWorkspace, workspacePath } from "./workspace/workspace.js";
112
- import { runMemoryMigrationOnce } from "./workspace/memory/run.js";
113
- import { runTopicMigrationOnce } from "./workspace/memory/topic-run.js";
111
+ import { startMemoryMigrations } from "./workspace/memory/startup.js";
114
112
  import { migrateCookingRecipesFromPlugin } from "./workspace/cooking-recipes/migrate.js";
115
113
  import { env, isAblated, isGeminiAvailable } from "./system/env.js";
116
114
  import { buildSandboxStatus } from "./api/sandboxStatus.js";
@@ -242,27 +240,21 @@ warnIfCspExtended();
242
240
  //
243
241
  // Both runners are idempotent: legacy no-ops when the source file
244
242
  // is gone, topic no-ops when the workspace already uses the topic
245
- // format or staging is already pending review. The agent can serve
246
- // traffic while the chain runs.
247
- //
248
- // `.then(noop, noop)` keeps the floating-promises rule happy
249
- // without smuggling in a `void` (banned by sonarjs/void-use). Each
250
- // runner logs its own failures; the chain's outer rejection
251
- // handler is therefore a hard backstop only.
243
+ // format, and retries the swap when staging is left from a crash. The agent can serve
244
+ // traffic while the chain runs, but the journal waits for it
245
+ // (`memoryMigrationsSettled`) so its extractor can't append to a
246
+ // `memory.md` that is mid-migration.
252
247
  //
253
248
  // CLEANUP 2026-07-01: this whole chain is one-shot migration code
254
249
  // for #1029 + #1070. After every active workspace has flipped to
255
250
  // the topic format, delete the chain plus the runners under
256
- // `server/workspace/memory/` (run.ts / migrate.ts /
251
+ // `server/workspace/memory/` (startup.ts + the journal's wait on it / run.ts / migrate.ts /
257
252
  // llm-classifier.ts / topic-run.ts / topic-migrate.ts /
258
253
  // topic-cluster.ts / topic-swap.ts) and the
259
254
  // `scripts/memory-swap-topic-staging.ts` helper. Topic-format
260
255
  // reading / writing (`topic-types.ts`, `topic-io.ts`,
261
256
  // `topic-detect.ts`) plus the topic branch in `prompt.ts` stays.
262
- const noop = (): void => {};
263
- runMemoryMigrationOnce(workspacePath)
264
- .then(() => runTopicMigrationOnce(workspacePath))
265
- .then(noop, noop);
257
+ startMemoryMigrations(workspacePath);
266
258
 
267
259
  // Recipe-book plugin → `mc-cooking-coach` skill migration (#1286).
268
260
  // Boot-time idempotent copy from the plugin's `files.data` scope
@@ -1081,13 +1073,6 @@ async function initBootDiagnostics(): Promise<void> {
1081
1073
  // inside the request. No-op when voice input is off / not ready.
1082
1074
  warmupVoiceInput(bootSettings);
1083
1075
 
1084
- // --- Billing-suite migration ---
1085
- // The invoicing collections moved from bundled `mc-*` presets to
1086
- // help-file recipes. Remove any lingering starred `mc-*` billing
1087
- // skill from `.claude/skills/` (records preserved) and fire a
1088
- // one-time bell. No-op once the dirs are gone. Never throws.
1089
- migrateLegacyBillingPresets();
1090
-
1091
1076
  // --- Collection completion watchers ---
1092
1077
  // Mount `fs.watch` over every discovered collection's dataDir so
1093
1078
  // schemas with `completionField` fire / clear bell notifications
@@ -19,9 +19,8 @@ All data lives in the workspace directory as plain files:
19
19
  - `conversations/chat/` — chat session history (one .jsonl per session)
20
20
  - `conversations/memory/` — distilled user facts as topic files (`<type>/<topic>.md`); see the Memory section below for the index and read rules.
21
21
  - `conversations/summaries/` — journal output (daily / topics / archive)
22
- - `data/contacts/` — address book entries
23
22
  - `data/wiki/` — personal knowledge wiki (index.md, pages/, sources/, log.md)
24
- - `data/scheduler/` — scheduled tasks
23
+ - `config/scheduler/tasks.json` — scheduled tasks (Automations)
25
24
  - `artifacts/documents/`, `artifacts/images/`, `artifacts/html/`, `artifacts/charts/`, `artifacts/spreadsheets/`, `artifacts/stories/` — LLM-generated output
26
25
  - `config/` — settings.json, mcp.json, roles/, helps/
27
26
  - `github/` — git-cloned repositories. Clone here, not /tmp/. If the dir already exists with the same remote, `git pull` to update. If a different remote, ask the user for a new dir name.
@@ -18,6 +18,7 @@ import { buildIndexMarkdown, type IndexTopicEntry, type IndexDailyEntry } from "
18
18
  import { runClaudeCli, ClaudeCliNotFoundError, type Summarize, type JournalSummaryModel } from "./archivist-cli.js";
19
19
  import { extractFirstH1 } from "@mulmoclaude/markdown-utils/markdown/extractFirstH1";
20
20
  import { log } from "../../system/logger/index.js";
21
+ import { memoryMigrationsSettled } from "../memory/startup.js";
21
22
  import { journalMode as resolveJournalMode, loadSettings, type JournalMode } from "../../system/config.js";
22
23
 
23
24
  export { extractFirstH1 };
@@ -57,6 +58,8 @@ export async function maybeRunJournal(opts: MaybeRunJournalOptions = {}): Promis
57
58
  if (mode === "off") return;
58
59
  running = true;
59
60
  try {
61
+ // Waiting under the lock makes triggers that arrive meanwhile return instead of queueing a second pass.
62
+ await memoryMigrationsSettled();
60
63
  await runJournalPass(opts, mode);
61
64
  } catch (err) {
62
65
  if (err instanceof ClaudeCliNotFoundError) {
@@ -7,6 +7,7 @@ import { WORKSPACE_FILES } from "../paths.js";
7
7
  import { writeFileAtomic } from "../../utils/files/atomic.js";
8
8
  import { log } from "../../system/logger/index.js";
9
9
  import { ClaudeCliNotFoundError } from "./archivist-cli.js";
10
+ import { hasTopicFormat } from "../memory/topic-detect.js";
10
11
 
11
12
  const EXTRACTION_SYSTEM_PROMPT = `You are a personal-fact extractor. Given a batch of chat excerpts between a user and an AI assistant, extract ONLY durable facts about the USER — things that would still be true next week.
12
13
 
@@ -39,6 +40,11 @@ export interface MemoryExtractionDeps {
39
40
  }
40
41
 
41
42
  export async function extractAndAppendMemory(deps: MemoryExtractionDeps): Promise<number> {
43
+ // Same predicate the prompt reader uses: in topic format memory.md is never read, so writing it only grows a dead file.
44
+ if (hasTopicFormat(deps.workspaceRoot)) {
45
+ log.debug("memory-extractor", "topic-format workspace, skipping legacy memory.md extraction");
46
+ return 0;
47
+ }
42
48
  const memoryPath = path.join(deps.workspaceRoot, WORKSPACE_FILES.memory);
43
49
  const existingMemory = existsSync(memoryPath) ? readFileSync(memoryPath, "utf-8") : "";
44
50
 
@@ -0,0 +1,34 @@
1
+ // Bullets from a leftover `conversations/memory.md` that the atomic → topic cluster must include, or they drop out of
2
+ // the prompt at the swap (#3460). By the time topic migration clusters, a present `memory.md` is never going to be
3
+ // handled by the #1029 migration: it sits beside `.backup`, or is under its placeholder threshold.
4
+
5
+ import type { MemoryCandidate } from "./migrate.js";
6
+ import type { MemoryEntry, MemoryType } from "./types.js";
7
+
8
+ // Nothing classifies these bullets; the clusterer files them by the type it is given.
9
+ export const FOLDED_LEGACY_TYPE: MemoryType = "fact";
10
+
11
+ const FOLDED_NAME_MAX_CHARS = 80;
12
+
13
+ function dedupeKey(text: string): string {
14
+ return text.trim().replace(/\s+/g, " ").toLowerCase();
15
+ }
16
+
17
+ /** Turn legacy bullets into cluster input, skipping any already present as an atomic entry body or earlier bullet. */
18
+ export function legacyFoldEntries(candidates: readonly MemoryCandidate[], atomicEntries: readonly MemoryEntry[]): MemoryEntry[] {
19
+ const seen = new Set(atomicEntries.map((entry) => dedupeKey(entry.body)));
20
+ const folded: MemoryEntry[] = [];
21
+ candidates.forEach((candidate, index) => {
22
+ const key = dedupeKey(candidate.body);
23
+ if (key.length === 0 || seen.has(key)) return;
24
+ seen.add(key);
25
+ folded.push({
26
+ type: FOLDED_LEGACY_TYPE,
27
+ name: candidate.body.slice(0, FOLDED_NAME_MAX_CHARS),
28
+ description: candidate.section || "from conversations/memory.md",
29
+ body: candidate.body,
30
+ slug: `${FOLDED_LEGACY_TYPE}_legacy-${index}`,
31
+ });
32
+ });
33
+ return folded;
34
+ }
@@ -0,0 +1,27 @@
1
+ // Decides whether the one-shot `memory.md` → atomic migration (#1029) may run, from facts the
2
+ // caller has already read off disk. Pure so every skip reason can be tested without a workspace.
3
+
4
+ // The init placeholder we used to write was smaller than this; a file this small has no facts.
5
+ export const LEGACY_MEMORY_PLACEHOLDER_MAX_BYTES = 64;
6
+
7
+ export interface LegacyMemoryState {
8
+ /** Size of `conversations/memory.md`, or null when the file is absent. */
9
+ legacySizeBytes: number | null;
10
+ /** `memory.md.backup` exists — the marker a previous migration completed. */
11
+ backupExists: boolean;
12
+ /** The workspace already uses the topic layout (#1070). */
13
+ topicFormat: boolean;
14
+ }
15
+
16
+ export type LegacyMigrationSkipReason = "no-legacy-file" | "topic-format" | "placeholder" | "already-migrated";
17
+
18
+ export type LegacyMigrationGate = { run: true } | { run: false; reason: LegacyMigrationSkipReason };
19
+
20
+ export function decideLegacyMigration(state: LegacyMemoryState): LegacyMigrationGate {
21
+ if (state.legacySizeBytes === null) return { run: false, reason: "no-legacy-file" };
22
+ // Atomic files written into a topic `memory/` are never read and never re-clustered.
23
+ if (state.topicFormat) return { run: false, reason: "topic-format" };
24
+ if (state.legacySizeBytes < LEGACY_MEMORY_PLACEHOLDER_MAX_BYTES) return { run: false, reason: "placeholder" };
25
+ if (state.backupExists) return { run: false, reason: "already-migrated" };
26
+ return { run: true };
27
+ }
@@ -18,10 +18,8 @@ import type { MemoryClassification, MemoryCandidate, MemoryClassifier } from "./
18
18
  import { isMemoryType } from "./types.js";
19
19
  import { extractFirstObject, stripFenceAndWhitespace } from "./llm-json.js";
20
20
 
21
- import { ClaudeCliNotFoundError, type Summarize } from "../journal/archivist-cli.js";
22
- import { errorMessage } from "../../utils/errors.js";
21
+ import type { Summarize } from "../journal/archivist-cli.js";
23
22
  import { isRecord } from "../../utils/types.js";
24
- import { log } from "../../system/logger/index.js";
25
23
 
26
24
  const SYSTEM_PROMPT = `You are classifying a single bullet from a personal-memory file into one of four types.
27
25
 
@@ -46,26 +44,10 @@ interface DepsForLlmClassifier {
46
44
  }
47
45
 
48
46
  export function makeLlmMemoryClassifier(deps: DepsForLlmClassifier): MemoryClassifier {
47
+ // A failed call throws (CLI missing, quota, auth, timeout) so the migration can tell it apart from
48
+ // the LLM deliberately answering `null` — only the latter is a real skip.
49
49
  return async (candidate: MemoryCandidate) => {
50
- const userPrompt = buildUserPrompt(candidate);
51
- let raw: string;
52
- try {
53
- raw = await deps.summarize(SYSTEM_PROMPT, userPrompt);
54
- } catch (err) {
55
- // Let `ClaudeCliNotFoundError` escape so the caller can abort
56
- // the whole migration. Without this, every candidate runs
57
- // through the classifier, gets `null` back, and migrateLegacyMemory
58
- // produces an empty result + (pre-#1091) renames the legacy file
59
- // to `.backup`. Even with #1091 the user pays N classifier
60
- // failures (each spawns claude) before realising the CLI is
61
- // missing (#1061 review).
62
- if (err instanceof ClaudeCliNotFoundError) throw err;
63
- log.warn("memory", "llm classifier: summarize threw", {
64
- preview: candidate.body.slice(0, 80),
65
- error: errorMessage(err),
66
- });
67
- return null;
68
- }
50
+ const raw = await deps.summarize(SYSTEM_PROMPT, buildUserPrompt(candidate));
69
51
  return parseClassifierVerdict(raw);
70
52
  };
71
53
  }
@@ -58,8 +58,13 @@ export interface MigrationResult {
58
58
  * surface to the user. The migration continues past write errors
59
59
  * so a single bad entry doesn't strand the whole batch. */
60
60
  writeErrors: number;
61
+ /** Candidates whose classifier call failed. Any failure aborts before writing or renaming,
62
+ * so `memory.md` stays readable and the next start retries. */
63
+ classifierFailures: number;
61
64
  }
62
65
 
66
+ type ClassifyOutcome = { kind: "classified"; classification: MemoryClassification } | { kind: "skipped" } | { kind: "failed" };
67
+
63
68
  // Number of in-flight `classify` calls. Each call spawns the Claude
64
69
  // CLI, so there's a real cost to going wide; 4 is empirically a
65
70
  // sweet spot — well above the 1-at-a-time baseline (~10× faster on
@@ -85,13 +90,35 @@ export async function migrateLegacyMemory(workspaceRoot: string, classify: Memor
85
90
  candidateCount: candidates.length,
86
91
  concurrency: CLASSIFY_CONCURRENCY,
87
92
  });
88
- const classifications = await classifyInParallel(classify, candidates);
93
+ const outcomes = await classifyInParallel(classify, candidates);
89
94
 
90
95
  const result = emptyResult(false);
96
+ result.classifierFailures = outcomes.filter((outcome) => outcome.kind === "failed").length;
97
+ if (result.classifierFailures > 0) {
98
+ log.warn("memory", "migration: classifier failed, leaving memory.md in place for the next start", {
99
+ classifierFailures: result.classifierFailures,
100
+ candidateCount: candidates.length,
101
+ });
102
+ return result;
103
+ }
104
+ await writeClassifiedEntries(workspaceRoot, candidates, outcomes, result);
105
+
106
+ await regenerateIndex(workspaceRoot);
107
+ await renameToBackup(sourcePath);
108
+ return result;
109
+ }
110
+
111
+ async function writeClassifiedEntries(
112
+ workspaceRoot: string,
113
+ candidates: readonly MemoryCandidate[],
114
+ outcomes: readonly ClassifyOutcome[],
115
+ result: MigrationResult,
116
+ ): Promise<void> {
91
117
  const usedSlugs = new Set<string>();
92
118
 
93
119
  for (const [index, candidate] of candidates.entries()) {
94
- const classification = classifications[index];
120
+ const outcome = outcomes[index];
121
+ const classification = outcome?.kind === "classified" ? outcome.classification : null;
95
122
  if (!classification) {
96
123
  result.skippedByClassifier += 1;
97
124
  } else {
@@ -119,32 +146,35 @@ export async function migrateLegacyMemory(workspaceRoot: string, classify: Memor
119
146
  });
120
147
  }
121
148
  }
122
-
123
- await regenerateIndex(workspaceRoot);
124
- await renameToBackup(sourcePath);
125
- return result;
126
149
  }
127
150
 
128
151
  // Classify candidates with bounded concurrency. Workers pull
129
152
  // indices off a shared cursor and write into a pre-allocated array
130
153
  // so `result[i]` matches `candidates[i]`. Each await falls inside
131
- // `safeClassify`, which already swallows individual classifier
132
- // throws (logging once); a concurrent failure can therefore not
133
- // poison the rest of the batch.
134
- async function classifyInParallel(classify: MemoryClassifier, candidates: readonly MemoryCandidate[]): Promise<(MemoryClassification | null)[]> {
135
- // `Array.from`, not `new Array(n).fill(null)`: `fill` on the `any[]` that
136
- // `new Array(n)` produces keeps the element type `any`. Both build the same
137
- // dense array of nulls.
138
- const results: (MemoryClassification | null)[] = Array.from({ length: candidates.length }, () => null);
154
+ // `safeClassify`, which turns an individual classifier throw into a
155
+ // `failed` outcome (logging once); a concurrent failure can therefore
156
+ // not poison the rest of the batch.
157
+ async function classifyInParallel(classify: MemoryClassifier, candidates: readonly MemoryCandidate[]): Promise<ClassifyOutcome[]> {
158
+ const results: ClassifyOutcome[] = Array.from({ length: candidates.length }, (): ClassifyOutcome => ({ kind: "skipped" }));
139
159
  let nextIndex = 0;
160
+ // One failure already aborts the migration, so stop spending classifier calls on the rest.
161
+ let failed = false;
140
162
  const worker = async (): Promise<void> => {
141
- while (true) {
163
+ while (!failed) {
142
164
  const index = nextIndex;
143
165
  nextIndex += 1;
144
166
  const candidate = candidates[index];
145
167
  // Absent element == the shared cursor ran past the end.
146
168
  if (candidate === undefined) return;
147
- results[index] = await safeClassify(classify, candidate);
169
+ let outcome: ClassifyOutcome;
170
+ try {
171
+ outcome = await safeClassify(classify, candidate);
172
+ } catch (err) {
173
+ failed = true;
174
+ throw err;
175
+ }
176
+ results[index] = outcome;
177
+ if (outcome.kind === "failed") failed = true;
148
178
  }
149
179
  };
150
180
  const workers = Array.from({ length: Math.min(CLASSIFY_CONCURRENCY, candidates.length) }, () => worker());
@@ -158,6 +188,7 @@ function emptyResult(noop: boolean): MigrationResult {
158
188
  written: { preference: 0, interest: 0, fact: 0, reference: 0 },
159
189
  skippedByClassifier: 0,
160
190
  writeErrors: 0,
191
+ classifierFailures: 0,
161
192
  };
162
193
  }
163
194
 
@@ -167,7 +198,7 @@ function emptyResult(noop: boolean): MigrationResult {
167
198
  // rides along as `section` so the classifier can use it as context.
168
199
  // Parsing is character-driven (no regex) to keep sonar happy and to
169
200
  // make the bullet-vs-header classification obvious from the code.
170
- function parseCandidates(raw: string): MemoryCandidate[] {
201
+ export function parseCandidates(raw: string): MemoryCandidate[] {
171
202
  const lines = raw.split("\n");
172
203
  let section = "";
173
204
  const out: MemoryCandidate[] = [];
@@ -200,11 +231,11 @@ function extractBullet(line: string): string | null {
200
231
  return body.length > 0 ? body : null;
201
232
  }
202
233
 
203
- async function safeClassify(classify: MemoryClassifier, candidate: MemoryCandidate): Promise<MemoryClassification | null> {
234
+ async function safeClassify(classify: MemoryClassifier, candidate: MemoryCandidate): Promise<ClassifyOutcome> {
204
235
  try {
205
236
  const verdict = await classify(candidate);
206
- if (verdict && isMemoryType(verdict.type)) return verdict;
207
- return null;
237
+ if (verdict && isMemoryType(verdict.type)) return { kind: "classified", classification: verdict };
238
+ return { kind: "skipped" };
208
239
  } catch (err) {
209
240
  // Startup-wide failures (CLI missing) MUST escape so
210
241
  // `runMemoryMigrationOnce` can log once + leave the legacy file
@@ -216,7 +247,7 @@ async function safeClassify(classify: MemoryClassifier, candidate: MemoryCandida
216
247
  preview: candidate.body.slice(0, 80),
217
248
  error: errorMessage(err),
218
249
  });
219
- return null;
250
+ return { kind: "failed" };
220
251
  }
221
252
  }
222
253
 
@@ -22,6 +22,9 @@ import path from "node:path";
22
22
  import { runClaudeCli, ClaudeCliNotFoundError, type Summarize } from "../journal/archivist-cli.js";
23
23
  import { makeLlmMemoryClassifier } from "./llm-classifier.js";
24
24
  import { migrateLegacyMemory } from "./migrate.js";
25
+ import { decideLegacyMigration, type LegacyMemoryState, type LegacyMigrationSkipReason } from "./legacy-migration-gate.js";
26
+ import { hasTopicFormat } from "./topic-detect.js";
27
+ import { WORKSPACE_FILES } from "../paths.js";
25
28
  import { errorMessage } from "../../utils/errors.js";
26
29
  import { log } from "../../system/logger/index.js";
27
30
 
@@ -32,35 +35,11 @@ export interface RunMigrationDeps {
32
35
  }
33
36
 
34
37
  export async function runMemoryMigrationOnce(workspaceRoot: string, deps: RunMigrationDeps = {}): Promise<void> {
35
- const legacyPath = path.join(workspaceRoot, "conversations", "memory.md");
36
- if (!existsSync(legacyPath)) {
37
- log.debug("memory", "migration: no legacy file, skipping");
38
- return;
39
- }
40
- // If the file is suspiciously empty (just the placeholder we used
41
- // to write at init), skip — the migration would yield zero entries
42
- // and the rename to `.backup` is pointless.
43
- const stat = statSync(legacyPath);
44
- if (stat.size < 64) {
45
- log.info("memory", "migration: legacy file is below the placeholder threshold, skipping");
46
- return;
47
- }
48
- // The rename to `.backup` is the final step of `migrateLegacyMemory`,
49
- // so its presence is the "migration completed" marker. A workspace
50
- // where both `memory.md` AND `memory.md.backup` exist means the
51
- // user re-introduced the legacy file after a previous successful
52
- // migration (probably by mistake or by extracting the backup).
53
- // Re-running here would re-classify the bullets and could clobber
54
- // typed entries the user has been editing in place; skip with a
55
- // clear log instead. The interrupted-migration retry case still
56
- // works because no `.backup` exists in that state — the rename
57
- // never ran.
38
+ const legacyPath = path.join(workspaceRoot, WORKSPACE_FILES.memory);
58
39
  const backupPath = `${legacyPath}.backup`;
59
- if (existsSync(backupPath)) {
60
- log.info("memory", "migration: legacy file present but .backup also exists — refusing to re-run", {
61
- legacyPath,
62
- backupPath,
63
- });
40
+ const gate = decideLegacyMigration(readLegacyMemoryState(workspaceRoot, legacyPath, backupPath));
41
+ if (!gate.run) {
42
+ logSkip(gate.reason, legacyPath, backupPath);
64
43
  return;
65
44
  }
66
45
  const summarize = deps.summarize ?? runClaudeCli;
@@ -73,6 +52,7 @@ export async function runMemoryMigrationOnce(workspaceRoot: string, deps: RunMig
73
52
  written: result.written,
74
53
  skippedByClassifier: result.skippedByClassifier,
75
54
  writeErrors: result.writeErrors,
55
+ classifierFailures: result.classifierFailures,
76
56
  });
77
57
  } catch (err) {
78
58
  if (err instanceof ClaudeCliNotFoundError) {
@@ -82,3 +62,27 @@ export async function runMemoryMigrationOnce(workspaceRoot: string, deps: RunMig
82
62
  log.error("memory", "migration: threw, legacy memory left in place", { error: errorMessage(err) });
83
63
  }
84
64
  }
65
+
66
+ function readLegacyMemoryState(workspaceRoot: string, legacyPath: string, backupPath: string): LegacyMemoryState {
67
+ let legacySizeBytes: number | null = null;
68
+ try {
69
+ legacySizeBytes = statSync(legacyPath).size;
70
+ } catch {
71
+ // ENOENT (or a rename racing us) → treat as absent.
72
+ }
73
+ return { legacySizeBytes, backupExists: existsSync(backupPath), topicFormat: hasTopicFormat(workspaceRoot) };
74
+ }
75
+
76
+ function logSkip(reason: LegacyMigrationSkipReason, legacyPath: string, backupPath: string): void {
77
+ if (reason === "no-legacy-file") {
78
+ log.debug("memory", "migration: no legacy file, skipping");
79
+ } else if (reason === "topic-format") {
80
+ log.info("memory", "migration: workspace uses topic format, leaving legacy file alone", { legacyPath });
81
+ } else if (reason === "placeholder") {
82
+ log.info("memory", "migration: legacy file is below the placeholder threshold, skipping");
83
+ } else {
84
+ // A previous run renamed memory.md to .backup as its last step, so the user re-introduced the
85
+ // file; re-classifying it could clobber typed entries they have been editing.
86
+ log.info("memory", "migration: legacy file present but .backup also exists — refusing to re-run", { legacyPath, backupPath });
87
+ }
88
+ }
@@ -0,0 +1,50 @@
1
+ import { lstatSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { WORKSPACE_DIRS, WORKSPACE_FILES } from "../paths.js";
5
+ import { MEMORY_TYPES } from "./types.js";
6
+ import { shouldSeedTopicLayout, type MemoryLayoutState } from "./seed-topic-layout.js";
7
+ import { errorMessage } from "../../utils/errors.js";
8
+ import { log } from "../../system/logger/index.js";
9
+
10
+ // `undefined` = could not be read for a reason other than absence.
11
+ type ReadResult<T> = T | null | undefined;
12
+
13
+ function isNotFound(err: unknown): boolean {
14
+ return err instanceof Error && "code" in err && err.code === "ENOENT";
15
+ }
16
+
17
+ function readOrMissing<T>(read: () => T): ReadResult<T> {
18
+ try {
19
+ return read();
20
+ } catch (err) {
21
+ return isNotFound(err) ? null : undefined;
22
+ }
23
+ }
24
+
25
+ function readMemoryLayoutState(workspaceRoot: string): MemoryLayoutState {
26
+ const entries = readOrMissing(() => readdirSync(path.join(workspaceRoot, WORKSPACE_DIRS.memoryDir)));
27
+ const legacyText = readOrMissing(() => readFileSync(path.join(workspaceRoot, WORKSPACE_FILES.memory), "utf-8"));
28
+ // lstat, not existsSync: a broken symlink or a permission error must not read as "no staging".
29
+ const staging = readOrMissing(() => lstatSync(path.join(workspaceRoot, WORKSPACE_DIRS.memoryStaging)));
30
+ return {
31
+ memoryDirEntries: entries ?? [],
32
+ legacyMemoryText: legacyText ?? null,
33
+ stagingExists: staging !== null,
34
+ unreadable: entries === undefined || legacyText === undefined || staging === undefined,
35
+ };
36
+ }
37
+
38
+ /** Create the topic-layout type directories when the workspace has no memory to lose. Returns whether it did.
39
+ * Never throws: boot must not depend on it. */
40
+ export function seedTopicLayoutIfFresh(workspaceRoot: string): boolean {
41
+ if (!shouldSeedTopicLayout(readMemoryLayoutState(workspaceRoot))) return false;
42
+ const memoryRoot = path.join(workspaceRoot, WORKSPACE_DIRS.memoryDir);
43
+ try {
44
+ MEMORY_TYPES.forEach((type) => mkdirSync(path.join(memoryRoot, type), { recursive: true }));
45
+ return true;
46
+ } catch (err) {
47
+ log.warn("memory", "could not create the topic memory layout; staying on the atomic one", { memoryRoot, error: errorMessage(err) });
48
+ return false;
49
+ }
50
+ }
@@ -0,0 +1,34 @@
1
+ // Whether a workspace may start directly in the topic memory layout (#1070) instead of passing through the
2
+ // atomic one. Only when there is no memory to lose: anything else must go through the migration chain.
3
+
4
+ // What `initWorkspace` used to write into a fresh `memory.md` before #1029; it holds no facts.
5
+ export const LEGACY_MEMORY_PLACEHOLDER = "# Memory\n\nDistilled facts about you and your work.\n";
6
+
7
+ // Entries that can sit in an empty memory dir: the atomic layout's generated index and Finder metadata.
8
+ // Anything else — including a `.atomic-backup-*` dir, which can hold entries — means there is memory to keep.
9
+ const HARMLESS_MEMORY_DIR_ENTRIES: ReadonlySet<string> = new Set(["MEMORY.md", ".DS_Store"]);
10
+
11
+ export interface MemoryLayoutState {
12
+ /** Names directly under `conversations/memory/`; empty when the directory is absent. */
13
+ memoryDirEntries: readonly string[];
14
+ /** Contents of `conversations/memory.md`, or null when absent. */
15
+ legacyMemoryText: string | null;
16
+ /** `conversations/memory.next/` exists — a topic migration is staged or mid-swap. */
17
+ stagingExists: boolean;
18
+ /** Something could not be read (other than "does not exist"), so the state is unknown. */
19
+ unreadable: boolean;
20
+ }
21
+
22
+ function normaliseText(text: string): string {
23
+ return text.replace(/^\uFEFF/u, "").replace(/\r\n/g, "\n");
24
+ }
25
+
26
+ export function isLegacyMemoryPlaceholder(text: string): boolean {
27
+ return normaliseText(text) === LEGACY_MEMORY_PLACEHOLDER;
28
+ }
29
+
30
+ export function shouldSeedTopicLayout(state: MemoryLayoutState): boolean {
31
+ if (state.unreadable || state.stagingExists) return false;
32
+ if (state.legacyMemoryText !== null && !isLegacyMemoryPlaceholder(state.legacyMemoryText)) return false;
33
+ return state.memoryDirEntries.every((name) => HARMLESS_MEMORY_DIR_ENTRIES.has(name));
34
+ }
@@ -0,0 +1,29 @@
1
+ // Boot-time memory migration chain plus the promise other subsystems wait on before touching
2
+ // `conversations/memory.md` — the journal's extractor appending mid-migration loses those bullets.
3
+
4
+ import { runMemoryMigrationOnce } from "./run.js";
5
+ import { runTopicMigrationOnce } from "./topic-run.js";
6
+
7
+ export type MemoryMigrationRunner = (workspaceRoot: string) => Promise<void>;
8
+
9
+ const DEFAULT_RUNNERS: readonly MemoryMigrationRunner[] = [runMemoryMigrationOnce, runTopicMigrationOnce];
10
+
11
+ let migrationsSettled: Promise<void> = Promise.resolve();
12
+
13
+ // Chained so a second start never lets the journal slip past a chain that is still running.
14
+ export function startMemoryMigrations(workspaceRoot: string, runners: readonly MemoryMigrationRunner[] = DEFAULT_RUNNERS): void {
15
+ migrationsSettled = migrationsSettled.then(() => runInOrder(workspaceRoot, runners));
16
+ }
17
+
18
+ // Never rejects: a failed migration must not wedge the journal behind it.
19
+ export function memoryMigrationsSettled(): Promise<void> {
20
+ return migrationsSettled;
21
+ }
22
+
23
+ async function runInOrder(workspaceRoot: string, runners: readonly MemoryMigrationRunner[]): Promise<void> {
24
+ try {
25
+ await runners.reduce<Promise<void>>((previous, runner) => previous.then(() => runner(workspaceRoot)), Promise.resolve());
26
+ } catch {
27
+ // Each runner logs its own failure; stopping the chain here matches the previous `.then(noop, noop)`.
28
+ }
29
+ }