mulmoclaude 2.4.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/assets/{index-ijQN8Kty.js → index-CkmW0yWc.js} +11 -11
- package/client/assets/{marp-DbrRRh1n.js → marp-OXXg-yyV.js} +1 -1
- package/client/index.html +1 -1
- package/package.json +2 -2
- package/server/agent/index.ts +5 -3
- package/server/agent/prompt.ts +6 -3
- package/server/agent/promptWorkspacePaths.ts +12 -0
- package/server/build/mcp-server.mjs +604 -604
- package/server/index.ts +7 -22
- package/server/prompts/system/system.md +1 -2
- 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/config/systemFileDescriptors.ts +1 -1
- package/src/lang/de.ts +5 -4
- package/src/lang/en.ts +5 -4
- package/src/lang/es.ts +5 -4
- package/src/lang/fr.ts +5 -4
- package/src/lang/ja.ts +6 -4
- package/src/lang/ko.ts +6 -4
- package/src/lang/pt-BR.ts +5 -4
- package/src/lang/zh.ts +6 -4
- package/server/workspace/billing-migration.ts +0 -69
package/server/index.ts
CHANGED
|
@@ -84,7 +84,6 @@ import { announceShadowedEnv, SHADOWED_ENV_KEYS_VAR } from "./system/shadowedEnv
|
|
|
84
84
|
import { announceOptionalDeps } from "./system/announceOptionalDeps.js";
|
|
85
85
|
import { announceGeminiKey } from "./system/announceGeminiKey.js";
|
|
86
86
|
import { currentLaunchRouteFacts } from "./system/geminiKeyGuidance.js";
|
|
87
|
-
import { migrateLegacyBillingPresets } from "./workspace/billing-migration.js";
|
|
88
87
|
import { APP_VERSION } from "./system/appVersion.js";
|
|
89
88
|
import { createChatService } from "@mulmobridge/chat-service";
|
|
90
89
|
import { readSessionJsonl, readSessionMeta } from "./utils/files/session-io.js";
|
|
@@ -109,8 +108,7 @@ import { preflightUserServers, logPreflightResult } from "./agent/mcpPreflight.j
|
|
|
109
108
|
import { loadMcpConfig, loadSettings } from "./system/config.js";
|
|
110
109
|
import { getVoiceInputStatus, stopWhisperSidecar, warmupVoiceInput } from "./system/whisper/index.js";
|
|
111
110
|
import { initWorkspace, workspacePath } from "./workspace/workspace.js";
|
|
112
|
-
import {
|
|
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
|
|
246
|
-
// traffic while the chain runs
|
|
247
|
-
//
|
|
248
|
-
//
|
|
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
|
-
|
|
263
|
-
runMemoryMigrationOnce(workspacePath)
|
|
264
|
-
.then(() => runTopicMigrationOnce(workspacePath))
|
|
265
|
-
.then(noop, noop);
|
|
257
|
+
startMemoryMigrations(workspacePath);
|
|
266
258
|
|
|
267
259
|
// Recipe-book plugin → `mc-cooking-coach` skill migration (#1286).
|
|
268
260
|
// Boot-time idempotent copy from the plugin's `files.data` scope
|
|
@@ -1081,13 +1073,6 @@ async function initBootDiagnostics(): Promise<void> {
|
|
|
1081
1073
|
// inside the request. No-op when voice input is off / not ready.
|
|
1082
1074
|
warmupVoiceInput(bootSettings);
|
|
1083
1075
|
|
|
1084
|
-
// --- Billing-suite migration ---
|
|
1085
|
-
// The invoicing collections moved from bundled `mc-*` presets to
|
|
1086
|
-
// help-file recipes. Remove any lingering starred `mc-*` billing
|
|
1087
|
-
// skill from `.claude/skills/` (records preserved) and fire a
|
|
1088
|
-
// one-time bell. No-op once the dirs are gone. Never throws.
|
|
1089
|
-
migrateLegacyBillingPresets();
|
|
1090
|
-
|
|
1091
1076
|
// --- Collection completion watchers ---
|
|
1092
1077
|
// Mount `fs.watch` over every discovered collection's dataDir so
|
|
1093
1078
|
// schemas with `completionField` fire / clear bell notifications
|
|
@@ -19,9 +19,8 @@ All data lives in the workspace directory as plain files:
|
|
|
19
19
|
- `conversations/chat/` — chat session history (one .jsonl per session)
|
|
20
20
|
- `conversations/memory/` — distilled user facts as topic files (`<type>/<topic>.md`); see the Memory section below for the index and read rules.
|
|
21
21
|
- `conversations/summaries/` — journal output (daily / topics / archive)
|
|
22
|
-
- `data/contacts/` — address book entries
|
|
23
22
|
- `data/wiki/` — personal knowledge wiki (index.md, pages/, sources/, log.md)
|
|
24
|
-
- `
|
|
23
|
+
- `config/scheduler/tasks.json` — scheduled tasks (Automations)
|
|
25
24
|
- `artifacts/documents/`, `artifacts/images/`, `artifacts/html/`, `artifacts/charts/`, `artifacts/spreadsheets/`, `artifacts/stories/` — LLM-generated output
|
|
26
25
|
- `config/` — settings.json, mcp.json, roles/, helps/
|
|
27
26
|
- `github/` — git-cloned repositories. Clone here, not /tmp/. If the dir already exists with the same remote, `git pull` to update. If a different remote, ask the user for a new dir name.
|
|
@@ -18,6 +18,7 @@ import { buildIndexMarkdown, type IndexTopicEntry, type IndexDailyEntry } from "
|
|
|
18
18
|
import { runClaudeCli, ClaudeCliNotFoundError, type Summarize, type JournalSummaryModel } from "./archivist-cli.js";
|
|
19
19
|
import { extractFirstH1 } from "@mulmoclaude/markdown-utils/markdown/extractFirstH1";
|
|
20
20
|
import { log } from "../../system/logger/index.js";
|
|
21
|
+
import { memoryMigrationsSettled } from "../memory/startup.js";
|
|
21
22
|
import { journalMode as resolveJournalMode, loadSettings, type JournalMode } from "../../system/config.js";
|
|
22
23
|
|
|
23
24
|
export { extractFirstH1 };
|
|
@@ -57,6 +58,8 @@ export async function maybeRunJournal(opts: MaybeRunJournalOptions = {}): Promis
|
|
|
57
58
|
if (mode === "off") return;
|
|
58
59
|
running = true;
|
|
59
60
|
try {
|
|
61
|
+
// Waiting under the lock makes triggers that arrive meanwhile return instead of queueing a second pass.
|
|
62
|
+
await memoryMigrationsSettled();
|
|
60
63
|
await runJournalPass(opts, mode);
|
|
61
64
|
} catch (err) {
|
|
62
65
|
if (err instanceof ClaudeCliNotFoundError) {
|
|
@@ -7,6 +7,7 @@ import { WORKSPACE_FILES } from "../paths.js";
|
|
|
7
7
|
import { writeFileAtomic } from "../../utils/files/atomic.js";
|
|
8
8
|
import { log } from "../../system/logger/index.js";
|
|
9
9
|
import { ClaudeCliNotFoundError } from "./archivist-cli.js";
|
|
10
|
+
import { hasTopicFormat } from "../memory/topic-detect.js";
|
|
10
11
|
|
|
11
12
|
const EXTRACTION_SYSTEM_PROMPT = `You are a personal-fact extractor. Given a batch of chat excerpts between a user and an AI assistant, extract ONLY durable facts about the USER — things that would still be true next week.
|
|
12
13
|
|
|
@@ -39,6 +40,11 @@ export interface MemoryExtractionDeps {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export async function extractAndAppendMemory(deps: MemoryExtractionDeps): Promise<number> {
|
|
43
|
+
// Same predicate the prompt reader uses: in topic format memory.md is never read, so writing it only grows a dead file.
|
|
44
|
+
if (hasTopicFormat(deps.workspaceRoot)) {
|
|
45
|
+
log.debug("memory-extractor", "topic-format workspace, skipping legacy memory.md extraction");
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
42
48
|
const memoryPath = path.join(deps.workspaceRoot, WORKSPACE_FILES.memory);
|
|
43
49
|
const existingMemory = existsSync(memoryPath) ? readFileSync(memoryPath, "utf-8") : "";
|
|
44
50
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Bullets from a leftover `conversations/memory.md` that the atomic → topic cluster must include, or they drop out of
|
|
2
|
+
// the prompt at the swap (#3460). By the time topic migration clusters, a present `memory.md` is never going to be
|
|
3
|
+
// handled by the #1029 migration: it sits beside `.backup`, or is under its placeholder threshold.
|
|
4
|
+
|
|
5
|
+
import type { MemoryCandidate } from "./migrate.js";
|
|
6
|
+
import type { MemoryEntry, MemoryType } from "./types.js";
|
|
7
|
+
|
|
8
|
+
// Nothing classifies these bullets; the clusterer files them by the type it is given.
|
|
9
|
+
export const FOLDED_LEGACY_TYPE: MemoryType = "fact";
|
|
10
|
+
|
|
11
|
+
const FOLDED_NAME_MAX_CHARS = 80;
|
|
12
|
+
|
|
13
|
+
function dedupeKey(text: string): string {
|
|
14
|
+
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Turn legacy bullets into cluster input, skipping any already present as an atomic entry body or earlier bullet. */
|
|
18
|
+
export function legacyFoldEntries(candidates: readonly MemoryCandidate[], atomicEntries: readonly MemoryEntry[]): MemoryEntry[] {
|
|
19
|
+
const seen = new Set(atomicEntries.map((entry) => dedupeKey(entry.body)));
|
|
20
|
+
const folded: MemoryEntry[] = [];
|
|
21
|
+
candidates.forEach((candidate, index) => {
|
|
22
|
+
const key = dedupeKey(candidate.body);
|
|
23
|
+
if (key.length === 0 || seen.has(key)) return;
|
|
24
|
+
seen.add(key);
|
|
25
|
+
folded.push({
|
|
26
|
+
type: FOLDED_LEGACY_TYPE,
|
|
27
|
+
name: candidate.body.slice(0, FOLDED_NAME_MAX_CHARS),
|
|
28
|
+
description: candidate.section || "from conversations/memory.md",
|
|
29
|
+
body: candidate.body,
|
|
30
|
+
slug: `${FOLDED_LEGACY_TYPE}_legacy-${index}`,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
return folded;
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Decides whether the one-shot `memory.md` → atomic migration (#1029) may run, from facts the
|
|
2
|
+
// caller has already read off disk. Pure so every skip reason can be tested without a workspace.
|
|
3
|
+
|
|
4
|
+
// The init placeholder we used to write was smaller than this; a file this small has no facts.
|
|
5
|
+
export const LEGACY_MEMORY_PLACEHOLDER_MAX_BYTES = 64;
|
|
6
|
+
|
|
7
|
+
export interface LegacyMemoryState {
|
|
8
|
+
/** Size of `conversations/memory.md`, or null when the file is absent. */
|
|
9
|
+
legacySizeBytes: number | null;
|
|
10
|
+
/** `memory.md.backup` exists — the marker a previous migration completed. */
|
|
11
|
+
backupExists: boolean;
|
|
12
|
+
/** The workspace already uses the topic layout (#1070). */
|
|
13
|
+
topicFormat: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type LegacyMigrationSkipReason = "no-legacy-file" | "topic-format" | "placeholder" | "already-migrated";
|
|
17
|
+
|
|
18
|
+
export type LegacyMigrationGate = { run: true } | { run: false; reason: LegacyMigrationSkipReason };
|
|
19
|
+
|
|
20
|
+
export function decideLegacyMigration(state: LegacyMemoryState): LegacyMigrationGate {
|
|
21
|
+
if (state.legacySizeBytes === null) return { run: false, reason: "no-legacy-file" };
|
|
22
|
+
// Atomic files written into a topic `memory/` are never read and never re-clustered.
|
|
23
|
+
if (state.topicFormat) return { run: false, reason: "topic-format" };
|
|
24
|
+
if (state.legacySizeBytes < LEGACY_MEMORY_PLACEHOLDER_MAX_BYTES) return { run: false, reason: "placeholder" };
|
|
25
|
+
if (state.backupExists) return { run: false, reason: "already-migrated" };
|
|
26
|
+
return { run: true };
|
|
27
|
+
}
|
|
@@ -18,10 +18,8 @@ import type { MemoryClassification, MemoryCandidate, MemoryClassifier } from "./
|
|
|
18
18
|
import { isMemoryType } from "./types.js";
|
|
19
19
|
import { extractFirstObject, stripFenceAndWhitespace } from "./llm-json.js";
|
|
20
20
|
|
|
21
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
132
|
-
//
|
|
133
|
-
// poison the rest of the batch.
|
|
134
|
-
async function classifyInParallel(classify: MemoryClassifier, candidates: readonly MemoryCandidate[]): Promise<
|
|
135
|
-
|
|
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 (
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
|
|
250
|
+
return { kind: "failed" };
|
|
220
251
|
}
|
|
221
252
|
}
|
|
222
253
|
|
|
@@ -22,6 +22,9 @@ import path from "node:path";
|
|
|
22
22
|
import { runClaudeCli, ClaudeCliNotFoundError, type Summarize } from "../journal/archivist-cli.js";
|
|
23
23
|
import { makeLlmMemoryClassifier } from "./llm-classifier.js";
|
|
24
24
|
import { migrateLegacyMemory } from "./migrate.js";
|
|
25
|
+
import { decideLegacyMigration, type LegacyMemoryState, type LegacyMigrationSkipReason } from "./legacy-migration-gate.js";
|
|
26
|
+
import { hasTopicFormat } from "./topic-detect.js";
|
|
27
|
+
import { WORKSPACE_FILES } from "../paths.js";
|
|
25
28
|
import { errorMessage } from "../../utils/errors.js";
|
|
26
29
|
import { log } from "../../system/logger/index.js";
|
|
27
30
|
|
|
@@ -32,35 +35,11 @@ export interface RunMigrationDeps {
|
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export async function runMemoryMigrationOnce(workspaceRoot: string, deps: RunMigrationDeps = {}): Promise<void> {
|
|
35
|
-
const legacyPath = path.join(workspaceRoot,
|
|
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
|
+
}
|