mulmoclaude 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bin/mulmoclaude.js +2 -1
  2. package/client/assets/{index-Dc0R-HW5.js → index-B3NxFcEH.js} +46 -46
  3. package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
  4. package/client/assets/{marp-CSq0PPfK.js → marp-C9QDHFAJ.js} +1 -1
  5. package/client/index.html +2 -2
  6. package/package.json +11 -10
  7. package/server/api/routes/agent.ts +5 -1
  8. package/server/api/routes/chat-index.ts +5 -1
  9. package/server/api/routes/collections.ts +152 -1
  10. package/server/api/routes/config.ts +12 -0
  11. package/server/index.ts +14 -2
  12. package/server/prompts/system/system.md +7 -1
  13. package/server/remoteHost/firebase.ts +6 -0
  14. package/server/remoteHost/handlers/collectionPage.ts +15 -17
  15. package/server/remoteHost/handlers/getCollection.ts +2 -2
  16. package/server/remoteHost/handlers/getFeed.ts +2 -2
  17. package/server/remoteHost/handlers/getRemoteView.ts +37 -0
  18. package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
  19. package/server/remoteHost/handlers/index.ts +10 -0
  20. package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
  21. package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
  22. package/server/remoteHost/handlers/listCollections.ts +6 -1
  23. package/server/remoteHost/handlers/listSkills.ts +39 -0
  24. package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
  25. package/server/remoteHost/handlers/startChat.ts +103 -39
  26. package/server/system/config.ts +101 -4
  27. package/server/system/logs/aaa +737 -0
  28. package/server/system/logs/bb +446 -0
  29. package/server/utils/files/thumbnail-store.ts +97 -0
  30. package/server/workspace/chat-index/index.ts +29 -3
  31. package/server/workspace/chat-index/indexer.ts +187 -29
  32. package/server/workspace/chat-index/summarizer.ts +18 -10
  33. package/server/workspace/collections/index.ts +2 -1
  34. package/server/workspace/collections/remoteView.ts +290 -0
  35. package/server/workspace/journal/archivist-cli.ts +17 -3
  36. package/server/workspace/journal/dailyPass.ts +52 -2
  37. package/server/workspace/journal/index.ts +20 -4
  38. package/src/App.vue +62 -3
  39. package/src/components/FileTree.vue +21 -1
  40. package/src/components/FileTreePane.vue +41 -25
  41. package/src/components/FilesView.vue +4 -0
  42. package/src/components/RemoteHostControl.vue +18 -0
  43. package/src/components/SessionHistoryPanel.vue +24 -11
  44. package/src/components/SettingsChatIndexTab.vue +119 -0
  45. package/src/components/SettingsJournalTab.vue +117 -0
  46. package/src/components/SettingsModal.vue +12 -2
  47. package/src/composables/collections/uiHost.ts +15 -0
  48. package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
  49. package/src/composables/usePubSub.ts +40 -2
  50. package/src/composables/useSessionSync.ts +9 -0
  51. package/src/composables/useShowHiddenSystemFiles.ts +23 -0
  52. package/src/config/apiRoutes.ts +20 -0
  53. package/src/config/roles.ts +2 -2
  54. package/src/config/visibleWorkspaceDirs.ts +21 -0
  55. package/src/lang/de.ts +47 -0
  56. package/src/lang/en.ts +45 -0
  57. package/src/lang/es.ts +46 -0
  58. package/src/lang/fr.ts +47 -0
  59. package/src/lang/ja.ts +46 -0
  60. package/src/lang/ko.ts +45 -0
  61. package/src/lang/pt-BR.ts +46 -0
  62. package/src/lang/zh.ts +44 -0
  63. package/src/plugins/textResponse/View.vue +49 -14
  64. package/src/plugins/textResponse/utils.ts +37 -0
  65. package/src/utils/html/previewCsp.ts +7 -16
  66. package/src/utils/role/icon.ts +9 -2
  67. package/src/utils/session/sessionPreview.ts +29 -0
@@ -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, MAX_LIMIT] (default 50) so a runaway page can't blow that
6
- // budget. Params arrive as JSON over the channel, so coerce defensively.
7
- import type { JsonObject, JsonValue } from "../commandChannel.js";
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
- const DEFAULT_LIMIT = 50;
10
- const MAX_LIMIT = 200;
13
+ export { clampLimit, clampOffset };
11
14
 
12
- const toInt = (value: JsonValue): number | null => {
13
- const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
14
- return Number.isFinite(num) ? Math.floor(num) : null;
15
- };
16
-
17
- export const clampOffset = (value: JsonValue): number => Math.max(0, toInt(value) ?? 0);
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
- // Fire-and-forget: composes the collection's slash command as a prefix on the
4
- // user's message (`/<slug> <message>` for a collection, `/<slug> id=<itemId>
5
- // <message>` for one record), then spawns a VISIBLE host chat session (origin
6
- // `skill`, openable from history) via spawnSystemWorker, and returns the new
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
- // Mirrors the desktop's two entry points over the command channel: the
10
- // collection-level `__MC_VIEW.startChat` and the per-record chat box in
11
- // CollectionRecordPanel.vue (`/<slug> id=<itemId> <message>`).
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
- // Role is intentionally NOT part of the remote API the caller passes only
14
- // message + slug + optional itemId. The host runs the chat in its default role
15
- // (spawnSystemWorker startChat requires a concrete roleId; empty is rejected).
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
- // Feeds are refused: a feed (`source === "feed"`) has no `/<slug>` skill
18
- // command, so a slash-command seed would invoke nothing (see desktop
19
- // `CollectionView.buildChatSeed`). The mobile remote doesn't offer chat for
20
- // feeds; this is the host-side backstop so a stray feed slug can't produce a
21
- // broken chat.
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
- // Prefix the message with the collection's slash command. `itemId` scopes the
36
- // chat to one record; empty the whole collection. Matches the desktop
37
- // item-chat format documented in CollectionRecordPanel.vue.
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 (`/<slug>`,
44
- // `id=<itemId>`), so a whitespace-containing or non-string value would break
45
- // the command parse (e.g. `/ hello`). Accept only a trimmed, whitespace-free
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
- // Resolve the target so we never seed a `/<slug>` command that resolves to
64
- // nothing: an unknown/stale slug (`null`) is rejected, and a feed
65
- // (`source === "feed"`, no skill command) is refused.
66
- const target = await deps.loadCollection(slug);
67
- if (!target) throw new Error(`collection '${slug}' not found`);
68
- if (target.source === "feed") throw new Error("chat is not available for feeds");
69
- const result = await deps.spawn({
70
- message: composeMessage(slug, itemId, message),
71
- roleId: DEFAULT_ROLE_ID,
72
- hidden: false,
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 });