mulmoclaude 1.2.0 → 1.4.0

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 (96) hide show
  1. package/README.md +1 -1
  2. package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
  3. package/client/assets/index-7QnbsZZr.css +2 -0
  4. package/client/assets/{index-yQkLdtMk.js → index-C3SRVT2p.js} +84 -85
  5. package/client/assets/{marp-Bs_qwoKZ.js → marp-DxctLEy1.js} +12 -3
  6. package/client/index.html +3 -3
  7. package/package.json +7 -6
  8. package/server/agent/attachmentConverter.ts +0 -25
  9. package/server/agent/mcp-server.ts +27 -8
  10. package/server/api/auth/viewToken.ts +16 -8
  11. package/server/api/routes/agent.ts +49 -11
  12. package/server/api/routes/collections.ts +274 -102
  13. package/server/api/routes/files.ts +57 -54
  14. package/server/api/routes/mulmo-script.ts +257 -895
  15. package/server/api/routes/wiki/history.ts +17 -19
  16. package/server/build/dispatcher.mjs +2 -2
  17. package/server/events/collection-change.ts +1 -5
  18. package/server/events/file-change.ts +1 -6
  19. package/server/events/pub-sub/index.ts +15 -2
  20. package/server/events/relay-client.ts +6 -1
  21. package/server/events/session-store/index.ts +11 -2
  22. package/server/index.ts +51 -78
  23. package/server/plugins/diagnostics.ts +0 -6
  24. package/server/plugins/mulmoscript-server.ts +58 -0
  25. package/server/prompts/system/system.md +2 -3
  26. package/server/remoteHost/handlers/getCollection.ts +5 -4
  27. package/server/remoteHost/handlers/getFeed.ts +10 -5
  28. package/server/remoteHost/handlers/googleCalendar.ts +50 -3
  29. package/server/remoteHost/handlers/index.ts +3 -1
  30. package/server/services/translation/index.ts +10 -3
  31. package/server/utils/errors.ts +7 -21
  32. package/server/utils/files/html-store.ts +2 -16
  33. package/server/utils/files/journal-io.ts +0 -12
  34. package/server/utils/files/plugins-io.ts +2 -11
  35. package/server/utils/files/safe.ts +39 -0
  36. package/server/utils/httpError.ts +6 -0
  37. package/server/utils/text.ts +7 -23
  38. package/server/utils/time.ts +0 -3
  39. package/server/workspace/collections/index.ts +6 -4
  40. package/server/workspace/collections/remoteView.ts +31 -22
  41. package/server/workspace/collections/watcher.ts +1 -0
  42. package/server/workspace/custom-dirs.ts +16 -6
  43. package/server/workspace/journal/state.ts +1 -9
  44. package/server/workspace/memory/topic-index-hook.ts +0 -9
  45. package/server/workspace/memory/topic-io.ts +0 -4
  46. package/server/workspace/reference-dirs.ts +8 -3
  47. package/server/workspace/skills/external/catalog.ts +24 -13
  48. package/server/workspace/skills/writer.ts +20 -16
  49. package/server/workspace/wiki-pages/snapshot.ts +13 -12
  50. package/src/components/SettingsGoogleTab.vue +4 -1
  51. package/src/components/StackView.vue +12 -1
  52. package/src/composables/collections/uiHost.ts +2 -1
  53. package/src/composables/useChatScroll.ts +18 -6
  54. package/src/composables/useFileDropZone.ts +3 -15
  55. package/src/composables/useFileSelection.ts +4 -1
  56. package/src/composables/useStickToBottom.ts +51 -0
  57. package/src/config/apiRoutes.ts +20 -0
  58. package/src/config/roles.ts +0 -4
  59. package/src/lang/de.ts +0 -30
  60. package/src/lang/en.ts +0 -30
  61. package/src/lang/es.ts +0 -30
  62. package/src/lang/fr.ts +0 -30
  63. package/src/lang/ja.ts +0 -30
  64. package/src/lang/ko.ts +0 -30
  65. package/src/lang/pt-BR.ts +0 -30
  66. package/src/lang/zh.ts +0 -30
  67. package/src/plugins/accounting/index.ts +0 -2
  68. package/src/plugins/api.ts +0 -6
  69. package/src/plugins/canvas/index.ts +0 -2
  70. package/src/plugins/chart/index.ts +0 -2
  71. package/src/plugins/editImages/index.ts +0 -2
  72. package/src/plugins/generateImage/index.ts +0 -2
  73. package/src/plugins/manageSkills/index.ts +0 -2
  74. package/src/plugins/markdown/index.ts +0 -2
  75. package/src/plugins/photoLocations/index.ts +0 -2
  76. package/src/plugins/presentCollection/index.ts +0 -2
  77. package/src/plugins/presentForm/index.ts +0 -2
  78. package/src/plugins/presentHtml/index.ts +0 -2
  79. package/src/plugins/presentMulmoScript/definition.ts +11 -116
  80. package/src/plugins/presentMulmoScript/index.ts +62 -12
  81. package/src/plugins/presentSVG/index.ts +0 -2
  82. package/src/plugins/skill/index.ts +0 -3
  83. package/src/plugins/spreadsheet/engine/parser.ts +0 -103
  84. package/src/plugins/spreadsheet/index.ts +0 -2
  85. package/src/plugins/textResponse/index.ts +0 -3
  86. package/src/plugins/wiki/index.ts +0 -2
  87. package/src/types/notification.ts +0 -10
  88. package/src/utils/api.ts +0 -4
  89. package/src/utils/dom/scrollable.ts +15 -0
  90. package/src/utils/errors.ts +7 -38
  91. package/client/assets/index-D5nJbMO1.css +0 -2
  92. package/server/api/routes/mulmoScriptValidate.ts +0 -101
  93. package/server/utils/mulmoErrorCapture.ts +0 -93
  94. package/src/plugins/presentMulmoScript/Preview.vue +0 -23
  95. package/src/plugins/presentMulmoScript/View.vue +0 -1949
  96. package/src/plugins/presentMulmoScript/helpers.ts +0 -206
