mulmoclaude 2.3.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 (60) hide show
  1. package/README.md +1 -1
  2. package/client/assets/{JsonEditor-CU5Q6RZZ.js → JsonEditor-ZvNJuqs_.js} +1 -1
  3. package/client/assets/{index-hiEYYDcg.js → index-CkmW0yWc.js} +396 -396
  4. package/client/assets/{marp-WnOHAJUy.js → marp-OXXg-yyV.js} +1 -1
  5. package/client/assets/vue-i18n-BbRMUmOW.js +3 -0
  6. package/client/index.html +2 -2
  7. package/package.json +11 -11
  8. package/server/agent/index.ts +5 -3
  9. package/server/agent/prompt.ts +6 -3
  10. package/server/agent/promptWorkspacePaths.ts +12 -0
  11. package/server/api/auth/bearerAuth.ts +10 -24
  12. package/server/api/auth/viewToken.ts +5 -12
  13. package/server/api/routes/files.ts +34 -17
  14. package/server/build/mcp-server.mjs +604 -607
  15. package/server/events/pub-sub/handshakeAuth.ts +9 -0
  16. package/server/events/pub-sub/index.ts +47 -14
  17. package/server/index.ts +8 -23
  18. package/server/plugins/dev-watcher.ts +1 -1
  19. package/server/prompts/system/system.md +1 -2
  20. package/server/utils/launcher/preflight.d.mts +8 -5
  21. package/server/utils/launcher/preflight.mjs +34 -8
  22. package/server/workspace/journal/index.ts +3 -0
  23. package/server/workspace/journal/memoryExtractor.ts +6 -0
  24. package/server/workspace/memory/legacy-fold.ts +34 -0
  25. package/server/workspace/memory/legacy-migration-gate.ts +27 -0
  26. package/server/workspace/memory/llm-classifier.ts +4 -22
  27. package/server/workspace/memory/migrate.ts +52 -21
  28. package/server/workspace/memory/run.ts +32 -28
  29. package/server/workspace/memory/seed-topic-layout-run.ts +50 -0
  30. package/server/workspace/memory/seed-topic-layout.ts +34 -0
  31. package/server/workspace/memory/startup.ts +29 -0
  32. package/server/workspace/memory/topic-migrate.ts +78 -9
  33. package/server/workspace/memory/topic-run.ts +14 -9
  34. package/server/workspace/memory/topic-swap.ts +4 -6
  35. package/server/workspace/workspace.ts +4 -7
  36. package/src/App.vue +5 -0
  37. package/src/components/AuthTokenStaleBanner.vue +27 -0
  38. package/src/components/BackendOfflineBanner.vue +13 -24
  39. package/src/components/RemoteHostOfflineBanner.vue +12 -21
  40. package/src/components/StatusBanner.vue +65 -0
  41. package/src/composables/usePubSub.ts +32 -0
  42. package/src/config/systemFileDescriptors.ts +1 -1
  43. package/src/lang/de.ts +10 -4
  44. package/src/lang/en.ts +10 -4
  45. package/src/lang/es.ts +10 -4
  46. package/src/lang/fr.ts +10 -4
  47. package/src/lang/ja.ts +11 -4
  48. package/src/lang/ko.ts +11 -4
  49. package/src/lang/pt-BR.ts +10 -4
  50. package/src/lang/zh.ts +11 -4
  51. package/src/main.ts +1 -1
  52. package/src/utils/api.ts +45 -22
  53. package/src/utils/auth/authToken.ts +14 -0
  54. package/src/utils/auth/authTokenHtml.ts +19 -0
  55. package/src/utils/auth/authTokenRecovery.ts +73 -0
  56. package/src/utils/dom/authTokenMeta.ts +3 -1
  57. package/client/assets/vue-i18n-CdSW1CeF.js +0 -3
  58. package/server/system/logs/aaa +0 -737
  59. package/server/system/logs/bb +0 -446
  60. package/server/workspace/billing-migration.ts +0 -69
@@ -0,0 +1,9 @@
1
+ import { isAuthorizedToken } from "@mulmoclaude/common/token-guard";
2
+ import { isRecord } from "../../utils/types.js";
3
+
4
+ // `auth` is whatever the socket.io client put in `io({ auth })`. Only a record
5
+ // whose `token` passes the shared bearer rule connects; a server with no token
6
+ // yet refuses everything, as `bearerAuth` does for HTTP.
7
+ export function isAuthorizedPubSubHandshake(auth: unknown, expectedToken: string | null): boolean {
8
+ return isRecord(auth) && isAuthorizedToken(auth.token, expectedToken);
9
+ }
@@ -1,26 +1,59 @@
1
1
  import http from "http";
2
- import { Server as IOServer } from "socket.io";
2
+ import { Server as IOServer, type Socket } from "socket.io";
3
3
 
4
+ import { UNAUTHORIZED_MESSAGE } from "@mulmoclaude/common/token-guard";
4
5
  import { log } from "../../system/logger/index.js";
6
+ import { isAuthorizedPubSubHandshake } from "./handshakeAuth.js";
5
7
 
