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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { isAuthorizedToken } from "@mulmoclaude/common/token-guard";
|
|
2
|
+
import { isRecord } from "../../utils/types.js";
|
|
3
|
+
|
|
4
|
+
// `auth` is whatever the socket.io client put in `io({ auth })`. Only a record
|
|
5
|
+
// whose `token` passes the shared bearer rule connects; a server with no token
|
|
6
|
+
// yet refuses everything, as `bearerAuth` does for HTTP.
|
|
7
|
+
export function isAuthorizedPubSubHandshake(auth: unknown, expectedToken: string | null): boolean {
|
|
8
|
+
return isRecord(auth) && isAuthorizedToken(auth.token, expectedToken);
|
|
9
|
+
}
|
|
@@ -1,26 +1,59 @@
|
|
|
1
1
|
import http from "http";
|
|
2
|
-
import { Server as IOServer } from "socket.io";
|
|
2
|
+
import { Server as IOServer, type Socket } from "socket.io";
|
|
3
3
|
|
|
4
|
+
import { UNAUTHORIZED_MESSAGE } from "@mulmoclaude/common/token-guard";
|
|
4
5
|
import { log } from "../../system/logger/index.js";
|
|
6
|
+
import { isAuthorizedPubSubHandshake } from "./handshakeAuth.js";
|
|
5
7
|
|
|
6
8
|
export interface IPubSub {
|
|
7
9
|
/** Publish data to all clients subscribed to this channel. */
|
|
8
10
|
publish: (channel: string, data: unknown) => void;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
export interface PubSubOptions {
|
|
14
|
+
/** The bearer token a handshake must present. `null` while the server
|
|
15
|
+
* has none yet, which refuses every handshake. */
|
|
16
|
+
tokenProvider: () => string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
const logRoomError =
|
|
12
20
|
(action: string, channel: string) =>
|
|
13
21
|
(err: unknown): void => {
|
|
14
22
|
log.warn("pubsub", `socket room ${action} failed`, { channel, error: String(err) });
|
|
15
23
|
};
|
|
16
24
|
|
|
25
|
+
// `join`/`leave` return `void | Promise<void>`: the in-memory adapter runs
|
|
26
|
+
// synchronously and returns undefined, while a clustered adapter goes over
|
|
27
|
+
// the wire. Nothing here depends on the result, but the promise branch
|
|
28
|
+
// still needs a terminal handler — a bare `void` would turn an adapter
|
|
29
|
+
// failure into an unhandled rejection. `Promise.resolve` normalises both.
|
|
30
|
+
function bindRoomEvents(socket: Socket): void {
|
|
31
|
+
socket.on("subscribe", (channel: unknown) => {
|
|
32
|
+
if (typeof channel === "string") Promise.resolve(socket.join(channel)).catch(logRoomError("subscribe", channel));
|
|
33
|
+
});
|
|
34
|
+
socket.on("unsubscribe", (channel: unknown) => {
|
|
35
|
+
if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A refused handshake leaves the engine.io connection open, and that one
|
|
40
|
+
// connection can keep sending CONNECT packets; one line per connection keeps
|
|
41
|
+
// a misconfigured relay diagnosable without letting a loop fill the log.
|
|
42
|
+
const warnedConnections = new WeakSet<object>();
|
|
43
|
+
|
|
44
|
+
function warnRefusedOnce(socket: Socket): void {
|
|
45
|
+
if (warnedConnections.has(socket.conn)) return;
|
|
46
|
+
warnedConnections.add(socket.conn);
|
|
47
|
+
log.warn("pubsub", "handshake refused: bearer token missing or invalid", { address: socket.handshake.address });
|
|
48
|
+
}
|
|
49
|
+
|
|
17
50
|
// Channel names are treated as socket.io rooms — one room per
|
|
18
51
|
// channel. Subscribe/unsubscribe is plain `socket.join` /
|
|
19
52
|
// `socket.leave`. Publish broadcasts to the room. Reconnect /
|
|
20
53
|
// heartbeat / multi-transport fallback are handled by socket.io
|
|
21
54
|
// itself, which is why we switched off raw ws.
|
|
22
55
|
|
|
23
|
-
export function createPubSub(server: http.Server): IPubSub {
|
|
56
|
+
export function createPubSub(server: http.Server, options: PubSubOptions): IPubSub {
|
|
24
57
|
const ioServer = new IOServer(server, {
|
|
25
58
|
path: "/ws/pubsub",
|
|
26
59
|
// Server binds to 127.0.0.1 only, so CORS is moot — but
|
|
@@ -36,20 +69,20 @@ export function createPubSub(server: http.Server): IPubSub {
|
|
|
36
69
|
transports: ["websocket"],
|
|
37
70
|
});
|
|
38
71
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
|
|
50
|
-
});
|
|
72
|
+
// Same token every `/api/*` call presents (#3433). A refused handshake
|
|
73
|
+
// destroys the client socket, so socket.io does not retry it — the SPA
|
|
74
|
+
// reads that as "reload for a fresh token" rather than spinning.
|
|
75
|
+
ioServer.use((socket, next) => {
|
|
76
|
+
if (isAuthorizedPubSubHandshake(socket.handshake.auth, options.tokenProvider())) {
|
|
77
|
+
next();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
warnRefusedOnce(socket);
|
|
81
|
+
next(new Error(UNAUTHORIZED_MESSAGE));
|
|
51
82
|
});
|
|
52
83
|
|
|
84
|
+
ioServer.on("connection", bindRoomEvents);
|
|
85
|
+
|
|
53
86
|
return {
|
|
54
87
|
publish(channel: string, data: unknown): void {
|
|
55
88
|
ioServer.to(channel).emit("data", { channel, data });
|
package/server/index.ts
CHANGED
|
@@ -84,7 +84,6 @@ import { announceShadowedEnv, SHADOWED_ENV_KEYS_VAR } from "./system/shadowedEnv
|
|
|
84
84
|
import { announceOptionalDeps } from "./system/announceOptionalDeps.js";
|
|
85
85
|
import { announceGeminiKey } from "./system/announceGeminiKey.js";
|
|
86
86
|
import { currentLaunchRouteFacts } from "./system/geminiKeyGuidance.js";
|
|
87
|
-
import { migrateLegacyBillingPresets } from "./workspace/billing-migration.js";
|
|
88
87
|
import { APP_VERSION } from "./system/appVersion.js";
|
|
89
88
|
import { createChatService } from "@mulmobridge/chat-service";
|
|
90
89
|
import { readSessionJsonl, readSessionMeta } from "./utils/files/session-io.js";
|
|
@@ -109,8 +108,7 @@ import { preflightUserServers, logPreflightResult } from "./agent/mcpPreflight.j
|
|
|
109
108
|
import { loadMcpConfig, loadSettings } from "./system/config.js";
|
|
110
109
|
import { getVoiceInputStatus, stopWhisperSidecar, warmupVoiceInput } from "./system/whisper/index.js";
|
|
111
110
|
import { initWorkspace, workspacePath } from "./workspace/workspace.js";
|
|
112
|
-
import {
|
|
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
|
|
@@ -1548,7 +1533,7 @@ process.on("SIGTERM", () => {
|
|
|
1548
1533
|
// (CodeRabbit review on PR #1196). Both `createPubSub` and
|
|
1549
1534
|
// `initNotifier` are sync, so wiring them up here costs nothing
|
|
1550
1535
|
// and closes the window.
|
|
1551
|
-
const earlyPubsub = createPubSub(httpServer);
|
|
1536
|
+
const earlyPubsub = createPubSub(httpServer, { tokenProvider: getCurrentToken });
|
|
1552
1537
|
initNotifier({
|
|
1553
1538
|
publish: (channel, payload) => earlyPubsub.publish(channel, payload),
|
|
1554
1539
|
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// chokidar to avoid pulling another runtime dependency. Recursive
|
|
7
7
|
// watching is reliable on macOS / Linux / Windows starting from Node
|
|
8
8
|
// 20.12 (Linux had race conditions and crash-on-delete bugs in earlier
|
|
9
|
-
// 20.x patches), which engines.node
|
|
9
|
+
// 20.x patches), which engines.node clears comfortably.
|
|
10
10
|
//
|
|
11
11
|
// The publish + warnServerSideChange callbacks are injected so tests
|
|
12
12
|
// can exercise the debounce + classification logic without booting the
|
|
@@ -19,9 +19,8 @@ All data lives in the workspace directory as plain files:
|
|
|
19
19
|
- `conversations/chat/` — chat session history (one .jsonl per session)
|
|
20
20
|
- `conversations/memory/` — distilled user facts as topic files (`<type>/<topic>.md`); see the Memory section below for the index and read rules.
|
|
21
21
|
- `conversations/summaries/` — journal output (daily / topics / archive)
|
|
22
|
-
- `data/contacts/` — address book entries
|
|
23
22
|
- `data/wiki/` — personal knowledge wiki (index.md, pages/, sources/, log.md)
|
|
24
|
-
- `
|
|
23
|
+
- `config/scheduler/tasks.json` — scheduled tasks (Automations)
|
|
25
24
|
- `artifacts/documents/`, `artifacts/images/`, `artifacts/html/`, `artifacts/charts/`, `artifacts/spreadsheets/`, `artifacts/stories/` — LLM-generated output
|
|
26
25
|
- `config/` — settings.json, mcp.json, roles/, helps/
|
|
27
26
|
- `github/` — git-cloned repositories. Clone here, not /tmp/. If the dir already exists with the same remote, `git pull` to update. If a different remote, ask the user for a new dir name.
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
// Type declarations for preflight.mjs.
|
|
2
2
|
|
|
3
|
-
export interface
|
|
3
|
+
export interface NodeVersion {
|
|
4
4
|
major: number;
|
|
5
5
|
minor: number;
|
|
6
|
+
patch: number;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
export const REQUIRED_NODE:
|
|
9
|
+
export const REQUIRED_NODE: readonly NodeVersion[];
|
|
9
10
|
|
|
10
|
-
export function parseNodeVersion(raw: string):
|
|
11
|
+
export function parseNodeVersion(raw: string): NodeVersion | null;
|
|
11
12
|
|
|
12
|
-
export function isNodeVersionSupported(raw: string, required?:
|
|
13
|
+
export function isNodeVersionSupported(raw: string, required?: readonly NodeVersion[]): boolean;
|
|
13
14
|
|
|
14
|
-
export function formatRequiredNode(required?:
|
|
15
|
+
export function formatRequiredNode(required?: readonly NodeVersion[]): string;
|
|
16
|
+
|
|
17
|
+
export function formatEnginesRange(required?: readonly NodeVersion[]): string;
|
|
15
18
|
|
|
16
19
|
export interface CommandProbeOptions {
|
|
17
20
|
env?: Record<string, string | undefined>;
|
|
@@ -9,7 +9,13 @@ import { execFileSync } from "node:child_process";
|
|
|
9
9
|
|
|
10
10
|
// Mirrors `engines.node` in packages/mulmoclaude/package.json.
|
|
11
11
|
// test/utils/launcher/test_preflight.ts fails if the two drift.
|
|
12
|
-
|
|
12
|
+
// One floor per supported major; the last also admits every later major,
|
|
13
|
+
// and a major with no floor of its own (23, 25) is unsupported.
|
|
14
|
+
export const REQUIRED_NODE = [
|
|
15
|
+
{ major: 22, minor: 22, patch: 2 },
|
|
16
|
+
{ major: 24, minor: 15, patch: 0 },
|
|
17
|
+
{ major: 26, minor: 0, patch: 0 },
|
|
18
|
+
];
|
|
13
19
|
|
|
14
20
|
// These probes run a local binary that reads its own package metadata;
|
|
15
21
|
// anything past a few seconds means it is wedged, not slow.
|
|
@@ -22,30 +28,50 @@ const COMMAND_PROBE_TIMEOUT_MS = 5000;
|
|
|
22
28
|
* Returns null for anything unparseable rather than guessing — an
|
|
23
29
|
* unknown version must not silently pass the requirement.
|
|
24
30
|
* @param {string} raw
|
|
25
|
-
* @returns {
|
|
31
|
+
* @returns {import("./preflight.d.mts").NodeVersion | null}
|
|
26
32
|
*/
|
|
27
33
|
export function parseNodeVersion(raw) {
|
|
28
34
|
if (typeof raw !== "string") return null;
|
|
29
|
-
const match = /^v?(\d+)\.(\d+)
|
|
35
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
|
|
30
36
|
if (!match) return null;
|
|
31
|
-
return { major: Number(match[1]), minor: Number(match[2]) };
|
|
37
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function compareVersions(left, right) {
|
|
41
|
+
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function floorFor(found, required) {
|
|
45
|
+
const newest = required[required.length - 1];
|
|
46
|
+
if (found.major > newest.major) return newest;
|
|
47
|
+
return required.find((floor) => floor.major === found.major);
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
/**
|
|
35
51
|
* @param {string} raw
|
|
36
|
-
* @param {
|
|
52
|
+
* @param {readonly import("./preflight.d.mts").NodeVersion[]} [required]
|
|
37
53
|
* @returns {boolean}
|
|
38
54
|
*/
|
|
39
55
|
export function isNodeVersionSupported(raw, required = REQUIRED_NODE) {
|
|
40
56
|
const found = parseNodeVersion(raw);
|
|
41
57
|
if (found === null) return false;
|
|
42
|
-
|
|
43
|
-
return found
|
|
58
|
+
const floor = floorFor(found, required);
|
|
59
|
+
return floor !== undefined && compareVersions(found, floor) >= 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatVersion({ major, minor, patch }) {
|
|
63
|
+
return `${major}.${minor}.${patch}`;
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
/** Human-readable form of a requirement, for the {required} placeholder. */
|
|
47
67
|
export function formatRequiredNode(required = REQUIRED_NODE) {
|
|
48
|
-
return
|
|
68
|
+
return required.map(formatVersion).join(" / ");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The same requirement as an `engines.node` range. */
|
|
72
|
+
export function formatEnginesRange(required = REQUIRED_NODE) {
|
|
73
|
+
const carets = required.slice(0, -1).map((floor) => `^${formatVersion(floor)}`);
|
|
74
|
+
return [...carets, `>=${formatVersion(required[required.length - 1])}`].join(" || ");
|
|
49
75
|
}
|
|
50
76
|
|
|
51
77
|
/**
|
|
@@ -18,6 +18,7 @@ import { buildIndexMarkdown, type IndexTopicEntry, type IndexDailyEntry } from "
|
|
|
18
18
|
import { runClaudeCli, ClaudeCliNotFoundError, type Summarize, type JournalSummaryModel } from "./archivist-cli.js";
|
|
19
19
|
import { extractFirstH1 } from "@mulmoclaude/markdown-utils/markdown/extractFirstH1";
|
|
20
20
|
import { log } from "../../system/logger/index.js";
|
|
21
|
+
import { memoryMigrationsSettled } from "../memory/startup.js";
|
|
21
22
|
import { journalMode as resolveJournalMode, loadSettings, type JournalMode } from "../../system/config.js";
|
|
22
23
|
|
|
23
24
|
export { extractFirstH1 };
|
|
@@ -57,6 +58,8 @@ export async function maybeRunJournal(opts: MaybeRunJournalOptions = {}): Promis
|
|
|
57
58
|
if (mode === "off") return;
|
|
58
59
|
running = true;
|
|
59
60
|
try {
|
|
61
|
+
// Waiting under the lock makes triggers that arrive meanwhile return instead of queueing a second pass.
|
|
62
|
+
await memoryMigrationsSettled();
|
|
60
63
|
await runJournalPass(opts, mode);
|
|
61
64
|
} catch (err) {
|
|
62
65
|
if (err instanceof ClaudeCliNotFoundError) {
|
|
@@ -7,6 +7,7 @@ import { WORKSPACE_FILES } from "../paths.js";
|
|
|
7
7
|
import { writeFileAtomic } from "../../utils/files/atomic.js";
|
|
8
8
|
import { log } from "../../system/logger/index.js";
|
|
9
9
|
import { ClaudeCliNotFoundError } from "./archivist-cli.js";
|
|
10
|
+
import { hasTopicFormat } from "../memory/topic-detect.js";
|
|
10
11
|
|
|
11
12
|
const EXTRACTION_SYSTEM_PROMPT = `You are a personal-fact extractor. Given a batch of chat excerpts between a user and an AI assistant, extract ONLY durable facts about the USER — things that would still be true next week.
|
|
12
13
|
|
|
@@ -39,6 +40,11 @@ export interface MemoryExtractionDeps {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export async function extractAndAppendMemory(deps: MemoryExtractionDeps): Promise<number> {
|
|
43
|
+
// Same predicate the prompt reader uses: in topic format memory.md is never read, so writing it only grows a dead file.
|
|
44
|
+
if (hasTopicFormat(deps.workspaceRoot)) {
|
|
45
|
+
log.debug("memory-extractor", "topic-format workspace, skipping legacy memory.md extraction");
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
42
48
|
const memoryPath = path.join(deps.workspaceRoot, WORKSPACE_FILES.memory);
|
|
43
49
|
const existingMemory = existsSync(memoryPath) ? readFileSync(memoryPath, "utf-8") : "";
|
|
44
50
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Bullets from a leftover `conversations/memory.md` that the atomic → topic cluster must include, or they drop out of
|
|
2
|
+
// the prompt at the swap (#3460). By the time topic migration clusters, a present `memory.md` is never going to be
|
|
3
|
+
// handled by the #1029 migration: it sits beside `.backup`, or is under its placeholder threshold.
|
|
4
|
+
|
|
5
|
+
import type { MemoryCandidate } from "./migrate.js";
|
|
6
|
+
import type { MemoryEntry, MemoryType } from "./types.js";
|
|
7
|
+
|
|
8
|
+
// Nothing classifies these bullets; the clusterer files them by the type it is given.
|
|
9
|
+
export const FOLDED_LEGACY_TYPE: MemoryType = "fact";
|
|
10
|
+
|
|
11
|
+
const FOLDED_NAME_MAX_CHARS = 80;
|
|
12
|
+
|
|
13
|
+
function dedupeKey(text: string): string {
|
|
14
|
+
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Turn legacy bullets into cluster input, skipping any already present as an atomic entry body or earlier bullet. */
|
|
18
|
+
export function legacyFoldEntries(candidates: readonly MemoryCandidate[], atomicEntries: readonly MemoryEntry[]): MemoryEntry[] {
|
|
19
|
+
const seen = new Set(atomicEntries.map((entry) => dedupeKey(entry.body)));
|
|
20
|
+
const folded: MemoryEntry[] = [];
|
|
21
|
+
candidates.forEach((candidate, index) => {
|
|
22
|
+
const key = dedupeKey(candidate.body);
|
|
23
|
+
if (key.length === 0 || seen.has(key)) return;
|
|
24
|
+
seen.add(key);
|
|
25
|
+
folded.push({
|
|
26
|
+
type: FOLDED_LEGACY_TYPE,
|
|
27
|
+
name: candidate.body.slice(0, FOLDED_NAME_MAX_CHARS),
|
|
28
|
+
description: candidate.section || "from conversations/memory.md",
|
|
29
|
+
body: candidate.body,
|
|
30
|
+
slug: `${FOLDED_LEGACY_TYPE}_legacy-${index}`,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
return folded;
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Decides whether the one-shot `memory.md` → atomic migration (#1029) may run, from facts the
|
|
2
|
+
// caller has already read off disk. Pure so every skip reason can be tested without a workspace.
|
|
3
|
+
|
|
4
|
+
// The init placeholder we used to write was smaller than this; a file this small has no facts.
|
|
5
|
+
export const LEGACY_MEMORY_PLACEHOLDER_MAX_BYTES = 64;
|
|
6
|
+
|
|
7
|
+
export interface LegacyMemoryState {
|
|
8
|
+
/** Size of `conversations/memory.md`, or null when the file is absent. */
|
|
9
|
+
legacySizeBytes: number | null;
|
|
10
|
+
/** `memory.md.backup` exists — the marker a previous migration completed. */
|
|
11
|
+
backupExists: boolean;
|
|
12
|
+
/** The workspace already uses the topic layout (#1070). */
|
|
13
|
+
topicFormat: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type LegacyMigrationSkipReason = "no-legacy-file" | "topic-format" | "placeholder" | "already-migrated";
|
|
17
|
+
|
|
18
|
+
export type LegacyMigrationGate = { run: true } | { run: false; reason: LegacyMigrationSkipReason };
|
|
19
|
+
|
|
20
|
+
export function decideLegacyMigration(state: LegacyMemoryState): LegacyMigrationGate {
|
|
21
|
+
if (state.legacySizeBytes === null) return { run: false, reason: "no-legacy-file" };
|
|
22
|
+
// Atomic files written into a topic `memory/` are never read and never re-clustered.
|
|
23
|
+
if (state.topicFormat) return { run: false, reason: "topic-format" };
|
|
24
|
+
if (state.legacySizeBytes < LEGACY_MEMORY_PLACEHOLDER_MAX_BYTES) return { run: false, reason: "placeholder" };
|
|
25
|
+
if (state.backupExists) return { run: false, reason: "already-migrated" };
|
|
26
|
+
return { run: true };
|
|
27
|
+
}
|
|
@@ -18,10 +18,8 @@ import type { MemoryClassification, MemoryCandidate, MemoryClassifier } from "./
|
|
|
18
18
|
import { isMemoryType } from "./types.js";
|
|
19
19
|
import { extractFirstObject, stripFenceAndWhitespace } from "./llm-json.js";
|
|
20
20
|
|
|
21
|
-
import {
|
|
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
|
|