@@ -8,17 +8,22 @@
8
8
  import {
9
9
  createCalendarEvent,
10
10
  DEFAULT_LIST_MAX_RESULTS,
11
+ getCalendarColors,
11
12
  getGoogleAccessToken,
12
13
  isIsoDateTimeWithOffset,
13
14
  listCalendarEvents,
15
+ listCalendars,
14
16
  MAX_LIST_RESULTS,
17
+ type CalendarColorEntry,
15
18
  } from "@mulmoclaude/core/google";
16
- import type { CommandHandler, JsonObject } from "../commandChannel.js";
19
+ import type { CommandHandler, JsonObject, JsonValue } from "../commandChannel.js";
17
20
 
18
21
  export interface GoogleCalendarDeps {
19
22
  getAccessToken: typeof getGoogleAccessToken;
20
23
  createEvent: typeof createCalendarEvent;
21
24
  listEvents: typeof listCalendarEvents;
25
+ listCalendars: typeof listCalendars;
26
+ getColors: typeof getCalendarColors;
22
27
  }
23
28
 
24
29
  const requiredString = (params: JsonObject, key: string): string => {
@@ -27,6 +32,15 @@ const requiredString = (params: JsonObject, key: string): string => {
27
32
  return value;
28
33
  };
29
34
 
35
+ const optionalString = (params: JsonObject, key: string): string | undefined => {
36
+ const value = params[key];
37
+ if (value === undefined || value === null) return undefined;
38
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${key} must be a non-empty string`);
39
+ // Return trimmed so whitespace can't reach the Google API (matches the
40
+ // plugin's Zod .trim() normalization).
41
+ return value.trim();
42
+ };
43
+
30
44
  // Calendar's `dateTime`/`timeMin` are RFC3339 and reject date-only,
31
45
  // offset-less, or impossible values with an opaque 400, so the strict shared
32
46
  // validator runs here where the remote gets an actionable message.
@@ -47,6 +61,14 @@ const clampMaxResults = (value: unknown): number => {
47
61
  return Math.min(Math.max(value, 1), MAX_LIST_RESULTS);
48
62
  };
49
63
 
64
+ // Spread rebuilds an anonymous object type — the named CalendarColorEntry
65
+ // interface (no index signature) can't satisfy the channel's structural
66
+ // JsonValue directly (same constraint as CalendarEventSummary).
67
+ const toColorMapJson = (map: Record<string, CalendarColorEntry>): JsonObject =>
68
+ Object.fromEntries(
69
+ Object.entries(map).map(([colorId, entry]): [string, JsonValue] => [colorId, { background: entry.background, foreground: entry.foreground }]),
70
+ );
71
+
50
72
  export const createGoogleCalendarCreateEvent =
51
73
  (deps: GoogleCalendarDeps): CommandHandler =>
52
74
  async (params: JsonObject) => {
@@ -55,6 +77,8 @@ export const createGoogleCalendarCreateEvent =
55
77
  startDateTime: asDateTime(requiredString(params, "start"), "start"),
56
78
  endDateTime: asDateTime(requiredString(params, "end"), "end"),
57
79
  description: typeof params.description === "string" ? params.description : undefined,
80
+ calendarId: optionalString(params, "calendarId"),
81
+ colorId: optionalString(params, "colorId"),
58
82
  };
59
83
  const event = await deps.createEvent(await deps.getAccessToken(), input);
60
84
  // Spread rebuilds an anonymous object type — the CalendarEventSummary
@@ -68,10 +92,33 @@ export const createGoogleCalendarListEvents =
68
92
  async (params: JsonObject) => {
69
93
  const timeMin = optionalDateTime(params, "timeMin");
70
94
  const maxResults = clampMaxResults(params.maxResults);
71
- const events = await deps.listEvents(await deps.getAccessToken(), { timeMin, maxResults });
95
+ const calendarId = optionalString(params, "calendarId");
96
+ const events = await deps.listEvents(await deps.getAccessToken(), { timeMin, maxResults, calendarId });
72
97
  return { events: events.map((event) => ({ ...event })) };
73
98
  };
74
99
 
75
- const deps: GoogleCalendarDeps = { getAccessToken: getGoogleAccessToken, createEvent: createCalendarEvent, listEvents: listCalendarEvents };
100
+ export const createGoogleCalendarListCalendars =
101
+ (deps: GoogleCalendarDeps): CommandHandler =>
102
+ async () => {
103
+ const calendars = await deps.listCalendars(await deps.getAccessToken());
104
+ return { calendars: calendars.map((calendar) => ({ ...calendar })) };
105
+ };
106
+
107
+ export const createGoogleCalendarColors =
108
+ (deps: GoogleCalendarDeps): CommandHandler =>
109
+ async () => {
110
+ const colors = await deps.getColors(await deps.getAccessToken());
111
+ return { colors: { event: toColorMapJson(colors.event), calendar: toColorMapJson(colors.calendar) } };
112
+ };
113
+
114
+ const deps: GoogleCalendarDeps = {
115
+ getAccessToken: getGoogleAccessToken,
116
+ createEvent: createCalendarEvent,
117
+ listEvents: listCalendarEvents,
118
+ listCalendars,
119
+ getColors: getCalendarColors,
120
+ };
76
121
  export const googleCalendarCreateEvent = createGoogleCalendarCreateEvent(deps);
77
122
  export const googleCalendarListEvents = createGoogleCalendarListEvents(deps);
123
+ export const googleCalendarListCalendars = createGoogleCalendarListCalendars(deps);
124
+ export const googleCalendarColors = createGoogleCalendarColors(deps);
@@ -6,7 +6,7 @@ import { getCollection } from "./getCollection.js";
6
6
  import { getFeed } from "./getFeed.js";
7
7
  import { getRemoteView } from "./getRemoteView.js";
8
8
  import { getRemoteViewItems } from "./getRemoteViewItems.js";
9
- import { googleCalendarCreateEvent, googleCalendarListEvents } from "./googleCalendar.js";
9
+ import { googleCalendarColors, googleCalendarCreateEvent, googleCalendarListCalendars, googleCalendarListEvents } from "./googleCalendar.js";
10
10
  import { listAccountingBooks } from "./listAccountingBooks.js";
11
11
  import { listCollections } from "./listCollections.js";
12
12
  import { listFeeds } from "./listFeeds.js";
@@ -29,4 +29,6 @@ export const handlers: CommandHandlers = {
29
29
  startChat,
30
30
  "google.calendar.createEvent": googleCalendarCreateEvent,
31
31
  "google.calendar.listEvents": googleCalendarListEvents,
32
+ "google.calendar.listCalendars": googleCalendarListCalendars,
33
+ "google.calendar.colors": googleCalendarColors,
32
34
  };
@@ -88,9 +88,16 @@ export function createTranslationService(deps: TranslationServiceDeps): Translat
88
88
  const next = prev.catch(() => undefined).then(runner);
89
89
  const tracked = next.catch(() => undefined);
90
90
  chains.set(namespace, tracked);
91
- tracked.then(() => {
92
- if (chains.get(namespace) === tracked) chains.delete(namespace);
93
- });
91
+ // Housekeeping only — the caller waits on `next`, not on this. Still needs
92
+ // a terminal handler: `tracked` swallowed the runner's rejection, but the
93
+ // cleanup callback itself is not covered by that. Silent by design: a
94
+ // failed map-entry cleanup is not worth a log line, and it used to reach
95
+ // the process-level `unhandledRejection` handler and exit the server.
96
+ tracked
97
+ .then(() => {
98
+ if (chains.get(namespace) === tracked) chains.delete(namespace);
99
+ })
100
+ .catch(() => {});
94
101
  return next;
95
102
  }
96
103
 
@@ -2,24 +2,10 @@
2
2
  // `err instanceof Error ? err.message : String(err)` — searching for
3
3
  // one canonical helper is easier than grepping for the inline form.
4
4
  //
5
- // Non-Error objects with a `details` (gRPC convention) or `message`
6
- // string field have that field surfaced without this, gRPC errors
7
- // like `{ code, details, metadata }` show up to users as
8
- // `[object Object]`.
9
- //
10
- // The optional `fallback` covers the common route-handler idiom where
11
- // a throw of a plain non-Error value should surface as a descriptive
12
- // message ("rebuild failed") rather than `String(err)` noise. Prefer
13
- // passing a fallback at error-response boundaries — omit it for
14
- // logging contexts where `String(err)` is fine.
15
-
16
- export function errorMessage(err: unknown, fallback?: string): string {
17
- if (err instanceof Error) return err.message;
18
- if (err !== null && typeof err === "object") {
19
- const obj = err as { details?: unknown; message?: unknown };
20
- if (typeof obj.details === "string" && obj.details) return obj.details;
21
- if (typeof obj.message === "string" && obj.message) return obj.message;
22
- }
23
- if (fallback !== undefined) return fallback;
24
- return String(err);
25
- }
5
+ // The implementation lives in `@mulmoclaude/core/utils` so the host, the
6
+ // collection engine, the scheduler and the Google engine can't drift apart
7
+ // again: before #2217 this function existed 14 times across 4 behaviours, and
8
+ // gRPC-shaped errors surfaced as "[object Object]" through half of them.
9
+ // Re-exported (rather than repointing 92 files) so host code keeps reaching
10
+ // for `server/utils/errors.js` by name.
11
+ export { errorMessage } from "@mulmoclaude/core/utils";
@@ -1,20 +1,6 @@
1
- import path from "path";
2
- import { workspacePath } from "../../workspace/workspace.js";
3
1
  import { WORKSPACE_DIRS } from "../../workspace/paths.js";
4
- import { writeFileAtomic } from "./atomic.js";
5
2
  import { makePathValidator } from "./path-validator.js";
6
3
 
7
- // Strict overwriteHtml's path.join doesn't normalize traversal, so this gate is the primary defence.
4
+ // The sole guard on html-store paths: `path.join` elsewhere doesn't normalize
5
+ // traversal, so callers must pre-check writes with this validator.
8
6
  export const isHtmlPath = makePathValidator({ prefix: WORKSPACE_DIRS.htmls, ext: ".html" });
9
-
10
- // Defense in depth (matches `overwriteSvg`): if a caller forgets to
11
- // pre-check via `isHtmlPath`, `path.join(workspacePath, relativePath)`
12
- // would silently produce a traversal escape. The re-check inside the
13
- // write closes that trust chain.
14
- export async function overwriteHtml(relativePath: string, content: string): Promise<void> {
15
- if (!isHtmlPath(relativePath)) {
16
- throw new Error(`invalid html path: ${relativePath}`);
17
- }
18
- const absPath = path.join(workspacePath, relativePath);
19
- await writeFileAtomic(absPath, content);
20
- }
@@ -7,20 +7,8 @@ import { isEnoent } from "./safe.js";
7
7
  import { log } from "../../system/logger/index.js";
8
8
  import { summariesRoot, dailyPathFor, topicPathFor, TOPICS_DIR, INDEX_FILE, STATE_FILE, DAILY_DIR, ARCHIVE_DIR } from "../../workspace/journal/paths.js";
9
9
 
10
- import { statSync } from "node:fs";
11
-
12
10
  const root = (rootOverride?: string) => rootOverride ?? workspacePath;
13
11
 
14
- export function journalStateExists(rootOverride?: string): boolean {
15
- const filePath = path.join(summariesRoot(root(rootOverride)), STATE_FILE);
16
- try {
17
- statSync(filePath);
18
- return true;
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
12
  export async function readJournalState<T>(fallback: T, rootOverride?: string): Promise<T> {
25
13
  const filePath = path.join(summariesRoot(root(rootOverride)), STATE_FILE);
26
14
  try {
@@ -15,10 +15,9 @@
15
15
  // written ledger never bricks server boot. Writes go through the
16
16
  // atomic helper, so a crashed install can't leave a corrupt file.
17
17
 
18
- import { existsSync } from "node:fs";
19
- import { mkdir, readFile } from "node:fs/promises";
18
+ import { readFile } from "node:fs/promises";
20
19
  import path from "node:path";
21
- import { loadJsonFile, writeJsonAtomic } from "./json.js";
20
+ import { loadJsonFile } from "./json.js";
22
21
  import { WORKSPACE_PATHS } from "../../workspace/paths.js";
23
22
 
24
23
  export interface LedgerEntry {
@@ -49,14 +48,6 @@ export function readLedger(): LedgerEntry[] {
49
48
  return sanitiseLedger(raw);
50
49
  }
51
50
 
52
- export async function writeLedger(entries: readonly LedgerEntry[]): Promise<void> {
53
- const dir = path.dirname(WORKSPACE_PATHS.pluginsLedger);
54
- if (!existsSync(dir)) {
55
- await mkdir(dir, { recursive: true });
56
- }
57
- await writeJsonAtomic(WORKSPACE_PATHS.pluginsLedger, [...entries]);
58
- }
59
-
60
51
  /** Read a runtime-plugin asset (extracted under
61
52
  * `plugins/.cache/<name>/<version>/`) and return its bytes plus
62
53
  * the inferred Content-Type. The route handler in
@@ -99,6 +99,45 @@ export function hasTraversalSegment(value: string): boolean {
99
99
  return value.split(/[/\\]/).some((segment) => segment === ".." || segment === ".");
100
100
  }
101
101
 
102
+ // Lazily realpath a directory once and cache the result. Returns null
103
+ // until the directory exists on disk (a fresh workspace hasn't created
104
+ // it yet) and retries on the next call. The /artifacts/* static mounts
105
+ // each need their storage root as a realpath for the traversal check,
106
+ // but the root may not be materialised at boot.
107
+ export function makeCachedRealpath(dir: string): () => Promise<string | null> {
108
+ let cached: string | null = null;
109
+ return async () => {
110
+ if (cached) return cached;
111
+ try {
112
+ cached = await promises.realpath(dir);
113
+ return cached;
114
+ } catch {
115
+ return null;
116
+ }
117
+ };
118
+ }
119
+
120
+ // Decode and traversal-guard an `/artifacts/*` request path against an
121
+ // already-realpath'd storage root. Returns the in-root relative path to
122
+ // serve, or null when the URL is malformed, escapes the root, or (when
123
+ // `denyDotfiles`) touches a dotfile segment. The images / html / svg
124
+ // static mounts share this so the decode + traversal + dotfile policy is
125
+ // defined once. `rootReal` MUST be a realpath (see `resolveWithinRoot`).
126
+ export function resolveArtifactRequestPath(rootReal: string, reqPath: string, denyDotfiles: boolean): string | null {
127
+ let relPath: string;
128
+ try {
129
+ // decodeURIComponent throws URIError on malformed escapes (`%ZZ`,
130
+ // stray `%`). Fail closed so a junk URL 404s instead of bubbling a
131
+ // 500 out of the express error chain.
132
+ relPath = decodeURIComponent(reqPath.replace(/^\//, ""));
133
+ } catch {
134
+ return null;
135
+ }
136
+ if (!resolveWithinRoot(rootReal, relPath)) return null;
137
+ if (denyDotfiles && containsDotfileSegment(relPath)) return null;
138
+ return relPath;
139
+ }
140
+
102
141
  // `rootReal` MUST already be a realpath. Returns null on traversal or if either path doesn't exist on disk.
103
142
  export function resolveWithinRoot(rootReal: string, relPath: string): string | null {
104
143
  const normalized = path.normalize(relPath || "");
@@ -50,6 +50,12 @@ export function notFound(res: Response, error: string): Response {
50
50
  return sendError(res, 404, error);
51
51
  }
52
52
 
53
+ /** 405 Method Not Allowed — the resource exists but refuses this verb
54
+ * (e.g. a write against a read-only `dataSource` collection). */
55
+ export function methodNotAllowed(res: Response, error: string): Response {
56
+ return sendError(res, 405, error);
57
+ }
58
+
53
59
  /** 409 Conflict — duplicate, concurrent modification, already running, etc. */
54
60
  export function conflict(res: Response, error: string): Response {
55
61
  return sendError(res, 409, error);
@@ -1,29 +1,13 @@
1
1
  // Shared text helpers. Use these instead of re-implementing the
2
2
  // same operation per file (#1306).
3
3
  //
4
+ // `truncate` lives in `@mulmoclaude/core/utils` so the host, the collection
5
+ // engine and the Google engine share one implementation — the copies had
6
+ // already drifted on the `ellipsis.length >= max` guard (#2217). Re-exported
7
+ // (rather than repointing ~5 import sites) to keep `server/utils/text.ts` the
8
+ // one place host code looks for general string helpers.
9
+ //
4
10
  // Why not in `format/`: these are general string operations, not
5
11
  // presentation-layer formatters. Reserve `format/` for locale-aware
6
12
  // or unit-aware display helpers.
7
-
8
- /** Truncate `text` to at most `max` characters, appending `ellipsis`
9
- * when the input is too long.
10
- *
11
- * The ellipsis is part of the budget: `truncate("hello world", 8)`
12
- * yields `"hello w…"` (7 chars of the original + the ellipsis = 8
13
- * total). This avoids the off-by-one bug where naive
14
- * `slice(0, max) + "…"` overshoots `max`.
15
- *
16
- * Edge cases:
17
- * - `text.length <= max` → return `text` unchanged.
18
- * - `max <= 0` → return the empty string (callers asking for "no
19
- * output" should get "no output", not a stray ellipsis).
20
- * - If `ellipsis.length > max`, the ellipsis itself is truncated
21
- * to fit `max` rather than throwing — keeps callers safe from
22
- * surprising errors when they pick a tiny max with a multi-char
23
- * ellipsis. */
24
- export function truncate(text: string, max: number, ellipsis = "…"): string {
25
- if (max <= 0) return "";
26
- if (text.length <= max) return text;
27
- if (ellipsis.length >= max) return ellipsis.slice(0, max);
28
- return text.slice(0, max - ellipsis.length) + ellipsis;
29
- }
13
+ export { truncate } from "@mulmoclaude/core/utils";
@@ -47,6 +47,3 @@ export const SUBPROCESS_WORK_TIMEOUT_MS = ONE_MINUTE_MS;
47
47
 
48
48
  /** CLI subprocess timeout (claude -p for summarization, etc.) */
49
49
  export const CLI_SUBPROCESS_TIMEOUT_MS = 5 * ONE_MINUTE_MS;
50
-
51
- /** Maximum one-shot notification delay */
52
- export const MAX_NOTIFICATION_DELAY_SEC = 3_600; // 1 hour in seconds
@@ -11,13 +11,15 @@ export { validateCollectionRecords, validateRecordObject, COMPUTED_TYPES, type R
11
11
  export { buildWorkspaceOntology, schemaRelations, type CollectionOntologyEntry, type OntologyRelation } from "@mulmoclaude/core/collection/server";
12
12
  export { applyMutateAction, firstMutateParamProblem, type MutateActionOutcome } from "@mulmoclaude/core/collection/server";
13
13
  export { enrichItems, computeCollectionIcon } from "@mulmoclaude/core/collection/server";
14
+ export { storeFor, collectionWritable, readOnlyRefusal, type CollectionStore } from "@mulmoclaude/core/collection/server";
14
15
  export { deleteCollection, deleteCollectionRefusalMessage, type DeleteCollectionResult } from "@mulmoclaude/core/collection/server";
15
16
  export { deleteCustomView, type DeleteViewResult } from "@mulmoclaude/core/collection/server";
17
+ // NOTE (storage virtualization, plans/refactor-storage-virtualization.md):
18
+ // the raw io functions (`listItems` / `readItem` / `writeItem` /
19
+ // `deleteItem`) are deliberately NOT re-exported — host code reads AND
20
+ // writes records only through `storeFor(...)` (write/delete exist only on
21
+ // writable stores) so a future storage backend can't be bypassed.
16
22
  export {
17
- listItems,
18
- readItem,
19
- writeItem,
20
- deleteItem,
21
23
  safeRecordId,
22
24
  generateItemId,
23
25
  resolveCreateItemId,
@@ -23,16 +23,14 @@ import {
23
23
  } from "@mulmoclaude/core/remote-view";
24
24
  import { enrichItems } from "@mulmoclaude/core/collection/server";
25
25
  import {
26
- deleteItem,
27
- listItems,
28
26
  readCustomViewHtml,
29
27
  readCustomViewI18n,
30
- readItem,
31
28
  safeRecordId,
32
- writeItem,
29
+ storeFor,
33
30
  type CollectionCustomView,
34
31
  type CollectionItem,
35
32
  type CollectionSchema,
33
+ type CollectionStore,
36
34
  type LoadedCollection,
37
35
  } from "./index.js";
38
36
  import { resolveThumbnail } from "../../utils/files/thumbnail-store.js";
@@ -108,6 +106,7 @@ export type MutateRemoteViewResult =
108
106
  | { kind: "view-not-found"; viewId: string }
109
107
  | { kind: "not-mobile"; viewId: string }
110
108
  | { kind: "not-writable"; viewId: string }
109
+ | { kind: "read-only-collection" }
111
110
  | { kind: "field-not-editable"; field: string }
112
111
  | { kind: "delete-not-allowed" }
113
112
  | { kind: "invalid-patch" }
@@ -116,9 +115,7 @@ export type MutateRemoteViewResult =
116
115
  | { kind: "path-escape" };
117
116
 
118
117
  export interface MutateRemoteViewDeps {
119
- readItem: typeof readItem;
120
- writeItem: typeof writeItem;
121
- deleteItem: typeof deleteItem;
118
+ storeFor: (collection: LoadedCollection) => CollectionStore;
122
119
  enrichItems: typeof enrichItems;
123
120
  resolveThumbnail: typeof resolveThumbnail;
124
121
  }
@@ -129,13 +126,18 @@ export const createMutateRemoteView =
129
126
  const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
130
127
  if (!view) return { kind: "view-not-found", viewId };
131
128
  if (view.target !== "mobile") return { kind: "not-mobile", viewId };
129
+ // A dataSource collection is read-only regardless of what write surface
130
+ // the view declares — the collection-level rule outranks the view's. The
131
+ // store encodes it as absent write/delete methods.
132
+ const store = deps.storeFor(collection);
133
+ if (!store.write || !store.delete) return { kind: "read-only-collection" };
132
134
  if (!isWritableView(view)) return { kind: "not-writable", viewId };
133
- return request.op === "delete" ? deleteViaView(deps, collection, view.allowDelete === true, request.id) : updateViaView(deps, collection, view, request);
135
+ return request.op === "delete" ? deleteViaView(store.delete, view.allowDelete === true, request.id) : updateViaView(deps, store, collection, view, request);
134
136
  };
135
137
 
136
- async function deleteViaView(deps: MutateRemoteViewDeps, collection: LoadedCollection, allowDelete: boolean, itemId: string): Promise<MutateRemoteViewResult> {
138
+ async function deleteViaView(remove: NonNullable<CollectionStore["delete"]>, allowDelete: boolean, itemId: string): Promise<MutateRemoteViewResult> {
137
139
  if (!allowDelete) return { kind: "delete-not-allowed" };
138
- const result = await deps.deleteItem(collection.dataDir, itemId, { slug: collection.slug });
140
+ const result = await remove(itemId);
139
141
  if (result.kind === "invalid-id") return { kind: "invalid-id", id: result.itemId };
140
142
  if (result.kind === "path-escape") return { kind: "path-escape" };
141
143
  if (result.kind === "not-found") return { kind: "item-not-found", id: result.itemId };
@@ -144,10 +146,13 @@ async function deleteViaView(deps: MutateRemoteViewDeps, collection: LoadedColle
144
146
 
145
147
  async function updateViaView(
146
148
  deps: MutateRemoteViewDeps,
149
+ store: CollectionStore,
147
150
  collection: LoadedCollection,
148
151
  view: CollectionCustomView,
149
152
  request: Extract<RemoteViewMutateRequest, { op: "update" }>,
150
153
  ): Promise<MutateRemoteViewResult> {
154
+ const { write } = store;
155
+ if (!write) return { kind: "read-only-collection" }; // unreachable: caller guards presence
151
156
  const { primaryKey } = collection.schema;
152
157
  const patchKeys = Object.keys(request.patch);
153
158
  if (patchKeys.length === 0) return { kind: "invalid-patch" };
@@ -156,17 +161,17 @@ async function updateViaView(
156
161
  // desync the file name from the record) even if an author listed it.
157
162
  const offending = patchKeys.find((key) => key === primaryKey || !allowed.has(key));
158
163
  if (offending) return { kind: "field-not-editable", field: offending };
159
- // Classify a bad id BEFORE readItem — which returns null for an unsafe id, a
160
- // path-escape, AND a genuinely-missing record alike — so update reports the
161
- // same explicit `invalid-id` the delete path does (via deleteItem) instead of
162
- // masking it as a 404. (A valid id whose dataDir escapes the workspace can
163
- // hold no record, so it still resolves to item-not-found; a real write is
164
- // additionally refused by writeItem's own containment guard below.)
164
+ // Classify a bad id BEFORE the store read — which returns null for an unsafe
165
+ // id, a path-escape, AND a genuinely-missing record alike — so update reports
166
+ // the same explicit `invalid-id` the delete path does (via `store.delete`)
167
+ // instead of masking it as a 404. (A valid id whose data location escapes the
168
+ // workspace can hold no record, so it still resolves to item-not-found; a
169
+ // real write is additionally refused by the store's own containment guard.)
165
170
  if (safeRecordId(request.id) === null) return { kind: "invalid-id", id: request.id };
166
- const existing = await deps.readItem(collection.dataDir, request.id, { slug: collection.slug });
171
+ const existing = await store.read(request.id);
167
172
  if (!existing) return { kind: "item-not-found", id: request.id };
168
173
  const merged: CollectionItem = { ...existing, ...request.patch, [primaryKey]: request.id };
169
- const result = await deps.writeItem(collection.dataDir, request.id, merged, { slug: collection.slug });
174
+ const result = await write(request.id, merged);
170
175
  if (result.kind === "invalid-id") return { kind: "invalid-id", id: result.itemId };
171
176
  if (result.kind === "path-escape") return { kind: "path-escape" };
172
177
  if (result.kind === "conflict") return { kind: "item-not-found", id: result.itemId }; // unreachable: refuseOverwrite is false
@@ -196,7 +201,7 @@ async function updateViaView(
196
201
  return { kind: "ok", op: "update", item: item as CollectionItem };
197
202
  }
198
203
 
199
- export const mutateRemoteView = createMutateRemoteView({ readItem, writeItem, deleteItem, enrichItems, resolveThumbnail });
204
+ export const mutateRemoteView = createMutateRemoteView({ storeFor, enrichItems, resolveThumbnail });
200
205
 
201
206
  // ── Item pages with inlined image thumbnails (phase 5 — plans/feat-remote-view-images.md) ──
202
207
  // A mobile view's `getItems`, view-aware so it can inline the `imageFields` its
@@ -213,7 +218,9 @@ export type RemoteViewItemsResult =
213
218
  | { kind: "too-large"; bytes: number };
214
219
 
215
220
  export interface RemoteViewItemsDeps {
216
- listItems: typeof listItems;
221
+ /** Load every record of the collection — store-aware (file records or a
222
+ * `dataSource` CSV's rows), unlike a raw dataDir `listItems`. */
223
+ listRecords: (collection: LoadedCollection) => Promise<CollectionItem[]>;
217
224
  enrichItems: typeof enrichItems;
218
225
  resolveThumbnail: typeof resolveThumbnail;
219
226
  }
@@ -271,7 +278,7 @@ export const createRemoteViewItems =
271
278
  // formulas evaluated with a full ref cache (`ticker.price`, `shares * ticker.price`
272
279
  // resolve), toggles projected, embeds resolved. The phone gets plain resolved
273
280
  // scalars — no network, no dataUrl — so mobile numbers match desktop exactly.
274
- const items = await deps.listItems(collection.dataDir);
281
+ const items = await deps.listRecords(collection);
275
282
  const derived = (await deps.enrichItems(collection, items)) as RemoteViewItem[];
276
283
  const page = pageFromItems(derived, request, collection.schema.primaryKey);
277
284
  // Resolving an `embed` column attaches a whole target record per row, so the
@@ -289,7 +296,7 @@ export const createRemoteViewItems =
289
296
  return { kind: "ok", page, inlined, omitted };
290
297
  };
291
298
 
292
- export const remoteViewItems = createRemoteViewItems({ listItems, enrichItems, resolveThumbnail });
299
+ export const remoteViewItems = createRemoteViewItems({ listRecords: (collection) => storeFor(collection).list(), enrichItems, resolveThumbnail });
293
300
 
294
301
  /** Message per non-ok item-page kind — shared by the channel handler (throws)
295
302
  * and the HTTP route (sends with the matching status). */
@@ -307,6 +314,8 @@ export function mutateRemoteViewFailureMessage(result: Exclude<MutateRemoteViewR
307
314
  if (result.kind === "not-mobile") return `custom view '${result.viewId}' is not a mobile view — declare target: "mobile" in its views[] entry`;
308
315
  if (result.kind === "not-writable")
309
316
  return `mobile view '${result.viewId}' is read-only — declare editableFields and/or allowDelete in its views[] entry to allow writes`;
317
+ if (result.kind === "read-only-collection")
318
+ return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
310
319
  if (result.kind === "field-not-editable")
311
320
  return `field '${result.field}' is not editable from this view — add it to the view's editableFields (the primary key is never editable)`;
312
321
  if (result.kind === "delete-not-allowed") return `this view may not delete records — set allowDelete: true in its views[] entry`;
@@ -15,5 +15,6 @@ export {
15
15
  _syncWatchersForTesting,
16
16
  _tickTimeTriggersForTesting,
17
17
  _scheduleItemReconcileForTesting,
18
+ _scheduleStorageReconcileForTesting,
18
19
  type CollectionWatcherOptions,
19
20
  } from "@mulmoclaude/core/collection-watchers";
@@ -9,7 +9,7 @@ import path from "path";
9
9
  import { workspacePath, WORKSPACE_DIRS } from "./paths.js";
10
10
  import { log } from "../system/logger/index.js";
11
11
  import { writeJsonAtomicSync } from "../utils/files/json.js";
12
- import { isRecord } from "../utils/types.js";
12
+ import { hasStringProp, isRecord } from "../utils/types.js";
13
13
 
14
14
  // ── Types ───────────────────────────────────────────────────────
15
15
 
@@ -82,16 +82,23 @@ function sanitizeDescription(raw: string): string {
82
82
 
83
83
  function validateEntry(raw: unknown): CustomDirEntry | null {
84
84
  if (!isRecord(raw)) return null;
85
- const obj = raw as Record<string, unknown>;
86
85
 
87
- const validPath = validatePath(String(obj.path ?? ""));
86
+ // Type-check the field rather than `String(...)`-ing it. This file is fed by a
87
+ // hand-edited config, so `path` can be an array or object; stringifying one
88
+ // handed `validatePath` the literal "[object Object]" to evaluate as a path.
89
+ // (`validatePath`'s own `typeof !== "string"` check could never fire behind a
90
+ // `String()` call.)
91
+ if (!hasStringProp(raw, "path")) return null;
92
+ const validPath = validatePath(raw.path);
88
93
  if (!validPath) return null;
89
94
 
90
- const structure = isValidStructure(obj.structure) ? obj.structure : DIR_STRUCTURES.flat;
95
+ const structure = isValidStructure(raw.structure) ? raw.structure : DIR_STRUCTURES.flat;
91
96
 
92
97
  return {
93
98
  path: validPath,
94
- description: sanitizeDescription(String(obj.description ?? "")),
99
+ // A non-string description is dropped rather than described as
100
+ // "[object Object]" — it is optional, so absent is the honest reading.
101
+ description: sanitizeDescription(hasStringProp(raw, "description") ? raw.description : ""),
95
102
  structure,
96
103
  };
97
104
  }
@@ -153,7 +160,10 @@ export function validateCustomDirs(raw: unknown): { entries: CustomDirEntry[] }
153
160
  if (entry) {
154
161
  entries.push(entry);
155
162
  } else {
156
- const itemPath = isRecord(item) ? String((item as Record<string, unknown>).path ?? "") : "";
163
+ // Only echo a genuine string back in the error; a non-string `path` is
164
+ // exactly the case where "[object Object]" would mislead the reader about
165
+ // what their config actually says.
166
+ const itemPath = hasStringProp(item, "path") ? item.path : "";
157
167
  errors.push(`entry ${i}: invalid path "${itemPath}"`);
158
168
  }
159
169
  });
@@ -1,8 +1,4 @@
1
- import {
2
- readJournalState as readJournalStateRaw,
3
- writeJournalState as writeJournalStateRaw,
4
- journalStateExists as journalStateExistsRaw,
5
- } from "../../utils/files/journal-io.js";
1
+ import { readJournalState as readJournalStateRaw, writeJournalState as writeJournalStateRaw } from "../../utils/files/journal-io.js";
6
2
  import { ONE_HOUR_MS, ONE_DAY_MS } from "../../utils/time.js";
7
3
  import { log } from "../../system/logger/index.js";
8
4
  import { isRecord } from "../../utils/types.js";
@@ -106,7 +102,3 @@ export async function readState(workspaceRoot: string): Promise<JournalState> {
106
102
  export async function writeState(workspaceRoot: string, state: JournalState): Promise<void> {
107
103
  await writeJournalStateRaw(state, workspaceRoot);
108
104
  }
109
-
110
- export function stateFileExists(workspaceRoot: string): boolean {
111
- return journalStateExistsRaw(workspaceRoot);
112
- }
@@ -117,12 +117,3 @@ export async function maybeRegenerateTopicIndex(relativePath: string): Promise<b
117
117
  return false;
118
118
  }
119
119
  }
120
-
121
- /** Test-only — drop the per-workspace regen chain so each test
122
- * starts with a fresh queue. Without this, a chain rejection from
123
- * one test could carry into the next (the .catch in `chainRegen`
124
- * swallows it for the next caller, but the test runner sees the
125
- * unhandled-rejection from the original promise). */
126
- export function _resetTopicIndexRegenChainForTesting(): void {
127
- regenChains.clear();
128
- }
@@ -33,10 +33,6 @@ export function topicMemoryIndexPath(workspaceRoot: string): string {
33
33
  return path.join(workspaceRoot, WORKSPACE_FILES.memoryIndex);
34
34
  }
35
35
 
36
- export function topicFilePath(workspaceRoot: string, type: MemoryType, topic: string): string {
37
- return path.join(topicMemoryRoot(workspaceRoot), type, `${topic}.md`);
38
- }
39
-
40
36
  // Both loaders walk types in `MEMORY_TYPES` order (a stable
41
37
  // constant) and sort filenames within each type by name. This pins
42
38
  // the order of entries in the agent's system prompt so a `readdir`