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.
- package/README.md +1 -1
- package/client/assets/{PluginScopedRoot-B53YSjaC.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
- package/client/assets/index-7QnbsZZr.css +2 -0
- package/client/assets/{index-DUdqhWg6.js → index-C3SRVT2p.js} +71 -71
- package/client/assets/{marp-CyWxL3bc.js → marp-DxctLEy1.js} +1 -1
- package/client/index.html +3 -3
- package/package.json +5 -5
- package/server/agent/mcp-server.ts +27 -8
- package/server/api/auth/viewToken.ts +7 -1
- package/server/api/routes/agent.ts +14 -1
- package/server/api/routes/collections.ts +127 -9
- package/server/build/dispatcher.mjs +2 -2
- package/server/events/collection-change.ts +1 -0
- package/server/events/pub-sub/index.ts +15 -2
- package/server/events/relay-client.ts +6 -1
- package/server/events/session-store/index.ts +11 -2
- package/server/index.ts +26 -2
- package/server/prompts/system/system.md +0 -1
- package/server/remoteHost/handlers/getFeed.ts +10 -5
- package/server/services/translation/index.ts +10 -3
- package/server/utils/errors.ts +7 -21
- package/server/utils/text.ts +7 -23
- package/server/workspace/collections/index.ts +5 -4
- package/server/workspace/collections/remoteView.ts +21 -21
- package/server/workspace/collections/watcher.ts +1 -0
- package/server/workspace/custom-dirs.ts +16 -6
- package/server/workspace/reference-dirs.ts +8 -3
- package/src/components/SettingsGoogleTab.vue +4 -1
- package/src/components/StackView.vue +12 -1
- package/src/composables/collections/uiHost.ts +2 -1
- package/src/composables/useChatScroll.ts +18 -6
- package/src/composables/useFileSelection.ts +4 -1
- package/src/composables/useStickToBottom.ts +51 -0
- package/src/config/apiRoutes.ts +14 -0
- package/src/utils/dom/scrollable.ts +15 -0
- package/src/utils/errors.ts +7 -38
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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({
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
package/server/utils/errors.ts
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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";
|
package/server/utils/text.ts
CHANGED
|
@@ -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,
|