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
@@ -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
+ }
@@ -3,8 +3,8 @@
3
3
  // Reads the existing #1029-style atomic entries from
4
4
  // `conversations/memory/`, runs a clusterer, and writes the
5
5
  // proposed topic layout to a STAGING dir
6
- // `conversations/memory.next/`. Does NOT swap. The user runs
7
- // `topic-swap.ts` after reviewing.
6
+ // `conversations/memory.next/`. Does NOT swap — `runTopicMigrationOnce`
7
+ // does that right after. A cluster that dropped bullets is never staged.
8
8
  //
9
9
  // Library only — `runTopicMigrationOnce` (in PR-B) decides when
10
10
  // to call this from server startup.
@@ -13,13 +13,16 @@
13
13
  // the one-shot atomic → topic migration chain and goes when the
14
14
  // chain goes.
15
15
 
16
- import { mkdir, rm } from "node:fs/promises";
16
+ import { mkdir, readdir, rm } from "node:fs/promises";
17
17
  import path from "node:path";
18
18
 
19
19
  import { writeFileAtomic } from "../../utils/files/atomic.js";
20
+ import { readTextSafe } from "../../utils/files/safe.js";
20
21
  import { log } from "../../system/logger/index.js";
21
22
  import { errorMessage } from "../../utils/errors.js";
22
- import { WORKSPACE_DIRS } from "../paths.js";
23
+ import { WORKSPACE_DIRS, WORKSPACE_FILES } from "../paths.js";
24
+ import { parseCandidates } from "./migrate.js";
25
+ import { legacyFoldEntries } from "./legacy-fold.js";
23
26
  import { loadAllMemoryEntries } from "./io.js";
24
27
  import { MEMORY_TYPES, type MemoryEntry, type MemoryType } from "./types.js";
25
28
  import type { ClusterMap, ClusterTopic, MemoryClusterer } from "./topic-cluster.js";
@@ -42,6 +45,38 @@ export function topicStagingPath(workspaceRoot: string): string {
42
45
  return path.join(workspaceRoot, WORKSPACE_DIRS.memoryStaging);
43
46
  }
44
47
 
