mulmoclaude 0.9.2 → 0.9.4
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/bin/mulmoclaude.js +2 -1
- package/client/assets/{index-Dc0R-HW5.js → index-B3NxFcEH.js} +46 -46
- package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
- package/client/assets/{marp-CSq0PPfK.js → marp-C9QDHFAJ.js} +1 -1
- package/client/index.html +2 -2
- package/package.json +11 -10
- package/server/api/routes/agent.ts +5 -1
- package/server/api/routes/chat-index.ts +5 -1
- package/server/api/routes/collections.ts +152 -1
- package/server/api/routes/config.ts +12 -0
- package/server/index.ts +14 -2
- package/server/prompts/system/system.md +7 -1
- package/server/remoteHost/firebase.ts +6 -0
- package/server/remoteHost/handlers/collectionPage.ts +15 -17
- package/server/remoteHost/handlers/getCollection.ts +2 -2
- package/server/remoteHost/handlers/getFeed.ts +2 -2
- package/server/remoteHost/handlers/getRemoteView.ts +37 -0
- package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
- package/server/remoteHost/handlers/index.ts +10 -0
- package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
- package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
- package/server/remoteHost/handlers/listCollections.ts +6 -1
- package/server/remoteHost/handlers/listSkills.ts +39 -0
- package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
- package/server/remoteHost/handlers/startChat.ts +103 -39
- package/server/system/config.ts +101 -4
- package/server/system/logs/aaa +737 -0
- package/server/system/logs/bb +446 -0
- package/server/utils/files/thumbnail-store.ts +97 -0
- package/server/workspace/chat-index/index.ts +29 -3
- package/server/workspace/chat-index/indexer.ts +187 -29
- package/server/workspace/chat-index/summarizer.ts +18 -10
- package/server/workspace/collections/index.ts +2 -1
- package/server/workspace/collections/remoteView.ts +290 -0
- package/server/workspace/journal/archivist-cli.ts +17 -3
- package/server/workspace/journal/dailyPass.ts +52 -2
- package/server/workspace/journal/index.ts +20 -4
- package/src/App.vue +62 -3
- package/src/components/FileTree.vue +21 -1
- package/src/components/FileTreePane.vue +41 -25
- package/src/components/FilesView.vue +4 -0
- package/src/components/RemoteHostControl.vue +18 -0
- package/src/components/SessionHistoryPanel.vue +24 -11
- package/src/components/SettingsChatIndexTab.vue +119 -0
- package/src/components/SettingsJournalTab.vue +117 -0
- package/src/components/SettingsModal.vue +12 -2
- package/src/composables/collections/uiHost.ts +15 -0
- package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
- package/src/composables/usePubSub.ts +40 -2
- package/src/composables/useSessionSync.ts +9 -0
- package/src/composables/useShowHiddenSystemFiles.ts +23 -0
- package/src/config/apiRoutes.ts +20 -0
- package/src/config/roles.ts +2 -2
- package/src/config/visibleWorkspaceDirs.ts +21 -0
- package/src/lang/de.ts +47 -0
- package/src/lang/en.ts +45 -0
- package/src/lang/es.ts +46 -0
- package/src/lang/fr.ts +47 -0
- package/src/lang/ja.ts +46 -0
- package/src/lang/ko.ts +45 -0
- package/src/lang/pt-BR.ts +46 -0
- package/src/lang/zh.ts +44 -0
- package/src/plugins/textResponse/View.vue +49 -14
- package/src/plugins/textResponse/utils.ts +37 -0
- package/src/utils/html/previewCsp.ts +7 -16
- package/src/utils/role/icon.ts +9 -2
- package/src/utils/session/sessionPreview.ts +29 -0
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// at a `mkdtempSync` directory without touching the real
|
|
9
9
|
// ~/mulmoclaude.
|
|
10
10
|
|
|
11
|
-
import { readdir, readFile, rm } from "node:fs/promises";
|
|
11
|
+
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
12
13
|
import { defaultSummarize, loadJsonlInput, type SummarizeFn } from "./summarizer.js";
|
|
13
14
|
import { chatDirFor, indexEntryPathFor, manifestPathFor, sessionJsonlPathFor, sessionMetaPathFor } from "./paths.js";
|
|
14
15
|
import type { ChatIndexEntry, ChatIndexManifest } from "./types.js";
|
|
@@ -22,6 +23,12 @@ import { isRecord } from "../../utils/types.js";
|
|
|
22
23
|
// — long enough that a single conversation doesn't re-summarize
|
|
23
24
|
// every turn, short enough that a user who leaves for lunch and
|
|
24
25
|
// comes back sees the title refresh.
|
|
26
|
+
//
|
|
27
|
+
// Complementary to the content-change gate below: `isFresh` says
|
|
28
|
+
// "we JUST indexed, don't retry mid-conversation", while
|
|
29
|
+
// `sessionJsonlChangedSinceIndex` says "the jsonl hasn't been
|
|
30
|
+
// touched since we last indexed, no work to do". Both run when
|
|
31
|
+
// `force` is false; `force: true` bypasses both.
|
|
25
32
|
export const MIN_INDEX_INTERVAL_MS = 15 * ONE_MINUTE_MS;
|
|
26
33
|
|
|
27
34
|
// Injection points for tests. Defaults are the production spawn +
|
|
@@ -30,11 +37,21 @@ export interface IndexerDeps {
|
|
|
30
37
|
summarize?: SummarizeFn;
|
|
31
38
|
now?: () => number;
|
|
32
39
|
minIntervalMs?: number;
|
|
33
|
-
// Bypass the `isFresh` freshness throttle
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
40
|
+
// Bypass both the `isFresh` freshness throttle AND the content-
|
|
41
|
+
// changed gate (`sessionJsonlChangedSinceIndex`). Used by the
|
|
42
|
+
// manual rebuild endpoint and the `CHAT_INDEX_FORCE_RUN_ON_STARTUP`
|
|
43
|
+
// startup path so "regenerate everything" semantics keep working
|
|
44
|
+
// even when the summariser is unchanged — e.g. the summariser
|
|
45
|
+
// prompt was edited and existing summaries are stale by design,
|
|
46
|
+
// not by content.
|
|
37
47
|
force?: boolean;
|
|
48
|
+
// Chat-index mode from `AppSettings.chatIndex` (default "off"). "off"
|
|
49
|
+
// short-circuits `indexSession` before any I/O; "haiku"/"sonnet"
|
|
50
|
+
// selects the model passed to the summariser. Injected from the
|
|
51
|
+
// callers (turn-finish hook, backfill scheduler, manual rebuild,
|
|
52
|
+
// startup force) so a single settings load happens at trigger time
|
|
53
|
+
// rather than N times inside the indexer.
|
|
54
|
+
mode?: "off" | "haiku" | "sonnet";
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
// --- manifest I/O ---------------------------------------------------
|
|
@@ -110,50 +127,139 @@ export async function updateManifest(workspaceRoot: string, mutator: (m: ChatInd
|
|
|
110
127
|
// disk. Both removals tolerate "missing" — sessions that were never
|
|
111
128
|
// indexed have no entry to prune.
|
|
112
129
|
export async function removeSessionFromIndex(workspaceRoot: string, sessionId: string): Promise<void> {
|
|
113
|
-
|
|
130
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
131
|
+
if (safeId === null) return;
|
|
132
|
+
await rm(indexEntryPathFor(workspaceRoot, safeId), { force: true });
|
|
114
133
|
await updateManifest(workspaceRoot, (manifest) => ({
|
|
115
134
|
...manifest,
|
|
116
|
-
entries: manifest.entries.filter((entry) => entry.id !==
|
|
135
|
+
entries: manifest.entries.filter((entry) => entry.id !== safeId),
|
|
117
136
|
}));
|
|
118
137
|
}
|
|
119
138
|
|
|
120
139
|
// --- freshness check ------------------------------------------------
|
|
121
140
|
|
|
141
|
+
// Shared parsing of `<indexDir>/<sessionId>.json` → `indexedAt` as
|
|
142
|
+
// milliseconds since epoch. Returns null on any failure (file
|
|
143
|
+
// missing, unreadable, JSON parse error, wrong shape, unparseable
|
|
144
|
+
// timestamp) so callers can each pick their own "no entry" semantic
|
|
145
|
+
// (skip vs reindex vs throttle) without duplicating the read.
|
|
146
|
+
// Exported so `isFresh` / `sessionJsonlChangedSinceIndex` share one
|
|
147
|
+
// canonical parse — CodeRabbit review on #1930.
|
|
148
|
+
export async function readIndexedAtMs(workspaceRoot: string, sessionId: string): Promise<number | null> {
|
|
149
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
150
|
+
if (safeId === null) return null;
|
|
151
|
+
try {
|
|
152
|
+
const raw = await readFile(indexEntryPathFor(workspaceRoot, safeId), "utf-8");
|
|
153
|
+
const entry: unknown = JSON.parse(raw);
|
|
154
|
+
if (!isRecord(entry)) return null;
|
|
155
|
+
const { indexedAt } = entry as Record<string, unknown>;
|
|
156
|
+
if (typeof indexedAt !== "string") return null;
|
|
157
|
+
const parsed = Date.parse(indexedAt);
|
|
158
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
122
164
|
// A session is "fresh" when its per-session index file exists and
|
|
123
165
|
// was written less than `minIntervalMs` ago. Fresh sessions are
|
|
124
166
|
// skipped so a long conversation doesn't spam the CLI on every
|
|
125
|
-
// turn.
|
|
167
|
+
// turn. Delegates disk / parse work to `readIndexedAtMs` so the
|
|
168
|
+
// two throttles stay in lockstep on entry-file semantics.
|
|
126
169
|
export async function isFresh(workspaceRoot: string, sessionId: string, now: number, minIntervalMs: number): Promise<boolean> {
|
|
170
|
+
const indexedMs = await readIndexedAtMs(workspaceRoot, sessionId);
|
|
171
|
+
if (indexedMs === null) return false;
|
|
172
|
+
return now - indexedMs < minIntervalMs;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Returns true when the session's jsonl was written / appended AFTER
|
|
176
|
+
// its last index entry — i.e. there IS new content to summarize.
|
|
177
|
+
//
|
|
178
|
+
// Complements `isFresh`: without this gate, the hourly scheduler
|
|
179
|
+
// tick re-summarizes every session even when nothing new was
|
|
180
|
+
// written since the last index (issue #1929). With it, the tick's
|
|
181
|
+
// per-session cost drops to O(stat + file read) for unchanged
|
|
182
|
+
// sessions and only spends a Claude CLI call on the ones that
|
|
183
|
+
// actually saw a new turn.
|
|
184
|
+
//
|
|
185
|
+
// Return-value contract, for each unknown state:
|
|
186
|
+
// - Hostile / malformed sessionId → false (skip; something upstream
|
|
187
|
+
// handed us a value that could escape the chat dir via `..`).
|
|
188
|
+
// - Entry file missing/malformed → true (we've never captured this
|
|
189
|
+
// session; index it).
|
|
190
|
+
// - Jsonl file missing → false (nothing to reindex; the caller
|
|
191
|
+
// would return null downstream anyway, but we short-circuit).
|
|
192
|
+
// - Otherwise → jsonl mtime > entry.indexedAt.
|
|
193
|
+
export async function sessionJsonlChangedSinceIndex(workspaceRoot: string, sessionId: string): Promise<boolean> {
|
|
194
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
195
|
+
if (safeId === null) return false;
|
|
196
|
+
const indexedMs = await readIndexedAtMs(workspaceRoot, safeId);
|
|
197
|
+
if (indexedMs === null) return true;
|
|
127
198
|
try {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
if (!isRecord(entry)) return false;
|
|
131
|
-
const { indexedAt } = entry as Record<string, unknown>;
|
|
132
|
-
if (typeof indexedAt !== "string") return false;
|
|
133
|
-
const indexedTimestamp = Date.parse(indexedAt);
|
|
134
|
-
if (Number.isNaN(indexedTimestamp)) return false;
|
|
135
|
-
return now - indexedTimestamp < minIntervalMs;
|
|
199
|
+
const info = await stat(sessionJsonlPathFor(workspaceRoot, safeId));
|
|
200
|
+
return info.mtimeMs > indexedMs;
|
|
136
201
|
} catch {
|
|
137
202
|
return false;
|
|
138
203
|
}
|
|
139
204
|
}
|
|
140
205
|
|
|
206
|
+
// Path-traversal guard for session IDs used to derive on-disk paths.
|
|
207
|
+
// Two-step check so both a static analyser (CodeQL taints
|
|
208
|
+
// `sessionId` as a possible route-param input) AND a human reader
|
|
209
|
+
// can convince themselves the derived path stays inside the chat
|
|
210
|
+
// dir:
|
|
211
|
+
//
|
|
212
|
+
// 1. `path.basename()` collapses any `..` / separator to the last
|
|
213
|
+
// segment. CodeQL recognises this as a barrier — a value whose
|
|
214
|
+
// taint originates upstream is neutralised here.
|
|
215
|
+
// 2. `SAFE_SESSION_ID_RE` narrows the character class further
|
|
216
|
+
// (word chars, `.`, `-`, up to 200 long) and rejects any
|
|
217
|
+
// `..` substring, mirroring `server/api/bridge/sessionRole.ts`.
|
|
218
|
+
//
|
|
219
|
+
// Returns the cleaned id on success (identical to the input for
|
|
220
|
+
// legit ids) or `null` on any hostile / malformed input.
|
|
221
|
+
const SAFE_SESSION_ID_RE = /^[\w.-]{1,200}$/;
|
|
222
|
+
export function safeSessionIdOrNull(sessionId: string): string | null {
|
|
223
|
+
const basename = path.basename(sessionId);
|
|
224
|
+
if (basename !== sessionId) return null;
|
|
225
|
+
if (!SAFE_SESSION_ID_RE.test(basename)) return null;
|
|
226
|
+
if (basename.includes("..")) return null;
|
|
227
|
+
return basename;
|
|
228
|
+
}
|
|
229
|
+
|
|
141
230
|
// --- session metadata ----------------------------------------------
|
|
142
231
|
|
|
143
232
|
interface SessionMeta {
|
|
144
233
|
roleId?: string;
|
|
145
234
|
startedAt?: string;
|
|
235
|
+
origin?: string;
|
|
146
236
|
}
|
|
147
237
|
|
|
238
|
+
// Origins the chat-index summarizer intentionally skips (#1944). `system`
|
|
239
|
+
// sessions are hidden workers whose title never appears in any UI, and
|
|
240
|
+
// `scheduler` sessions fall back to their own `firstUserMessage` (the
|
|
241
|
+
// automation prompt) which identifies them well enough without an AI
|
|
242
|
+
// summary. Kept as a plain string set — `SESSION_ORIGINS` lives in
|
|
243
|
+
// src/types/session.ts and the indexer must not depend on the front-end
|
|
244
|
+
// types module.
|
|
245
|
+
const NON_INDEXED_ORIGINS: ReadonlySet<string> = new Set(["system", "scheduler"]);
|
|
246
|
+
|
|
148
247
|
async function readSessionMeta(workspaceRoot: string, sessionId: string): Promise<SessionMeta> {
|
|
248
|
+
// Defence-in-depth: only `indexSession` calls this (with an
|
|
249
|
+
// already-sanitized id), but a future caller wiring the helper
|
|
250
|
+
// into a new code path shouldn't be able to escape the chat dir
|
|
251
|
+
// by mistake.
|
|
252
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
253
|
+
if (safeId === null) return {};
|
|
149
254
|
try {
|
|
150
|
-
const raw = await readFile(sessionMetaPathFor(workspaceRoot,
|
|
255
|
+
const raw = await readFile(sessionMetaPathFor(workspaceRoot, safeId), "utf-8");
|
|
151
256
|
const parsed: unknown = JSON.parse(raw);
|
|
152
257
|
if (!isRecord(parsed)) return {};
|
|
153
258
|
const metaRecord = parsed as Record<string, unknown>;
|
|
154
259
|
return {
|
|
155
260
|
roleId: typeof metaRecord.roleId === "string" ? metaRecord.roleId : undefined,
|
|
156
261
|
startedAt: typeof metaRecord.startedAt === "string" ? metaRecord.startedAt : undefined,
|
|
262
|
+
origin: typeof metaRecord.origin === "string" ? metaRecord.origin : undefined,
|
|
157
263
|
};
|
|
158
264
|
} catch {
|
|
159
265
|
return {};
|
|
@@ -161,11 +267,22 @@ async function readSessionMeta(workspaceRoot: string, sessionId: string): Promis
|
|
|
161
267
|
}
|
|
162
268
|
|
|
163
269
|
// List every session id that has a .jsonl file in the workspace
|
|
164
|
-
// chat dir. Used by the backfill helper.
|
|
270
|
+
// chat dir. Used by the backfill helper. Filenames from disk are
|
|
271
|
+
// filtered through `safeSessionIdOrNull` so a stray file with a
|
|
272
|
+
// hostile name (`.` / `..` / a slash-containing name that survived
|
|
273
|
+
// readdir on some FS) can never flow through the backfill loop
|
|
274
|
+
// into `indexSession`.
|
|
165
275
|
export async function listSessionIds(workspaceRoot: string): Promise<string[]> {
|
|
166
276
|
try {
|
|
167
277
|
const files = await readdir(chatDirFor(workspaceRoot));
|
|
168
|
-
|
|
278
|
+
const ids: string[] = [];
|
|
279
|
+
for (const fileName of files) {
|
|
280
|
+
if (!fileName.endsWith(".jsonl")) continue;
|
|
281
|
+
const stem = fileName.slice(0, -".jsonl".length);
|
|
282
|
+
const safeId = safeSessionIdOrNull(stem);
|
|
283
|
+
if (safeId !== null) ids.push(safeId);
|
|
284
|
+
}
|
|
285
|
+
return ids;
|
|
169
286
|
} catch {
|
|
170
287
|
return [];
|
|
171
288
|
}
|
|
@@ -175,27 +292,68 @@ export async function listSessionIds(workspaceRoot: string): Promise<string[]> {
|
|
|
175
292
|
|
|
176
293
|
// Index (or re-index) a single session. Returns the entry on
|
|
177
294
|
// success, or null if the session was skipped (fresh, empty,
|
|
178
|
-
// missing). The only exception that escapes is
|
|
295
|
+
// missing, or hostile id). The only exception that escapes is
|
|
179
296
|
// `ClaudeCliNotFoundError` — the caller uses it to disable the
|
|
180
297
|
// module for the rest of the process lifetime.
|
|
298
|
+
//
|
|
299
|
+
// `sessionId` is sanitized at entry (`safeSessionIdOrNull`) and the
|
|
300
|
+
// resulting `safeId` is threaded through every downstream file
|
|
301
|
+
// access, so `force: true` — a debug / rollout knob — cannot become
|
|
302
|
+
// a path-injection escape hatch (Codex review on #1930).
|
|
303
|
+
// Freshness / content-change / origin gates. All three are cheap-ish
|
|
304
|
+
// checks that short-circuit before the summarizer spawn. Extracted so
|
|
305
|
+
// `indexSession` stays under the cognitive-complexity ceiling.
|
|
306
|
+
async function shouldSkipBeforeSummarize(
|
|
307
|
+
workspaceRoot: string,
|
|
308
|
+
safeId: string,
|
|
309
|
+
now: number,
|
|
310
|
+
minInterval: number,
|
|
311
|
+
force: boolean,
|
|
312
|
+
meta: SessionMeta,
|
|
313
|
+
): Promise<boolean> {
|
|
314
|
+
// Origin filter (#1944). Always applied, even under `force: true`:
|
|
315
|
+
// `system` sessions never surface a title in any UI, and `scheduler`
|
|
316
|
+
// sessions fall back to `firstUserMessage` (the automation prompt)
|
|
317
|
+
// which identifies them without an AI summary.
|
|
318
|
+
if (meta.origin !== undefined && NON_INDEXED_ORIGINS.has(meta.origin)) return true;
|
|
319
|
+
if (force) return false;
|
|
320
|
+
if (await isFresh(workspaceRoot, safeId, now, minInterval)) return true;
|
|
321
|
+
// Second gate: even past the freshness window, if the jsonl has NOT
|
|
322
|
+
// been written since the last index there's no new content to
|
|
323
|
+
// summarize. Cuts idle scheduler tick cost from O(sessions) Claude
|
|
324
|
+
// CLI calls to O(actual updates) — see #1929.
|
|
325
|
+
return !(await sessionJsonlChangedSinceIndex(workspaceRoot, safeId));
|
|
326
|
+
}
|
|
327
|
+
|
|
181
328
|
export async function indexSession(workspaceRoot: string, sessionId: string, deps: IndexerDeps = {}): Promise<ChatIndexEntry | null> {
|
|
329
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
330
|
+
if (safeId === null) return null;
|
|
182
331
|
const summarize = deps.summarize ?? defaultSummarize;
|
|
183
332
|
const now = (deps.now ?? Date.now)();
|
|
184
333
|
const minInterval = deps.minIntervalMs ?? MIN_INDEX_INTERVAL_MS;
|
|
185
334
|
const force = deps.force === true;
|
|
335
|
+
const mode = deps.mode ?? "off";
|
|
186
336
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
337
|
+
// Config-driven kill switch (#1944). "off" is the default so a fresh
|
|
338
|
+
// workspace doesn't burn CLI budget until the user opts in from
|
|
339
|
+
// Settings → Chat index. Bailing before any I/O keeps the check
|
|
340
|
+
// ~free (~1 branch) so a backfill tick over N sessions costs O(N)
|
|
341
|
+
// branches, not O(N × meta reads).
|
|
342
|
+
if (mode === "off") return null;
|
|
343
|
+
|
|
344
|
+
// Meta is read here (before jsonl load / summarizer spawn) so the
|
|
345
|
+
// origin filter can short-circuit heavy work; the same object is
|
|
346
|
+
// reused below when building the entry.
|
|
347
|
+
const meta = await readSessionMeta(workspaceRoot, safeId);
|
|
348
|
+
if (await shouldSkipBeforeSummarize(workspaceRoot, safeId, now, minInterval, force, meta)) return null;
|
|
190
349
|
|
|
191
|
-
const input = await loadJsonlInput(sessionJsonlPathFor(workspaceRoot,
|
|
350
|
+
const input = await loadJsonlInput(sessionJsonlPathFor(workspaceRoot, safeId));
|
|
192
351
|
if (!input.trim()) return null;
|
|
193
352
|
|
|
194
|
-
const summary = await summarize(input);
|
|
195
|
-
const meta = await readSessionMeta(workspaceRoot, sessionId);
|
|
353
|
+
const summary = await summarize(input, { model: mode });
|
|
196
354
|
|
|
197
355
|
const entry: ChatIndexEntry = {
|
|
198
|
-
id:
|
|
356
|
+
id: safeId,
|
|
199
357
|
roleId: meta.roleId ?? DEFAULT_ROLE_ID,
|
|
200
358
|
startedAt: meta.startedAt ?? new Date(now).toISOString(),
|
|
201
359
|
indexedAt: new Date(now).toISOString(),
|
|
@@ -207,12 +365,12 @@ export async function indexSession(workspaceRoot: string, sessionId: string, dep
|
|
|
207
365
|
// Per-session file is written first so partial progress survives
|
|
208
366
|
// a crash between the two writes: the next run can still observe
|
|
209
367
|
// the fresh entry via isFresh and skip it.
|
|
210
|
-
await writeJsonAtomic(indexEntryPathFor(workspaceRoot,
|
|
368
|
+
await writeJsonAtomic(indexEntryPathFor(workspaceRoot, safeId), entry);
|
|
211
369
|
|
|
212
370
|
// Upsert into manifest under the in-process lock: replace any
|
|
213
371
|
// prior entry with the same id, sort newest-first by startedAt.
|
|
214
372
|
await updateManifest(workspaceRoot, (current) => {
|
|
215
|
-
const filtered = current.entries.filter((entryItem) => entryItem.id !==
|
|
373
|
+
const filtered = current.entries.filter((entryItem) => entryItem.id !== safeId);
|
|
216
374
|
filtered.push(entry);
|
|
217
375
|
filtered.sort((leftEntry, rightEntry) => Date.parse(rightEntry.startedAt) - Date.parse(leftEntry.startedAt));
|
|
218
376
|
return { version: 1, entries: filtered };
|
|
@@ -39,11 +39,18 @@ const SUMMARY_SCHEMA = {
|
|
|
39
39
|
required: ["title", "summary", "keywords"],
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
//
|
|
43
|
-
// primary way a user finds a past chat, so a
|
|
44
|
-
// history list unusable.
|
|
45
|
-
//
|
|
46
|
-
|
|
42
|
+
// Default model used when a caller doesn't specify one. Sonnet, not
|
|
43
|
+
// haiku: the title is the primary way a user finds a past chat, so a
|
|
44
|
+
// weak title makes the history list unusable. Since #1944 the model
|
|
45
|
+
// is user-selectable from Settings → Chat index — "off" disables the
|
|
46
|
+
// indexer, "haiku" / "sonnet" pick the model here — so this constant
|
|
47
|
+
// is only the internal fallback when the indexer runs without a
|
|
48
|
+
// model override (e.g. a manual rebuild triggered before the setting
|
|
49
|
+
// resolves).
|
|
50
|
+
const DEFAULT_SUMMARY_MODEL = "sonnet";
|
|
51
|
+
// Explicit union so a bad string from the settings layer can't reach
|
|
52
|
+
// the CLI unchecked. Mirror in `AppSettings.chatIndex` (server/system/config.ts).
|
|
53
|
+
export type SummaryModel = "haiku" | "sonnet";
|
|
47
54
|
|
|
48
55
|
// Prompt-building constants. Sized for Sonnet's large context: the
|
|
49
56
|
// window is wide enough to carry a long, topic-shifting session's
|
|
@@ -68,7 +75,7 @@ const MAX_BUDGET_USD = 0.4;
|
|
|
68
75
|
// Any module that wants to drive the summarizer — including the
|
|
69
76
|
// indexer — takes a SummarizeFn so tests can supply a deterministic
|
|
70
77
|
// fake. Production path is `defaultSummarize` below.
|
|
71
|
-
export type SummarizeFn = (input: string) => Promise<SummaryResult>;
|
|
78
|
+
export type SummarizeFn = (input: string, opts?: { model?: SummaryModel }) => Promise<SummaryResult>;
|
|
72
79
|
|
|
73
80
|
interface JsonlEntry {
|
|
74
81
|
source?: string;
|
|
@@ -184,7 +191,7 @@ export async function loadJsonlInput(jsonlPath: string): Promise<string> {
|
|
|
184
191
|
|
|
185
192
|
// --- spawn layer ----------------------------------------------------
|
|
186
193
|
|
|
187
|
-
function spawnClaudeSummarize(input: string, timeoutMs: number): Promise<string> {
|
|
194
|
+
function spawnClaudeSummarize(input: string, timeoutMs: number, model: SummaryModel): Promise<string> {
|
|
188
195
|
return new Promise((resolve, reject) => {
|
|
189
196
|
const args = [
|
|
190
197
|
"--print",
|
|
@@ -192,7 +199,7 @@ function spawnClaudeSummarize(input: string, timeoutMs: number): Promise<string>
|
|
|
192
199
|
"--output-format",
|
|
193
200
|
"json",
|
|
194
201
|
"--model",
|
|
195
|
-
|
|
202
|
+
model,
|
|
196
203
|
"--max-budget-usd",
|
|
197
204
|
String(MAX_BUDGET_USD),
|
|
198
205
|
"--json-schema",
|
|
@@ -252,7 +259,8 @@ function spawnClaudeSummarize(input: string, timeoutMs: number): Promise<string>
|
|
|
252
259
|
// Production SummarizeFn: prepare the input from a jsonl path and
|
|
253
260
|
// drive the CLI. Tests inject their own SummarizeFn that bypasses
|
|
254
261
|
// the CLI entirely.
|
|
255
|
-
export const defaultSummarize: SummarizeFn = async (input
|
|
256
|
-
const
|
|
262
|
+
export const defaultSummarize: SummarizeFn = async (input, opts) => {
|
|
263
|
+
const model: SummaryModel = opts?.model ?? DEFAULT_SUMMARY_MODEL;
|
|
264
|
+
const stdout = await spawnClaudeSummarize(input, DEFAULT_TIMEOUT_MS, model);
|
|
257
265
|
return parseClaudeJsonResult(stdout);
|
|
258
266
|
};
|
|
@@ -8,7 +8,7 @@ export {
|
|
|
8
8
|
type LoadedCollection,
|
|
9
9
|
} from "@mulmoclaude/core/collection/server";
|
|
10
10
|
export { validateCollectionRecords, validateRecordObject, COMPUTED_TYPES, type RecordIssue } from "@mulmoclaude/core/collection/server";
|
|
11
|
-
export { enrichItems } from "@mulmoclaude/core/collection/server";
|
|
11
|
+
export { enrichItems, computeCollectionIcon } from "@mulmoclaude/core/collection/server";
|
|
12
12
|
export { deleteCollection, deleteCollectionRefusalMessage, type DeleteCollectionResult } from "@mulmoclaude/core/collection/server";
|
|
13
13
|
export { deleteCustomView, type DeleteViewResult } from "@mulmoclaude/core/collection/server";
|
|
14
14
|
export {
|
|
@@ -16,6 +16,7 @@ export {
|
|
|
16
16
|
readItem,
|
|
17
17
|
writeItem,
|
|
18
18
|
deleteItem,
|
|
19
|
+
safeRecordId,
|
|
19
20
|
generateItemId,
|
|
20
21
|
resolveCreateItemId,
|
|
21
22
|
readSkillTemplate,
|