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.
Files changed (67) hide show
  1. package/bin/mulmoclaude.js +2 -1
  2. package/client/assets/{index-Dc0R-HW5.js → index-B3NxFcEH.js} +46 -46
  3. package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
  4. package/client/assets/{marp-CSq0PPfK.js → marp-C9QDHFAJ.js} +1 -1
  5. package/client/index.html +2 -2
  6. package/package.json +11 -10
  7. package/server/api/routes/agent.ts +5 -1
  8. package/server/api/routes/chat-index.ts +5 -1
  9. package/server/api/routes/collections.ts +152 -1
  10. package/server/api/routes/config.ts +12 -0
  11. package/server/index.ts +14 -2
  12. package/server/prompts/system/system.md +7 -1
  13. package/server/remoteHost/firebase.ts +6 -0
  14. package/server/remoteHost/handlers/collectionPage.ts +15 -17
  15. package/server/remoteHost/handlers/getCollection.ts +2 -2
  16. package/server/remoteHost/handlers/getFeed.ts +2 -2
  17. package/server/remoteHost/handlers/getRemoteView.ts +37 -0
  18. package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
  19. package/server/remoteHost/handlers/index.ts +10 -0
  20. package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
  21. package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
  22. package/server/remoteHost/handlers/listCollections.ts +6 -1
  23. package/server/remoteHost/handlers/listSkills.ts +39 -0
  24. package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
  25. package/server/remoteHost/handlers/startChat.ts +103 -39
  26. package/server/system/config.ts +101 -4
  27. package/server/system/logs/aaa +737 -0
  28. package/server/system/logs/bb +446 -0
  29. package/server/utils/files/thumbnail-store.ts +97 -0
  30. package/server/workspace/chat-index/index.ts +29 -3
  31. package/server/workspace/chat-index/indexer.ts +187 -29
  32. package/server/workspace/chat-index/summarizer.ts +18 -10
  33. package/server/workspace/collections/index.ts +2 -1
  34. package/server/workspace/collections/remoteView.ts +290 -0
  35. package/server/workspace/journal/archivist-cli.ts +17 -3
  36. package/server/workspace/journal/dailyPass.ts +52 -2
  37. package/server/workspace/journal/index.ts +20 -4
  38. package/src/App.vue +62 -3
  39. package/src/components/FileTree.vue +21 -1
  40. package/src/components/FileTreePane.vue +41 -25
  41. package/src/components/FilesView.vue +4 -0
  42. package/src/components/RemoteHostControl.vue +18 -0
  43. package/src/components/SessionHistoryPanel.vue +24 -11
  44. package/src/components/SettingsChatIndexTab.vue +119 -0
  45. package/src/components/SettingsJournalTab.vue +117 -0
  46. package/src/components/SettingsModal.vue +12 -2
  47. package/src/composables/collections/uiHost.ts +15 -0
  48. package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
  49. package/src/composables/usePubSub.ts +40 -2
  50. package/src/composables/useSessionSync.ts +9 -0
  51. package/src/composables/useShowHiddenSystemFiles.ts +23 -0
  52. package/src/config/apiRoutes.ts +20 -0
  53. package/src/config/roles.ts +2 -2
  54. package/src/config/visibleWorkspaceDirs.ts +21 -0
  55. package/src/lang/de.ts +47 -0
  56. package/src/lang/en.ts +45 -0
  57. package/src/lang/es.ts +46 -0
  58. package/src/lang/fr.ts +47 -0
  59. package/src/lang/ja.ts +46 -0
  60. package/src/lang/ko.ts +45 -0
  61. package/src/lang/pt-BR.ts +46 -0
  62. package/src/lang/zh.ts +44 -0
  63. package/src/plugins/textResponse/View.vue +49 -14
  64. package/src/plugins/textResponse/utils.ts +37 -0
  65. package/src/utils/html/previewCsp.ts +7 -16
  66. package/src/utils/role/icon.ts +9 -2
  67. 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. Used by the
34
- // backfill helper and the debug trigger endpoint so a manual
35
- // "rebuild everything" run doesn't silently skip entries that
36
- // happen to be within the 15-minute window.
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
- await rm(indexEntryPathFor(workspaceRoot, sessionId), { force: true });
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 !== sessionId),
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 raw = await readFile(indexEntryPathFor(workspaceRoot, sessionId), "utf-8");
129
- const entry: unknown = JSON.parse(raw);
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, sessionId), "utf-8");
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
- return files.filter((fileName) => fileName.endsWith(".jsonl")).map((fileName) => fileName.slice(0, -".jsonl".length));
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
- if (!force && (await isFresh(workspaceRoot, sessionId, now, minInterval))) {
188
- return null;
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, sessionId));
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: sessionId,
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, sessionId), entry);
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 !== sessionId);
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
- // Model used for summarization. Sonnet, not haiku: the title is the
43
- // primary way a user finds a past chat, so a weak title makes the
44
- // history list unusable. One cheap structured call per session is
45
- // negligible next to the agent turns it summarizes.
46
- const SUMMARY_MODEL = "sonnet";
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
- SUMMARY_MODEL,
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: string) => {
256
- const stdout = await spawnClaudeSummarize(input, DEFAULT_TIMEOUT_MS);
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,