@mulmoclaude/core 0.27.0 → 0.29.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/assets/helps/collection-skills.md +4 -2
- package/assets/helps/wiki.md +47 -2
- package/dist/collection/core/ontologyGraph.d.ts +6 -2
- package/dist/collection/index.cjs +4 -4
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +4 -4
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +2 -2
- package/dist/collection/registry/server/index.js +2 -2
- package/dist/collection/server/backendAvailability.d.ts +5 -0
- package/dist/collection/server/csvStore.d.ts +0 -5
- package/dist/collection/server/index.cjs +4 -2
- package/dist/collection/server/index.d.ts +1 -0
- package/dist/collection/server/index.js +3 -3
- package/dist/collection/server/manageTool.d.ts +4 -0
- package/dist/collection/server/schemaDocs.d.ts +21 -0
- package/dist/collection/server/store.d.ts +37 -0
- package/dist/collection/server/watchFs.d.ts +17 -0
- package/dist/collection-watchers/index.cjs +114 -203
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.d.ts +1 -1
- package/dist/collection-watchers/index.js +114 -202
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/collection-watchers/watcher.d.ts +8 -13
- package/dist/{discovery-B9Vfojyy.js → discovery-BbsJwVEq.js} +128 -15
- package/dist/discovery-BbsJwVEq.js.map +1 -0
- package/dist/{discovery-BxbJy2VN.cjs → discovery-Bklck7Ck.cjs} +137 -12
- package/dist/discovery-Bklck7Ck.cjs.map +1 -0
- package/dist/feeds/server/index.cjs +2 -2
- package/dist/feeds/server/index.js +2 -2
- package/dist/google/index.cjs +1 -1
- package/dist/google/index.js +1 -1
- package/dist/{promptSafety-0ZKHX-6J.cjs → promptSafety-BFt2g_wn.cjs} +2 -2
- package/dist/promptSafety-BFt2g_wn.cjs.map +1 -0
- package/dist/{promptSafety-RE1SPxBN.js → promptSafety-cZIeiZtB.js} +3 -3
- package/dist/promptSafety-cZIeiZtB.js.map +1 -0
- package/dist/remote-view/index.cjs +8 -0
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.d.ts +7 -0
- package/dist/remote-view/index.js +8 -1
- package/dist/remote-view/index.js.map +1 -1
- package/dist/{server-DOGKfUuD.cjs → server-CghbYZFY.cjs} +199 -22
- package/dist/server-CghbYZFY.cjs.map +1 -0
- package/dist/{server-sGNe-msA.js → server-DlG6ydHO.js} +200 -23
- package/dist/server-DlG6ydHO.js.map +1 -0
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.js.map +1 -1
- package/package.json +1 -1
- package/dist/discovery-B9Vfojyy.js.map +0 -1
- package/dist/discovery-BxbJy2VN.cjs.map +0 -1
- package/dist/promptSafety-0ZKHX-6J.cjs.map +0 -1
- package/dist/promptSafety-RE1SPxBN.js.map +0 -1
- package/dist/server-DOGKfUuD.cjs.map +0 -1
- package/dist/server-sGNe-msA.js.map +0 -1
|
@@ -38,6 +38,13 @@ export declare const DEFAULT_IMAGE_MAX_EDGE = 512;
|
|
|
38
38
|
export declare const SANDBOXED_VIEW_CDN_ALLOWLIST: readonly string[];
|
|
39
39
|
/** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */
|
|
40
40
|
export declare const clampOffset: (value: unknown) => number;
|
|
41
|
+
/** Read a channel/postMessage identifier (a slug, a view id) as a string.
|
|
42
|
+
* Params arrive as untyped JSON, so a caller can send anything; `String(...)`
|
|
43
|
+
* turned an object into the literal "[object Object]", which then travelled on
|
|
44
|
+
* as if the caller had asked for a collection by that name — surfacing as
|
|
45
|
+
* `collection '[object Object]' not found` rather than a bad-request. A value
|
|
46
|
+
* with no string form is simply absent. */
|
|
47
|
+
export declare const readIdParam: (value: unknown) => string;
|
|
41
48
|
/** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */
|
|
42
49
|
export declare const clampLimit: (value: unknown) => number;
|
|
43
50
|
/** Clamp an `imageMaxEdge` (arrives as untyped schema/JSON) to [64, 1024];
|
|
@@ -54,6 +54,13 @@ var toInt = (value) => {
|
|
|
54
54
|
};
|
|
55
55
|
/** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */
|
|
56
56
|
var clampOffset = (value) => Math.max(0, toInt(value) ?? 0);
|
|
57
|
+
/** Read a channel/postMessage identifier (a slug, a view id) as a string.
|
|
58
|
+
* Params arrive as untyped JSON, so a caller can send anything; `String(...)`
|
|
59
|
+
* turned an object into the literal "[object Object]", which then travelled on
|
|
60
|
+
* as if the caller had asked for a collection by that name — surfacing as
|
|
61
|
+
* `collection '[object Object]' not found` rather than a bad-request. A value
|
|
62
|
+
* with no string form is simply absent. */
|
|
63
|
+
var readIdParam = (value) => typeof value === "string" ? value : "";
|
|
57
64
|
/** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */
|
|
58
65
|
var clampLimit = (value) => {
|
|
59
66
|
const num = toInt(value);
|
|
@@ -270,6 +277,6 @@ async function handleRemoteViewMessage(data, handlers, reply) {
|
|
|
270
277
|
return true;
|
|
271
278
|
}
|
|
272
279
|
//#endregion
|
|
273
|
-
export { DEFAULT_IMAGE_MAX_EDGE, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, REMOTE_VIEW_ITEMS_MAX_BYTES, REMOTE_VIEW_MAX_BYTES, REMOTE_VIEW_MESSAGES, REMOTE_VIEW_PROTOCOL, SANDBOXED_VIEW_CDN_ALLOWLIST, buildRemoteViewCsp, buildRemoteViewSrcdoc, clampImageMaxEdge, clampLimit, clampOffset, handleRemoteViewMessage, normalizeFields, normalizeMutate, pageFromItems, projectItems };
|
|
280
|
+
export { DEFAULT_IMAGE_MAX_EDGE, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, REMOTE_VIEW_ITEMS_MAX_BYTES, REMOTE_VIEW_MAX_BYTES, REMOTE_VIEW_MESSAGES, REMOTE_VIEW_PROTOCOL, SANDBOXED_VIEW_CDN_ALLOWLIST, buildRemoteViewCsp, buildRemoteViewSrcdoc, clampImageMaxEdge, clampLimit, clampOffset, handleRemoteViewMessage, normalizeFields, normalizeMutate, pageFromItems, projectItems, readIdParam };
|
|
274
281
|
|
|
275
282
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/feat-remote-custom-view.md).\n//\n// Browser-safe single source of truth shared by the host server (which wraps\n// the view HTML into a sandboxed srcdoc), the desktop phone-frame preview, and\n// the mulmoserver mobile client (post-publish). A remote view runs on a phone\n// that can reach the internet but NOT the host's localhost, so — unlike the\n// desktop custom view (token + fetch to the view-data route) — its records\n// arrive over an async postMessage bridge owned by the parent page, and its\n// CSP locks `connect-src` to 'none' entirely.\n//\n// BACKWARD COMPATIBILITY — this bridge is a frozen public contract. Remote\n// views are LLM-authored HTML files persisted in users' workspaces\n// (`data/skills/*/views/*.html`), written against\n// `packages/core/assets/helps/custom-view-remote.md`; they cannot be\n// migrated centrally and must keep working across host upgrades and any\n// storage-virtualization work underneath. Evolve only by backward-compatible\n// supersets, the way protocol v2 added the mutate pair: bump\n// `REMOTE_VIEW_PROTOCOL`, add new message types / optional fields — never\n// repurpose an existing message type, change the `getItems` page shape\n// (`{ items, total, offset, limit }`), or tighten limits a shipped view may\n// already rely on.\n\nimport { projectRecordFields } from \"../collection/core/project\";\n\n/** Bump when the bootstrap/message contract changes shape; the bootstrap\n * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view.\n * v2 (phase 4) adds the mutate pair below — a backward-compatible superset,\n * so a v1 (read-only) parent still serves get-items/start-chat unchanged. */\nexport const REMOTE_VIEW_PROTOCOL = 2;\n\n/** postMessage types between the sandboxed view and its parent page.\n * `startChat` reuses the desktop custom-view message type on purpose — the\n * desktop parent already understands it. */\nexport const REMOTE_VIEW_MESSAGES = {\n /** view → parent: request one page of records ({ requestId, offset, limit, fields }). */\n getItems: \"mc-remote-get-items\",\n /** parent → view: the reply ({ requestId, ok, page | error }). */\n items: \"mc-remote-items\",\n /** view → parent: mutate one record ({ requestId, op: \"update\"|\"delete\", id, patch? }). */\n mutate: \"mc-remote-mutate\",\n /** parent → view: the mutate reply ({ requestId, ok, result | error }). */\n mutateResult: \"mc-remote-mutate-result\",\n /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */\n startChat: \"mc-start-chat\",\n} as const;\n\n/** Pagination defaults — mirrored by the phase-2 record handlers\n * (`server/remoteHost/handlers/collectionPage.ts` imports these) so a view\n * page can never outgrow what the command channel itself serves. */\nexport const DEFAULT_PAGE_LIMIT = 50;\nexport const MAX_PAGE_LIMIT = 200;\n\n/** Hard cap on the wrapped srcdoc: it travels to the phone INSIDE a Firestore\n * command document (1 MiB total), so leave envelope headroom. */\nexport const REMOTE_VIEW_MAX_BYTES = 900_000;\n\n/** Hard cap on ONE `getItems` page (phase 5 — plans/feat-remote-view-images.md).\n * Same 1 MiB command-document envelope as the srcdoc: when a view inlines image\n * fields as `data:` URLs, the host stops inlining once the serialized page would\n * exceed this, leaving the remaining image fields as their original path (which\n * the view renders as a placeholder). Guards the doc-write from ever failing. */\nexport const REMOTE_VIEW_ITEMS_MAX_BYTES = 900_000;\n\n/** Default longest-edge (px) a remote view's inlined image thumbnail is\n * downscaled to; a view may override via `imageMaxEdge`. */\nexport const DEFAULT_IMAGE_MAX_EDGE = 512;\n\n/** In-iframe `getItems` timeout — matches the remote client's `callHost`\n * response timeout so the two layers give up together. */\nconst GET_ITEMS_TIMEOUT_MS = 30_000;\n\n// CDN allowlist for sandboxed LLM-authored HTML (script/style/font loads).\n// Shared with the desktop preview + custom-view CSPs\n// (src/utils/html/previewCsp.ts re-exports it as its default) so the two\n// policies can't drift. Keep the list audited — every entry is a potential\n// supply-chain surface; the hosts here are reputable infrastructure that does\n// not expose per-request logs to third parties.\nexport const SANDBOXED_VIEW_CDN_ALLOWLIST: readonly string[] = [\n \"https://cdn.jsdelivr.net\",\n \"https://unpkg.com\",\n \"https://cdnjs.cloudflare.com\",\n \"https://fonts.googleapis.com\",\n \"https://fonts.gstatic.com\",\n // Plotly's first-party CDN — the LLM defaults to it for Plotly charts.\n \"https://cdn.plot.ly\",\n];\n\nconst toInt = (value: unknown): number | null => {\n const num = typeof value === \"number\" ? value : typeof value === \"string\" ? Number(value) : NaN;\n return Number.isFinite(num) ? Math.floor(num) : null;\n};\n\n/** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */\nexport const clampOffset = (value: unknown): number => Math.max(0, toInt(value) ?? 0);\n\n/** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */\nexport const clampLimit = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_PAGE_LIMIT;\n return Math.min(num, MAX_PAGE_LIMIT);\n};\n\n/** Clamp an `imageMaxEdge` (arrives as untyped schema/JSON) to [64, 1024];\n * default 512. Keeps a runaway edge from defeating the thumbnail's purpose. */\nexport const clampImageMaxEdge = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_IMAGE_MAX_EDGE;\n return Math.min(Math.max(num, 64), 1024);\n};\n\n/** Coerce a `fields` projection list from untyped message JSON. */\nexport const normalizeFields = (value: unknown): string[] | undefined => {\n if (!Array.isArray(value)) return undefined;\n const cleaned = value\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0);\n return cleaned.length > 0 ? cleaned : undefined;\n};\n\nexport type RemoteViewItem = Record<string, unknown>;\n\n/** One page of records, the resolved value of the view's `getItems()`. Same\n * shape as the phase-2 `getCollection` page so a parent can pass a channel\n * page straight through. */\nexport interface RemoteViewPage {\n items: RemoteViewItem[];\n total: number;\n offset: number;\n limit: number;\n}\n\n/** A normalized (clamped, fields-cleaned) page request handed to a parent's\n * `getPage` — `handleRemoteViewMessage` does the coercion so every parent\n * answers identical values. */\nexport interface RemoteViewPageRequest {\n offset: number;\n limit: number;\n fields?: string[];\n}\n\n/** Keep only `fields` (+ always the primary key) on each record. Parents apply\n * this uniformly — the desktop preview via `pageFromItems`, the phone parent\n * over the page it fetched through the channel — so a view sees the same\n * projection everywhere. No-op without `fields`; an EMPTY `fields` array is\n * also a no-op here (frozen bridge behavior — the shared helper would\n * project down to the primary key alone). */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n return projectRecordFields(items, fields, primaryKey);\n}\n\n/** Answer a page request from an already-loaded record array (the desktop\n * preview's data source): slice + project. Observable behavior matches the\n * phone paging over the command channel. */\nexport function pageFromItems(items: RemoteViewItem[], request: RemoteViewPageRequest, primaryKey: string): RemoteViewPage {\n const pageItems = items.slice(request.offset, request.offset + request.limit);\n return { items: projectItems(pageItems, request.fields, primaryKey), total: items.length, offset: request.offset, limit: request.limit };\n}\n\n/**\n * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view\n * policy: the view's data arrives over postMessage, so `connect-src` is\n * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which\n * closes the bidirectional-exfiltration channel completely (there is no token\n * to steal either). Script/style/font keep the curated CDN allowlist (the\n * phone can reach the internet; only the host is unreachable), and\n * `img-src`/`media-src` allow any `https:` host so record image/media URLs\n * render — the same knowingly-accepted one-way GET-exfil tradeoff as the\n * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).\n */\nexport function buildRemoteViewCsp(cdns: readonly string[] = SANDBOXED_VIEW_CDN_ALLOWLIST): string {\n const cdnList = cdns.join(\" \");\n return [\n \"default-src 'none'\",\n `script-src 'unsafe-inline' ${cdnList}`,\n `style-src 'unsafe-inline' ${cdnList}`,\n `font-src ${cdnList}`,\n `img-src ${cdnList} data: blob: https:`,\n \"media-src https: data: blob:\",\n \"connect-src 'none'\",\n ].join(\"; \");\n}\n\n/** The in-iframe bootstrap installed before any of the view's own scripts.\n * Owns the fiddly part of the contract — request/response correlation — so an\n * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)` /\n * `.updateItem(...)` / `.deleteItem(...)`:\n *\n * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`\n * with a fresh `requestId`, resolves on the matching `mc-remote-items`\n * reply (validated to come from `window.parent`), rejects on `ok: false`\n * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret\n * and the parent is by construction the party supplying the data.\n * - `updateItem(id, patch)` / `deleteItem(id)` (phase 4): post an\n * `mc-remote-mutate` and resolve on the matching `mc-remote-mutate-result`,\n * sharing the same `call()` correlation as `getItems`. Installed ONLY when\n * the host set `writable` (the view declared `editableFields`/`allowDelete`);\n * otherwise both reject `\"this view is read-only\"` so a mis-declared view\n * fails loudly instead of silently no-op'ing. The HOST still re-derives and\n * enforces the write policy — `writable` only gates the client surface.\n * - `startChat(prompt, role)`: same message type + semantics as the desktop\n * bridge — the parent opens a new chat with `prompt` prefilled as an\n * editable draft, never auto-sent.\n * - `t(key, named)`: the same vue-i18n-compatible dict helper as the desktop\n * bootstrap (named interpolation only), over the host-picked `dict`.\n *\n * Self-contained one-line string (no `<`, no `</script>`, `${}` only for the\n * interpolated constants). */\nfunction remoteViewBootstrap(): string {\n return `(function(){var v=window.__MC_VIEW,seq=0,pend={};window.addEventListener('message',function(e){if(e.source!==window.parent)return;var d=e.data;if(!d)return;if(d.type!=='${REMOTE_VIEW_MESSAGES.items}'&&d.type!=='${REMOTE_VIEW_MESSAGES.mutateResult}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.type==='${REMOTE_VIEW_MESSAGES.items}'?d.page:d.result);else p.reject(new Error(typeof d.error==='string'?d.error:'request failed'));});function call(type,payload){return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error(type+' timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};var m={type:type,slug:v.slug,requestId:id};for(var k in payload){m[k]=payload[k];}window.parent.postMessage(m,'*');});}v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return call('${REMOTE_VIEW_MESSAGES.getItems}',{offset:opts.offset,limit:opts.limit,fields:opts.fields});};if(v.writable){v.updateItem=function(id,patch){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'update',id:String(id),patch:patch&&typeof patch==='object'?patch:{}});};v.deleteItem=function(id){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'delete',id:String(id)});};}else{v.updateItem=v.deleteItem=function(){return Promise.reject(new Error('this view is read-only'));};}v.startChat=function(prompt,role){window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.startChat}',slug:v.slug,prompt:String(prompt),role:typeof role==='string'?role:undefined},'*');};v.dict=v.dict||{};v.t=function(key,named){var s=v.dict[key];if(typeof s!=='string')return typeof key==='string'?key:String(key);if(!named||typeof named!=='object')return s;return s.replace(/\\\\{(\\\\w+)\\\\}/g,function(m,n){var x=named[n];return x==null?m:String(x);});};})();`;\n}\n\n/** What the host injects into `window.__MC_VIEW` — note what is ABSENT\n * compared to the desktop boot: no token, no dataUrl, no origin. */\nexport interface RemoteViewBoot {\n slug: string;\n /** Locale the dict was picked for; empty string when no translations. */\n locale?: string;\n /** Host-picked, locale-filtered flat string map (same contract as the\n * desktop custom-view dict). */\n dict?: Record<string, string>;\n /** True when the view declared a mutable surface (`editableFields` and/or\n * `allowDelete`). Gates the client-side `updateItem`/`deleteItem` install\n * only — the host re-enforces the actual policy on every mutate. */\n writable?: boolean;\n}\n\n/** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +\n * bridge bootstrap injected at the start of `<head>` (before any view\n * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop\n * preview receive the identical finished artifact. */\nexport function buildRemoteViewSrcdoc(html: string, boot: RemoteViewBoot): string {\n const cspMeta = `<meta http-equiv=\"Content-Security-Policy\" content=\"${buildRemoteViewCsp()}\">`;\n // `<`-escape the JSON so a hostile slug/dict string can't break out of the\n // <script> element (same escape as the desktop srcdoc builder).\n const json = JSON.stringify({\n slug: boot.slug,\n locale: boot.locale ?? \"\",\n dict: boot.dict ?? {},\n target: \"mobile\",\n protocol: REMOTE_VIEW_PROTOCOL,\n writable: boot.writable ?? false,\n }).replace(/</g, \"\\\\u003c\");\n const injection = `${cspMeta}<script>window.__MC_VIEW=${json};${remoteViewBootstrap()}</script>`;\n if (/<head\\b[^>]*>/i.test(html)) {\n return html.replace(/(<head\\b[^>]*>)/i, `$1${injection}`);\n }\n return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;\n}\n\n/** A normalized mutate request handed to a parent's `onMutate`\n * (`handleRemoteViewMessage` validates op/id/patch first). `update` carries a\n * partial record; the HOST decides which keys are actually writable. */\nexport type RemoteViewMutateRequest = { op: \"update\"; id: string; patch: Record<string, unknown> } | { op: \"delete\"; id: string };\n\n/** The resolved value of a mutate: the merged record for an update, the removed\n * id for a delete. Sent back to the view as the `mc-remote-mutate-result`\n * `result`. */\nexport interface RemoteViewMutateResult {\n item?: RemoteViewItem;\n id?: string;\n}\n\n/** What a parent page provides to answer the sandboxed view. Deliberately\n * minimal — exactly the phone runtime's capabilities, nothing more, so the\n * desktop preview can never exceed what works on the phone. */\nexport interface RemoteViewBridgeHandlers {\n slug: string;\n /** Answer one normalized page request (already clamped + fields-cleaned). */\n getPage: (request: RemoteViewPageRequest) => Promise<RemoteViewPage> | RemoteViewPage;\n /** Apply one normalized mutate (update/delete). Omit on a read-only parent —\n * the handler then replies `ok: false, \"this view is read-only\"`. The parent\n * forwards to the host (which enforces the write policy authoritatively). */\n onMutate?: (request: RemoteViewMutateRequest) => Promise<RemoteViewMutateResult> | RemoteViewMutateResult;\n /** Relay a `startChat` draft; omit on a parent without a chat surface. */\n onStartChat?: (prompt: string, role?: string) => void;\n}\n\n/** Coerce an untyped `mc-remote-mutate` payload to a normalized request, or\n * null when it is malformed (unknown op, missing id, non-object update patch —\n * the parent then replies with an `\"invalid mutate request\"` error). */\nexport function normalizeMutate(data: { op?: unknown; id?: unknown; patch?: unknown }): RemoteViewMutateRequest | null {\n const itemId = typeof data.id === \"string\" ? data.id : typeof data.id === \"number\" && Number.isFinite(data.id) ? String(data.id) : \"\";\n if (!itemId) return null;\n if (data.op === \"delete\") return { op: \"delete\", id: itemId };\n if (data.op === \"update\") {\n if (typeof data.patch !== \"object\" || data.patch === null || Array.isArray(data.patch)) return null;\n return { op: \"update\", id: itemId, patch: data.patch as Record<string, unknown> };\n }\n return null;\n}\n\nasync function answerGetItems(requestId: string, request: RemoteViewPageRequest, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<void> {\n try {\n const page = await handlers.getPage(request);\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: true, page });\n } catch (err) {\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: false, error: err instanceof Error ? err.message : String(err) });\n }\n}\n\n/** Validate + dispatch a `mc-remote-mutate` payload, replying on every path\n * (malformed request, read-only parent, handler success/throw). Split out of\n * `handleRemoteViewMessage` so that function stays under the 20-line limit. */\nasync function answerMutate(\n requestId: string,\n msg: { op?: unknown; id?: unknown; patch?: unknown },\n handlers: RemoteViewBridgeHandlers,\n reply: RemoteViewReply,\n): Promise<void> {\n const request = normalizeMutate(msg);\n if (!request) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"invalid mutate request\" });\n return;\n }\n if (!handlers.onMutate) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"this view is read-only\" });\n return;\n }\n try {\n const result = await handlers.onMutate(request);\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: true, result });\n } catch (err) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: err instanceof Error ? err.message : String(err) });\n }\n}\n\ntype RemoteViewReply = (message: Record<string, unknown>) => void;\n\n/**\n * Handle one message-event payload from a sandboxed remote view. DOM- and\n * framework-free: the caller owns the `message` listener (and MUST verify\n * `event.source === iframe.contentWindow` before calling), `reply` posts the\n * response back into the iframe (targetOrigin `\"*\"` — the sandboxed document's\n * origin is opaque, so nothing else can match). Returns true when the payload\n * was a remote-view request for this slug (callers ignore everything else).\n */\nexport async function handleRemoteViewMessage(data: unknown, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<boolean> {\n if (typeof data !== \"object\" || data === null) return false;\n const msg = data as {\n type?: unknown;\n slug?: unknown;\n requestId?: unknown;\n offset?: unknown;\n limit?: unknown;\n fields?: unknown;\n op?: unknown;\n id?: unknown;\n patch?: unknown;\n prompt?: unknown;\n role?: unknown;\n };\n if (msg.slug !== handlers.slug) return false;\n if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {\n const prompt = typeof msg.prompt === \"string\" ? msg.prompt.trim() : \"\";\n if (prompt) handlers.onStartChat?.(prompt, typeof msg.role === \"string\" ? msg.role : undefined);\n return true;\n }\n if (msg.type === REMOTE_VIEW_MESSAGES.mutate && typeof msg.requestId === \"string\") {\n await answerMutate(msg.requestId, msg, handlers, reply);\n return true;\n }\n if (msg.type !== REMOTE_VIEW_MESSAGES.getItems || typeof msg.requestId !== \"string\") return false;\n const request: RemoteViewPageRequest = { offset: clampOffset(msg.offset), limit: clampLimit(msg.limit), fields: normalizeFields(msg.fields) };\n await answerGetItems(msg.requestId, request, handlers, reply);\n return true;\n}\n"],"mappings":";;;;;;AA4BA,IAAa,uBAAuB;;;;AAKpC,IAAa,uBAAuB;;CAElC,UAAU;;CAEV,OAAO;;CAEP,QAAQ;;CAER,cAAc;;CAEd,WAAW;AACb;;;;AAKA,IAAa,qBAAqB;AAClC,IAAa,iBAAiB;;;AAI9B,IAAa,wBAAwB;;;;;;AAOrC,IAAa,8BAA8B;;;AAI3C,IAAa,yBAAyB;;;AAItC,IAAM,uBAAuB;AAQ7B,IAAa,+BAAkD;CAC7D;CACA;CACA;CACA;CACA;CAEA;AACF;AAEA,IAAM,SAAS,UAAkC;CAC/C,MAAM,MAAM,OAAO,UAAU,WAAW,QAAQ,OAAO,UAAU,WAAW,OAAO,KAAK,IAAI;CAC5F,OAAO,OAAO,SAAS,GAAG,IAAI,KAAK,MAAM,GAAG,IAAI;AAClD;;AAGA,IAAa,eAAe,UAA2B,KAAK,IAAI,GAAG,MAAM,KAAK,KAAK,CAAC;;AAGpF,IAAa,cAAc,UAA2B;CACpD,MAAM,MAAM,MAAM,KAAK;CACvB,IAAI,QAAQ,QAAQ,OAAO,GAAG,OAAA;CAC9B,OAAO,KAAK,IAAI,KAAA,GAAmB;AACrC;;;AAIA,IAAa,qBAAqB,UAA2B;CAC3D,MAAM,MAAM,MAAM,KAAK;CACvB,IAAI,QAAQ,QAAQ,OAAO,GAAG,OAAA;CAC9B,OAAO,KAAK,IAAI,KAAK,IAAI,KAAK,EAAE,GAAG,IAAI;AACzC;;AAGA,IAAa,mBAAmB,UAAyC;CACvE,IAAI,CAAC,MAAM,QAAQ,KAAK,GAAG,OAAO,KAAA;CAClC,MAAM,UAAU,MACb,QAAQ,UAA2B,OAAO,UAAU,QAAQ,CAAC,CAC7D,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,CAC5B,QAAQ,UAAU,MAAM,SAAS,CAAC;CACrC,OAAO,QAAQ,SAAS,IAAI,UAAU,KAAA;AACxC;;;;;;;AA6BA,SAAgB,aAAa,OAAyB,QAA8B,YAAsC;CACxH,IAAI,CAAC,UAAU,OAAO,WAAW,GAAG,OAAO;CAC3C,OAAO,oBAAoB,OAAO,QAAQ,UAAU;AACtD;;;;AAKA,SAAgB,cAAc,OAAyB,SAAgC,YAAoC;CAEzH,OAAO;EAAE,OAAO,aADE,MAAM,MAAM,QAAQ,QAAQ,QAAQ,SAAS,QAAQ,KAC1C,GAAW,QAAQ,QAAQ,UAAU;EAAG,OAAO,MAAM;EAAQ,QAAQ,QAAQ;EAAQ,OAAO,QAAQ;CAAM;AACzI;;;;;;;;;;;;AAaA,SAAgB,mBAAmB,OAA0B,8BAAsC;CACjG,MAAM,UAAU,KAAK,KAAK,GAAG;CAC7B,OAAO;EACL;EACA,8BAA8B;EAC9B,6BAA6B;EAC7B,YAAY;EACZ,WAAW,QAAQ;EACnB;EACA;CACF,CAAC,CAAC,KAAK,IAAI;AACb;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAS,sBAA8B;CACrC,OAAO,4KAA4K,qBAAqB,MAAM,eAAe,qBAAqB,aAAa,2HAA2H,qBAAqB,MAAM,uRAAuR,qBAAqB,iQAAiQ,qBAAqB,SAAS,4HAA4H,qBAAqB,OAAO,wHAAwH,qBAAqB,OAAO,+MAA+M,qBAAqB,UAAU;AAC5/C;;;;;AAqBA,SAAgB,sBAAsB,MAAc,MAA8B;CAYhF,MAAM,YAAY,GAAG,uDAXkD,mBAAmB,EAAE,IAW/D,2BARhB,KAAK,UAAU;EAC1B,MAAM,KAAK;EACX,QAAQ,KAAK,UAAU;EACvB,MAAM,KAAK,QAAQ,CAAC;EACpB,QAAQ;EACR,UAAA;EACA,UAAU,KAAK,YAAY;CAC7B,CAAC,CAAC,CAAC,QAAQ,MAAM,SACuC,EAAK,GAAG,oBAAoB,EAAE;CACtF,IAAI,iBAAiB,KAAK,IAAI,GAC5B,OAAO,KAAK,QAAQ,oBAAoB,KAAK,WAAW;CAE1D,OAAO,8BAA8B,UAAU,eAAe,KAAK;AACrE;;;;AAiCA,SAAgB,gBAAgB,MAAuF;CACrH,MAAM,SAAS,OAAO,KAAK,OAAO,WAAW,KAAK,KAAK,OAAO,KAAK,OAAO,YAAY,OAAO,SAAS,KAAK,EAAE,IAAI,OAAO,KAAK,EAAE,IAAI;CACnI,IAAI,CAAC,QAAQ,OAAO;CACpB,IAAI,KAAK,OAAO,UAAU,OAAO;EAAE,IAAI;EAAU,IAAI;CAAO;CAC5D,IAAI,KAAK,OAAO,UAAU;EACxB,IAAI,OAAO,KAAK,UAAU,YAAY,KAAK,UAAU,QAAQ,MAAM,QAAQ,KAAK,KAAK,GAAG,OAAO;EAC/F,OAAO;GAAE,IAAI;GAAU,IAAI;GAAQ,OAAO,KAAK;EAAiC;CAClF;CACA,OAAO;AACT;AAEA,eAAe,eAAe,WAAmB,SAAgC,UAAoC,OAAuC;CAC1J,IAAI;EACF,MAAM,OAAO,MAAM,SAAS,QAAQ,OAAO;EAC3C,MAAM;GAAE,MAAM,qBAAqB;GAAO;GAAW,IAAI;GAAM;EAAK,CAAC;CACvE,SAAS,KAAK;EACZ,MAAM;GAAE,MAAM,qBAAqB;GAAO;GAAW,IAAI;GAAO,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;EAAE,CAAC;CAC3H;AACF;;;;AAKA,eAAe,aACb,WACA,KACA,UACA,OACe;CACf,MAAM,UAAU,gBAAgB,GAAG;CACnC,IAAI,CAAC,SAAS;EACZ,MAAM;GAAE,MAAM,qBAAqB;GAAc;GAAW,IAAI;GAAO,OAAO;EAAyB,CAAC;EACxG;CACF;CACA,IAAI,CAAC,SAAS,UAAU;EACtB,MAAM;GAAE,MAAM,qBAAqB;GAAc;GAAW,IAAI;GAAO,OAAO;EAAyB,CAAC;EACxG;CACF;CACA,IAAI;EACF,MAAM,SAAS,MAAM,SAAS,SAAS,OAAO;EAC9C,MAAM;GAAE,MAAM,qBAAqB;GAAc;GAAW,IAAI;GAAM;EAAO,CAAC;CAChF,SAAS,KAAK;EACZ,MAAM;GAAE,MAAM,qBAAqB;GAAc;GAAW,IAAI;GAAO,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;EAAE,CAAC;CAClI;AACF;;;;;;;;;AAYA,eAAsB,wBAAwB,MAAe,UAAoC,OAA0C;CACzI,IAAI,OAAO,SAAS,YAAY,SAAS,MAAM,OAAO;CACtD,MAAM,MAAM;CAaZ,IAAI,IAAI,SAAS,SAAS,MAAM,OAAO;CACvC,IAAI,IAAI,SAAS,qBAAqB,WAAW;EAC/C,MAAM,SAAS,OAAO,IAAI,WAAW,WAAW,IAAI,OAAO,KAAK,IAAI;EACpE,IAAI,QAAQ,SAAS,cAAc,QAAQ,OAAO,IAAI,SAAS,WAAW,IAAI,OAAO,KAAA,CAAS;EAC9F,OAAO;CACT;CACA,IAAI,IAAI,SAAS,qBAAqB,UAAU,OAAO,IAAI,cAAc,UAAU;EACjF,MAAM,aAAa,IAAI,WAAW,KAAK,UAAU,KAAK;EACtD,OAAO;CACT;CACA,IAAI,IAAI,SAAS,qBAAqB,YAAY,OAAO,IAAI,cAAc,UAAU,OAAO;CAC5F,MAAM,UAAiC;EAAE,QAAQ,YAAY,IAAI,MAAM;EAAG,OAAO,WAAW,IAAI,KAAK;EAAG,QAAQ,gBAAgB,IAAI,MAAM;CAAE;CAC5I,MAAM,eAAe,IAAI,WAAW,SAAS,UAAU,KAAK;CAC5D,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/remote-view/index.ts"],"sourcesContent":["// The remote custom-view contract (phase 3 — plans/feat-remote-custom-view.md).\n//\n// Browser-safe single source of truth shared by the host server (which wraps\n// the view HTML into a sandboxed srcdoc), the desktop phone-frame preview, and\n// the mulmoserver mobile client (post-publish). A remote view runs on a phone\n// that can reach the internet but NOT the host's localhost, so — unlike the\n// desktop custom view (token + fetch to the view-data route) — its records\n// arrive over an async postMessage bridge owned by the parent page, and its\n// CSP locks `connect-src` to 'none' entirely.\n//\n// BACKWARD COMPATIBILITY — this bridge is a frozen public contract. Remote\n// views are LLM-authored HTML files persisted in users' workspaces\n// (`data/skills/*/views/*.html`), written against\n// `packages/core/assets/helps/custom-view-remote.md`; they cannot be\n// migrated centrally and must keep working across host upgrades and any\n// storage-virtualization work underneath. Evolve only by backward-compatible\n// supersets, the way protocol v2 added the mutate pair: bump\n// `REMOTE_VIEW_PROTOCOL`, add new message types / optional fields — never\n// repurpose an existing message type, change the `getItems` page shape\n// (`{ items, total, offset, limit }`), or tighten limits a shipped view may\n// already rely on.\n\nimport { projectRecordFields } from \"../collection/core/project\";\n\n/** Bump when the bootstrap/message contract changes shape; the bootstrap\n * exposes it as `__MC_VIEW.protocol` so a parent can refuse a stale view.\n * v2 (phase 4) adds the mutate pair below — a backward-compatible superset,\n * so a v1 (read-only) parent still serves get-items/start-chat unchanged. */\nexport const REMOTE_VIEW_PROTOCOL = 2;\n\n/** postMessage types between the sandboxed view and its parent page.\n * `startChat` reuses the desktop custom-view message type on purpose — the\n * desktop parent already understands it. */\nexport const REMOTE_VIEW_MESSAGES = {\n /** view → parent: request one page of records ({ requestId, offset, limit, fields }). */\n getItems: \"mc-remote-get-items\",\n /** parent → view: the reply ({ requestId, ok, page | error }). */\n items: \"mc-remote-items\",\n /** view → parent: mutate one record ({ requestId, op: \"update\"|\"delete\", id, patch? }). */\n mutate: \"mc-remote-mutate\",\n /** parent → view: the mutate reply ({ requestId, ok, result | error }). */\n mutateResult: \"mc-remote-mutate-result\",\n /** view → parent: open a new chat with a prefilled, NOT auto-sent draft. */\n startChat: \"mc-start-chat\",\n} as const;\n\n/** Pagination defaults — mirrored by the phase-2 record handlers\n * (`server/remoteHost/handlers/collectionPage.ts` imports these) so a view\n * page can never outgrow what the command channel itself serves. */\nexport const DEFAULT_PAGE_LIMIT = 50;\nexport const MAX_PAGE_LIMIT = 200;\n\n/** Hard cap on the wrapped srcdoc: it travels to the phone INSIDE a Firestore\n * command document (1 MiB total), so leave envelope headroom. */\nexport const REMOTE_VIEW_MAX_BYTES = 900_000;\n\n/** Hard cap on ONE `getItems` page (phase 5 — plans/feat-remote-view-images.md).\n * Same 1 MiB command-document envelope as the srcdoc: when a view inlines image\n * fields as `data:` URLs, the host stops inlining once the serialized page would\n * exceed this, leaving the remaining image fields as their original path (which\n * the view renders as a placeholder). Guards the doc-write from ever failing. */\nexport const REMOTE_VIEW_ITEMS_MAX_BYTES = 900_000;\n\n/** Default longest-edge (px) a remote view's inlined image thumbnail is\n * downscaled to; a view may override via `imageMaxEdge`. */\nexport const DEFAULT_IMAGE_MAX_EDGE = 512;\n\n/** In-iframe `getItems` timeout — matches the remote client's `callHost`\n * response timeout so the two layers give up together. */\nconst GET_ITEMS_TIMEOUT_MS = 30_000;\n\n// CDN allowlist for sandboxed LLM-authored HTML (script/style/font loads).\n// Shared with the desktop preview + custom-view CSPs\n// (src/utils/html/previewCsp.ts re-exports it as its default) so the two\n// policies can't drift. Keep the list audited — every entry is a potential\n// supply-chain surface; the hosts here are reputable infrastructure that does\n// not expose per-request logs to third parties.\nexport const SANDBOXED_VIEW_CDN_ALLOWLIST: readonly string[] = [\n \"https://cdn.jsdelivr.net\",\n \"https://unpkg.com\",\n \"https://cdnjs.cloudflare.com\",\n \"https://fonts.googleapis.com\",\n \"https://fonts.gstatic.com\",\n // Plotly's first-party CDN — the LLM defaults to it for Plotly charts.\n \"https://cdn.plot.ly\",\n];\n\nconst toInt = (value: unknown): number | null => {\n const num = typeof value === \"number\" ? value : typeof value === \"string\" ? Number(value) : NaN;\n return Number.isFinite(num) ? Math.floor(num) : null;\n};\n\n/** Coerce a channel/postMessage offset (arrives as untyped JSON) to a non-negative int. */\nexport const clampOffset = (value: unknown): number => Math.max(0, toInt(value) ?? 0);\n\n/** Read a channel/postMessage identifier (a slug, a view id) as a string.\n * Params arrive as untyped JSON, so a caller can send anything; `String(...)`\n * turned an object into the literal \"[object Object]\", which then travelled on\n * as if the caller had asked for a collection by that name — surfacing as\n * `collection '[object Object]' not found` rather than a bad-request. A value\n * with no string form is simply absent. */\nexport const readIdParam = (value: unknown): string => (typeof value === \"string\" ? value : \"\");\n\n/** Coerce a channel/postMessage limit to [1, MAX_PAGE_LIMIT] (default 50). */\nexport const clampLimit = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_PAGE_LIMIT;\n return Math.min(num, MAX_PAGE_LIMIT);\n};\n\n/** Clamp an `imageMaxEdge` (arrives as untyped schema/JSON) to [64, 1024];\n * default 512. Keeps a runaway edge from defeating the thumbnail's purpose. */\nexport const clampImageMaxEdge = (value: unknown): number => {\n const num = toInt(value);\n if (num === null || num <= 0) return DEFAULT_IMAGE_MAX_EDGE;\n return Math.min(Math.max(num, 64), 1024);\n};\n\n/** Coerce a `fields` projection list from untyped message JSON. */\nexport const normalizeFields = (value: unknown): string[] | undefined => {\n if (!Array.isArray(value)) return undefined;\n const cleaned = value\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0);\n return cleaned.length > 0 ? cleaned : undefined;\n};\n\nexport type RemoteViewItem = Record<string, unknown>;\n\n/** One page of records, the resolved value of the view's `getItems()`. Same\n * shape as the phase-2 `getCollection` page so a parent can pass a channel\n * page straight through. */\nexport interface RemoteViewPage {\n items: RemoteViewItem[];\n total: number;\n offset: number;\n limit: number;\n}\n\n/** A normalized (clamped, fields-cleaned) page request handed to a parent's\n * `getPage` — `handleRemoteViewMessage` does the coercion so every parent\n * answers identical values. */\nexport interface RemoteViewPageRequest {\n offset: number;\n limit: number;\n fields?: string[];\n}\n\n/** Keep only `fields` (+ always the primary key) on each record. Parents apply\n * this uniformly — the desktop preview via `pageFromItems`, the phone parent\n * over the page it fetched through the channel — so a view sees the same\n * projection everywhere. No-op without `fields`; an EMPTY `fields` array is\n * also a no-op here (frozen bridge behavior — the shared helper would\n * project down to the primary key alone). */\nexport function projectItems(items: RemoteViewItem[], fields: string[] | undefined, primaryKey: string): RemoteViewItem[] {\n if (!fields || fields.length === 0) return items;\n return projectRecordFields(items, fields, primaryKey);\n}\n\n/** Answer a page request from an already-loaded record array (the desktop\n * preview's data source): slice + project. Observable behavior matches the\n * phone paging over the command channel. */\nexport function pageFromItems(items: RemoteViewItem[], request: RemoteViewPageRequest, primaryKey: string): RemoteViewPage {\n const pageItems = items.slice(request.offset, request.offset + request.limit);\n return { items: projectItems(pageItems, request.fields, primaryKey), total: items.length, offset: request.offset, limit: request.limit };\n}\n\n/**\n * CSP for a remote (mobile) custom view. Stricter than the desktop custom-view\n * policy: the view's data arrives over postMessage, so `connect-src` is\n * `'none'` — no fetch / XHR / WebSocket / sendBeacon to ANY origin, which\n * closes the bidirectional-exfiltration channel completely (there is no token\n * to steal either). Script/style/font keep the curated CDN allowlist (the\n * phone can reach the internet; only the host is unreachable), and\n * `img-src`/`media-src` allow any `https:` host so record image/media URLs\n * render — the same knowingly-accepted one-way GET-exfil tradeoff as the\n * desktop policy (see buildCustomViewCsp in src/utils/html/previewCsp.ts).\n */\nexport function buildRemoteViewCsp(cdns: readonly string[] = SANDBOXED_VIEW_CDN_ALLOWLIST): string {\n const cdnList = cdns.join(\" \");\n return [\n \"default-src 'none'\",\n `script-src 'unsafe-inline' ${cdnList}`,\n `style-src 'unsafe-inline' ${cdnList}`,\n `font-src ${cdnList}`,\n `img-src ${cdnList} data: blob: https:`,\n \"media-src https: data: blob:\",\n \"connect-src 'none'\",\n ].join(\"; \");\n}\n\n/** The in-iframe bootstrap installed before any of the view's own scripts.\n * Owns the fiddly part of the contract — request/response correlation — so an\n * LLM-authored view only ever awaits `__MC_VIEW.getItems(...)` /\n * `.updateItem(...)` / `.deleteItem(...)`:\n *\n * - `getItems({ offset, limit, fields })`: posts an `mc-remote-get-items`\n * with a fresh `requestId`, resolves on the matching `mc-remote-items`\n * reply (validated to come from `window.parent`), rejects on `ok: false`\n * or after 30 s. targetOrigin `'*'` is safe: the request carries no secret\n * and the parent is by construction the party supplying the data.\n * - `updateItem(id, patch)` / `deleteItem(id)` (phase 4): post an\n * `mc-remote-mutate` and resolve on the matching `mc-remote-mutate-result`,\n * sharing the same `call()` correlation as `getItems`. Installed ONLY when\n * the host set `writable` (the view declared `editableFields`/`allowDelete`);\n * otherwise both reject `\"this view is read-only\"` so a mis-declared view\n * fails loudly instead of silently no-op'ing. The HOST still re-derives and\n * enforces the write policy — `writable` only gates the client surface.\n * - `startChat(prompt, role)`: same message type + semantics as the desktop\n * bridge — the parent opens a new chat with `prompt` prefilled as an\n * editable draft, never auto-sent.\n * - `t(key, named)`: the same vue-i18n-compatible dict helper as the desktop\n * bootstrap (named interpolation only), over the host-picked `dict`.\n *\n * Self-contained one-line string (no `<`, no `</script>`, `${}` only for the\n * interpolated constants). */\nfunction remoteViewBootstrap(): string {\n return `(function(){var v=window.__MC_VIEW,seq=0,pend={};window.addEventListener('message',function(e){if(e.source!==window.parent)return;var d=e.data;if(!d)return;if(d.type!=='${REMOTE_VIEW_MESSAGES.items}'&&d.type!=='${REMOTE_VIEW_MESSAGES.mutateResult}')return;var p=pend[d.requestId];if(!p)return;delete pend[d.requestId];clearTimeout(p.timer);if(d.ok)p.resolve(d.type==='${REMOTE_VIEW_MESSAGES.items}'?d.page:d.result);else p.reject(new Error(typeof d.error==='string'?d.error:'request failed'));});function call(type,payload){return new Promise(function(resolve,reject){var id='q'+(++seq);var timer=setTimeout(function(){delete pend[id];reject(new Error(type+' timed out'));},${GET_ITEMS_TIMEOUT_MS});pend[id]={resolve:resolve,reject:reject,timer:timer};var m={type:type,slug:v.slug,requestId:id};for(var k in payload){m[k]=payload[k];}window.parent.postMessage(m,'*');});}v.getItems=function(opts){opts=opts&&typeof opts==='object'?opts:{};return call('${REMOTE_VIEW_MESSAGES.getItems}',{offset:opts.offset,limit:opts.limit,fields:opts.fields});};if(v.writable){v.updateItem=function(id,patch){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'update',id:String(id),patch:patch&&typeof patch==='object'?patch:{}});};v.deleteItem=function(id){return call('${REMOTE_VIEW_MESSAGES.mutate}',{op:'delete',id:String(id)});};}else{v.updateItem=v.deleteItem=function(){return Promise.reject(new Error('this view is read-only'));};}v.startChat=function(prompt,role){window.parent.postMessage({type:'${REMOTE_VIEW_MESSAGES.startChat}',slug:v.slug,prompt:String(prompt),role:typeof role==='string'?role:undefined},'*');};v.dict=v.dict||{};v.t=function(key,named){var s=v.dict[key];if(typeof s!=='string')return typeof key==='string'?key:String(key);if(!named||typeof named!=='object')return s;return s.replace(/\\\\{(\\\\w+)\\\\}/g,function(m,n){var x=named[n];return x==null?m:String(x);});};})();`;\n}\n\n/** What the host injects into `window.__MC_VIEW` — note what is ABSENT\n * compared to the desktop boot: no token, no dataUrl, no origin. */\nexport interface RemoteViewBoot {\n slug: string;\n /** Locale the dict was picked for; empty string when no translations. */\n locale?: string;\n /** Host-picked, locale-filtered flat string map (same contract as the\n * desktop custom-view dict). */\n dict?: Record<string, string>;\n /** True when the view declared a mutable surface (`editableFields` and/or\n * `allowDelete`). Gates the client-side `updateItem`/`deleteItem` install\n * only — the host re-enforces the actual policy on every mutate. */\n writable?: boolean;\n}\n\n/** Wrap a view's HTML into the sandboxed srcdoc: CSP meta + `__MC_VIEW` boot +\n * bridge bootstrap injected at the start of `<head>` (before any view\n * script). Runs HOST-side (`getRemoteView`) so the phone and the desktop\n * preview receive the identical finished artifact. */\nexport function buildRemoteViewSrcdoc(html: string, boot: RemoteViewBoot): string {\n const cspMeta = `<meta http-equiv=\"Content-Security-Policy\" content=\"${buildRemoteViewCsp()}\">`;\n // `<`-escape the JSON so a hostile slug/dict string can't break out of the\n // <script> element (same escape as the desktop srcdoc builder).\n const json = JSON.stringify({\n slug: boot.slug,\n locale: boot.locale ?? \"\",\n dict: boot.dict ?? {},\n target: \"mobile\",\n protocol: REMOTE_VIEW_PROTOCOL,\n writable: boot.writable ?? false,\n }).replace(/</g, \"\\\\u003c\");\n const injection = `${cspMeta}<script>window.__MC_VIEW=${json};${remoteViewBootstrap()}</script>`;\n if (/<head\\b[^>]*>/i.test(html)) {\n return html.replace(/(<head\\b[^>]*>)/i, `$1${injection}`);\n }\n return `<!DOCTYPE html><html><head>${injection}</head><body>${html}</body></html>`;\n}\n\n/** A normalized mutate request handed to a parent's `onMutate`\n * (`handleRemoteViewMessage` validates op/id/patch first). `update` carries a\n * partial record; the HOST decides which keys are actually writable. */\nexport type RemoteViewMutateRequest = { op: \"update\"; id: string; patch: Record<string, unknown> } | { op: \"delete\"; id: string };\n\n/** The resolved value of a mutate: the merged record for an update, the removed\n * id for a delete. Sent back to the view as the `mc-remote-mutate-result`\n * `result`. */\nexport interface RemoteViewMutateResult {\n item?: RemoteViewItem;\n id?: string;\n}\n\n/** What a parent page provides to answer the sandboxed view. Deliberately\n * minimal — exactly the phone runtime's capabilities, nothing more, so the\n * desktop preview can never exceed what works on the phone. */\nexport interface RemoteViewBridgeHandlers {\n slug: string;\n /** Answer one normalized page request (already clamped + fields-cleaned). */\n getPage: (request: RemoteViewPageRequest) => Promise<RemoteViewPage> | RemoteViewPage;\n /** Apply one normalized mutate (update/delete). Omit on a read-only parent —\n * the handler then replies `ok: false, \"this view is read-only\"`. The parent\n * forwards to the host (which enforces the write policy authoritatively). */\n onMutate?: (request: RemoteViewMutateRequest) => Promise<RemoteViewMutateResult> | RemoteViewMutateResult;\n /** Relay a `startChat` draft; omit on a parent without a chat surface. */\n onStartChat?: (prompt: string, role?: string) => void;\n}\n\n/** Coerce an untyped `mc-remote-mutate` payload to a normalized request, or\n * null when it is malformed (unknown op, missing id, non-object update patch —\n * the parent then replies with an `\"invalid mutate request\"` error). */\nexport function normalizeMutate(data: { op?: unknown; id?: unknown; patch?: unknown }): RemoteViewMutateRequest | null {\n const itemId = typeof data.id === \"string\" ? data.id : typeof data.id === \"number\" && Number.isFinite(data.id) ? String(data.id) : \"\";\n if (!itemId) return null;\n if (data.op === \"delete\") return { op: \"delete\", id: itemId };\n if (data.op === \"update\") {\n if (typeof data.patch !== \"object\" || data.patch === null || Array.isArray(data.patch)) return null;\n return { op: \"update\", id: itemId, patch: data.patch as Record<string, unknown> };\n }\n return null;\n}\n\nasync function answerGetItems(requestId: string, request: RemoteViewPageRequest, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<void> {\n try {\n const page = await handlers.getPage(request);\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: true, page });\n } catch (err) {\n reply({ type: REMOTE_VIEW_MESSAGES.items, requestId, ok: false, error: err instanceof Error ? err.message : String(err) });\n }\n}\n\n/** Validate + dispatch a `mc-remote-mutate` payload, replying on every path\n * (malformed request, read-only parent, handler success/throw). Split out of\n * `handleRemoteViewMessage` so that function stays under the 20-line limit. */\nasync function answerMutate(\n requestId: string,\n msg: { op?: unknown; id?: unknown; patch?: unknown },\n handlers: RemoteViewBridgeHandlers,\n reply: RemoteViewReply,\n): Promise<void> {\n const request = normalizeMutate(msg);\n if (!request) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"invalid mutate request\" });\n return;\n }\n if (!handlers.onMutate) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: \"this view is read-only\" });\n return;\n }\n try {\n const result = await handlers.onMutate(request);\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: true, result });\n } catch (err) {\n reply({ type: REMOTE_VIEW_MESSAGES.mutateResult, requestId, ok: false, error: err instanceof Error ? err.message : String(err) });\n }\n}\n\ntype RemoteViewReply = (message: Record<string, unknown>) => void;\n\n/**\n * Handle one message-event payload from a sandboxed remote view. DOM- and\n * framework-free: the caller owns the `message` listener (and MUST verify\n * `event.source === iframe.contentWindow` before calling), `reply` posts the\n * response back into the iframe (targetOrigin `\"*\"` — the sandboxed document's\n * origin is opaque, so nothing else can match). Returns true when the payload\n * was a remote-view request for this slug (callers ignore everything else).\n */\nexport async function handleRemoteViewMessage(data: unknown, handlers: RemoteViewBridgeHandlers, reply: RemoteViewReply): Promise<boolean> {\n if (typeof data !== \"object\" || data === null) return false;\n const msg = data as {\n type?: unknown;\n slug?: unknown;\n requestId?: unknown;\n offset?: unknown;\n limit?: unknown;\n fields?: unknown;\n op?: unknown;\n id?: unknown;\n patch?: unknown;\n prompt?: unknown;\n role?: unknown;\n };\n if (msg.slug !== handlers.slug) return false;\n if (msg.type === REMOTE_VIEW_MESSAGES.startChat) {\n const prompt = typeof msg.prompt === \"string\" ? msg.prompt.trim() : \"\";\n if (prompt) handlers.onStartChat?.(prompt, typeof msg.role === \"string\" ? msg.role : undefined);\n return true;\n }\n if (msg.type === REMOTE_VIEW_MESSAGES.mutate && typeof msg.requestId === \"string\") {\n await answerMutate(msg.requestId, msg, handlers, reply);\n return true;\n }\n if (msg.type !== REMOTE_VIEW_MESSAGES.getItems || typeof msg.requestId !== \"string\") return false;\n const request: RemoteViewPageRequest = { offset: clampOffset(msg.offset), limit: clampLimit(msg.limit), fields: normalizeFields(msg.fields) };\n await answerGetItems(msg.requestId, request, handlers, reply);\n return true;\n}\n"],"mappings":"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"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const require_rolldown_runtime = require("./rolldown-runtime-D6vf50IK.cjs");
|
|
2
2
|
const require_errors = require("./errors-7P5eMOSX.cjs");
|
|
3
3
|
const require_ids = require("./ids-DWmHjm17.cjs");
|
|
4
|
-
const require_promptSafety = require("./promptSafety-
|
|
5
|
-
const require_discovery = require("./discovery-
|
|
4
|
+
const require_promptSafety = require("./promptSafety-BFt2g_wn.cjs");
|
|
5
|
+
const require_discovery = require("./discovery-Bklck7Ck.cjs");
|
|
6
6
|
const require_skill_bridge_index = require("./skill-bridge/index.cjs");
|
|
7
7
|
let node_path = require("node:path");
|
|
8
8
|
node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
@@ -150,7 +150,7 @@ function toRefRecords(linked) {
|
|
|
150
150
|
function projectBacklinks(field, schema, enriched, linked) {
|
|
151
151
|
const source = linked[field.from];
|
|
152
152
|
if (!source) return [];
|
|
153
|
-
return require_promptSafety.backlinkRows(field,
|
|
153
|
+
return require_promptSafety.backlinkRows(field, require_ids.fieldText(enriched[schema.primaryKey]), Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
|
|
154
154
|
}
|
|
155
155
|
/** Project the computed (never-stored) field kinds onto one derived
|
|
156
156
|
* record: `toggle` → boolean off its enum, `embed` → the target record
|
|
@@ -180,7 +180,7 @@ function projectRollups(schema, record, linked) {
|
|
|
180
180
|
if (field.type !== "rollup") continue;
|
|
181
181
|
if (out === record) out = { ...record };
|
|
182
182
|
const source = linked[field.from];
|
|
183
|
-
const selfId =
|
|
183
|
+
const selfId = require_ids.fieldText(record[schema.primaryKey]);
|
|
184
184
|
out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
|
|
185
185
|
}
|
|
186
186
|
return out;
|
|
@@ -351,7 +351,7 @@ async function validateStoreRecords(collection, opts) {
|
|
|
351
351
|
const issues = [];
|
|
352
352
|
for (const item of items) {
|
|
353
353
|
if (issues.length >= MAX_ISSUES) break;
|
|
354
|
-
const itemId =
|
|
354
|
+
const itemId = require_ids.fieldText(item[collection.schema.primaryKey]);
|
|
355
355
|
const problem = validateRecordObject(item, itemId, collection.schema, "strict");
|
|
356
356
|
if (problem) issues.push({
|
|
357
357
|
file: itemId,
|
|
@@ -398,6 +398,15 @@ async function inspectRecord(fullPath, name, schema) {
|
|
|
398
398
|
problem
|
|
399
399
|
} : null;
|
|
400
400
|
}
|
|
401
|
+
/** What a non-string primary key actually is, for the error message. Names the
|
|
402
|
+
* shape rather than stringifying the value — "[object Object]" tells the reader
|
|
403
|
+
* nothing about what is wrong. */
|
|
404
|
+
function describeIdType(value) {
|
|
405
|
+
if (value === null) return "null";
|
|
406
|
+
if (value === void 0) return "missing";
|
|
407
|
+
if (Array.isArray(value)) return "an array";
|
|
408
|
+
return `a ${typeof value}`;
|
|
409
|
+
}
|
|
401
410
|
/** First schema problem on an in-memory record (primaryKey↔id mismatch,
|
|
402
411
|
* then the compiled per-field checks — see `../core/recordZ` for the two
|
|
403
412
|
* tiers), or null when it's fine. One issue per record keeps the report
|
|
@@ -409,7 +418,8 @@ async function inspectRecord(fullPath, name, schema) {
|
|
|
409
418
|
* report-only surfaces. */
|
|
410
419
|
function validateRecordObject(record, itemId, schema, tier = "enforced") {
|
|
411
420
|
const idValue = record[schema.primaryKey];
|
|
412
|
-
if (typeof idValue !== "string"
|
|
421
|
+
if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
422
|
+
if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
413
423
|
return firstRecordProblem(record, schema, tier);
|
|
414
424
|
}
|
|
415
425
|
//#endregion
|
|
@@ -535,7 +545,8 @@ async function countRecordFiles(dataDir, workspaceRoot) {
|
|
|
535
545
|
async function countRecords(collection, workspaceRoot) {
|
|
536
546
|
if (require_ids.storageKindFor(collection.schema) !== "file") try {
|
|
537
547
|
return (await require_discovery.storeFor(collection, { workspaceRoot }).page({ limit: 0 })).total;
|
|
538
|
-
} catch {
|
|
548
|
+
} catch (err) {
|
|
549
|
+
if (require_discovery.isBackendUnavailable(err)) return null;
|
|
539
550
|
return 0;
|
|
540
551
|
}
|
|
541
552
|
return countRecordFiles(collection.dataDir, workspaceRoot);
|
|
@@ -577,7 +588,7 @@ function buildRecordsById(items, primaryKey) {
|
|
|
577
588
|
* ties pick a different record — and thus a different icon — between
|
|
578
589
|
* reconciles. A stable id sort pins one answer. */
|
|
579
590
|
function sortByPrimaryKey(items, primaryKey) {
|
|
580
|
-
return [...items].sort((left, right) =>
|
|
591
|
+
return [...items].sort((left, right) => require_ids.fieldText(left[primaryKey]).localeCompare(require_ids.fieldText(right[primaryKey])));
|
|
581
592
|
}
|
|
582
593
|
/** Compute the effective launcher icon for `collection`: its static
|
|
583
594
|
* `schema.icon` when it declares no `dynamicIcon`, else the icon
|
|
@@ -713,7 +724,8 @@ function successorId(sourceId, next) {
|
|
|
713
724
|
function matchesWhen(when, schema, item) {
|
|
714
725
|
if (when) {
|
|
715
726
|
const raw = item[when.field];
|
|
716
|
-
|
|
727
|
+
const text = require_ids.fieldTextOrNull(raw);
|
|
728
|
+
return text !== null && when.in.includes(text);
|
|
717
729
|
}
|
|
718
730
|
return require_promptSafety.itemIsDone(schema, item);
|
|
719
731
|
}
|
|
@@ -727,7 +739,8 @@ function resolveEvery(every, sourceItem) {
|
|
|
727
739
|
if (!require_ids.isFieldDrivenEvery(every)) return every;
|
|
728
740
|
const raw = sourceItem[every.fromField];
|
|
729
741
|
if (raw === void 0 || raw === null || raw === "") return null;
|
|
730
|
-
|
|
742
|
+
const key = require_ids.fieldTextOrNull(raw);
|
|
743
|
+
return key === null ? null : every.map[key] ?? null;
|
|
731
744
|
}
|
|
732
745
|
/** Build the successor record purely from (schema, source record, source
|
|
733
746
|
* id). Returns null when the schema has no spawn/triggerField or the
|
|
@@ -1114,6 +1127,151 @@ async function deleteCustomView(collection, viewId, opts = {}) {
|
|
|
1114
1127
|
viewId
|
|
1115
1128
|
};
|
|
1116
1129
|
}
|
|
1130
|
+
/** Ceiling for ANY assembled reply — topic or default — safely inside
|
|
1131
|
+
* the agent's per-result limit (the full doc is what overflowed it).
|
|
1132
|
+
* A matched section too large to fit degrades to its own prose plus a
|
|
1133
|
+
* list of its subsections to fetch individually; an oversized leaf is
|
|
1134
|
+
* clipped outright; the default drops whole core sections into a
|
|
1135
|
+
* pointer note. Sized so the CURRENT bundled doc's default reply
|
|
1136
|
+
* (~33KB) fits without degrading. */
|
|
1137
|
+
var SCHEMA_DOCS_RESULT_BUDGET = 36e3;
|
|
1138
|
+
/** Hard truncation for a body no sectioning trick can shrink further.
|
|
1139
|
+
* The marker tells the agent it did NOT see everything and how to get
|
|
1140
|
+
* the rest, so a silent cut can't read as complete coverage. The limit
|
|
1141
|
+
* is clamped at zero: a negative limit would make `slice(0, limit)`
|
|
1142
|
+
* count from the END and return almost the whole body — the exact
|
|
1143
|
+
* overflow this function exists to prevent. */
|
|
1144
|
+
function clip(text, limit) {
|
|
1145
|
+
const max = Math.max(0, limit);
|
|
1146
|
+
if (text.length <= max) return text;
|
|
1147
|
+
return `${text.slice(0, max)}\n\n[…clipped ${text.length - max} chars — fetch a narrower \`topic\`, or read the doc file directly]`;
|
|
1148
|
+
}
|
|
1149
|
+
/** Slack a budget-spending assembler must reserve for what it adds
|
|
1150
|
+
* AROUND the budgeted pieces — clip markers, join separators, pointer
|
|
1151
|
+
* notes — so the final hard cap doesn't eat the reply's own tail (in
|
|
1152
|
+
* renderDefault's case, the TOC). */
|
|
1153
|
+
var ASSEMBLY_RESERVE = 500;
|
|
1154
|
+
/** The sections every schema author needs, matched against headings so
|
|
1155
|
+
* the doc can be reorganised without touching this list (an unmatched
|
|
1156
|
+
* pattern is simply skipped): what a collection IS, the schema DSL and
|
|
1157
|
+
* its field types, and the create/edit walkthroughs. Advanced sections
|
|
1158
|
+
* (actions, bells, views, dataSource, storage) stay TOC-only. */
|
|
1159
|
+
var CORE_SECTION_PATTERNS = [
|
|
1160
|
+
"anatomy",
|
|
1161
|
+
"skill.md",
|
|
1162
|
+
"the dsl",
|
|
1163
|
+
"field types",
|
|
1164
|
+
"end-to-end",
|
|
1165
|
+
"editing an existing"
|
|
1166
|
+
];
|
|
1167
|
+
/** Backticks stripped + lowercased, so `topic: "dataSource"` matches the
|
|
1168
|
+
* heading "External data (CSV) collections — `dataSource`". */
|
|
1169
|
+
var normalize = (text) => text.toLowerCase().replace(/`/g, "");
|
|
1170
|
+
var sliceLines = (lines, from, until) => lines.slice(from, until).join("\n").trim();
|
|
1171
|
+
/** All `#`–`###` headings, skipping fenced code blocks (a `# comment`
|
|
1172
|
+
* inside an example must not become a section boundary). */
|
|
1173
|
+
function headingLines(lines) {
|
|
1174
|
+
const found = [];
|
|
1175
|
+
let fenced = false;
|
|
1176
|
+
lines.forEach((line, index) => {
|
|
1177
|
+
if (line.trimStart().startsWith("```")) fenced = !fenced;
|
|
1178
|
+
const match = fenced ? null : /^(#{1,3}) (.+)$/.exec(line);
|
|
1179
|
+
if (match) found.push({
|
|
1180
|
+
index,
|
|
1181
|
+
level: match[1].length,
|
|
1182
|
+
heading: match[2].trim()
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
return found;
|
|
1186
|
+
}
|
|
1187
|
+
function parseSections(lines) {
|
|
1188
|
+
const heads = headingLines(lines);
|
|
1189
|
+
return heads.map((head, i) => {
|
|
1190
|
+
const closer = heads.slice(i + 1).find((other) => other.level <= head.level);
|
|
1191
|
+
return {
|
|
1192
|
+
level: head.level,
|
|
1193
|
+
heading: head.heading,
|
|
1194
|
+
start: head.index,
|
|
1195
|
+
ownEnd: heads[i + 1]?.index ?? lines.length,
|
|
1196
|
+
deepEnd: closer?.index ?? lines.length
|
|
1197
|
+
};
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
/** Bounded to half the reply budget: every render path appends the TOC,
|
|
1201
|
+
* so a doc with thousands of headings must not blow the ceiling through
|
|
1202
|
+
* the TOC itself — and renderDefault subtracts the TOC's length from
|
|
1203
|
+
* its budget, which must stay positive. */
|
|
1204
|
+
function tableOfContents(sections) {
|
|
1205
|
+
return clip(`Sections (call schemaDocs with \`topic: "<heading>"\` for any of them; \`topic: "all"\` for the full document):\n${sections.map((section) => `${" ".repeat(section.level - 1)}- ${section.heading}`).join("\n")}`, Math.floor(SCHEMA_DOCS_RESULT_BUDGET / 2));
|
|
1206
|
+
}
|
|
1207
|
+
/** The no-topic reply: the doc's intro + the core authoring sections
|
|
1208
|
+
* (own prose only — a core parent's advanced subsections stay TOC-only),
|
|
1209
|
+
* closed by the full table of contents. Budgeted: a core section that
|
|
1210
|
+
* no longer fits is dropped into a pointer note (the first is clipped
|
|
1211
|
+
* instead, so the reply is never bodiless) — an oversized workspace
|
|
1212
|
+
* copy must not recreate the overflow this module exists to prevent. */
|
|
1213
|
+
function renderDefault(lines, sections) {
|
|
1214
|
+
const isCore = (section, index) => index === 0 || CORE_SECTION_PATTERNS.some((pattern) => normalize(section.heading).includes(pattern));
|
|
1215
|
+
const toc = tableOfContents(sections);
|
|
1216
|
+
const parts = [];
|
|
1217
|
+
const skipped = [];
|
|
1218
|
+
let remaining = SCHEMA_DOCS_RESULT_BUDGET - toc.length - ASSEMBLY_RESERVE;
|
|
1219
|
+
for (const section of sections.filter(isCore)) {
|
|
1220
|
+
const body = sliceLines(lines, section.start, section.ownEnd);
|
|
1221
|
+
if (body.length > remaining && parts.length > 0) {
|
|
1222
|
+
skipped.push(section.heading);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const fitted = clip(body, remaining);
|
|
1226
|
+
parts.push(fitted);
|
|
1227
|
+
remaining -= fitted.length;
|
|
1228
|
+
}
|
|
1229
|
+
const note = skipped.length > 0 ? `\n\n[Omitted for size — fetch each with \`topic\`: ${skipped.join(" · ")}]` : "";
|
|
1230
|
+
return `${parts.join("\n\n")}${note}\n\n---\n\n${toc}`;
|
|
1231
|
+
}
|
|
1232
|
+
/** Case-insensitive substring match on headings, minus any match already
|
|
1233
|
+
* contained in another match's subtree (its parent's deep body covers it). */
|
|
1234
|
+
function matchSections(sections, topic) {
|
|
1235
|
+
const needle = normalize(topic).trim();
|
|
1236
|
+
const matched = sections.filter((section) => normalize(section.heading).includes(needle));
|
|
1237
|
+
return matched.filter((section) => !matched.some((other) => other !== section && other.start < section.start && section.deepEnd <= other.deepEnd));
|
|
1238
|
+
}
|
|
1239
|
+
/** One matched section: its whole subtree when that fits the budget,
|
|
1240
|
+
* otherwise its own prose (budget-clipped) + a pointer list of
|
|
1241
|
+
* subsections to fetch — the list clipped to what the prose left over
|
|
1242
|
+
* (thousands of child headings must not overflow through the list
|
|
1243
|
+
* itself) — and a leaf with no subsections to offer is simply clipped. */
|
|
1244
|
+
function renderSection(lines, sections, section, budget) {
|
|
1245
|
+
const deep = sliceLines(lines, section.start, section.deepEnd);
|
|
1246
|
+
if (deep.length <= budget) return deep;
|
|
1247
|
+
const children = sections.filter((child) => child.start > section.start && child.start < section.deepEnd && child.level === section.level + 1);
|
|
1248
|
+
const own = clip(sliceLines(lines, section.start, section.ownEnd), budget);
|
|
1249
|
+
if (children.length === 0) return own;
|
|
1250
|
+
return `${own}\n\nSubsections (too large to include together — fetch each with \`topic\`):\n${clip(children.map((child) => `- ${child.heading}`).join("\n"), budget - own.length)}`;
|
|
1251
|
+
}
|
|
1252
|
+
function renderTopic(lines, sections, topic) {
|
|
1253
|
+
const matched = matchSections(sections, topic);
|
|
1254
|
+
if (matched.length === 0) return `manageCollection: no schemaDocs section matches '${require_promptSafety.defangForPrompt(topic)}'.\n\n${tableOfContents(sections)}`;
|
|
1255
|
+
const perMatch = Math.floor(SCHEMA_DOCS_RESULT_BUDGET / matched.length);
|
|
1256
|
+
return matched.map((section) => renderSection(lines, sections, section, perMatch)).join("\n\n");
|
|
1257
|
+
}
|
|
1258
|
+
/** Render the authoring reference for one schemaDocs call. Small docs and
|
|
1259
|
+
* docs without headings pass through verbatim — there is nothing better
|
|
1260
|
+
* to key a section off. The assembled reply gets one final hard cap: the
|
|
1261
|
+
* inner budgeting degrades gracefully, but per-piece overheads (clip
|
|
1262
|
+
* markers on thousands of matches, join separators, pointer notes) add
|
|
1263
|
+
* up outside any single piece's budget, and the ceiling must hold no
|
|
1264
|
+
* matter what shape of document arrives. Only the explicit \`"all"\`
|
|
1265
|
+
* opt-in and the small-doc verbatim path may exceed it. */
|
|
1266
|
+
function renderSchemaDocs(doc, topic) {
|
|
1267
|
+
const requested = topic?.trim() ?? "";
|
|
1268
|
+
if (normalize(requested) === "all") return doc;
|
|
1269
|
+
if (doc.length <= 2e4) return doc;
|
|
1270
|
+
const lines = doc.split("\n");
|
|
1271
|
+
const sections = parseSections(lines);
|
|
1272
|
+
if (sections.length === 0) return doc;
|
|
1273
|
+
return clip(requested ? renderTopic(lines, sections, requested) : renderDefault(lines, sections), SCHEMA_DOCS_RESULT_BUDGET);
|
|
1274
|
+
}
|
|
1117
1275
|
/** True for a launcher-managed preset slug (`mc-<something>`). The sync logic uses
|
|
1118
1276
|
* this to bound which active skills it may refresh/prune, so user-authored skills are
|
|
1119
1277
|
* never touched. */
|
|
@@ -1185,7 +1343,10 @@ async function loadRequestedItems(collection, ids, deps) {
|
|
|
1185
1343
|
const items = [];
|
|
1186
1344
|
const missing = [];
|
|
1187
1345
|
for (const recordId of ids) {
|
|
1188
|
-
const item = await store.read(recordId).catch(() =>
|
|
1346
|
+
const item = await store.read(recordId).catch((err) => {
|
|
1347
|
+
if (require_discovery.isBackendUnavailable(err)) throw err;
|
|
1348
|
+
return null;
|
|
1349
|
+
});
|
|
1189
1350
|
if (item) items.push(item);
|
|
1190
1351
|
else missing.push(recordId);
|
|
1191
1352
|
}
|
|
@@ -1246,7 +1407,8 @@ async function mergeWithExisting(collection, store, record, itemId) {
|
|
|
1246
1407
|
let existing;
|
|
1247
1408
|
try {
|
|
1248
1409
|
existing = await store.read(itemId);
|
|
1249
|
-
} catch {
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
if (require_discovery.isBackendUnavailable(err)) throw err;
|
|
1250
1412
|
return `'${itemId}' has a malformed stored file — mode "merge" needs to read it; fix the file (Read → correct → Write) or replace it whole with "upsert"`;
|
|
1251
1413
|
}
|
|
1252
1414
|
if (!existing) return `'${itemId}' not found — mode "merge" updates an existing record; use "upsert" or "create" to add it`;
|
|
@@ -1378,14 +1540,17 @@ async function handleGetOntology(deps) {
|
|
|
1378
1540
|
collections
|
|
1379
1541
|
});
|
|
1380
1542
|
}
|
|
1381
|
-
/** Return the collection-authoring reference (`collection-skills.md`)
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
|
|
1543
|
+
/** Return the collection-authoring reference (`collection-skills.md`),
|
|
1544
|
+
* rendered by `renderSchemaDocs` — the full doc overflows the agent's
|
|
1545
|
+
* per-result limit, so the default reply is the core guide + a table of
|
|
1546
|
+
* contents, and `topic` fetches individual sections. Workspace copy
|
|
1547
|
+
* first (reflects user edits), bundled asset as the always-present
|
|
1548
|
+
* fallback. Both reads guarded; if neither resolves the agent still
|
|
1549
|
+
* gets an actionable message instead of a thrown call. */
|
|
1550
|
+
async function handleSchemaDocs(deps, topic) {
|
|
1386
1551
|
const candidates = [node_path.default.join(resolveBase(deps), HELPS_DIR, SCHEMA_DOCS_FILE), ...deps.bundledHelpsDir ? [node_path.default.join(deps.bundledHelpsDir(), SCHEMA_DOCS_FILE)] : []];
|
|
1387
1552
|
for (const candidate of candidates) try {
|
|
1388
|
-
return await (0, node_fs_promises.readFile)(candidate, "utf-8");
|
|
1553
|
+
return renderSchemaDocs(await (0, node_fs_promises.readFile)(candidate, "utf-8"), topic);
|
|
1389
1554
|
} catch {}
|
|
1390
1555
|
return `manageCollection: could not read the collection-authoring reference (${SCHEMA_DOCS_FILE}).`;
|
|
1391
1556
|
}
|
|
@@ -1464,7 +1629,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
|
|
|
1464
1629
|
written: true
|
|
1465
1630
|
});
|
|
1466
1631
|
}
|
|
1467
|
-
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference,
|
|
1632
|
+
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
1468
1633
|
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
1469
1634
|
async function dispatchGetItems(collection, args, deps) {
|
|
1470
1635
|
const ids = optionalStringArray(args.ids, "ids");
|
|
@@ -1498,8 +1663,16 @@ async function dispatchRecordAction(action, collection, args, deps) {
|
|
|
1498
1663
|
return typeof parsed === "string" ? parsed : handlePutItems(collection, parsed, deps);
|
|
1499
1664
|
}
|
|
1500
1665
|
async function manageCollectionHandler(deps, args) {
|
|
1666
|
+
try {
|
|
1667
|
+
return await dispatchManageCollection(deps, args);
|
|
1668
|
+
} catch (err) {
|
|
1669
|
+
if (require_discovery.isBackendUnavailable(err)) return `manageCollection: ${err.message}`;
|
|
1670
|
+
throw err;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
async function dispatchManageCollection(deps, args) {
|
|
1501
1674
|
const action = typeof args.action === "string" ? args.action : "";
|
|
1502
|
-
if (action === "schemaDocs") return handleSchemaDocs(deps);
|
|
1675
|
+
if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
|
|
1503
1676
|
if (action === "getOntology") return handleGetOntology(deps);
|
|
1504
1677
|
const slug = typeof args.slug === "string" ? args.slug.trim() : "";
|
|
1505
1678
|
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
@@ -1512,7 +1685,7 @@ async function manageCollectionHandler(deps, args) {
|
|
|
1512
1685
|
}
|
|
1513
1686
|
var MANAGE_COLLECTION_DEFINITION = {
|
|
1514
1687
|
name: "manageCollection",
|
|
1515
|
-
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference; getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
|
|
1688
|
+
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
|
|
1516
1689
|
inputSchema: {
|
|
1517
1690
|
type: "object",
|
|
1518
1691
|
properties: {
|
|
@@ -1562,6 +1735,10 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
1562
1735
|
type: "object",
|
|
1563
1736
|
description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
|
|
1564
1737
|
},
|
|
1738
|
+
topic: {
|
|
1739
|
+
type: "string",
|
|
1740
|
+
description: "schemaDocs: fetch one section of the reference by heading (case-insensitive substring — e.g. \"field types\", \"kanban\", \"calendar\", \"dataSource\"). Omit for the core authoring guide plus a table of contents of every section; \"all\" returns the full document (large — it can exceed your tool-result limit)."
|
|
1741
|
+
},
|
|
1565
1742
|
query: {
|
|
1566
1743
|
type: "object",
|
|
1567
1744
|
description: "queryItems: a structured aggregation query — `{ groupBy?: [\"col\"], aggregates?: { alias: { op: \"count\"|\"sum\"|\"avg\"|\"min\"|\"max\", column? } }, where?: [{ field, op, value }], orderBy?: [{ field, dir? }], limit? }`. At least one of groupBy/aggregates. On a dataSource (CSV) collection it scans the WHOLE file uncapped (unlike getItems); on a file-backed collection it aggregates the ENRICHED records, so computed fields (derived/rollup/toggle) are queryable columns. Use it for counts / sums / averages / group-bys instead of arithmetic over getItems output."
|
|
@@ -1748,4 +1925,4 @@ Object.defineProperty(exports, "validateRecordObject", {
|
|
|
1748
1925
|
}
|
|
1749
1926
|
});
|
|
1750
1927
|
|
|
1751
|
-
//# sourceMappingURL=server-
|
|
1928
|
+
//# sourceMappingURL=server-CghbYZFY.cjs.map
|