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.
- package/README.md +1 -1
- package/client/assets/{JsonEditor-CU5Q6RZZ.js → JsonEditor-ZvNJuqs_.js} +1 -1
- package/client/assets/{index-hiEYYDcg.js → index-CkmW0yWc.js} +396 -396
- package/client/assets/{marp-WnOHAJUy.js → marp-OXXg-yyV.js} +1 -1
- package/client/assets/vue-i18n-BbRMUmOW.js +3 -0
- package/client/index.html +2 -2
- package/package.json +11 -11
- package/server/agent/index.ts +5 -3
- package/server/agent/prompt.ts +6 -3
- package/server/agent/promptWorkspacePaths.ts +12 -0
- package/server/api/auth/bearerAuth.ts +10 -24
- package/server/api/auth/viewToken.ts +5 -12
- package/server/api/routes/files.ts +34 -17
- package/server/build/mcp-server.mjs +604 -607
- package/server/events/pub-sub/handshakeAuth.ts +9 -0
- package/server/events/pub-sub/index.ts +47 -14
- package/server/index.ts +8 -23
- package/server/plugins/dev-watcher.ts +1 -1
- package/server/prompts/system/system.md +1 -2
- package/server/utils/launcher/preflight.d.mts +8 -5
- package/server/utils/launcher/preflight.mjs +34 -8
- package/server/workspace/journal/index.ts +3 -0
- package/server/workspace/journal/memoryExtractor.ts +6 -0
- package/server/workspace/memory/legacy-fold.ts +34 -0
- package/server/workspace/memory/legacy-migration-gate.ts +27 -0
- package/server/workspace/memory/llm-classifier.ts +4 -22
- package/server/workspace/memory/migrate.ts +52 -21
- package/server/workspace/memory/run.ts +32 -28
- package/server/workspace/memory/seed-topic-layout-run.ts +50 -0
- package/server/workspace/memory/seed-topic-layout.ts +34 -0
- package/server/workspace/memory/startup.ts +29 -0
- package/server/workspace/memory/topic-migrate.ts +78 -9
- package/server/workspace/memory/topic-run.ts +14 -9
- package/server/workspace/memory/topic-swap.ts +4 -6
- package/server/workspace/workspace.ts +4 -7
- package/src/App.vue +5 -0
- package/src/components/AuthTokenStaleBanner.vue +27 -0
- package/src/components/BackendOfflineBanner.vue +13 -24
- package/src/components/RemoteHostOfflineBanner.vue +12 -21
- package/src/components/StatusBanner.vue +65 -0
- package/src/composables/usePubSub.ts +32 -0
- package/src/config/systemFileDescriptors.ts +1 -1
- package/src/lang/de.ts +10 -4
- package/src/lang/en.ts +10 -4
- package/src/lang/es.ts +10 -4
- package/src/lang/fr.ts +10 -4
- package/src/lang/ja.ts +11 -4
- package/src/lang/ko.ts +11 -4
- package/src/lang/pt-BR.ts +10 -4
- package/src/lang/zh.ts +11 -4
- package/src/main.ts +1 -1
- package/src/utils/api.ts +45 -22
- package/src/utils/auth/authToken.ts +14 -0
- package/src/utils/auth/authTokenHtml.ts +19 -0
- package/src/utils/auth/authTokenRecovery.ts +73 -0
- package/src/utils/dom/authTokenMeta.ts +3 -1
- package/client/assets/vue-i18n-CdSW1CeF.js +0 -3
- package/server/system/logs/aaa +0 -737
- package/server/system/logs/bb +0 -446
- 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,
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
7
|
-
//
|
|
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
|
|
89
|
+
const { atomicEntries, foldedEntries } = await loadClusterInput(workspaceRoot);
|
|
55
90
|
const stagingPath = topicStagingPath(workspaceRoot);
|
|
56
|
-
if (
|
|
57
|
-
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 >=
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
27
|
-
//
|
|
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
|
-
<
|
|
2
|
+
<StatusBanner
|
|
3
3
|
v-if="!backendReachable"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
<
|
|
2
|
+
<StatusBanner
|
|
3
3
|
v-if="needsReconnect"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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>
|