pikiloom 0.4.21 → 0.4.23

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 (41) hide show
  1. package/dashboard/dist/assets/{AgentTab-Crg9GEUv.js → AgentTab-hzdSX7gP.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-Dj2eoHWz.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-Do4XzF2X.js → DirBrowser-C4DPPZ1V.js} +1 -1
  4. package/dashboard/dist/assets/ExtensionsTab-BdGHiEnP.js +1 -0
  5. package/dashboard/dist/assets/{IMAccessTab-BX90Aiuq.js → IMAccessTab-C4RlXeCP.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-Dd5twuoE.js → Modal-D22Z00Ux.js} +1 -1
  7. package/dashboard/dist/assets/{Modals--Xl6dsUf.js → Modals-CHXLG4V2.js} +1 -1
  8. package/dashboard/dist/assets/{Select-Bp6SwWv6.js → Select-D4JIewGZ.js} +1 -1
  9. package/dashboard/dist/assets/{SessionPanel-CHaOUor-.js → SessionPanel-Ba93D9jh.js} +1 -1
  10. package/dashboard/dist/assets/{SystemTab-MVpMgFFD.js → SystemTab-M8r-o2KI.js} +1 -1
  11. package/dashboard/dist/assets/index-C02W4e24.js +3 -0
  12. package/dashboard/dist/assets/index-CyCspgtc.css +1 -0
  13. package/dashboard/dist/assets/{index-7pyJSn4B.js → index-p2XCgnzw.js} +17 -17
  14. package/dashboard/dist/assets/{shared-CxR9M3rn.js → shared-DW8iXiGd.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/index.js +1 -1
  17. package/dist/agent/skill-installer.js +95 -0
  18. package/dist/catalog/skill-repos.js +12 -0
  19. package/dist/cli/main.js +31 -1
  20. package/dist/core/secrets/ref.js +8 -0
  21. package/dist/core/secrets/store.js +31 -11
  22. package/dist/dashboard/routes/extensions.js +137 -24
  23. package/dist/dashboard/server.js +27 -64
  24. package/dist/pikichannel/adapter-pikiloom.js +220 -0
  25. package/dist/pikichannel/code.js +35 -0
  26. package/dist/pikichannel/codec.js +50 -0
  27. package/dist/pikichannel/host.js +252 -0
  28. package/dist/pikichannel/protocol.js +105 -0
  29. package/dist/pikichannel/rendezvous-broker.js +114 -0
  30. package/dist/pikichannel/rendezvous-host.js +138 -0
  31. package/dist/pikichannel/server.js +284 -0
  32. package/dist/pikichannel/transport.js +49 -0
  33. package/dist/pikichannel/transports/webrtc-host.js +61 -0
  34. package/dist/pikichannel/transports/webrtc-shared.js +132 -0
  35. package/dist/pikichannel/transports/websocket-host.js +78 -0
  36. package/dist/pikichannel/web/demo.html +246 -0
  37. package/dist/pikichannel/web/sdk.js +361 -0
  38. package/package.json +4 -2
  39. package/dashboard/dist/assets/ExtensionsTab-j77Yqoz1.js +0 -1
  40. package/dashboard/dist/assets/index-BiPI4uFE.js +0 -3
  41. package/dashboard/dist/assets/index-dzfjF9Js.css +0 -1
@@ -9,7 +9,6 @@ import { serveStatic } from '@hono/node-server/serve-static';
9
9
  import path from 'node:path';
10
10
  import fs from 'node:fs';
11
11
  import { exec } from 'node:child_process';
12
- import { WebSocketServer } from 'ws';
13
12
  import configRoutes from './routes/config.js';
14
13
  import agentRoutes, { preloadAgentStatus } from './routes/agents.js';
15
14
  import sessionRoutes from './routes/sessions.js';
@@ -19,67 +18,15 @@ import modelsRoutes from './routes/models.js';
19
18
  import localModelsRoutes from './routes/local-models.js';
20
19
  import { runtime } from './runtime.js';
21
20
  import { registerProcessRuntime } from '../core/process-control.js';
21
+ import { mountPikichannel } from '../pikichannel/server.js';
22
22
  // ---------------------------------------------------------------------------
23
23
  // Constants
24
24
  // ---------------------------------------------------------------------------
25
25
  const DASHBOARD_PORT_RETRY_LIMIT = 10;
26
- const WS_KEEPALIVE_MS = 25_000;
27
- function attachWebSocketServer(httpServer) {
28
- const wss = new WebSocketServer({ noServer: true });
29
- const clients = new Set();
30
- httpServer.on('upgrade', (req, socket, head) => {
31
- const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
32
- if (url.pathname !== '/ws') {
33
- socket.destroy();
34
- return;
35
- }
36
- wss.handleUpgrade(req, socket, head, (ws) => {
37
- wss.emit('connection', ws, req);
38
- });
39
- });
40
- wss.on('connection', (ws) => {
41
- clients.add(ws);
42
- const keepalive = setInterval(() => {
43
- if (ws.readyState === ws.OPEN)
44
- ws.ping();
45
- }, WS_KEEPALIVE_MS);
46
- ws.on('message', (raw) => {
47
- try {
48
- const msg = JSON.parse(String(raw));
49
- if (msg?.type === 'ping') {
50
- ws.send(JSON.stringify({ type: 'pong' }));
51
- }
52
- }
53
- catch { /* ignore malformed messages */ }
54
- });
55
- ws.on('close', () => {
56
- clients.delete(ws);
57
- clearInterval(keepalive);
58
- });
59
- ws.on('error', () => {
60
- clients.delete(ws);
61
- clearInterval(keepalive);
62
- });
63
- });
64
- const onEvent = (event) => {
65
- const data = JSON.stringify(event);
66
- for (const ws of clients) {
67
- if (ws.readyState === ws.OPEN) {
68
- ws.send(data);
69
- }
70
- }
71
- };
72
- runtime.events.on('dashboard-event', onEvent);
73
- const closeAllClients = () => {
74
- runtime.events.off('dashboard-event', onEvent);
75
- for (const ws of clients)
76
- ws.close();
77
- clients.clear();
78
- wss.close();
79
- };
80
- httpServer.on('close', closeAllClients);
81
- return { closeAllClients };
82
- }
26
+ // The dashboard's live push transport is pikichannel (`/pikichannel/ws`) — the
27
+ // same universal channel mobile/web clients use. There is no separate dashboard
28
+ // WebSocket layer anymore; the pikichannel adapter subscribes to the runtime
29
+ // 'dashboard-event' bus directly, so the SPA and remote clients share one stack.
83
30
  // ---------------------------------------------------------------------------
84
31
  // Server
85
32
  // ---------------------------------------------------------------------------
@@ -103,6 +50,17 @@ export async function startDashboard(opts = {}) {
103
50
  app.route('/', cliRoutes);
104
51
  app.route('/', modelsRoutes);
105
52
  app.route('/', localModelsRoutes);
53
+ // -- pikichannel: pluggable agent-session transport (WebSocket + WebRTC) --
54
+ // Mounted BEFORE the static catch-all so its routes (demo page, browser SDK,
55
+ // status) win. Returns a handle whose upgrade router is attached per HTTP
56
+ // server below. Failure here must not block the dashboard, so it is guarded.
57
+ let pikichannel = null;
58
+ try {
59
+ pikichannel = await mountPikichannel(app);
60
+ }
61
+ catch (err) {
62
+ runtime.warn(`[pikichannel] mount failed: ${err?.message || err}`);
63
+ }
106
64
  // -- Static files: serve dashboard build output --
107
65
  // Resolve path relative to this file's location (src/ or dist/)
108
66
  const dashboardRoot = path.resolve(import.meta.dirname, '..', '..', 'dashboard', 'dist');
@@ -149,7 +107,6 @@ export async function startDashboard(opts = {}) {
149
107
  });
150
108
  // -- Process runtime registration --
151
109
  let nodeServer = null;
152
- let wsHandle = null;
153
110
  const RESTART_CLOSE_TIMEOUT_MS = 3000;
154
111
  const unregisterProcessRuntime = registerProcessRuntime({
155
112
  label: 'dashboard',
@@ -158,9 +115,9 @@ export async function startDashboard(opts = {}) {
158
115
  resolve();
159
116
  return;
160
117
  }
161
- // Close all WebSocket clients first — otherwise server.close() hangs
162
- // waiting for persistent connections to end.
163
- wsHandle?.closeAllClients();
118
+ // Tear down pikichannel peers first — otherwise server.close() hangs
119
+ // waiting for the persistent WebSocket/datachannel connections to end.
120
+ pikichannel?.stop();
164
121
  const timer = setTimeout(resolve, RESTART_CLOSE_TIMEOUT_MS);
165
122
  nodeServer.close(() => { clearTimeout(timer); resolve(); });
166
123
  }),
@@ -181,8 +138,14 @@ export async function startDashboard(opts = {}) {
181
138
  // before Hono's request listener consumes the connection.
182
139
  const requestListener = getRequestListener(app.fetch);
183
140
  const server = http.createServer(requestListener);
184
- // Attach WebSocket BEFORE listening ensures upgrade events are captured
185
- wsHandle = attachWebSocketServer(server);
141
+ // Single WebSocket-upgrade dispatcher (attached before listening so no
142
+ // upgrade is missed): pikichannel owns `/pikichannel/*` — its WS data
143
+ // channel and WebRTC signaling. Anything else is rejected.
144
+ server.on('upgrade', (req, socket, head) => {
145
+ if (pikichannel?.handleUpgrade(req, socket, head))
146
+ return;
147
+ socket.destroy();
148
+ });
186
149
  server.listen(port, () => {
187
150
  if (settled)
188
151
  return;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * pikichannel/adapter-pikiloom.ts — the reference {@link SessionSource}.
3
+ *
4
+ * Binds the universal protocol to pikiloom's live runtime *without touching the
5
+ * bot*. It is a peer consumer of the very same surfaces the Web Dashboard uses:
6
+ * - data plane: the `runtime.events` 'dashboard-event' bus (same bus the
7
+ * dashboard WebSocket layer listens to). We must NOT call
8
+ * `bot.onStreamSnapshot` — that is a single-slot callback already owned by
9
+ * the runtime; re-registering it would silently break the dashboard.
10
+ * - control plane: the exported session-control functions
11
+ * (`queueDashboardSessionTask`, `stopSessionTasks`, …) — the same entry
12
+ * points the dashboard REST routes call.
13
+ *
14
+ * The StreamSnapshot → UniversalSnapshot projection lives here, keeping the
15
+ * protocol package free of any pikiloom-internal type.
16
+ */
17
+ import { EventEmitter } from 'node:events';
18
+ import { runtime } from '../dashboard/runtime.js';
19
+ import { queueDashboardSessionTask, stopSessionTasks, steerSessionTask, cancelSessionTask, interactionSelectOption, interactionSubmitText, interactionSkip, interactionCancel, } from '../dashboard/session-control.js';
20
+ import { loadUserConfig } from '../core/config/user-config.js';
21
+ import { VERSION } from '../core/version.js';
22
+ const CAPABILITIES = ['prompt', 'stop', 'steer', 'recall', 'interact', 'subscribe-all', 'artifacts', 'tunnel'];
23
+ function splitKey(sessionKey) {
24
+ const idx = sessionKey.indexOf(':');
25
+ if (idx < 0)
26
+ return { agent: '', sessionId: sessionKey };
27
+ return { agent: sessionKey.slice(0, idx), sessionId: sessionKey.slice(idx + 1) };
28
+ }
29
+ function projectInteractions(snap) {
30
+ if (!snap.interactions?.length)
31
+ return undefined;
32
+ return snap.interactions.map((it) => ({
33
+ promptId: it.promptId,
34
+ kind: it.kind,
35
+ title: it.title,
36
+ hint: it.hint,
37
+ currentIndex: it.currentIndex,
38
+ questions: (it.questions || []).map((q) => ({
39
+ id: String(q.id),
40
+ text: String(q.text ?? q.label ?? ''),
41
+ type: q.type,
42
+ choices: Array.isArray(q.choices)
43
+ ? q.choices.map((c) => ({ label: String(c.label ?? c), description: c.description }))
44
+ : undefined,
45
+ })),
46
+ }));
47
+ }
48
+ /** Strip empty optionals so the projection is lean AND deterministic (the diff
49
+ * baseline must be stable: a field is either consistently present or absent). */
50
+ function compact(obj) {
51
+ for (const k of Object.keys(obj)) {
52
+ const v = obj[k];
53
+ const emptyArr = Array.isArray(v) && v.length === 0;
54
+ const emptyObj = v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0;
55
+ if (v === null || v === undefined || v === '' || emptyArr || emptyObj)
56
+ delete obj[k];
57
+ }
58
+ return obj;
59
+ }
60
+ /** Project pikiloom's StreamSnapshot into the agent-agnostic wire snapshot. */
61
+ export function projectSnapshot(sessionKey, snap) {
62
+ const { agent } = splitKey(sessionKey);
63
+ const meta = snap.previewMeta;
64
+ return compact({
65
+ phase: snap.phase,
66
+ taskId: snap.taskId ?? undefined,
67
+ agent: agent || undefined,
68
+ model: snap.model ?? undefined,
69
+ effort: snap.effort ?? undefined,
70
+ prompt: snap.question ?? undefined,
71
+ text: snap.text || undefined,
72
+ reasoning: snap.thinking || undefined,
73
+ activity: snap.activity || undefined,
74
+ plan: snap.plan
75
+ ? { explanation: snap.plan.explanation, steps: snap.plan.steps.map((s) => ({ text: s.step, status: s.status })) }
76
+ : undefined,
77
+ toolCalls: meta?.toolCalls?.map((t) => ({
78
+ id: t.id, name: t.name, summary: t.summary, input: t.input, result: t.result, status: t.status,
79
+ })),
80
+ subAgents: meta?.subAgents?.map((s) => ({
81
+ id: s.id, kind: s.kind, description: s.description, model: s.model, tools: s.tools, status: s.status,
82
+ })),
83
+ usage: meta
84
+ ? compact({
85
+ inputTokens: meta.inputTokens,
86
+ outputTokens: meta.outputTokens,
87
+ cachedInputTokens: meta.cachedInputTokens,
88
+ contextUsedTokens: meta.contextUsedTokens,
89
+ contextPercent: meta.contextPercent,
90
+ turnOutputTokens: meta.turnOutputTokens,
91
+ providerName: meta.providerName,
92
+ generatingImages: meta.generatingImages,
93
+ })
94
+ : undefined,
95
+ artifacts: snap.artifacts,
96
+ interactions: projectInteractions(snap),
97
+ queued: snap.queuedTasks?.length ? snap.queuedTasks.map((q) => ({ taskId: q.taskId, prompt: q.prompt })) : undefined,
98
+ error: snap.error ?? undefined,
99
+ ...(snap.incomplete ? { incomplete: true } : {}),
100
+ startedAt: snap.startedAt,
101
+ updatedAt: snap.updatedAt,
102
+ });
103
+ }
104
+ function metaFromSnapshot(sessionKey, snap) {
105
+ const title = (snap.prompt || snap.text || '').slice(0, 80) || null;
106
+ return { sessionKey, agent: snap.agent, title, phase: snap.phase, updatedAt: snap.updatedAt };
107
+ }
108
+ /**
109
+ * The pikiloom SessionSource. One instance per process; it attaches to the
110
+ * runtime event bus lazily on first `onUpdate`.
111
+ */
112
+ export class PikiloomSessionSource {
113
+ forwarder;
114
+ seqs = new Map();
115
+ known = new Map();
116
+ bus = new EventEmitter();
117
+ wired = false;
118
+ /** @param forwarder forwards tunneled `/api/*` requests to the HTTP router. */
119
+ constructor(forwarder) {
120
+ this.forwarder = forwarder;
121
+ }
122
+ hostInfo() {
123
+ return { name: 'pikiloom', version: VERSION, capabilities: CAPABILITIES, authRequired: true };
124
+ }
125
+ /** Control-plane HTTP tunnel — delegates to the embedder-supplied forwarder
126
+ * (the dashboard's Hono router). The host has already enforced auth + the
127
+ * `/api/*` allowlist before we get here. */
128
+ async handleRequest(req) {
129
+ if (!this.forwarder)
130
+ return { status: 503, body: 'tunnel forwarder not configured' };
131
+ return this.forwarder(req);
132
+ }
133
+ listSessions() {
134
+ // Most-recent first, bounded so a long-lived host never ships an unbounded
135
+ // list over the wire (remote clients want a recent picker, not all history).
136
+ return Array.from(this.known.values()).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)).slice(0, 50);
137
+ }
138
+ getSnapshot(sessionKey) {
139
+ const bot = runtime.getBotRef();
140
+ const raw = bot?.getStreamSnapshot(sessionKey);
141
+ if (!raw)
142
+ return null;
143
+ return { snapshot: projectSnapshot(sessionKey, raw), seq: this.seqs.get(sessionKey) || 0 };
144
+ }
145
+ onUpdate(cb) {
146
+ this.ensureWired();
147
+ const handler = (k, s, seq) => cb(k, s, seq);
148
+ this.bus.on('update', handler);
149
+ return () => this.bus.off('update', handler);
150
+ }
151
+ onSessionsChanged(cb) {
152
+ this.ensureWired();
153
+ const handler = () => cb(this.listSessions());
154
+ this.bus.on('sessions', handler);
155
+ return () => this.bus.off('sessions', handler);
156
+ }
157
+ /** Subscribe once to the runtime's dashboard-event bus and fan out internally. */
158
+ ensureWired() {
159
+ if (this.wired)
160
+ return;
161
+ this.wired = true;
162
+ runtime.events.on('dashboard-event', (event) => {
163
+ if (event.type === 'stream-update' && event.key && event.snapshot) {
164
+ const snapshot = projectSnapshot(event.key, event.snapshot);
165
+ const seq = (this.seqs.get(event.key) || 0) + 1;
166
+ this.seqs.set(event.key, seq);
167
+ this.known.set(event.key, metaFromSnapshot(event.key, snapshot));
168
+ this.bus.emit('update', event.key, snapshot, seq);
169
+ }
170
+ else if (event.type === 'sessions-changed') {
171
+ this.bus.emit('sessions');
172
+ }
173
+ });
174
+ }
175
+ // -- control plane (delegates to the shared session-control surface) -----
176
+ async prompt(cmd) {
177
+ const config = loadUserConfig();
178
+ const workdir = cmd.workdir || runtime.getRequestWorkdir(config);
179
+ const agent = cmd.agent || runtime.getRuntimeDefaultAgent(config);
180
+ const sessionId = cmd.sessionKey ? splitKey(cmd.sessionKey).sessionId : '';
181
+ const res = await queueDashboardSessionTask({
182
+ workdir,
183
+ agent,
184
+ sessionId,
185
+ prompt: cmd.prompt,
186
+ model: cmd.model,
187
+ effort: cmd.effort,
188
+ workflow: cmd.workflow,
189
+ attachments: cmd.attachments,
190
+ });
191
+ if (!res?.ok)
192
+ return { ok: false, error: res?.error || 'submit failed' };
193
+ return { ok: true, sessionKey: res.sessionKey, taskId: res.taskId };
194
+ }
195
+ stop(sessionKey) {
196
+ const { agent, sessionId } = splitKey(sessionKey);
197
+ const r = stopSessionTasks(agent, sessionId);
198
+ return r?.ok ? { ok: true } : { ok: false, error: r?.error || 'stop failed' };
199
+ }
200
+ async steer(taskId) {
201
+ const r = await steerSessionTask(taskId);
202
+ return r?.ok ? { ok: true } : { ok: false, error: r?.error || 'steer failed' };
203
+ }
204
+ recall(taskId) {
205
+ const r = cancelSessionTask(taskId);
206
+ return r?.ok ? { ok: true } : { ok: false, error: r?.error || 'recall failed' };
207
+ }
208
+ interact(promptId, action, value, requestFreeform) {
209
+ let r;
210
+ if (action === 'select')
211
+ r = interactionSelectOption(promptId, value || '', { requestFreeform });
212
+ else if (action === 'text')
213
+ r = interactionSubmitText(promptId, value || '');
214
+ else if (action === 'skip')
215
+ r = interactionSkip(promptId);
216
+ else
217
+ r = interactionCancel(promptId);
218
+ return r?.ok ? { ok: true } : { ok: false, error: r?.error || 'interaction failed' };
219
+ }
220
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * pikichannel/code.ts — the shareable connection code (server side).
3
+ *
4
+ * One short string a host shows and a client pastes. Same format the browser
5
+ * `endpoint.ts` decodes — keep the keys (h/r/n/t) in lockstep:
6
+ * h = direct host authority r = rendezvous URL n = nodeId t = token
7
+ *
8
+ * `buildServerCode` picks the best reachability for THIS host:
9
+ * - public address set → DIRECT code {h,t} (public-IP server; no NAT hop)
10
+ * - else rendezvous on → REMOTE code {r,n,t} (NAT traversal)
11
+ * - else → none (only reachable locally)
12
+ */
13
+ export function encodeConnectionCode(d) {
14
+ const lean = {};
15
+ if (d.host)
16
+ lean.h = d.host;
17
+ if (d.rendezvous)
18
+ lean.r = d.rendezvous;
19
+ if (d.nodeId)
20
+ lean.n = d.nodeId;
21
+ if (d.token)
22
+ lean.t = d.token;
23
+ return Buffer.from(JSON.stringify(lean), 'utf8').toString('base64url');
24
+ }
25
+ export function buildServerCode(opts) {
26
+ const token = (opts.token || '').trim();
27
+ const publicHost = (opts.publicHost || '').trim();
28
+ const rendezvous = (opts.rendezvous || '').trim();
29
+ const nodeId = (opts.nodeId || '').trim();
30
+ if (publicHost)
31
+ return { mode: 'direct', code: encodeConnectionCode({ host: publicHost, token }), detail: publicHost };
32
+ if (rendezvous && nodeId)
33
+ return { mode: 'remote', code: encodeConnectionCode({ rendezvous, nodeId, token }), detail: rendezvous };
34
+ return { mode: 'none', code: '', detail: '' };
35
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * pikichannel/codec.ts — frame (de)serialization for the L2 protocol.
3
+ *
4
+ * The transport layer (L1) moves opaque frames; this module is the only place
5
+ * that knows how a {@link ServerMessage} / {@link ClientMessage} becomes a frame
6
+ * and back. Today that is newline-free JSON text. The indirection is deliberate:
7
+ * swapping to a binary codec (CBOR / protobuf) later is a change here alone, with
8
+ * the wire-format tag negotiated in the handshake — no transport or host edits.
9
+ */
10
+ export const DEFAULT_WIRE_FORMAT = 'json';
11
+ /** Encode a host→client message into a transport frame. */
12
+ export function encodeServer(msg, _fmt = DEFAULT_WIRE_FORMAT) {
13
+ return JSON.stringify(msg);
14
+ }
15
+ /** Encode a client→host message into a transport frame. */
16
+ export function encodeClient(msg, _fmt = DEFAULT_WIRE_FORMAT) {
17
+ return JSON.stringify(msg);
18
+ }
19
+ /** Coerce a transport frame (string / Buffer / ArrayBuffer) into text. */
20
+ function frameToText(raw) {
21
+ if (typeof raw === 'string')
22
+ return raw;
23
+ if (Buffer.isBuffer(raw))
24
+ return raw.toString('utf8');
25
+ return Buffer.from(new Uint8Array(raw)).toString('utf8');
26
+ }
27
+ /** Decode a client→host frame. Returns null on malformed input (never throws). */
28
+ export function decodeClient(raw, _fmt = DEFAULT_WIRE_FORMAT) {
29
+ try {
30
+ const value = JSON.parse(frameToText(raw));
31
+ if (!value || typeof value !== 'object' || typeof value.type !== 'string')
32
+ return null;
33
+ return value;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** Decode a host→client frame. Returns null on malformed input (never throws). */
40
+ export function decodeServer(raw, _fmt = DEFAULT_WIRE_FORMAT) {
41
+ try {
42
+ const value = JSON.parse(frameToText(raw));
43
+ if (!value || typeof value !== 'object' || typeof value.type !== 'string')
44
+ return null;
45
+ return value;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }