mulmoclaude 1.3.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 (37) hide show
  1. package/README.md +1 -1
  2. package/client/assets/{PluginScopedRoot-B53YSjaC.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
  3. package/client/assets/index-7QnbsZZr.css +2 -0
  4. package/client/assets/{index-DUdqhWg6.js → index-C3SRVT2p.js} +71 -71
  5. package/client/assets/{marp-CyWxL3bc.js → marp-DxctLEy1.js} +1 -1
  6. package/client/index.html +3 -3
  7. package/package.json +5 -5
  8. package/server/agent/mcp-server.ts +27 -8
  9. package/server/api/auth/viewToken.ts +7 -1
  10. package/server/api/routes/agent.ts +14 -1
  11. package/server/api/routes/collections.ts +127 -9
  12. package/server/build/dispatcher.mjs +2 -2
  13. package/server/events/collection-change.ts +1 -0
  14. package/server/events/pub-sub/index.ts +15 -2
  15. package/server/events/relay-client.ts +6 -1
  16. package/server/events/session-store/index.ts +11 -2
  17. package/server/index.ts +26 -2
  18. package/server/prompts/system/system.md +0 -1
  19. package/server/remoteHost/handlers/getFeed.ts +10 -5
  20. package/server/services/translation/index.ts +10 -3
  21. package/server/utils/errors.ts +7 -21
  22. package/server/utils/text.ts +7 -23
  23. package/server/workspace/collections/index.ts +5 -4
  24. package/server/workspace/collections/remoteView.ts +21 -21
  25. package/server/workspace/collections/watcher.ts +1 -0
  26. package/server/workspace/custom-dirs.ts +16 -6
  27. package/server/workspace/reference-dirs.ts +8 -3
  28. package/src/components/SettingsGoogleTab.vue +4 -1
  29. package/src/components/StackView.vue +12 -1
  30. package/src/composables/collections/uiHost.ts +2 -1
  31. package/src/composables/useChatScroll.ts +18 -6
  32. package/src/composables/useFileSelection.ts +4 -1
  33. package/src/composables/useStickToBottom.ts +51 -0
  34. package/src/config/apiRoutes.ts +14 -0
  35. package/src/utils/dom/scrollable.ts +15 -0
  36. package/src/utils/errors.ts +7 -38
  37. package/client/assets/index-D3czxq1I.css +0 -2
@@ -19,7 +19,6 @@ import {
19
19
  deleteCollection,
20
20
  deleteCollectionRefusalMessage,
21
21
  deleteCustomView,
22
- deleteItem,
23
22
  loadCollection,
24
23
  readSkillTemplate,
25
24
  readCustomViewHtml,
@@ -27,6 +26,7 @@ import {
27
26
  readOnlyRefusal,
28
27
  buildActionSeedPrompt,
29
28
  buildCollectionActionSeedPrompt,
29
+ buildWorkspaceOntology,
30
30
  promptPathsFor,
31
31
  resolveCreateItemId,
32
32
  storeFor,
@@ -34,13 +34,13 @@ import {
34
34
  toSummary,
35
35
  applyMutateAction,
36
36
  validateCollectionRecords,
37
- writeItem,
38
37
  } from "../../workspace/collections/index.js";
39
38
  import type {
40
39
  CollectionMutateAction,
41
40
  CollectionSeededAction,
42
41
  CollectionDetail,
43
42
  CollectionItem,
43
+ CollectionOntologyEntry,
44
44
  CollectionSummary,
45
45
  DeleteViewResult,
46
46
  LoadedCollection,
@@ -57,7 +57,8 @@ import {
57
57
  type RemoteViewBuildResult,
58
58
  type RemoteViewItemsResult,
59
59
  } from "../../workspace/collections/remoteView.js";
60
- import { clampLimit, clampOffset, normalizeFields, normalizeMutate } from "@mulmoclaude/core/remote-view";
60
+ import { clampImageMaxEdge, clampLimit, clampOffset, normalizeFields, normalizeMutate } from "@mulmoclaude/core/remote-view";
61
+ import { resolveThumbnail } from "../../utils/files/thumbnail-store.js";
61
62
  import { badRequest, notFound, conflict, forbidden, methodNotAllowed, serverError, serviceUnavailable } from "../../utils/httpError.js";
62
63
  import { ONE_MINUTE_MS } from "../../utils/time.js";
63
64
  import { errorMessage } from "../../utils/errors.js";
@@ -102,6 +103,10 @@ interface CollectionsListResponse {
102
103
  collections: CollectionSummary[];
103
104
  }
104
105
 
106
+ interface CollectionOntologyResponse {
107
+ entries: CollectionOntologyEntry[];
108
+ }
109
+
105
110
  interface CollectionDetailResponse {
106
111
  collection: CollectionDetail;
107
112
  items: CollectionItem[];
@@ -184,6 +189,19 @@ router.get(API_ROUTES.collections.list, async (_req: Request, res: Response<Coll
184
189
  }
185
190
  });
186
191
 
192
+ // Raw workspace-ontology entries (buildWorkspaceOntology — derived on
193
+ // demand, never stored); the /collections Map tab builds its graph from
194
+ // these with the shared `buildOntologyGraph`. Registered before the
195
+ // `:slug` routes so "ontology" is never swallowed as a slug.
196
+ router.get(API_ROUTES.collections.ontology, async (_req: Request, res: Response<CollectionOntologyResponse>) => {
197
+ try {
198
+ res.json({ entries: await buildWorkspaceOntology() });
199
+ } catch (err) {
200
+ log.warn("collections", "ontology failed", { error: errorMessage(err) });
201
+ serverError(res, errorMessage(err));
202
+ }
203
+ });
204
+
187
205
  router.get(API_ROUTES.collections.detail, async (req: Request<{ slug: string }>, res: Response<CollectionDetailResponse>) => {
188
206
  const collection = await loadCollectionOr404(req.params.slug, res);
189
207
  if (!collection) return;
@@ -243,7 +261,8 @@ function extractRecord(body: unknown): CollectionItem | null {
243
261
  router.post(API_ROUTES.collections.items, async (req: Request<{ slug: string }>, res: Response<ItemMutationResponse>) => {
244
262
  const collection = await loadCollectionOr404(req.params.slug, res);
245
263
  if (!collection) return;
246
- if (!collectionWritable(collection)) {
264
+ const createStore = storeFor(collection).write;
265
+ if (!createStore) {
247
266
  methodNotAllowed(res, readOnlyRefusal(collection.slug));
248
267
  return;
249
268
  }
@@ -262,7 +281,7 @@ router.post(API_ROUTES.collections.items, async (req: Request<{ slug: string }>,
262
281
  const itemId = resolveCreateItemId(collection.schema, record) ?? generateItemId();
263
282
  const recordWithId: CollectionItem = { ...record, [collection.schema.primaryKey]: itemId };
264
283
  try {
265
- const result = await writeItem(collection.dataDir, itemId, recordWithId, { refuseOverwrite: true, slug: collection.slug });
284
+ const result = await createStore(itemId, recordWithId, { refuseOverwrite: true });
266
285
  if (result.kind === "invalid-id") {
267
286
  badRequest(res, `invalid item id: ${result.itemId}`);
268
287
  return;
@@ -286,7 +305,8 @@ router.post(API_ROUTES.collections.items, async (req: Request<{ slug: string }>,
286
305
  router.put(API_ROUTES.collections.item, async (req: Request<{ slug: string; itemId: string }>, res: Response<ItemMutationResponse>) => {
287
306
  const collection = await loadCollectionOr404(req.params.slug, res);
288
307
  if (!collection) return;
289
- if (!collectionWritable(collection)) {
308
+ const updateStore = storeFor(collection).write;
309
+ if (!updateStore) {
290
310
  methodNotAllowed(res, readOnlyRefusal(collection.slug));
291
311
  return;
292
312
  }
@@ -307,7 +327,7 @@ router.put(API_ROUTES.collections.item, async (req: Request<{ slug: string; item
307
327
  // record id never drift.
308
328
  const recordWithId: CollectionItem = { ...record, [primaryKey]: req.params.itemId };
309
329
  try {
310
- const result = await writeItem(collection.dataDir, req.params.itemId, recordWithId, { slug: collection.slug });
330
+ const result = await updateStore(req.params.itemId, recordWithId);
311
331
  if (result.kind === "invalid-id") {
312
332
  badRequest(res, `invalid item id: ${result.itemId}`);
313
333
  return;
@@ -333,12 +353,13 @@ router.put(API_ROUTES.collections.item, async (req: Request<{ slug: string; item
333
353
  router.delete(API_ROUTES.collections.item, async (req: Request<{ slug: string; itemId: string }>, res: Response<DeleteResponse>) => {
334
354
  const collection = await loadCollectionOr404(req.params.slug, res);
335
355
  if (!collection) return;
336
- if (!collectionWritable(collection)) {
356
+ const deleteStore = storeFor(collection).delete;
357
+ if (!deleteStore) {
337
358
  methodNotAllowed(res, readOnlyRefusal(collection.slug));
338
359
  return;
339
360
  }
340
361
  try {
341
- const result = await deleteItem(collection.dataDir, req.params.itemId, { slug: collection.slug });
362
+ const result = await deleteStore(req.params.itemId);
342
363
  if (result.kind === "invalid-id") {
343
364
  badRequest(res, `invalid item id: ${result.itemId}`);
344
365
  return;
@@ -589,6 +610,20 @@ function sendToolResult(res: Response, raw: string): void {
589
610
  }
590
611
  }
591
612
 
613
+ // ── View-data routes: a FROZEN public contract ──────────────────────────────
614
+ // Everything under `viewData*` below is called by LLM-authored custom-view
615
+ // HTML files persisted in users' workspaces (`data/skills/*/views/*.html`,
616
+ // `feeds/*/views/*.html`), written against the contract in
617
+ // `packages/core/assets/helps/custom-view.md`. Those files cannot be
618
+ // re-generated or migrated centrally, so this surface must stay
619
+ // backward-compatible indefinitely: keep `?fields=` / `?ids=` semantics, the
620
+ // `{ collection, count, items }` / `{ written, rejected }` / `{ rows }`
621
+ // response shapes, and the status-code semantics (400 with `{ error }`,
622
+ // 403 mutate-kind, 409 require-gate) stable. Evolve by ADDITION only —
623
+ // new optional params, new routes — never by renaming or reshaping.
624
+ // Storage virtualization (new `CollectionStore` backends — see
625
+ // `@mulmoclaude/core` collection/server/store.ts) must be invisible here.
626
+ //
592
627
  // The view-data fetch comes from a sandboxed (opaque-origin) iframe, so it is
593
628
  // a cross-origin request that the browser gates with CORS. `*` is safe here:
594
629
  // auth is the unguessable scoped token in the Authorization header (not a
@@ -637,6 +672,12 @@ export function makeViewActionRateLimiter(max: number, windowMs: number, now: ()
637
672
 
638
673
  const VIEW_ACTION_RATE_LIMIT_PER_MINUTE = 60;
639
674
  const viewActionRateLimit = makeViewActionRateLimiter(VIEW_ACTION_RATE_LIMIT_PER_MINUTE, ONE_MINUTE_MS);
675
+ // Image thumbnails get their own, roomier bucket: a gallery legitimately
676
+ // fetches dozens of images on first paint, so the action budget (60/min)
677
+ // would starve it — while the endpoint still needs a ceiling (each request
678
+ // is a record scan + a thumbnail decode).
679
+ const VIEW_IMAGE_RATE_LIMIT_PER_MINUTE = 300;
680
+ const viewImageRateLimit = makeViewActionRateLimiter(VIEW_IMAGE_RATE_LIMIT_PER_MINUTE, ONE_MINUTE_MS);
640
681
 
641
682
  router.options(API_ROUTES.collections.viewData, viewDataCors, (_req: Request, res: Response) => {
642
683
  res.status(204).end();
@@ -931,6 +972,83 @@ router.post(
931
972
  },
932
973
  );
933
974
 
975
+ /** True when `relPath` is a CURRENT value of one of the schema's
976
+ * `image`-type fields (top-level fields only, matching the remote view's
977
+ * `inlineFields` rule) across `items`. This is the authorization rule for
978
+ * the view-data image route: a scoped view token may resolve exactly these
979
+ * paths and nothing else — never an arbitrary workspace file. Early-exit
980
+ * scan (no per-request Set allocation). Exported for the unit test. */
981
+ export function isAuthorizedImagePath(schema: LoadedCollection["schema"], items: CollectionItem[], relPath: string): boolean {
982
+ const imageFields = Object.entries(schema.fields)
983
+ .filter(([, spec]) => spec.type === "image")
984
+ .map(([name]) => name);
985
+ if (imageFields.length === 0 || relPath.length === 0) return false;
986
+ return items.some((item) => imageFields.some((field) => item[field] === relPath));
987
+ }
988
+
989
+ export interface ViewDataImageDeps {
990
+ loadCollection: (slug: string) => Promise<LoadedCollection | null>;
991
+ listRecords: (collection: LoadedCollection) => Promise<CollectionItem[]>;
992
+ resolveThumbnail: typeof resolveThumbnail;
993
+ }
994
+
995
+ /** The image-route handler behind a deps seam so its contract (400 missing
996
+ * path / 404 unknown collection / 404 unauthorized path / 404 unresolvable
997
+ * / clamped `maxEdge` plumbing / 500 on a thrown resolver) is
998
+ * unit-testable without mounting the express app — same factory pattern as
999
+ * the remote-view handlers. */
1000
+ export const createViewDataImageHandler =
1001
+ (deps: ViewDataImageDeps) =>
1002
+ async (req: Request<{ slug: string }>, res: Response): Promise<void> => {
1003
+ const collection = await deps.loadCollection(req.params.slug);
1004
+ if (!collection) {
1005
+ notFound(res, `collection '${req.params.slug}' not found`);
1006
+ return;
1007
+ }
1008
+ const relPath = typeof req.query.path === "string" ? req.query.path : "";
1009
+ if (relPath.length === 0) {
1010
+ badRequest(res, "pass `path` — an image field's workspace-relative value");
1011
+ return;
1012
+ }
1013
+ try {
1014
+ const items = await deps.listRecords(collection);
1015
+ if (!isAuthorizedImagePath(collection.schema, items, relPath)) {
1016
+ notFound(res, "path is not a current value of this collection's image fields");
1017
+ return;
1018
+ }
1019
+ const dataUrl = await deps.resolveThumbnail(relPath, clampImageMaxEdge(req.query.maxEdge));
1020
+ if (dataUrl === null) {
1021
+ notFound(res, "image could not be resolved");
1022
+ return;
1023
+ }
1024
+ res.json({ path: relPath, dataUrl });
1025
+ } catch (err) {
1026
+ log.warn("collections", "view-data image failed", { slug: collection.slug, error: errorMessage(err) });
1027
+ serverError(res, "image resolve failed");
1028
+ }
1029
+ };
1030
+
1031
+ router.options(API_ROUTES.collections.viewDataImage, viewDataCors, (_req: Request, res: Response) => {
1032
+ res.status(204).end();
1033
+ });
1034
+
1035
+ // Scoped image read: resolve one record-referenced image path into a
1036
+ // downscaled `data:` thumbnail (same resolver + clamps as the remote view's
1037
+ // `imageFields` inlining). The record scan doubles as the authorization
1038
+ // check — the requested path must be a CURRENT image-field value — and runs
1039
+ // under the same in-flight cap as /query (both are per-request full scans).
1040
+ // Sandboxed views can't attach the bearer to an <img>, so the JSON
1041
+ // { dataUrl } shape (fetch → img.src) is the contract; see
1042
+ // packages/core/assets/helps/custom-view.md "Displaying images".
1043
+ router.get(
1044
+ API_ROUTES.collections.viewDataImage,
1045
+ viewDataCors,
1046
+ viewImageRateLimit,
1047
+ viewQueryConcurrency,
1048
+ requireViewToken("read"),
1049
+ createViewDataImageHandler({ loadCollection, listRecords: (collection) => storeFor(collection).list(), resolveThumbnail }),
1050
+ );
1051
+
934
1052
  // Scoped write: validated putItems. Requires the `write` capability.
935
1053
  router.put(API_ROUTES.collections.viewData, viewDataCors, requireViewToken("write"), async (req: Request<{ slug: string }>, res: Response) => {
936
1054
  try {
@@ -126,7 +126,7 @@ async function handleConfigRefresh(payload) {
126
126
  await safePost(req);
127
127
  }
128
128
 
129
- // packages/core/dist/collection/paths.js
129
+ // packages/core/dist/templatePath-k_WNbL_Q.js
130
130
  var TEMPLATES_PREFIX = "templates/";
131
131
  function isSafeTemplatePath(value) {
132
132
  if (value.length === 0 || value.includes("\\") || value.startsWith("/")) return false;
@@ -201,7 +201,7 @@ function mirrorSkillDelete(workspaceRoot2, slug) {
201
201
  return { dest };
202
202
  }
203
203
 
204
- // server/utils/errors.ts
204
+ // packages/core/dist/errors-eid6Mes3.js
205
205
  function errorMessage(err, fallback) {
206
206
  if (err instanceof Error) return err.message;
207
207
  if (err !== null && typeof err === "object") {
@@ -24,6 +24,7 @@
24
24
  // package catches up, but the launcher boots. Matches the feature's
25
25
  // "optional everywhere" design (the View-side `subscribeChanges` is optional too).
26
26
 
27
+ // eslint-disable-next-line no-restricted-imports -- namespace import is the feature-detect described above; only `setCollectionChangePublisher` is accessed, never the restricted raw io readers
27
28
  import * as collectionPlugin from "@mulmoclaude/core/collection/server";
28
29
  import type { CollectionChangePayload } from "@mulmoclaude/core/collection/server";
29
30
  import { collectionChannel, type CollectionChannelPayload } from "../../src/config/pubsubChannels.js";
@@ -1,11 +1,19 @@
1
1
  import http from "http";
2
2
  import { Server as IOServer } from "socket.io";
3
3
 
4
+ import { log } from "../../system/logger/index.js";
5
+
4
6
  export interface IPubSub {
5
7
  /** Publish data to all clients subscribed to this channel. */
6
8
  publish: (channel: string, data: unknown) => void;
7
9
  }
8
10
 
11
+ const logRoomError =
12
+ (action: string, channel: string) =>
13
+ (err: unknown): void => {
14
+ log.warn("pubsub", `socket room ${action} failed`, { channel, error: String(err) });
15
+ };
16
+
9
17
  // Channel names are treated as socket.io rooms — one room per
10
18
  // channel. Subscribe/unsubscribe is plain `socket.join` /
11
19
  // `socket.leave`. Publish broadcasts to the room. Reconnect /
@@ -29,11 +37,16 @@ export function createPubSub(server: http.Server): IPubSub {
29
37
  });
30
38
 
31
39
  ioServer.on("connection", (socket) => {
40
+ // `join`/`leave` return `void | Promise<void>`: the in-memory adapter runs
41
+ // synchronously and returns undefined, while a clustered adapter goes over
42
+ // the wire. Nothing here depends on the result, but the promise branch
43
+ // still needs a terminal handler — a bare `void` would turn an adapter
44
+ // failure into an unhandled rejection. `Promise.resolve` normalises both.
32
45
  socket.on("subscribe", (channel: unknown) => {
33
- if (typeof channel === "string") socket.join(channel);
46
+ if (typeof channel === "string") Promise.resolve(socket.join(channel)).catch(logRoomError("subscribe", channel));
34
47
  });
35
48
  socket.on("unsubscribe", (channel: unknown) => {
36
- if (typeof channel === "string") socket.leave(channel);
49
+ if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
37
50
  });
38
51
  });
39
52
 
@@ -277,7 +277,12 @@ function attachSocketHandlers(socket: WebSocket, deps: AttachSocketHandlersDeps)
277
277
  });
278
278
 
279
279
  socket.on("message", (data) => {
280
- handleRelayMessage(String(data), { relay, logger, sendResponse: queue.send });
280
+ // One bad message must not take the socket down: `handleRelayMessage`
281
+ // already logs the failures it anticipates, so anything reaching here is
282
+ // unexpected and only needs to be recorded, not propagated.
283
+ void handleRelayMessage(String(data), { relay, logger, sendResponse: queue.send }).catch((err: unknown) => {
284
+ logger.error(LOG_PREFIX, "relay message handler threw", { error: String(err) });
285
+ });
281
286
  });
282
287
 
283
288
  socket.on("close", (code, reason) => {
@@ -183,7 +183,10 @@ export function endRun(chatSessionId: string): void {
183
183
  session.statusMessage = "";
184
184
  session.abortRun = undefined;
185
185
  session.updatedAt = new Date().toISOString();
186
- persistHasUnread(chatSessionId, true);
186
+ // Same fire-and-forget contract as the other unread writes: the in-memory
187
+ // flag above is what the UI reads, so a failed persist must not break the
188
+ // finish path.
189
+ persistHasUnread(chatSessionId, true).catch(() => {});
187
190
  publishToSessionChannel(chatSessionId, {
188
191
  type: EVENT_TYPES.sessionFinished,
189
192
  });
@@ -264,7 +267,13 @@ export function pushSessionEvent(chatSessionId: string, event: Record<string, un
264
267
  // notify.
265
268
  persistHasUnread(chatSessionId, true)
266
269
  .catch(() => {})
267
- .then(() => notifySessionsChanged());
270
+ .then(() => notifySessionsChanged())
271
+ // Terminal handler: the `.catch` above only covers the persist, so a throw
272
+ // out of `notifySessionsChanged` would still escape. Same trade as the
273
+ // agent route (see its BEHAVIOUR NOTE) — this used to bounce the whole
274
+ // server through the process-level `unhandledRejection` handler; now an
275
+ // unread-badge glitch stays a glitch. Rethrow here to go back to fail-fast.
276
+ .catch((err: unknown) => log.warn("session-store", "unread notify failed", { error: String(err) }));
268
277
  }
269
278
 
270
279
  /**
package/server/index.ts CHANGED
@@ -103,6 +103,7 @@ import { isDockerAvailable, ensureSandboxImage } from "./system/docker.js";
103
103
  import { maybeRunJournal } from "./workspace/journal/index.js";
104
104
  import { backfillAllSessions } from "./workspace/chat-index/index.js";
105
105
  import { feedRefreshTaskDef } from "@mulmoclaude/core/feeds/server";
106
+ import { googleCalendarSyncTaskDef } from "@mulmoclaude/core/google";
106
107
  import { configureFeeds } from "./workspace/feeds/configure.js";
107
108
  import { createPubSub } from "./events/pub-sub/index.js";
108
109
  import { PUBSUB_CHANNELS } from "../src/config/pubsubChannels.js";
@@ -1115,6 +1116,7 @@ function buildSystemTaskDefs(): SystemTaskDef[] {
1115
1116
  // factory so the id/schedule/run can't drift across hosts. The override
1116
1117
  // loop below still mutates `task.schedule` host-side.
1117
1118
  feedRefreshTaskDef(),
1119
+ googleCalendarSyncTaskDef(),
1118
1120
  ];
1119
1121
 
1120
1122
  // Apply user-configurable schedule overrides from
@@ -1302,7 +1304,12 @@ process.on("SIGTERM", () => {
1302
1304
  // `http://<laptop-ip>:3001/api/*`), which combined with the
1303
1305
  // workspace file API is a credential-theft risk. Personal dev
1304
1306
  // tool — localhost is the right default.
1305
- const httpServer = app.listen(port, "127.0.0.1", async () => {
1307
+ // Express discards whatever the listen callback returns, so an async callback
1308
+ // would leave a throw in it as an unhandled rejection. The callback below
1309
+ // stays synchronous and hands the async setup off here. The server arrives as
1310
+ // a parameter rather than a closure variable — `httpServer` is this call's
1311
+ // own result and isn't defined yet at this point.
1312
+ const onListening = async (httpServer: ReturnType<typeof app.listen>): Promise<void> => {
1306
1313
  // Initialize the notifier engine synchronously, before any await
1307
1314
  // in this callback. The HTTP listener is already accepting
1308
1315
  // connections by the time this callback fires, so any awaited
@@ -1359,8 +1366,25 @@ process.on("SIGTERM", () => {
1359
1366
  httpServer.close(() => process.exit(1));
1360
1367
  setTimeout(() => process.exit(1), STARTUP_FAILURE_FORCE_EXIT_MS).unref();
1361
1368
  });
1369
+ };
1370
+
1371
+ const httpServer = app.listen(port, "127.0.0.1", () => {
1372
+ // Terminal handler, not a bare `void`: `onListening` runs the whole
1373
+ // post-listen setup, and a rejection outside its own
1374
+ // `startRuntimeServices(...).catch(...)` branch would otherwise be an
1375
+ // unhandled rejection on a half-initialised server.
1376
+ onListening(httpServer).catch((err: unknown) => {
1377
+ log.error("server", "post-listen initialization failed — exiting", { error: String(err) });
1378
+ process.exit(1);
1379
+ });
1362
1380
  });
1363
- })();
1381
+ })().catch((err: unknown) => {
1382
+ // Anything thrown before the listener is up (port resolution, token write)
1383
+ // lands here. Without this the failure surfaced only as an unhandled
1384
+ // rejection — no log line, and the exit code depended on the Node version.
1385
+ log.error("server", "server startup failed — exiting", { error: String(err) });
1386
+ process.exit(1);
1387
+ });
1364
1388
 
1365
1389
  function registerDebugTasks(taskManager: ITaskManager, pubsub: IPubSub) {
1366
1390
  let tick = 0;
@@ -19,7 +19,6 @@ 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/calendar/` — calendar events
23
22
  - `data/contacts/` — address book entries
24
23
  - `data/wiki/` — personal knowledge wiki (index.md, pages/, sources/, log.md)
25
24
  - `data/scheduler/` — scheduled tasks
@@ -2,19 +2,19 @@
2
2
  //
3
3
  // Returns one feed's detail + a PAGE of its records. A feed IS a
4
4
  // LoadedCollection with an `ingest` block, so this reuses the exact collection
5
- // page path (listItems + toDetail + collectionPage) and returns the SAME shape
5
+ // page path (storeFor + toDetail + collectionPage) and returns the SAME shape
6
6
  // as getCollection — the remote renders feed records with the same card view.
7
7
  // The feed is located via the feed registry (listFeeds), since feeds live under
8
8
  // their own registry rather than the collections dir.
9
9
  import { listFeeds as listFeedsRegistry } from "@mulmoclaude/core/feeds/server";
10
- import { listItems, toDetail } from "../../workspace/collections/index.js";
10
+ import { storeFor, toDetail, type LoadedCollection, type CollectionItem } from "../../workspace/collections/index.js";
11
11
  import { workspacePath } from "../../workspace/workspace.js";
12
12
  import type { CommandHandler, JsonObject } from "../commandChannel.js";
13
13
  import { clampLimit, clampOffset, deriveItems, pageResult } from "./collectionPage.js";
14
14
 
15
15
  export interface GetFeedDeps {
16
16
  listFeeds: typeof listFeedsRegistry;
17
- listItems: typeof listItems;
17
+ listRecords: (collection: LoadedCollection) => Promise<CollectionItem[]>;
18
18
  toDetail: typeof toDetail;
19
19
  workspaceRoot: string;
20
20
  }
@@ -28,8 +28,13 @@ export const createGetFeed =
28
28
  const feeds = await deps.listFeeds(deps.workspaceRoot);
29
29
  const feed = feeds.find((entry) => entry.slug === slug);
30
30
  if (!feed) throw new Error(`feed '${slug}' not found`);
31
- const all = deriveItems(feed.schema, await deps.listItems(feed.dataDir));
31
+ const all = deriveItems(feed.schema, await deps.listRecords(feed));
32
32
  return pageResult(deps.toDetail(feed), all, offset, limit);
33
33
  };
34
34
 
35
- export const getFeed = createGetFeed({ listFeeds: listFeedsRegistry, listItems, toDetail, workspaceRoot: workspacePath });
35
+ export const getFeed = createGetFeed({
36
+ listFeeds: listFeedsRegistry,
37
+ listRecords: (collection) => storeFor(collection).list(),
38
+ toDetail,
39
+ workspaceRoot: workspacePath,
40
+ });
@@ -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,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";
@@ -14,11 +14,12 @@ export { enrichItems, computeCollectionIcon } from "@mulmoclaude/core/collection
14
14
  export { storeFor, collectionWritable, readOnlyRefusal, type CollectionStore } from "@mulmoclaude/core/collection/server";
15
15
  export { deleteCollection, deleteCollectionRefusalMessage, type DeleteCollectionResult } from "@mulmoclaude/core/collection/server";
16
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.
17
22
  export {
18
- listItems,
19
- readItem,
20
- writeItem,
21
- deleteItem,
22
23
  safeRecordId,
23
24
  generateItemId,
24
25
  resolveCreateItemId,