6
8
  export interface IPubSub {
7
9
  /** Publish data to all clients subscribed to this channel. */
8
10
  publish: (channel: string, data: unknown) => void;
9
11
  }
10
12
 
13
+ export interface PubSubOptions {
14
+ /** The bearer token a handshake must present. `null` while the server
15
+ * has none yet, which refuses every handshake. */
16
+ tokenProvider: () => string | null;
17
+ }
18
+
11
19
  const logRoomError =
12
20
  (action: string, channel: string) =>
13
21
  (err: unknown): void => {
14
22
  log.warn("pubsub", `socket room ${action} failed`, { channel, error: String(err) });
15
23
  };
16
24
 
25
+ // `join`/`leave` return `void | Promise<void>`: the in-memory adapter runs
26
+ // synchronously and returns undefined, while a clustered adapter goes over
27
+ // the wire. Nothing here depends on the result, but the promise branch
28
+ // still needs a terminal handler — a bare `void` would turn an adapter
29
+ // failure into an unhandled rejection. `Promise.resolve` normalises both.
30
+ function bindRoomEvents(socket: Socket): void {
31
+ socket.on("subscribe", (channel: unknown) => {
32
+ if (typeof channel === "string") Promise.resolve(socket.join(channel)).catch(logRoomError("subscribe", channel));
33
+ });
34
+ socket.on("unsubscribe", (channel: unknown) => {
35
+ if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
36
+ });
37
+ }
38
+
39
+ // A refused handshake leaves the engine.io connection open, and that one
40
+ // connection can keep sending CONNECT packets; one line per connection keeps
41
+ // a misconfigured relay diagnosable without letting a loop fill the log.
42
+ const warnedConnections = new WeakSet<object>();
43
+
44
+ function warnRefusedOnce(socket: Socket): void {
45
+ if (warnedConnections.has(socket.conn)) return;
46
+ warnedConnections.add(socket.conn);
47
+ log.warn("pubsub", "handshake refused: bearer token missing or invalid", { address: socket.handshake.address });
48
+ }
49
+
17
50
  // Channel names are treated as socket.io rooms — one room per
18
51
  // channel. Subscribe/unsubscribe is plain `socket.join` /
19
52
  // `socket.leave`. Publish broadcasts to the room. Reconnect /
20
53
  // heartbeat / multi-transport fallback are handled by socket.io
21
54
  // itself, which is why we switched off raw ws.
22
55
 