48
+ // Written last, so its presence means every topic file was staged.
49
+ const STAGING_INDEX_FILE = "MEMORY.md";
50
+
51
+ export function stagingIndexPath(stagingPath: string): string {
52
+ return path.join(stagingPath, STAGING_INDEX_FILE);
53
+ }
54
+
55
+ async function countBulletLines(file: string): Promise<number> {
56
+ const text = await readTextSafe(file);
57
+ return text === null ? 0 : text.split("\n").filter((line) => line.startsWith("- ")).length;
58
+ }
59
+
60
+ async function countStagedBullets(stagingPath: string): Promise<number> {
61
+ const perType = await Promise.all(
62
+ MEMORY_TYPES.map(async (type) => {
63
+ const dir = path.join(stagingPath, type);
64
+ const names = await readdir(dir).catch(() => []);
65
+ const counts = await Promise.all(names.filter((name) => name.endsWith(".md")).map((name) => countBulletLines(path.join(dir, name))));
66
+ return counts.reduce((total, count) => total + count, 0);
67
+ }),
68
+ );
69
+ return perType.reduce((total, count) => total + count, 0);
70
+ }
71
+
72
+ /** A leftover staging may be swapped only if it was fully written (index present) and holds a bullet for every
73
+ * input the cluster would take (`countClusterInput`) — the index alone is not enough, because builds before #3458
74
+ * indexed lossy stagings too, and builds before #3460 did not stage leftover `memory.md` bullets. */
75
+ export async function isStagingSwappable(stagingPath: string, requiredBulletCount: number): Promise<boolean> {
76
+ if ((await readTextSafe(stagingIndexPath(stagingPath))) === null) return false;
77
+ return (await countStagedBullets(stagingPath)) >= requiredBulletCount;
78
+ }
79
+
45
80
  interface WrittenTopic {
46
81
  type: MemoryType;
47
82
  topic: ClusterTopic;
@@ -51,29 +86,63 @@ interface WrittenTopic {
51
86
  }
52
87
 
53
88
  export async function clusterAtomicIntoStaging(workspaceRoot: string, clusterer: MemoryClusterer): Promise<TopicMigrationResult> {
54
- const entries = await loadAllMemoryEntries(workspaceRoot);
89
+ const { atomicEntries, foldedEntries } = await loadClusterInput(workspaceRoot);
55
90
  const stagingPath = topicStagingPath(workspaceRoot);
56
- if (entries.length === 0) return emptyResult(stagingPath);
57
- log.info("memory", "topic-migrate: clustering", { entryCount: entries.length });
91
+ if (atomicEntries.length === 0) return emptyResult(stagingPath);
92
+ const entries = [...atomicEntries, ...foldedEntries];
93
+ log.info("memory", "topic-migrate: clustering", { entryCount: entries.length, foldedFromMemoryMd: foldedEntries.length });
58
94
 
59
95
  const map = await runClustererOrCleanup(entries, stagingPath, clusterer);
60
96
  if (!map) return { ...emptyResult(stagingPath), inputCount: entries.length };
97
+ // Checked before anything is written: a lossy staging left by a crash would be promoted by the retry-swap path.
98
+ const bulletsLost = countBulletsLost(entries, map);
99
+ if (bulletsLost > 0) {
100
+ log.warn("memory", "topic-migrate: cluster dropped bullets, not staging it; the atomic layout stays", { bulletsLost });
101
+ await rm(stagingPath, { recursive: true, force: true });
102
+ return { ...emptyResult(stagingPath), inputCount: entries.length, bulletsLost };
103
+ }
61
104
 
62
105
  const topicCounts: Record<MemoryType, number> = { preference: 0, interest: 0, fact: 0, reference: 0 };
63
106
  const written = await writeTopicsToStaging(stagingPath, map, topicCounts);
107
+ const topicsInCluster = MEMORY_TYPES.reduce((total, type) => total + map[type].length, 0);
108
+ if (written.length < topicsInCluster) {
109
+ // The index marks a staging complete for the retry-swap path, so a partial tree must not get one.
110
+ log.warn("memory", "topic-migrate: some topic files failed to stage, discarding the staging", { written: written.length, topicsInCluster });
111
+ await rm(stagingPath, { recursive: true, force: true });
112
+ return { ...emptyResult(stagingPath), inputCount: entries.length };
113
+ }
64
114
  await writeStagingIndex(stagingPath, written);
65
115
 
66
116
  const result: TopicMigrationResult = {
67
117
  noop: false,
68
118
  inputCount: entries.length,
69
119
  topicCounts,
70
- bulletsLost: countBulletsLost(entries, map),
120
+ bulletsLost,
71
121
  stagingPath,
72
122
  };
73
123
  log.info("memory", "topic-migrate: staging ready", { stagingPath, topicCounts, bulletsLost: result.bulletsLost });
74
124
  return result;
75
125
  }
76
126
 
127
+ interface ClusterInput {
128
+ atomicEntries: MemoryEntry[];
129
+ /** Bullets from a leftover `memory.md` that the cluster must also place (#3460). */
130
+ foldedEntries: MemoryEntry[];
131
+ }
132
+
133
+ async function loadClusterInput(workspaceRoot: string): Promise<ClusterInput> {
134
+ const atomicEntries = await loadAllMemoryEntries(workspaceRoot);
135
+ const legacyText = await readTextSafe(path.join(workspaceRoot, WORKSPACE_FILES.memory));
136
+ const foldedEntries = legacyText === null ? [] : legacyFoldEntries(parseCandidates(legacyText), atomicEntries);
137
+ return { atomicEntries, foldedEntries };
138
+ }
139
+
140
+ /** How many bullets a staging must hold to cover this workspace's memory — the same input the cluster takes. */
141
+ export async function countClusterInput(workspaceRoot: string): Promise<number> {
142
+ const { atomicEntries, foldedEntries } = await loadClusterInput(workspaceRoot);
143
+ return atomicEntries.length + foldedEntries.length;
144
+ }
145
+
77
146
  // Wipe stale staging BEFORE the cluster call. If the clusterer
78
147
  // returns null or throws, we leave the workspace with no staging
79
148
  // dir at all — that's the correct "migration didn't complete"
@@ -233,7 +302,7 @@ async function writeStagingIndex(stagingPath: string, written: WrittenTopic[]):
233
302
  if (written.length === 0) {
234
303
  lines.push("_(no entries yet)_", "");
235
304
  }
236
- await writeFileAtomic(path.join(stagingPath, "MEMORY.md"), lines.join("\n"), { uniqueTmp: true });
305
+ await writeFileAtomic(stagingIndexPath(stagingPath), lines.join("\n"), { uniqueTmp: true });
237
306
  }
