mulmoclaude 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mulmoclaude.js +2 -1
- package/client/assets/{index-Dc0R-HW5.js → index-B3NxFcEH.js} +46 -46
- package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
- package/client/assets/{marp-CSq0PPfK.js → marp-C9QDHFAJ.js} +1 -1
- package/client/index.html +2 -2
- package/package.json +11 -10
- package/server/api/routes/agent.ts +5 -1
- package/server/api/routes/chat-index.ts +5 -1
- package/server/api/routes/collections.ts +152 -1
- package/server/api/routes/config.ts +12 -0
- package/server/index.ts +14 -2
- package/server/prompts/system/system.md +7 -1
- package/server/remoteHost/firebase.ts +6 -0
- package/server/remoteHost/handlers/collectionPage.ts +15 -17
- package/server/remoteHost/handlers/getCollection.ts +2 -2
- package/server/remoteHost/handlers/getFeed.ts +2 -2
- package/server/remoteHost/handlers/getRemoteView.ts +37 -0
- package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
- package/server/remoteHost/handlers/index.ts +10 -0
- package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
- package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
- package/server/remoteHost/handlers/listCollections.ts +6 -1
- package/server/remoteHost/handlers/listSkills.ts +39 -0
- package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
- package/server/remoteHost/handlers/startChat.ts +103 -39
- package/server/system/config.ts +101 -4
- package/server/system/logs/aaa +737 -0
- package/server/system/logs/bb +446 -0
- package/server/utils/files/thumbnail-store.ts +97 -0
- package/server/workspace/chat-index/index.ts +29 -3
- package/server/workspace/chat-index/indexer.ts +187 -29
- package/server/workspace/chat-index/summarizer.ts +18 -10
- package/server/workspace/collections/index.ts +2 -1
- package/server/workspace/collections/remoteView.ts +290 -0
- package/server/workspace/journal/archivist-cli.ts +17 -3
- package/server/workspace/journal/dailyPass.ts +52 -2
- package/server/workspace/journal/index.ts +20 -4
- package/src/App.vue +62 -3
- package/src/components/FileTree.vue +21 -1
- package/src/components/FileTreePane.vue +41 -25
- package/src/components/FilesView.vue +4 -0
- package/src/components/RemoteHostControl.vue +18 -0
- package/src/components/SessionHistoryPanel.vue +24 -11
- package/src/components/SettingsChatIndexTab.vue +119 -0
- package/src/components/SettingsJournalTab.vue +117 -0
- package/src/components/SettingsModal.vue +12 -2
- package/src/composables/collections/uiHost.ts +15 -0
- package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
- package/src/composables/usePubSub.ts +40 -2
- package/src/composables/useSessionSync.ts +9 -0
- package/src/composables/useShowHiddenSystemFiles.ts +23 -0
- package/src/config/apiRoutes.ts +20 -0
- package/src/config/roles.ts +2 -2
- package/src/config/visibleWorkspaceDirs.ts +21 -0
- package/src/lang/de.ts +47 -0
- package/src/lang/en.ts +45 -0
- package/src/lang/es.ts +46 -0
- package/src/lang/fr.ts +47 -0
- package/src/lang/ja.ts +46 -0
- package/src/lang/ko.ts +45 -0
- package/src/lang/pt-BR.ts +46 -0
- package/src/lang/zh.ts +44 -0
- package/src/plugins/textResponse/View.vue +49 -14
- package/src/plugins/textResponse/utils.ts +37 -0
- package/src/utils/html/previewCsp.ts +7 -16
- package/src/utils/role/icon.ts +9 -2
- package/src/utils/session/sessionPreview.ts +29 -0
|
@@ -2,25 +2,23 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The command channel writes the result INSIDE the command document, and
|
|
4
4
|
// Firestore caps a document at 1 MiB. offset/limit slice the records; limit is
|
|
5
|
-
// clamped to [1,
|
|
6
|
-
// budget.
|
|
7
|
-
|
|
5
|
+
// clamped to [1, MAX_PAGE_LIMIT] (default 50) so a runaway page can't blow
|
|
6
|
+
// that budget. The clamps live in @mulmoclaude/core/remote-view (params arrive
|
|
7
|
+
// as untyped JSON there too) so the record handlers and the remote-view bridge
|
|
8
|
+
// serve identical page semantics — re-exported here for the handlers.
|
|
9
|
+
import { clampLimit, clampOffset } from "@mulmoclaude/core/remote-view";
|
|
10
|
+
import { deriveAll, type DerivableFieldSpec, type DerivableRecord } from "@mulmoclaude/core/collection";
|
|
11
|
+
import type { JsonObject } from "../commandChannel.js";
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
const MAX_LIMIT = 200;
|
|
13
|
+
export { clampLimit, clampOffset };
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export const
|
|
18
|
-
|
|
19
|
-
export const clampLimit = (value: JsonValue): number => {
|
|
20
|
-
const num = toInt(value);
|
|
21
|
-
if (num === null || num <= 0) return DEFAULT_LIMIT;
|
|
22
|
-
return Math.min(num, MAX_LIMIT);
|
|
23
|
-
};
|
|
15
|
+
/** Resolve record-local computed fields (derived formulas) before paging, so
|
|
16
|
+
* channel consumers — the phase-2 card list and a remote view's `getItems` —
|
|
17
|
+
* see the same numbers the desktop renders. There is no ref cache over the
|
|
18
|
+
* channel, so formulas that dereference `ref` fields stay absent; the desktop
|
|
19
|
+
* phone-frame preview derives with the same empty cache (parity). */
|
|
20
|
+
export const deriveItems = (schema: { fields?: Record<string, DerivableFieldSpec> }, items: unknown[]): DerivableRecord[] =>
|
|
21
|
+
items.map((item) => deriveAll({ fields: schema.fields ?? {} }, item as DerivableRecord, {}));
|
|
24
22
|
|
|
25
23
|
// Build the paginated result. `detail` (a CollectionDetail) and `items`
|
|
26
24
|
// (CollectionItem[]) are plain JSON, but their interfaces lack an index
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// stubbed; the default export wires the real engine functions.
|
|
10
10
|
import { listItems, loadCollection, toDetail } from "../../workspace/collections/index.js";
|
|
11
11
|
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
12
|
-
import { clampLimit, clampOffset, pageResult } from "./collectionPage.js";
|
|
12
|
+
import { clampLimit, clampOffset, deriveItems, pageResult } from "./collectionPage.js";
|
|
13
13
|
|
|
14
14
|
export interface GetCollectionDeps {
|
|
15
15
|
loadCollection: typeof loadCollection;
|
|
@@ -25,7 +25,7 @@ export const createGetCollection =
|
|
|
25
25
|
const limit = clampLimit(params.limit);
|
|
26
26
|
const collection = await deps.loadCollection(slug);
|
|
27
27
|
if (!collection) throw new Error(`collection '${slug}' not found`);
|
|
28
|
-
const all = await deps.listItems(collection.dataDir);
|
|
28
|
+
const all = deriveItems(collection.schema, await deps.listItems(collection.dataDir));
|
|
29
29
|
return pageResult(deps.toDetail(collection), all, offset, limit);
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -10,7 +10,7 @@ import { listFeeds as listFeedsRegistry } from "@mulmoclaude/core/feeds/server";
|
|
|
10
10
|
import { listItems, toDetail } from "../../workspace/collections/index.js";
|
|
11
11
|
import { workspacePath } from "../../workspace/workspace.js";
|
|
12
12
|
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
13
|
-
import { clampLimit, clampOffset, pageResult } from "./collectionPage.js";
|
|
13
|
+
import { clampLimit, clampOffset, deriveItems, pageResult } from "./collectionPage.js";
|
|
14
14
|
|
|
15
15
|
export interface GetFeedDeps {
|
|
16
16
|
listFeeds: typeof listFeedsRegistry;
|
|
@@ -28,7 +28,7 @@ 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 = await deps.listItems(feed.dataDir);
|
|
31
|
+
const all = deriveItems(feed.schema, await deps.listItems(feed.dataDir));
|
|
32
32
|
return pageResult(deps.toDetail(feed), all, offset, limit);
|
|
33
33
|
};
|
|
34
34
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// getRemoteView command handler (remote-host phase 3 —
|
|
2
|
+
// plans/feat-remote-custom-view.md).
|
|
3
|
+
//
|
|
4
|
+
// Returns one mobile (`target: "mobile"`) custom view wrapped HOST-side into
|
|
5
|
+
// its sandboxed srcdoc (CSP + postMessage bootstrap), so the phone renders the
|
|
6
|
+
// artifact verbatim — the same builder the desktop phone-frame preview reads
|
|
7
|
+
// over HTTP, keeping preview === phone structural. The srcdoc travels inside
|
|
8
|
+
// the Firestore command document; the builder enforces the 1 MiB budget.
|
|
9
|
+
//
|
|
10
|
+
// Factory (createGetRemoteView) keeps the mapping unit-testable with the
|
|
11
|
+
// engine stubbed; the default export wires the real functions.
|
|
12
|
+
import { loadCollection } from "../../workspace/collections/index.js";
|
|
13
|
+
import { buildRemoteView, remoteViewFailureMessage } from "../../workspace/collections/remoteView.js";
|
|
14
|
+
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
15
|
+
|
|
16
|
+
export interface GetRemoteViewDeps {
|
|
17
|
+
loadCollection: typeof loadCollection;
|
|
18
|
+
buildRemoteView: typeof buildRemoteView;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const createGetRemoteView =
|
|
22
|
+
(deps: GetRemoteViewDeps): CommandHandler =>
|
|
23
|
+
async (params: JsonObject) => {
|
|
24
|
+
const slug = String(params.slug ?? "");
|
|
25
|
+
const viewId = String(params.viewId ?? "");
|
|
26
|
+
const locale = typeof params.locale === "string" ? params.locale : "";
|
|
27
|
+
const collection = await deps.loadCollection(slug);
|
|
28
|
+
if (!collection) throw new Error(`collection '${slug}' not found`);
|
|
29
|
+
const result = await deps.buildRemoteView(collection, viewId, locale);
|
|
30
|
+
if (result.kind !== "ok") throw new Error(remoteViewFailureMessage(result, slug));
|
|
31
|
+
const { view, srcdoc, bytes } = result;
|
|
32
|
+
// Plain JSON, but the interface lacks an index signature — cast like the
|
|
33
|
+
// phase-2 handlers.
|
|
34
|
+
return { view, srcdoc, bytes } as unknown as JsonObject;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const getRemoteView = createGetRemoteView({ loadCollection, buildRemoteView });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// getRemoteViewItems command handler (remote-host phase 5 —
|
|
2
|
+
// plans/feat-remote-view-images.md).
|
|
3
|
+
//
|
|
4
|
+
// One page of a mobile view's records, view-aware so the host can inline the
|
|
5
|
+
// view's declared `imageFields` as `data:` URL thumbnails (a phone can't reach
|
|
6
|
+
// the workspace to render an image path). Same builder the desktop phone-frame
|
|
7
|
+
// preview reads over HTTP, so preview === phone. Supersedes the phase-2
|
|
8
|
+
// getCollection for a custom view's getItems: the page is already projected +
|
|
9
|
+
// image-inlined, so the mulmoserver client passes it straight to the view.
|
|
10
|
+
//
|
|
11
|
+
// Factory (createGetRemoteViewItems) keeps the mapping unit-testable with the
|
|
12
|
+
// engine stubbed; the default export wires the real functions.
|
|
13
|
+
import { clampLimit, clampOffset, normalizeFields } from "@mulmoclaude/core/remote-view";
|
|
14
|
+
import { loadCollection } from "../../workspace/collections/index.js";
|
|
15
|
+
import { remoteViewItems, remoteViewItemsFailureMessage } from "../../workspace/collections/remoteView.js";
|
|
16
|
+
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
17
|
+
|
|
18
|
+
export interface GetRemoteViewItemsDeps {
|
|
19
|
+
loadCollection: typeof loadCollection;
|
|
20
|
+
remoteViewItems: typeof remoteViewItems;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const createGetRemoteViewItems =
|
|
24
|
+
(deps: GetRemoteViewItemsDeps): CommandHandler =>
|
|
25
|
+
async (params: JsonObject) => {
|
|
26
|
+
const slug = String(params.slug ?? "");
|
|
27
|
+
const viewId = String(params.viewId ?? "");
|
|
28
|
+
const request = { offset: clampOffset(params.offset), limit: clampLimit(params.limit), fields: normalizeFields(params.fields) };
|
|
29
|
+
const collection = await deps.loadCollection(slug);
|
|
30
|
+
if (!collection) throw new Error(`collection '${slug}' not found`);
|
|
31
|
+
const result = await deps.remoteViewItems(collection, viewId, request);
|
|
32
|
+
if (result.kind !== "ok") throw new Error(remoteViewItemsFailureMessage(result, slug));
|
|
33
|
+
// Plain JSON, but the interface lacks an index signature — cast like the
|
|
34
|
+
// phase-2/3 handlers.
|
|
35
|
+
return { page: result.page, inlined: result.inlined, omitted: result.omitted } as unknown as JsonObject;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const getRemoteViewItems = createGetRemoteViewItems({ loadCollection, remoteViewItems });
|
|
@@ -4,16 +4,26 @@
|
|
|
4
4
|
import type { CommandHandlers } from "../commandChannel.js";
|
|
5
5
|
import { getCollection } from "./getCollection.js";
|
|
6
6
|
import { getFeed } from "./getFeed.js";
|
|
7
|
+
import { getRemoteView } from "./getRemoteView.js";
|
|
8
|
+
import { getRemoteViewItems } from "./getRemoteViewItems.js";
|
|
9
|
+
import { listAccountingBooks } from "./listAccountingBooks.js";
|
|
7
10
|
import { listCollections } from "./listCollections.js";
|
|
8
11
|
import { listFeeds } from "./listFeeds.js";
|
|
9
12
|
import { listShortcuts } from "./listShortcuts.js";
|
|
13
|
+
import { listSkills } from "./listSkills.js";
|
|
14
|
+
import { mutateRemoteViewItem } from "./mutateRemoteView.js";
|
|
10
15
|
import { startChat } from "./startChat.js";
|
|
11
16
|
|
|
12
17
|
export const handlers: CommandHandlers = {
|
|
13
18
|
listCollections,
|
|
14
19
|
getCollection,
|
|
15
20
|
listShortcuts,
|
|
21
|
+
listSkills,
|
|
16
22
|
listFeeds,
|
|
17
23
|
getFeed,
|
|
24
|
+
getRemoteView,
|
|
25
|
+
getRemoteViewItems,
|
|
26
|
+
mutateRemoteViewItem,
|
|
27
|
+
listAccountingBooks,
|
|
18
28
|
startChat,
|
|
19
29
|
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Attachment ingest for remote chat (plans/feat-remote-chat-image-attachments.md).
|
|
2
|
+
//
|
|
3
|
+
// The phone can't carry full-res attachment bytes over the Firestore command
|
|
4
|
+
// channel (a command doc caps at ~1 MiB), so it uploads each file — photo,
|
|
5
|
+
// video, or PDF — to Firebase Storage at `users/{uid}/uploads/{storage_id}` and
|
|
6
|
+
// sends only the `storage_id` on startChat. This module — signed in as the same
|
|
7
|
+
// user — pulls each staged object, persists it into the workspace attachment
|
|
8
|
+
// store via `saveAttachment` (so it lands in `data/attachments/YYYY/MM/`, gets a
|
|
9
|
+
// correct mime, and is accepted by the same attachment pipeline Vue uploads
|
|
10
|
+
// use), deletes the Storage object (staging only), and returns a path-only
|
|
11
|
+
// `Attachment` per file for startChat to hand to the spawned chat.
|
|
12
|
+
//
|
|
13
|
+
// Factory (createIngestAttachments) keeps the flow unit-testable with the
|
|
14
|
+
// Storage + attachment-store deps stubbed; the default export wires the real ones.
|
|
15
|
+
import { deleteObject, getBytes, getMetadata, ref } from "firebase/storage";
|
|
16
|
+
import type { Attachment } from "@mulmobridge/protocol";
|
|
17
|
+
|
|
18
|
+
import { saveAttachment } from "../../utils/files/attachment-store.js";
|
|
19
|
+
import { errorMessage } from "../../utils/errors.js";
|
|
20
|
+
import { log } from "../../system/logger/index.js";
|
|
21
|
+
import { currentUid } from "../auth.js";
|
|
22
|
+
import { storage } from "../firebase.js";
|
|
23
|
+
|
|
24
|
+
const PREFIX = "remote-host";
|
|
25
|
+
|
|
26
|
+
// `storage_id` is a bare UUID minted by the remote (`crypto.randomUUID()`).
|
|
27
|
+
// Accept only a safe token so it can never reshape the Storage path (no `/`,
|
|
28
|
+
// no `..`) before it is interpolated into `users/{uid}/uploads/{storage_id}`.
|
|
29
|
+
const STORAGE_ID_RE = /^[A-Za-z0-9-]+$/;
|
|
30
|
+
|
|
31
|
+
// Belt-and-suspenders cap matching the remote's 100 MiB upload rule (full-res
|
|
32
|
+
// photos + short mobile videos), so a mis-sized object can't balloon host memory
|
|
33
|
+
// on download. A little headroom over the rule's ceiling.
|
|
34
|
+
const MAX_DOWNLOAD_BYTES = 110 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
export interface IngestDeps {
|
|
37
|
+
uid: () => string | null;
|
|
38
|
+
fetchObject: (storagePath: string) => Promise<{ base64: string; contentType: string }>;
|
|
39
|
+
saveAttachment: (base64: string, mimeType: string) => Promise<{ relativePath: string; mimeType: string }>;
|
|
40
|
+
deleteObject: (storagePath: string) => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// storage_ids -> path-only Attachments, in order. Rejects the whole batch on the
|
|
44
|
+
// first failure to get bytes INTO the workspace (host not signed in, malformed
|
|
45
|
+
// id, or a download/save that fails): the remote already uploaded and is waiting
|
|
46
|
+
// on the result, so a surfaced error beats silently starting a chat with a
|
|
47
|
+
// missing file. The subsequent Storage delete is best-effort — the bytes are
|
|
48
|
+
// already safe in the workspace, so a failed delete only logs and leaves an
|
|
49
|
+
// orphan for the Storage TTL sweep; it must NOT drop an already-ingested file.
|
|
50
|
+
export const createIngestAttachments =
|
|
51
|
+
(deps: IngestDeps) =>
|
|
52
|
+
async (storageIds: string[]): Promise<Attachment[]> => {
|
|
53
|
+
if (storageIds.length === 0) return [];
|
|
54
|
+
const uid = deps.uid();
|
|
55
|
+
if (!uid) throw new Error("remote host is not signed in");
|
|
56
|
+
const attachments: Attachment[] = [];
|
|
57
|
+
for (const storageId of storageIds) {
|
|
58
|
+
if (!STORAGE_ID_RE.test(storageId)) throw new Error(`invalid storage_id: ${storageId}`);
|
|
59
|
+
const storagePath = `users/${uid}/uploads/${storageId}`;
|
|
60
|
+
const { base64, contentType } = await deps.fetchObject(storagePath);
|
|
61
|
+
const saved = await deps.saveAttachment(base64, contentType);
|
|
62
|
+
// Staging cleanup — best-effort. The file is already in the workspace, so
|
|
63
|
+
// a delete failure must not abort the batch (that would drop an ingested
|
|
64
|
+
// attachment); log and let the Storage TTL sweep reap the orphan.
|
|
65
|
+
try {
|
|
66
|
+
await deps.deleteObject(storagePath);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
log.warn(PREFIX, "failed to delete staged upload after ingest; leaving orphan for TTL sweep", { storagePath, error: errorMessage(error) });
|
|
69
|
+
}
|
|
70
|
+
attachments.push({ path: saved.relativePath, mimeType: saved.mimeType });
|
|
71
|
+
}
|
|
72
|
+
return attachments;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Pull an object's bytes + content type from Storage. `getBytes` (not the
|
|
76
|
+
// browser-only `getBlob`) returns an ArrayBuffer that works on the Node host.
|
|
77
|
+
const fetchObject = async (storagePath: string): Promise<{ base64: string; contentType: string }> => {
|
|
78
|
+
const objectRef = ref(storage, storagePath);
|
|
79
|
+
const [bytes, metadata] = await Promise.all([getBytes(objectRef, MAX_DOWNLOAD_BYTES), getMetadata(objectRef)]);
|
|
80
|
+
return { base64: Buffer.from(bytes).toString("base64"), contentType: metadata.contentType ?? "application/octet-stream" };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const ingestAttachments = createIngestAttachments({
|
|
84
|
+
uid: currentUid,
|
|
85
|
+
fetchObject,
|
|
86
|
+
saveAttachment,
|
|
87
|
+
deleteObject: (storagePath) => deleteObject(ref(storage, storagePath)),
|
|
88
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// listAccountingBooks command handler (remote-host).
|
|
2
|
+
//
|
|
3
|
+
// Returns { books: [{ id, name }] } so the mobile remote can show a book picker
|
|
4
|
+
// (e.g. before starting an accounting chat). Runs in-process on the host, so it
|
|
5
|
+
// bypasses the HTTP bearer layer and calls the accounting engine's listBooks
|
|
6
|
+
// directly. Only id + name travel — the remote doesn't need currency / country /
|
|
7
|
+
// fiscalYearEnd / createdAt, and trimming keeps the command-channel payload
|
|
8
|
+
// minimal (same "only what the client needs" discipline as listSkills).
|
|
9
|
+
//
|
|
10
|
+
// Exposed as a factory (createListAccountingBooks) so the mapping is
|
|
11
|
+
// unit-testable with listBooks stubbed; the default export wires the real
|
|
12
|
+
// engine function.
|
|
13
|
+
import { listBooks } from "@mulmoclaude/accounting-plugin/server";
|
|
14
|
+
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
15
|
+
|
|
16
|
+
export interface ListAccountingBooksDeps {
|
|
17
|
+
listBooks: typeof listBooks;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const createListAccountingBooks =
|
|
21
|
+
(deps: ListAccountingBooksDeps): CommandHandler =>
|
|
22
|
+
// Takes no params (the `__` prefix marks it intentionally unused per lint).
|
|
23
|
+
async (__params: JsonObject) => {
|
|
24
|
+
const { books } = await deps.listBooks();
|
|
25
|
+
// { id, name } are always present on a BookSummary. The cast only satisfies
|
|
26
|
+
// the channel's structural JsonValue type, which the BookSummary interface
|
|
27
|
+
// (no index signature) can't match directly.
|
|
28
|
+
return { books: books.map((book) => ({ id: book.id, name: book.name })) } as unknown as JsonObject;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const listAccountingBooks = createListAccountingBooks({ listBooks });
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
// calls the collection engine directly, returning the same shape as
|
|
5
5
|
// GET /api/collections: { collections: CollectionSummary[] }.
|
|
6
6
|
//
|
|
7
|
+
// Feeds (`source: "feed"`) are excluded: `discoverCollections` merges the
|
|
8
|
+
// feeds registry as a third root (so the desktop can show them together), but
|
|
9
|
+
// the mobile remote serves feeds through the dedicated listFeeds / getFeed
|
|
10
|
+
// handlers, so surfacing them here too would double-list them.
|
|
11
|
+
//
|
|
7
12
|
// Exposed as a factory (createListCollections) so the mapping is unit-testable
|
|
8
13
|
// with discovery stubbed; the default export wires the real engine functions.
|
|
9
14
|
import { discoverCollections, toSummary } from "../../workspace/collections/index.js";
|
|
@@ -19,7 +24,7 @@ export const createListCollections =
|
|
|
19
24
|
// Handlers receive the command's params; listCollections takes none (the
|
|
20
25
|
// `__` prefix marks it intentionally unused per the lint config).
|
|
21
26
|
async (__params: JsonObject) => {
|
|
22
|
-
const collections = (await deps.discover()).map(deps.toSummary);
|
|
27
|
+
const collections = (await deps.discover()).filter((collection) => collection.source !== "feed").map(deps.toSummary);
|
|
23
28
|
// CollectionSummary is plain JSON (slug/title/icon/source strings), so this
|
|
24
29
|
// is safe — the cast only satisfies the channel's structural JsonValue type,
|
|
25
30
|
// which an interface without an index signature can't match directly.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// listSkills command handler (remote-host).
|
|
2
|
+
//
|
|
3
|
+
// Returns just the ids (names) of the discoverable Claude Code skills as a
|
|
4
|
+
// flat string[], mirroring the id column of GET /api/skills. Read-only:
|
|
5
|
+
// creating/editing skills stays desktop-only (like listShortcuts / listFeeds).
|
|
6
|
+
// Only ids travel — the phone asks for the detail it needs by other means, so
|
|
7
|
+
// there is no reason to carry descriptions/source over the channel.
|
|
8
|
+
//
|
|
9
|
+
// Collection skills are excluded: a skill dir that ships a `schema.json` is a
|
|
10
|
+
// collection (the same set `discoverCollections` finds under user/project
|
|
11
|
+
// scope), and the mobile remote serves those through listCollections. Listing
|
|
12
|
+
// them here too would double-list them, so we subtract the collection slugs.
|
|
13
|
+
import { discoverCollections } from "../../workspace/collections/index.js";
|
|
14
|
+
import { discoverSkills } from "../../workspace/skills/index.js";
|
|
15
|
+
import { workspacePath } from "../../workspace/workspace.js";
|
|
16
|
+
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
17
|
+
|
|
18
|
+
export interface ListSkillsDeps {
|
|
19
|
+
discoverSkills: typeof discoverSkills;
|
|
20
|
+
discoverCollections: typeof discoverCollections;
|
|
21
|
+
workspaceRoot: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const createListSkills =
|
|
25
|
+
(deps: ListSkillsDeps): CommandHandler =>
|
|
26
|
+
// Handler receives the command's params; listSkills takes none (the `__`
|
|
27
|
+
// prefix marks it intentionally unused per the lint config).
|
|
28
|
+
async (__params: JsonObject) => {
|
|
29
|
+
const [skills, collections] = await Promise.all([
|
|
30
|
+
deps.discoverSkills({ workspaceRoot: deps.workspaceRoot }),
|
|
31
|
+
deps.discoverCollections({ workspaceRoot: deps.workspaceRoot }),
|
|
32
|
+
]);
|
|
33
|
+
// Feeds aren't skills, so only the skill-backed (user/project) collections
|
|
34
|
+
// can shadow a skill id — subtract those.
|
|
35
|
+
const collectionSlugs = new Set(collections.filter((collection) => collection.source !== "feed").map((collection) => collection.slug));
|
|
36
|
+
return { skills: skills.map((skill) => skill.name).filter((name) => !collectionSlugs.has(name)) };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const listSkills = createListSkills({ discoverSkills, discoverCollections, workspaceRoot: workspacePath });
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// mutateRemoteViewItem command handler (remote-host phase 4 —
|
|
2
|
+
// plans/feat-remote-writable-view.md).
|
|
3
|
+
//
|
|
4
|
+
// Applies one update/delete requested by a `target: "mobile"` custom view on
|
|
5
|
+
// the phone, authorized by that view's OWN declared surface
|
|
6
|
+
// (editableFields / allowDelete) and enforced HOST-side (createMutateRemoteView)
|
|
7
|
+
// — the sandboxed view is never trusted. The parent (mulmoserver) supplies the
|
|
8
|
+
// `viewId` it mounted; the sandboxed document cannot spoof a different view's
|
|
9
|
+
// policy. Shares the builder with the desktop preview's HTTP route so both
|
|
10
|
+
// transports apply identical policy.
|
|
11
|
+
//
|
|
12
|
+
// Factory (createMutateRemoteView-backed) keeps the mapping unit-testable with
|
|
13
|
+
// the engine stubbed; the default export wires the real functions.
|
|
14
|
+
import { normalizeMutate } from "@mulmoclaude/core/remote-view";
|
|
15
|
+
import { loadCollection } from "../../workspace/collections/index.js";
|
|
16
|
+
import { mutateRemoteView, mutateRemoteViewFailureMessage } from "../../workspace/collections/remoteView.js";
|
|
17
|
+
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
18
|
+
|
|
19
|
+
export interface MutateRemoteViewHandlerDeps {
|
|
20
|
+
loadCollection: typeof loadCollection;
|
|
21
|
+
mutateRemoteView: typeof mutateRemoteView;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const createMutateRemoteViewHandler =
|
|
25
|
+
(deps: MutateRemoteViewHandlerDeps): CommandHandler =>
|
|
26
|
+
async (params: JsonObject) => {
|
|
27
|
+
const slug = String(params.slug ?? "");
|
|
28
|
+
const viewId = String(params.viewId ?? "");
|
|
29
|
+
const request = normalizeMutate({ op: params.op, id: params.id, patch: params.patch });
|
|
30
|
+
if (!request) throw new Error("invalid mutate request — expected { op: 'update'|'delete', id, patch? }");
|
|
31
|
+
const collection = await deps.loadCollection(slug);
|
|
32
|
+
if (!collection) throw new Error(`collection '${slug}' not found`);
|
|
33
|
+
const result = await deps.mutateRemoteView(collection, viewId, request);
|
|
34
|
+
if (result.kind !== "ok") throw new Error(mutateRemoteViewFailureMessage(result, slug));
|
|
35
|
+
// Plain JSON, but the interface lacks an index signature — cast like the
|
|
36
|
+
// other phase-2/3 handlers.
|
|
37
|
+
return (result.op === "delete" ? { op: "delete", id: result.id } : { op: "update", item: result.item }) as unknown as JsonObject;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const mutateRemoteViewItem = createMutateRemoteViewHandler({ loadCollection, mutateRemoteView });
|
|
@@ -1,78 +1,142 @@
|
|
|
1
1
|
// startChat command handler (remote-host — start a chat from the mobile remote).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// chatId. No streaming back — starting the chat on the host is enough.
|
|
3
|
+
// The remote sends text; the host starts a new VISIBLE chat session (origin
|
|
4
|
+
// `skill`, openable from desktop history) seeded with it, and returns the new
|
|
5
|
+
// chatId. Fire-and-forget: no streaming back — starting the chat on the host is
|
|
6
|
+
// enough.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// ┌─────────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
// │ THE CONTRACT — read this before touching the params. │
|
|
10
|
+
// │ │
|
|
11
|
+
// │ CURRENT clients send ONLY `{ message }`. The message is seeded VERBATIM │
|
|
12
|
+
// │ as the first user turn. The host does NOT interpret it, so the client is │
|
|
13
|
+
// │ free to put a slash command (`/<slug> …`), a plain question, or anything │
|
|
14
|
+
// │ else in the text. This is the ONLY form new code should emit. │
|
|
15
|
+
// │ │
|
|
16
|
+
// │ `slug` (and its optional companion `itemId`) is LEGACY-ONLY. Older │
|
|
17
|
+
// │ clients still send `{ slug, itemId?, message }` to target a collection │
|
|
18
|
+
// │ or one record; we keep composing `/<slug> [id=<itemId>] <message>` for │
|
|
19
|
+
// │ them so they don't break. DO NOT add new features to this branch, and DO │
|
|
20
|
+
// │ NOT teach new clients to send `slug` — put the slash command in │
|
|
21
|
+
// │ `message` instead. The whole legacy path can be deleted once no deployed │
|
|
22
|
+
// │ client sends `slug` anymore. │
|
|
23
|
+
// └─────────────────────────────────────────────────────────────────────────┘
|
|
12
24
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
25
|
+
// Optional `role` — the id of the role the chat should run in (built-in or
|
|
26
|
+
// custom). Absent / null / "" ⇒ the host default role. A provided id MUST match
|
|
27
|
+
// an existing role: spawnSystemWorker requires a concrete roleId and the
|
|
28
|
+
// downstream getRole silently falls back to `general` on a miss, so we validate
|
|
29
|
+
// here and reject an unknown role rather than seed the wrong assistant.
|
|
16
30
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
31
|
+
// Optional `attachments` — full-res files (photos, videos, PDFs) the remote
|
|
32
|
+
// staged to Storage, carried as `[{ storage_id }]`. The host ingests them into
|
|
33
|
+
// the workspace (ingestAttachments) and hands the resulting path-only
|
|
34
|
+
// Attachments to the spawned chat. Absent / empty ⇒ byte-for-byte the prior
|
|
35
|
+
// text-only behaviour.
|
|
22
36
|
//
|
|
23
37
|
// Factory (createStartChat) keeps composition/wiring unit-testable with the
|
|
24
38
|
// engine + spawner stubbed; the default export wires the real ones.
|
|
25
39
|
import { spawnSystemWorker } from "../../api/routes/agent.js";
|
|
26
40
|
import { loadCollection } from "../../workspace/collections/index.js";
|
|
41
|
+
import { loadAllRoles } from "../../workspace/roles.js";
|
|
27
42
|
import { DEFAULT_ROLE_ID } from "../../../src/config/roles.js";
|
|
28
43
|
import type { CommandHandler, JsonObject, JsonValue } from "../commandChannel.js";
|
|
44
|
+
import { ingestAttachments } from "./ingestAttachments.js";
|
|
29
45
|
|
|
30
46
|
export interface StartChatDeps {
|
|
31
47
|
spawn: typeof spawnSystemWorker;
|
|
32
48
|
loadCollection: typeof loadCollection;
|
|
49
|
+
ingest: typeof ingestAttachments;
|
|
50
|
+
loadRoles: typeof loadAllRoles;
|
|
33
51
|
}
|
|
34
52
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
53
|
+
// Parse the optional `attachments` param into a list of storage_ids. Absent ⇒ no
|
|
54
|
+
// attachments. A malformed shape (not an array, or an element without a string
|
|
55
|
+
// `storage_id`) rejects the whole command: the remote already uploaded the
|
|
56
|
+
// bytes and is waiting, so a surfaced error beats a chat with a missing file.
|
|
57
|
+
const readStorageIds = (attachments: JsonValue | undefined): string[] => {
|
|
58
|
+
if (attachments == null) return [];
|
|
59
|
+
if (!Array.isArray(attachments)) throw new Error("attachments must be an array of { storage_id }");
|
|
60
|
+
return attachments.map((entry) => {
|
|
61
|
+
const storageId = entry && typeof entry === "object" && !Array.isArray(entry) ? entry.storage_id : undefined;
|
|
62
|
+
if (typeof storageId !== "string" || storageId.length === 0) throw new Error("each attachments entry must be { storage_id: string }");
|
|
63
|
+
return storageId;
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// LEGACY. Prefix the message with the collection's slash command. `itemId`
|
|
68
|
+
// scopes the chat to one record; empty ⇒ the whole collection. Matches the
|
|
69
|
+
// desktop item-chat format documented in CollectionRecordPanel.vue. Only the
|
|
70
|
+
// legacy `slug` path calls this — new clients put the slash command in
|
|
71
|
+
// `message` themselves.
|
|
38
72
|
export const composeMessage = (slug: string, itemId: string, message: string): string => {
|
|
39
73
|
const prefix = itemId ? `/${slug} id=${itemId}` : `/${slug}`;
|
|
40
74
|
return `${prefix} ${message}`;
|
|
41
75
|
};
|
|
42
76
|
|
|
43
|
-
// slug and itemId become single tokens in the slash command
|
|
44
|
-
// `id=<itemId>`), so a whitespace-containing or non-string value
|
|
45
|
-
// the command parse (e.g. `/ hello`). Accept only a trimmed,
|
|
46
|
-
// string; anything else ⇒ "" so the caller rejects it.
|
|
77
|
+
// LEGACY. slug and itemId become single tokens in the slash command
|
|
78
|
+
// (`/<slug>`, `id=<itemId>`), so a whitespace-containing or non-string value
|
|
79
|
+
// would break the command parse (e.g. `/ hello`). Accept only a trimmed,
|
|
80
|
+
// whitespace-free string; anything else ⇒ "" so the caller rejects it.
|
|
47
81
|
const asToken = (value: JsonValue): string => {
|
|
48
82
|
if (typeof value !== "string") return "";
|
|
49
83
|
const trimmed = value.trim();
|
|
50
84
|
return /\s/.test(trimmed) ? "" : trimmed;
|
|
51
85
|
};
|
|
52
86
|
|
|
87
|
+
// LEGACY collection/record targeting for older clients that still send `slug`.
|
|
88
|
+
// Compose the `/<slug> [id=<itemId>] <message>` seed the desktop uses; reject a
|
|
89
|
+
// malformed or unknown slug and refuse feeds, so we never seed a `/<slug>`
|
|
90
|
+
// command that resolves to nothing. New clients skip all of this by sending the
|
|
91
|
+
// slash command (or plain text) in `message`.
|
|
92
|
+
const composeCollectionSeed = async (deps: StartChatDeps, params: JsonObject, message: string): Promise<string> => {
|
|
93
|
+
const slug = asToken(params.slug);
|
|
94
|
+
const itemId = asToken(params.itemId);
|
|
95
|
+
if (!slug) throw new Error("slug must be a non-empty, whitespace-free string");
|
|
96
|
+
if (params.itemId != null && !itemId) throw new Error("itemId must be a non-empty, whitespace-free string when provided");
|
|
97
|
+
const target = await deps.loadCollection(slug);
|
|
98
|
+
if (!target) throw new Error(`collection '${slug}' not found`);
|
|
99
|
+
if (target.source === "feed") throw new Error("chat is not available for feeds");
|
|
100
|
+
return composeMessage(slug, itemId, message);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// A `slug` is "provided" (⇒ LEGACY targeting) only when it is a non-empty
|
|
104
|
+
// value. Omitted / null / "" ⇒ the current free-text form. New clients never
|
|
105
|
+
// set it, so they always take the verbatim branch below.
|
|
106
|
+
const hasSlug = (value: JsonValue | undefined): boolean => value != null && value !== "";
|
|
107
|
+
|
|
108
|
+
// Resolve the optional `role` param to a concrete roleId. Absent / null / "" ⇒
|
|
109
|
+
// the host default. A provided id must be a string that matches an existing
|
|
110
|
+
// role (built-in or custom) — reject an unknown one so we never seed the chat
|
|
111
|
+
// with the wrong (default-fallback) assistant. `isDebugRole` roles are excluded:
|
|
112
|
+
// the desktop picker hides them from new sessions outside dev mode
|
|
113
|
+
// (RoleSelector.vue), and the remote channel is a production-facing entry point,
|
|
114
|
+
// so a debug role id is treated as not selectable here (rejected as unknown).
|
|
115
|
+
const resolveRoleId = (deps: StartChatDeps, role: JsonValue | undefined): string => {
|
|
116
|
+
if (role == null || role === "") return DEFAULT_ROLE_ID;
|
|
117
|
+
if (typeof role !== "string") throw new Error("role must be a string");
|
|
118
|
+
if (!deps.loadRoles().some((candidate) => candidate.id === role && !candidate.isDebugRole)) throw new Error(`role '${role}' not found`);
|
|
119
|
+
return role;
|
|
120
|
+
};
|
|
121
|
+
|
|
53
122
|
export const createStartChat =
|
|
54
123
|
(deps: StartChatDeps): CommandHandler =>
|
|
55
124
|
async (params: JsonObject) => {
|
|
56
125
|
// Params arrive as JSON over the channel — coerce/validate defensively.
|
|
57
|
-
const slug = asToken(params.slug);
|
|
58
|
-
const itemId = asToken(params.itemId);
|
|
59
126
|
const message = (typeof params.message === "string" ? params.message : "").trim();
|
|
60
|
-
if (!slug) throw new Error("slug must be a non-empty, whitespace-free string");
|
|
61
|
-
if (params.itemId != null && !itemId) throw new Error("itemId must be a non-empty, whitespace-free string when provided");
|
|
62
127
|
if (!message) throw new Error("message is required");
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
128
|
+
// Current clients: `message` only ⇒ seed it verbatim. Legacy clients that
|
|
129
|
+
// still send `slug` ⇒ compose the old `/<slug> [id=<itemId>] <message>` seed.
|
|
130
|
+
const seed = hasSlug(params.slug) ? await composeCollectionSeed(deps, params, message) : message;
|
|
131
|
+
// Resolve the role BEFORE ingest/spawn so an unknown role rejects the
|
|
132
|
+
// command without staging work or launching a chat.
|
|
133
|
+
const roleId = resolveRoleId(deps, params.role);
|
|
134
|
+
// Ingest any staged files BEFORE spawning so a download/validation failure
|
|
135
|
+
// rejects the command instead of starting a chat missing its attachments.
|
|
136
|
+
const attachments = await deps.ingest(readStorageIds(params.attachments));
|
|
137
|
+
const result = await deps.spawn({ message: seed, roleId, hidden: false, attachments });
|
|
74
138
|
if (!result.ok) throw new Error(result.error);
|
|
75
139
|
return { started: true, chatId: result.chatId };
|
|
76
140
|
};
|
|
77
141
|
|
|
78
|
-
export const startChat = createStartChat({ spawn: spawnSystemWorker, loadCollection });
|
|
142
|
+
export const startChat = createStartChat({ spawn: spawnSystemWorker, loadCollection, ingest: ingestAttachments, loadRoles: loadAllRoles });
|