23
- export function createPubSub(server: http.Server): IPubSub {
56
+ export function createPubSub(server: http.Server, options: PubSubOptions): IPubSub {
24
57
  const ioServer = new IOServer(server, {
25
58
  path: "/ws/pubsub",
26
59
  // Server binds to 127.0.0.1 only, so CORS is moot — but
@@ -36,20 +69,20 @@ export function createPubSub(server: http.Server): IPubSub {
36
69
  transports: ["websocket"],
37
70
  });
38
71
 
39
- ioServer.on("connection", (socket) => {
40
- // `join`/`leave` return `void | Promise<void>`: the in-memory adapter runs
41
- // synchronously and returns undefined, while a clustered adapter goes over
42
- // the wire. Nothing here depends on the result, but the promise branch
43
- // still needs a terminal handler — a bare `void` would turn an adapter
44
- // failure into an unhandled rejection. `Promise.resolve` normalises both.
45
- socket.on("subscribe", (channel: unknown) => {
46
- if (typeof channel === "string") Promise.resolve(socket.join(channel)).catch(logRoomError("subscribe", channel));
47
- });
48
- socket.on("unsubscribe", (channel: unknown) => {
49
- if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
50
- });
72
+ // Same token every `/api/*` call presents (#3433). A refused handshake
73
+ // destroys the client socket, so socket.io does not retry it — the SPA
74
+ // reads that as "reload for a fresh token" rather than spinning.
75
+ ioServer.use((socket, next) => {
76
+ if (isAuthorizedPubSubHandshake(socket.handshake.auth, options.tokenProvider())) {
77
+ next();
78
+ return;
79
+ }
80
+ warnRefusedOnce(socket);
81
+ next(new Error(UNAUTHORIZED_MESSAGE));
51
82
  });
52
83
 
84
+ ioServer.on("connection", bindRoomEvents);
85
+
53
86
  return {
54
87
  publish(channel: string, data: unknown): void {
55
88
  ioServer.to(channel).emit("data", { channel, data });
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
@@ -1548,7 +1533,7 @@ process.on("SIGTERM", () => {
1548
1533
  // (CodeRabbit review on PR #1196). Both `createPubSub` and
1549
1534
  // `initNotifier` are sync, so wiring them up here costs nothing
1550
1535
  // and closes the window.
1551
- const earlyPubsub = createPubSub(httpServer);
1536
+ const earlyPubsub = createPubSub(httpServer, { tokenProvider: getCurrentToken });
1552
1537
  initNotifier({
1553
1538
  publish: (channel, payload) => earlyPubsub.publish(channel, payload),
1554
1539
  });
@@ -6,7 +6,7 @@
6
6
  // chokidar to avoid pulling another runtime dependency. Recursive
7
7
  // watching is reliable on macOS / Linux / Windows starting from Node
8
8
  // 20.12 (Linux had race conditions and crash-on-delete bugs in earlier
9
- // 20.x patches), which engines.node (>=22.19) clears comfortably.
9
+ // 20.x patches), which engines.node clears comfortably.
10
10
  //
11
11
  // The publish + warnServerSideChange callbacks are injected so tests
12
12
  // can exercise the debounce + classification logic without booting the
@@ -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.
@@ -1,17 +1,20 @@
1
1
  // Type declarations for preflight.mjs.
2
2
 
3
- export interface NodeVersionRequirement {
3
+ export interface NodeVersion {
4
4
  major: number;
5
5
  minor: number;
6
+ patch: number;
6
7
  }
7
8
 
8
- export const REQUIRED_NODE: NodeVersionRequirement;
9
+ export const REQUIRED_NODE: readonly NodeVersion[];
9
10
 
10
- export function parseNodeVersion(raw: string): NodeVersionRequirement | null;
11
+ export function parseNodeVersion(raw: string): NodeVersion | null;
11
12
 
12
- export function isNodeVersionSupported(raw: string, required?: NodeVersionRequirement): boolean;
13
+ export function isNodeVersionSupported(raw: string, required?: readonly NodeVersion[]): boolean;
13
14
 
14
- export function formatRequiredNode(required?: NodeVersionRequirement): string;
15
+ export function formatRequiredNode(required?: readonly NodeVersion[]): string;
16
+
17
+ export function formatEnginesRange(required?: readonly NodeVersion[]): string;
15
18
 
16
19
  export interface CommandProbeOptions {
17
20
  env?: Record<string, string | undefined>;
@@ -9,7 +9,13 @@ import { execFileSync } from "node:child_process";
9
9
 
10
10
  // Mirrors `engines.node` in packages/mulmoclaude/package.json.
11
11
  // test/utils/launcher/test_preflight.ts fails if the two drift.
12
- export const REQUIRED_NODE = { major: 22, minor: 19 };
12
+ // One floor per supported major; the last also admits every later major,
13
+ // and a major with no floor of its own (23, 25) is unsupported.
14
+ export const REQUIRED_NODE = [
15
+ { major: 22, minor: 22, patch: 2 },
16
+ { major: 24, minor: 15, patch: 0 },
17
+ { major: 26, minor: 0, patch: 0 },
18
+ ];
13
19
 
14
20
  // These probes run a local binary that reads its own package metadata;
15
21
  // anything past a few seconds means it is wedged, not slow.
@@ -22,30 +28,50 @@ const COMMAND_PROBE_TIMEOUT_MS = 5000;
22
28
  * Returns null for anything unparseable rather than guessing — an
23
29
  * unknown version must not silently pass the requirement.
24
30
  * @param {string} raw
25
- * @returns {{ major: number, minor: number } | null}
31
+ * @returns {import("./preflight.d.mts").NodeVersion | null}
26
32
  */
27
33
  export function parseNodeVersion(raw) {
28
34
  if (typeof raw !== "string") return null;
29
- const match = /^v?(\d+)\.(\d+)\./.exec(raw.trim());
35
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
30
36
  if (!match) return null;
31
- return { major: Number(match[1]), minor: Number(match[2]) };
37
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
38
+ }
39
+
40
+ function compareVersions(left, right) {
41
+ return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
42
+ }
43
+
44
+ function floorFor(found, required) {
45
+ const newest = required[required.length - 1];
46
+ if (found.major > newest.major) return newest;
47
+ return required.find((floor) => floor.major === found.major);
32
48
  }
33
49
 
34
50
  /**
35
51
  * @param {string} raw
36
- * @param {{ major: number, minor: number }} [required]
52
+ * @param {readonly import("./preflight.d.mts").NodeVersion[]} [required]
37
53
  * @returns {boolean}
38
54
  */
39
55
  export function isNodeVersionSupported(raw, required = REQUIRED_NODE) {
40
56
  const found = parseNodeVersion(raw);
41
57
  if (found === null) return false;
42
- if (found.major !== required.major) return found.major > required.major;
43
- return found.minor >= required.minor;
58
+ const floor = floorFor(found, required);
59
+ return floor !== undefined && compareVersions(found, floor) >= 0;
60
+ }
61
+
62
+ function formatVersion({ major, minor, patch }) {
63
+ return `${major}.${minor}.${patch}`;
44
64
  }
45
65
 
46
66
  /** Human-readable form of a requirement, for the {required} placeholder. */
47
67
  export function formatRequiredNode(required = REQUIRED_NODE) {
48
- return `${required.major}.${required.minor}`;
68
+ return required.map(formatVersion).join(" / ");
69
+ }
70
+
71
+ /** The same requirement as an `engines.node` range. */
72
+ export function formatEnginesRange(required = REQUIRED_NODE) {
73
+ const carets = required.slice(0, -1).map((floor) => `^${formatVersion(floor)}`);
74
+ return [...carets, `>=${formatVersion(required[required.length - 1])}`].join(" || ");
49
75
  }
50
76
 
51
77
  /**
@@ -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