238
307
 
239
308
  function formatStagingIndexLine(entry: WrittenTopic): string {
@@ -27,13 +27,15 @@
27
27
  // `topic-detect.ts` — minus the atomic-format branch) stays.
28
28
 
29
29
  import { existsSync, statSync } from "node:fs";
30
+ import { rm } from "node:fs/promises";
30
31
  import path from "node:path";
31
32
 
32
33
  import { runClaudeCli, ClaudeCliNotFoundError, type Summarize } from "../journal/archivist-cli.js";
33
34
  import { WORKSPACE_DIRS, WORKSPACE_FILES } from "../paths.js";
35
+ import { LEGACY_MEMORY_PLACEHOLDER_MAX_BYTES } from "./legacy-migration-gate.js";
34
36
  import { loadAllMemoryEntries } from "./io.js";
35
37
  import { makeLlmMemoryClusterer, type MemoryClusterer } from "./topic-cluster.js";
36
- import { clusterAtomicIntoStaging, topicStagingPath } from "./topic-migrate.js";
38
+ import { clusterAtomicIntoStaging, countClusterInput, isStagingSwappable, topicStagingPath } from "./topic-migrate.js";
37
39
  import { swapStagingIntoMemory } from "./topic-swap.js";
38
40
  import { MEMORY_TYPES } from "./types.js";
39
41
  import { errorMessage } from "../../utils/errors.js";
@@ -71,14 +73,17 @@ export async function runTopicMigrationOnce(workspaceRoot: string, deps: RunTopi
71
73
  return;
72
74
  }
73
75
  const stagingPath = topicStagingPath(workspaceRoot);
74
- // If staging is left over from a prior run that crashed between
75
- // cluster and swap, just retry the swap. Re-clustering would burn
76
- // another LLM call and (because clusterAtomicIntoStaging wipes
77
- // staging up front) discard the prior cluster result.
76
+ // A complete staging left by a crash between cluster and swap is swapped without another LLM call. An
77
+ // incomplete one (crash during the cluster call or mid-write) would replace memory/ with a partial tree, so it
78
+ // is discarded and the cluster runs again below.
78
79
  if (existsSync(stagingPath)) {
79
- log.info("memory", "topic-run: existing staging detected, retrying swap", { stagingPath });
80
- await runSwap(workspaceRoot);
81
- return;
80
+ if (await isStagingSwappable(stagingPath, await countClusterInput(workspaceRoot))) {
81
+ log.info("memory", "topic-run: existing staging detected, retrying swap", { stagingPath });
82
+ await runSwap(workspaceRoot);
83
+ return;
84
+ }
85
+ log.warn("memory", "topic-run: discarding an incomplete or lossy staging from an interrupted run", { stagingPath });
86
+ await rm(stagingPath, { recursive: true, force: true });
82
87
  }
83
88
  if (shouldDeferForLegacyMigration(workspaceRoot)) return;
84
89
 
@@ -119,7 +124,7 @@ function shouldDeferForLegacyMigration(workspaceRoot: string): boolean {
119
124
  } catch {
120
125
  legacyStat = null;
121
126
  }
122
- if (legacyStat && legacyStat.size >= 64 && !existsSync(`${legacyPath}.backup`)) {
127
+ if (legacyStat && legacyStat.size >= LEGACY_MEMORY_PLACEHOLDER_MAX_BYTES && !existsSync(`${legacyPath}.backup`)) {
123
128
  log.debug("memory", "topic-run: legacy memory.md still in flight, deferring", { legacyPath });
124
129
  return true;
125
130
  }
@@ -1,10 +1,8 @@
1
- // Swap `conversations/memory/` ↔ `conversations/memory.next/`
2
- // after the user has approved the staging diff (#1070 PR-A).
1
+ // Swap `conversations/memory/` ↔ `conversations/memory.next/` (#1070 PR-A).
3
2
  //
4
- // Library only — invoked from a CLI helper or the agent (a tool
5
- // surface for the agent will land later). The swap is intentionally
6
- // NOT auto-run: the whole point of staging is to give the user a
7
- // chance to inspect.
3
+ // Run automatically by `runTopicMigrationOnce` right after staging,
4
+ // (a cluster that dropped bullets is never staged, so the workspace stays
5
+ // atomic). `yarn memory:swap` runs it by hand.
8
6
  //
9
7
  // CLEANUP 2026-07-01: see `topic-run.ts` — this file is part of
10
8
  // the one-shot atomic → topic migration chain and goes when the
@@ -5,6 +5,7 @@ import { log } from "../system/logger/index.js";
5
5
  import { EAGER_WORKSPACE_DIRS, WORKSPACE_PATHS, workspacePath } from "./paths.js";
6
6
  import { readWorkspaceTextSync, writeWorkspaceTextSync } from "../utils/files/workspace-io.js";
7
7
  import { loadCustomDirs, ensureCustomDirs } from "./custom-dirs.js";
8
+ import { seedTopicLayoutIfFresh } from "./memory/seed-topic-layout-run.js";
8
9
  // Helps + preset skills + their sync logic now live in @mulmoclaude/core/workspace-setup
9
10
  // (shared with MulmoTerminal); the bundled source dirs come from the package.
10
11
  import { seedHelps, presetSkillsAssetDir, syncActivePresetSkills, syncPresetSkills } from "@mulmoclaude/core/workspace-setup";
@@ -23,14 +24,10 @@ export function initWorkspace(): string {
23
24
  mkdirSync(WORKSPACE_PATHS[key], { recursive: true });
24
25
  }
25
26
 
26
- // Ensure the typed-memory directory exists (#1029). Individual
27
- // entry files are written by the agent or by the legacy-memory
28
- // migration; init just guarantees the directory is there so the
29
- // reader and migration both have a place to write to. The legacy
30
- // `conversations/memory.md` is no longer auto-created — migration
31
- // converts it on first start and the new layout becomes the source
32
- // of truth thereafter.
27
+ // Ensure the memory directory exists. A workspace with no memory to lose starts directly in the
28
+ // topic layout (#1070); one that has any goes through the boot-time migration chain instead.
33
29
  mkdirSync(WORKSPACE_PATHS.memoryDir, { recursive: true });
30
+ if (seedTopicLayoutIfFresh(workspacePath)) log.info("memory", "new workspace: starting in the topic memory layout");
34
31
 
35
32
  // Always sync the bundled help docs into workspace/helps/.
36
33
  seedHelps({ destDir: WORKSPACE_PATHS.helps });
package/src/App.vue CHANGED
@@ -4,6 +4,10 @@
4
4
  it's the first thing the user sees when the server isn't
5
5
  reachable. Self-hiding when fetchHealth succeeds. -->
6
6
  <BackendOfflineBanner :on-retry="fetchHealth" />
7
+ <!-- Stale-token notice (#3438) — the server refused this page's token and
8
+ fetching index.html again did not produce one it accepts. Recovery is
9
+ the normal path after a restart; this is its last resort. -->
10
+ <AuthTokenStaleBanner />
7
11
  <!-- Remote-host disconnect notice (#2535) — shown when the host was meant to
8
12
  be connected (a session is parked) but dropped and a silent reconnect
9
13
  couldn't restore it. Offers a one-click re-login. -->
@@ -361,6 +365,7 @@ import { ref, computed, watch, nextTick, onMounted, onScopeDispose, reactive } f
361
365
  import { useI18n } from "vue-i18n";
362
366
  import { getPlugin } from "./tools";
363
367
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
368
+ import AuthTokenStaleBanner from "./components/AuthTokenStaleBanner.vue";
364
369
  import BackendOfflineBanner from "./components/BackendOfflineBanner.vue";
365
370
  import RemoteHostOfflineBanner from "./components/RemoteHostOfflineBanner.vue";
366
371
  import RightSidebar from "./components/RightSidebar.vue";
@@ -0,0 +1,27 @@
1
+ <template>
2
+ <StatusBanner
3
+ v-if="authTokenStale"
4
+ tone="warning"
5
+ icon="sync_disabled"
6
+ testid="auth-token-stale-banner"
7
+ action-testid="auth-token-stale-reload"
8
+ :title="t('authTokenStale.title')"
9
+ :body="t('authTokenStale.body')"
10
+ :action-label="t('authTokenStale.reload')"
11
+ @action="reload"
12
+ />
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ // Shown once the server refused this page's token and fetching index.html
17
+ // again did not produce one it accepts (#3438). Auto-recovery is the normal
18
+ // path after a server restart; this is its last resort, and a reload is the
19
+ // only thing left that fetches a fresh token.
20
+ import { useI18n } from "vue-i18n";
21
+ import StatusBanner from "./StatusBanner.vue";
22
+ import { authTokenStale } from "../utils/auth/authTokenRecovery";
23
+
24
+ const { t } = useI18n();
25
+
26
+ const reload = (): void => window.location.reload();
27
+ </script>
@@ -1,34 +1,23 @@
1
1
  <template>
2
- <div
2
+ <StatusBanner
3
3
  v-if="!backendReachable"
4
- data-testid="backend-offline-banner"
5
- role="alert"
6
- class="flex items-center gap-2 px-3 py-2 bg-red-50 border-b border-red-200 text-red-700 text-sm"
7
- >
8
- <span class="material-icons text-base" aria-hidden="true">cloud_off</span>
9
- <div class="flex-1 min-w-0">
10
- <div class="font-medium">{{ t("backendOffline.title") }}</div>
11
- <div class="text-xs text-red-600 truncate">
12
- <span>{{ t("backendOffline.body") }}</span>
13
- <span v-if="lastBackendError" class="opacity-70">{{ ` — ${lastBackendError}` }}</span>
14
- </div>
15
- </div>
16
- <button
17
- type="button"
18
- data-testid="backend-offline-retry"
19
- class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-red-300 text-red-700 hover:bg-red-100 disabled:opacity-40"
20
- :disabled="retrying"
21
- @click="retry"
22
- >
23
- <span class="material-icons text-sm" :class="retrying ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
24
- {{ t("backendOffline.retry") }}
25
- </button>
26
- </div>
4
+ tone="error"
5
+ icon="cloud_off"
6
+ testid="backend-offline-banner"
7
+ action-testid="backend-offline-retry"
8
+ :title="t('backendOffline.title')"
9
+ :body="t('backendOffline.body')"
10
+ :detail="lastBackendError"
11
+ :action-label="t('backendOffline.retry')"
12
+ :busy="retrying"
13
+ @action="retry"
14
+ />
27
15
  </template>
28
16
 
29
17
  <script setup lang="ts">
30
18
  import { ref } from "vue";
31
19
  import { useI18n } from "vue-i18n";
20
+ import StatusBanner from "./StatusBanner.vue";
32
21
  import { backendReachable, lastBackendError } from "../utils/api";
33
22
 
34
23
  const { t } = useI18n();
@@ -1,26 +1,16 @@
1
1
  <template>
2
- <div
2
+ <StatusBanner
3
3
  v-if="needsReconnect"
4
- data-testid="remote-host-offline-banner"
5
- role="alert"
6
- class="flex items-center gap-2 px-3 py-2 bg-amber-50 border-b border-amber-200 text-amber-800 text-sm"
7
- >
8
- <span class="material-icons text-base" aria-hidden="true">phonelink_off</span>
9
- <div class="flex-1 min-w-0">
10
- <div class="font-medium">{{ t("remoteHostOffline.title") }}</div>
11
- <div class="text-xs text-amber-700 truncate">{{ t("remoteHostOffline.body") }}</div>
12
- </div>
13
- <button
14
- type="button"
15
- data-testid="remote-host-offline-reconnect"
16
- class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-amber-300 text-amber-800 hover:bg-amber-100 disabled:opacity-40"
17
- :disabled="busy"
18
- @click="reconnect"
19
- >
20
- <span class="material-icons text-sm" :class="busy ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
21
- {{ t("remoteHostOffline.reconnect") }}
22
- </button>
23
- </div>
4
+ tone="warning"
5
+ icon="phonelink_off"
6
+ testid="remote-host-offline-banner"
7
+ action-testid="remote-host-offline-reconnect"
8
+ :title="t('remoteHostOffline.title')"
9
+ :body="t('remoteHostOffline.body')"
10
+ :action-label="t('remoteHostOffline.reconnect')"
11
+ :busy="busy"
12
+ @action="reconnect"
13
+ />
24
14
  </template>
25
15
 
26
16
  <script setup lang="ts">
@@ -29,6 +19,7 @@
29
19
  // session expired). Reads the shared `useRemoteHost` store; Reconnect re-runs the
30
20
  // Google sign-in popup from this click (a user gesture, so the popup isn't blocked).
31
21
  import { useI18n } from "vue-i18n";
22
+ import StatusBanner from "./StatusBanner.vue";
32
23
  import { useRemoteHost } from "../composables/useRemoteHost";
33
24
 
34
25
  const { t } = useI18n();
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <div :data-testid="testid" role="alert" class="flex items-center gap-2 px-3 py-2 border-b text-sm" :class="palette.root">
3
+ <span class="material-icons text-base" aria-hidden="true">{{ icon }}</span>
4
+ <div class="flex-1 min-w-0">
5
+ <div class="font-medium">{{ title }}</div>
6
+ <div class="text-xs truncate" :class="palette.body">
7
+ <span>{{ body }}</span>
8
+ <span v-if="detail" class="opacity-70">{{ ` — ${detail}` }}</span>
9
+ </div>
10
+ </div>
11
+ <button
12
+ type="button"
13
+ :data-testid="actionTestid"
14
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border disabled:opacity-40"
15
+ :class="palette.button"
16
+ :disabled="busy"
17
+ @click="emit('action')"
18
+ >
19
+ <span class="material-icons text-sm" :class="busy ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
20
+ {{ actionLabel }}
21
+ </button>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ // The one chrome for a page-wide status notice pinned above the top bar: an
27
+ // icon, a title, one line of body with optional small print, and one action.
28
+ // The wrapper that mounts it owns the signal (`v-if`) and the action.
29
+ import { computed } from "vue";
30
+
31
+ const PALETTES = {
32
+ error: {
33
+ root: "bg-red-50 border-red-200 text-red-700",
34
+ body: "text-red-600",
35
+ button: "border-red-300 text-red-700 hover:bg-red-100",
36
+ },
37
+ warning: {
38
+ root: "bg-amber-50 border-amber-200 text-amber-800",
39
+ body: "text-amber-700",
40
+ button: "border-amber-300 text-amber-800 hover:bg-amber-100",
41
+ },
42
+ } as const;
43
+
44
+ const props = withDefaults(
45
+ defineProps<{
46
+ tone: keyof typeof PALETTES;
47
+ /** A Material Icons name. */
48
+ icon: string;
49
+ title: string;
50
+ body: string;
51
+ /** Small print after the body, rendered dimmer. */
52
+ detail?: string | null;
53
+ actionLabel: string;
54
+ /** Disables the action and spins its icon. */
55
+ busy?: boolean;
56
+ testid: string;
57
+ actionTestid: string;
58
+ }>(),
59
+ { detail: null, busy: false },
60
+ );
61
+
62
+ const emit = defineEmits<{ action: [] }>();
63
+
64
+ const palette = computed(() => PALETTES[props.tone]);
65
+ </script>