clankerbend 0.1.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/DEVELOPERS.md +63 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/apps/README.md +11 -0
- package/apps/sticky-notes/README.md +11 -0
- package/apps/sticky-notes/onewhack.manifest.json +74 -0
- package/apps/sticky-notes/package.json +14 -0
- package/apps/sticky-notes/public/app.js +117 -0
- package/apps/sticky-notes/public/index.html +23 -0
- package/apps/sticky-notes/public/styles.css +84 -0
- package/apps/sticky-notes/src/sticky-notes-app.js +413 -0
- package/apps/vim-nav/README.md +126 -0
- package/apps/vim-nav/onewhack.manifest.json +69 -0
- package/apps/vim-nav/package.json +16 -0
- package/apps/vim-nav/public/app.js +276 -0
- package/apps/vim-nav/public/index.html +53 -0
- package/apps/vim-nav/public/styles.css +211 -0
- package/apps/vim-nav/server.mjs +10 -0
- package/apps/vim-nav/src/vim-nav-app.js +221 -0
- package/assets/onewhack.jpg +0 -0
- package/cli.mjs +91 -0
- package/docs/app-lifecycle.md +63 -0
- package/docs/app-manifest.md +130 -0
- package/docs/author-guide.md +126 -0
- package/docs/launcher-profiles.md +50 -0
- package/docs/protocol.md +1935 -0
- package/host/README.md +18 -0
- package/host/src/app-registry.js +315 -0
- package/host/src/codex-desktop-cdp-adapter.js +1826 -0
- package/host/src/codex-desktop-renderer-bridge.js +3536 -0
- package/host/src/index.js +1177 -0
- package/launch/profiles.mjs +93 -0
- package/launch/runtime-paths.mjs +21 -0
- package/package.json +66 -0
- package/scripts/release-npm.mjs +202 -0
- package/server.mjs +58 -0
|
@@ -0,0 +1,1177 @@
|
|
|
1
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { extname, join, relative, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const ONEWHACK_VERSION = "0.1";
|
|
7
|
+
|
|
8
|
+
export class OneWhackHttpError extends Error {
|
|
9
|
+
constructor(status, code, message, detail) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.detail = detail;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function httpError(status, code, message, detail) {
|
|
18
|
+
return new OneWhackHttpError(status, code, message, detail);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createMockTranscriptAdapter(options = {}) {
|
|
22
|
+
const anchors = options.anchors || [
|
|
23
|
+
mockAnchor("mock-1:user", 1, "user", "What changed in the current branch?"),
|
|
24
|
+
mockAnchor("mock-2:assistant", 2, "assistant", "I inspected the diff and found two changed files."),
|
|
25
|
+
mockAnchor("mock-3:user", 3, "user", "Jump to the latest assistant answer."),
|
|
26
|
+
mockAnchor("mock-4:assistant", 4, "assistant", "Use G to jump to the last visible transcript item.")
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
name: "mock",
|
|
31
|
+
cdp: false,
|
|
32
|
+
rendererInjection: false,
|
|
33
|
+
providers: options.providers || undefined,
|
|
34
|
+
async start(host) {
|
|
35
|
+
host.setDesktopStatus({
|
|
36
|
+
cdpStatus: "connected",
|
|
37
|
+
target: { type: "mock", title: "Mock Codex Desktop", url: "app://-/index.html" },
|
|
38
|
+
error: null
|
|
39
|
+
});
|
|
40
|
+
host.updateTranscript({
|
|
41
|
+
anchors,
|
|
42
|
+
visibleCount: anchors.filter((anchor) => anchor.visible).length,
|
|
43
|
+
annotationCount: anchors.length,
|
|
44
|
+
scroll: { top: 0, height: 1200, clientHeight: 700 },
|
|
45
|
+
updatedAt: new Date().toISOString()
|
|
46
|
+
}, { broadcast: false });
|
|
47
|
+
host.acceptSelection({
|
|
48
|
+
selectionId: "mock-selected",
|
|
49
|
+
source: "adapter",
|
|
50
|
+
appId: options.defaultAppId,
|
|
51
|
+
anchorId: anchors[1]?.anchorId || anchors[0]?.anchorId,
|
|
52
|
+
entryId: anchors[1] ? `nav:${anchors[1].anchorId}` : undefined,
|
|
53
|
+
selectedAt: new Date().toISOString()
|
|
54
|
+
}, { broadcast: false });
|
|
55
|
+
},
|
|
56
|
+
async openPanel() {
|
|
57
|
+
return { ok: true, mode: "focused" };
|
|
58
|
+
},
|
|
59
|
+
async scrollToAnchor(anchorId) {
|
|
60
|
+
return { ok: true, anchorId };
|
|
61
|
+
},
|
|
62
|
+
async highlightAnchor(anchorId) {
|
|
63
|
+
return { ok: true, anchorId };
|
|
64
|
+
},
|
|
65
|
+
async highlightRange(range) {
|
|
66
|
+
return { ok: true, anchorId: range?.anchorId, range };
|
|
67
|
+
},
|
|
68
|
+
async setComposerDraft(draft) {
|
|
69
|
+
return { ok: true, draft };
|
|
70
|
+
},
|
|
71
|
+
async submitComposer(draft) {
|
|
72
|
+
return { ok: true, draft, submitted: true };
|
|
73
|
+
},
|
|
74
|
+
async attachFiles(files) {
|
|
75
|
+
return { ok: true, files, attached: true, mode: "mock" };
|
|
76
|
+
},
|
|
77
|
+
async stop() {}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mockAnchor(anchorId, order, inferredRole, textPreview) {
|
|
82
|
+
return {
|
|
83
|
+
anchorId,
|
|
84
|
+
kind: "content-search-unit",
|
|
85
|
+
visible: true,
|
|
86
|
+
top: order * 120,
|
|
87
|
+
height: 96,
|
|
88
|
+
textPreview,
|
|
89
|
+
order,
|
|
90
|
+
inferredRole
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class OneWhackHost {
|
|
95
|
+
constructor(options = {}) {
|
|
96
|
+
this.protocolVersion = options.protocolVersion || ONEWHACK_VERSION;
|
|
97
|
+
this.hostId = options.hostId || "onewill.onewhack.host";
|
|
98
|
+
this.hostName = options.hostName || "OneWhack Host";
|
|
99
|
+
this.localDevInsecure = options.localDevInsecure === true;
|
|
100
|
+
this.token = this.localDevInsecure ? "" : (isNonEmptyString(options.token) ? options.token : createSessionToken());
|
|
101
|
+
this.runDir = options.runDir || null;
|
|
102
|
+
this.transcriptAdapter = options.transcriptAdapter || createMockTranscriptAdapter();
|
|
103
|
+
this.apps = new Map();
|
|
104
|
+
this.sseClients = new Set();
|
|
105
|
+
this.actionResults = new Map();
|
|
106
|
+
this.server = null;
|
|
107
|
+
this.heartbeat = null;
|
|
108
|
+
|
|
109
|
+
this.state = {
|
|
110
|
+
sequence: 1,
|
|
111
|
+
generatedAt: new Date().toISOString(),
|
|
112
|
+
host: {
|
|
113
|
+
status: "starting",
|
|
114
|
+
hostId: this.hostId,
|
|
115
|
+
url: null,
|
|
116
|
+
launchedAt: new Date().toISOString(),
|
|
117
|
+
error: null
|
|
118
|
+
},
|
|
119
|
+
desktop: {
|
|
120
|
+
cdpStatus: "starting",
|
|
121
|
+
cdpPort: null,
|
|
122
|
+
desktopPid: null,
|
|
123
|
+
target: null,
|
|
124
|
+
error: null
|
|
125
|
+
},
|
|
126
|
+
panel: {
|
|
127
|
+
status: "closed",
|
|
128
|
+
activeAppId: null,
|
|
129
|
+
url: null,
|
|
130
|
+
preferredWidth: null,
|
|
131
|
+
lastOpenedAt: null,
|
|
132
|
+
error: null
|
|
133
|
+
},
|
|
134
|
+
transcript: {
|
|
135
|
+
anchors: [],
|
|
136
|
+
visibleCount: 0,
|
|
137
|
+
annotationCount: 0,
|
|
138
|
+
scroll: null,
|
|
139
|
+
updatedAt: new Date().toISOString()
|
|
140
|
+
},
|
|
141
|
+
selection: null,
|
|
142
|
+
overlay: null,
|
|
143
|
+
composer: {
|
|
144
|
+
contextItems: [],
|
|
145
|
+
attachments: [],
|
|
146
|
+
draft: {
|
|
147
|
+
text: "",
|
|
148
|
+
mode: "replace",
|
|
149
|
+
contextItemIds: [],
|
|
150
|
+
updatedAt: null
|
|
151
|
+
},
|
|
152
|
+
lastSubmittedAt: null
|
|
153
|
+
},
|
|
154
|
+
appServer: {
|
|
155
|
+
status: "disabled",
|
|
156
|
+
pid: null,
|
|
157
|
+
version: null,
|
|
158
|
+
error: "not started"
|
|
159
|
+
},
|
|
160
|
+
lastAction: null
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
registerApp(app) {
|
|
165
|
+
if (!app?.appId) throw new Error("appId is required");
|
|
166
|
+
if (this.apps.has(app.appId)) throw httpError(409, "conflict", `duplicate app id: ${app.appId}`);
|
|
167
|
+
this.apps.set(app.appId, app);
|
|
168
|
+
if (!this.state.panel.activeAppId) {
|
|
169
|
+
this.state.panel.activeAppId = app.appId;
|
|
170
|
+
this.state.panel.preferredWidth = app.panel?.preferredWidth || null;
|
|
171
|
+
}
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setActivePanelApp(appId) {
|
|
176
|
+
const app = this.requireApp(appId);
|
|
177
|
+
this.state.panel.activeAppId = app.appId;
|
|
178
|
+
this.state.panel.preferredWidth = app.panel?.preferredWidth || null;
|
|
179
|
+
this.state.panel.url = this.appEntryUrl(app.appId);
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async start() {
|
|
184
|
+
if (this.runDir) mkdirSync(this.runDir, { recursive: true });
|
|
185
|
+
await this.listen();
|
|
186
|
+
await this.transcriptAdapter.start?.(this);
|
|
187
|
+
this.touchAndBroadcast();
|
|
188
|
+
this.heartbeat = setInterval(() => {
|
|
189
|
+
this.broadcast("heartbeat", {});
|
|
190
|
+
}, 15000);
|
|
191
|
+
this.heartbeat.unref();
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async stop() {
|
|
196
|
+
this.heartbeat?.[Symbol.dispose]?.();
|
|
197
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
198
|
+
await this.transcriptAdapter.stop?.();
|
|
199
|
+
for (const res of this.sseClients) {
|
|
200
|
+
try {
|
|
201
|
+
res.end();
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
this.sseClients.clear();
|
|
205
|
+
if (this.server) {
|
|
206
|
+
await new Promise((resolvePromise) => this.server.close(resolvePromise));
|
|
207
|
+
this.server = null;
|
|
208
|
+
}
|
|
209
|
+
this.state.host.status = "exited";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
listen() {
|
|
213
|
+
this.server = http.createServer((req, res) => {
|
|
214
|
+
this.handleRequest(req, res).catch((err) => {
|
|
215
|
+
this.fail(res, err.status || 500, err.code || "internal_error", err.message, err.detail);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return new Promise((resolvePromise) => {
|
|
220
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
221
|
+
const address = this.server.address();
|
|
222
|
+
this.state.host.url = `http://127.0.0.1:${address.port}`;
|
|
223
|
+
this.state.host.status = "running";
|
|
224
|
+
this.state.panel.url = this.appEntryUrl(this.state.panel.activeAppId);
|
|
225
|
+
if (this.runDir) writeFileSync(resolve(this.runDir, "host-url"), `${this.state.host.url}\n`);
|
|
226
|
+
resolvePromise();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
capabilities() {
|
|
232
|
+
return {
|
|
233
|
+
protocolVersion: this.protocolVersion,
|
|
234
|
+
host: {
|
|
235
|
+
apps: true,
|
|
236
|
+
actions: true,
|
|
237
|
+
appState: true,
|
|
238
|
+
sidePanel: true
|
|
239
|
+
},
|
|
240
|
+
transcript: {
|
|
241
|
+
read: true,
|
|
242
|
+
annotate: true,
|
|
243
|
+
navigate: true,
|
|
244
|
+
select: true,
|
|
245
|
+
rangeSelect: true,
|
|
246
|
+
rangeHighlight: true
|
|
247
|
+
},
|
|
248
|
+
overlay: {
|
|
249
|
+
anchored: true,
|
|
250
|
+
forms: true
|
|
251
|
+
},
|
|
252
|
+
composer: {
|
|
253
|
+
contextItems: true,
|
|
254
|
+
draft: true,
|
|
255
|
+
submit: Boolean(this.transcriptAdapter.submitComposer)
|
|
256
|
+
},
|
|
257
|
+
adapter: {
|
|
258
|
+
name: this.transcriptAdapter.name || "unknown",
|
|
259
|
+
cdp: Boolean(this.transcriptAdapter.cdp),
|
|
260
|
+
rendererInjection: Boolean(this.transcriptAdapter.rendererInjection),
|
|
261
|
+
rendererFetchToLoopback: Boolean(this.transcriptAdapter.rendererFetchToLoopback),
|
|
262
|
+
providers: this.transcriptAdapter.providers || undefined
|
|
263
|
+
},
|
|
264
|
+
appServer: {
|
|
265
|
+
available: this.state.appServer.status === "connected",
|
|
266
|
+
correlateItems: false,
|
|
267
|
+
approvals: false,
|
|
268
|
+
rollback: false
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
appEntryUrl(appId) {
|
|
274
|
+
const app = this.apps.get(appId);
|
|
275
|
+
if (!app || !this.state.host.url) return null;
|
|
276
|
+
const base = `${this.state.host.url.replace(/\/$/, "")}/apps/${encodeURIComponent(app.appId)}/`;
|
|
277
|
+
return this.token ? `${base}#onewhack_token=${encodeURIComponent(this.token)}` : base;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
hostManifest() {
|
|
281
|
+
return {
|
|
282
|
+
oneWhackVersion: this.protocolVersion,
|
|
283
|
+
hostId: this.hostId,
|
|
284
|
+
hostName: this.hostName,
|
|
285
|
+
capabilities: this.capabilities(),
|
|
286
|
+
security: {
|
|
287
|
+
auth: this.token ? "bearer" : "none",
|
|
288
|
+
localDevInsecure: this.localDevInsecure
|
|
289
|
+
},
|
|
290
|
+
apps: [...this.apps.values()].map((app) => this.appManifest(app.appId))
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
appManifest(appId) {
|
|
295
|
+
const app = this.requireApp(appId);
|
|
296
|
+
const loadedManifest = app.manifest || {};
|
|
297
|
+
if (typeof app.getManifest === "function") {
|
|
298
|
+
return cleanObject({
|
|
299
|
+
...loadedManifest,
|
|
300
|
+
...app.getManifest(this.appContext(app)),
|
|
301
|
+
version: app.version || loadedManifest.version,
|
|
302
|
+
distribution: loadedManifest.distribution,
|
|
303
|
+
entrypoint: loadedManifest.entrypoint,
|
|
304
|
+
rendererBridge: loadedManifest.rendererBridge,
|
|
305
|
+
lifecycle: loadedManifest.lifecycle
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return cleanObject({
|
|
309
|
+
...loadedManifest,
|
|
310
|
+
oneWhackVersion: this.protocolVersion,
|
|
311
|
+
appId: app.appId,
|
|
312
|
+
name: app.name || app.appId,
|
|
313
|
+
version: app.version || loadedManifest.version,
|
|
314
|
+
entry: this.appEntryUrl(app.appId),
|
|
315
|
+
contributes: app.contributes || {},
|
|
316
|
+
permissions: app.permissions || {},
|
|
317
|
+
panel: app.panel,
|
|
318
|
+
distribution: loadedManifest.distribution,
|
|
319
|
+
entrypoint: loadedManifest.entrypoint,
|
|
320
|
+
rendererBridge: loadedManifest.rendererBridge,
|
|
321
|
+
lifecycle: loadedManifest.lifecycle
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
publicState() {
|
|
326
|
+
return {
|
|
327
|
+
protocolName: "onewhack",
|
|
328
|
+
protocolVersion: this.protocolVersion,
|
|
329
|
+
sequence: this.state.sequence,
|
|
330
|
+
generatedAt: this.state.generatedAt,
|
|
331
|
+
capabilities: this.capabilities(),
|
|
332
|
+
host: cleanObject({
|
|
333
|
+
status: this.state.host.status,
|
|
334
|
+
hostId: this.state.host.hostId,
|
|
335
|
+
url: this.state.host.url,
|
|
336
|
+
launchedAt: this.state.host.launchedAt,
|
|
337
|
+
error: this.state.host.error || undefined
|
|
338
|
+
}),
|
|
339
|
+
desktop: cleanObject({
|
|
340
|
+
cdpStatus: this.state.desktop.cdpStatus,
|
|
341
|
+
cdpPort: this.state.desktop.cdpPort || undefined,
|
|
342
|
+
desktopPid: this.state.desktop.desktopPid || undefined,
|
|
343
|
+
target: this.state.desktop.target || undefined,
|
|
344
|
+
error: this.state.desktop.error || undefined
|
|
345
|
+
}),
|
|
346
|
+
panel: cleanObject({
|
|
347
|
+
status: this.state.panel.status,
|
|
348
|
+
activeAppId: this.state.panel.activeAppId,
|
|
349
|
+
url: this.state.panel.url,
|
|
350
|
+
preferredWidth: this.state.panel.preferredWidth || undefined,
|
|
351
|
+
lastOpenedAt: this.state.panel.lastOpenedAt || undefined,
|
|
352
|
+
error: this.state.panel.error || undefined
|
|
353
|
+
}),
|
|
354
|
+
transcript: this.state.transcript,
|
|
355
|
+
selection: this.state.selection,
|
|
356
|
+
selectionActions: this.selectionActions(),
|
|
357
|
+
overlay: this.state.overlay,
|
|
358
|
+
composer: this.state.composer,
|
|
359
|
+
apps: [...this.apps.values()].map((app) => ({
|
|
360
|
+
entry: this.appEntryUrl(app.appId),
|
|
361
|
+
...this.appState(app.appId)
|
|
362
|
+
})),
|
|
363
|
+
appServer: this.state.appServer,
|
|
364
|
+
lastAction: this.state.lastAction || undefined
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
appState(appId) {
|
|
369
|
+
const app = this.requireApp(appId);
|
|
370
|
+
if (typeof app.getState !== "function") {
|
|
371
|
+
return {
|
|
372
|
+
appId: app.appId,
|
|
373
|
+
status: "ready",
|
|
374
|
+
source: "static",
|
|
375
|
+
connected: true,
|
|
376
|
+
entries: [],
|
|
377
|
+
updatedAt: this.state.generatedAt
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return this.sanitizeAppState(app, app.getState(this.appContext(app)));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
sanitizeAppState(app, appState) {
|
|
384
|
+
const permissions = app.permissions || {};
|
|
385
|
+
if (!appState || typeof appState !== "object") return appState;
|
|
386
|
+
return cleanObject({
|
|
387
|
+
...appState,
|
|
388
|
+
annotations: permissions.transcriptAnnotate === false ? undefined : appState.annotations
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
appContext(app) {
|
|
393
|
+
const permissions = app.permissions || {};
|
|
394
|
+
const transcript = permissions.transcriptRead === false
|
|
395
|
+
? {
|
|
396
|
+
anchors: [],
|
|
397
|
+
visibleCount: 0,
|
|
398
|
+
annotationCount: 0,
|
|
399
|
+
scroll: null,
|
|
400
|
+
updatedAt: this.state.transcript.updatedAt
|
|
401
|
+
}
|
|
402
|
+
: this.state.transcript;
|
|
403
|
+
const appServer = permissions.appServerRead === false ? { status: "disabled", error: "permission denied" } : this.state.appServer;
|
|
404
|
+
const scopedState = {
|
|
405
|
+
...this.state,
|
|
406
|
+
transcript,
|
|
407
|
+
appServer
|
|
408
|
+
};
|
|
409
|
+
return {
|
|
410
|
+
protocolVersion: this.protocolVersion,
|
|
411
|
+
appId: app.appId,
|
|
412
|
+
entry: this.appEntryUrl(app.appId),
|
|
413
|
+
state: scopedState,
|
|
414
|
+
transcript,
|
|
415
|
+
selection: this.state.selection,
|
|
416
|
+
desktop: this.state.desktop,
|
|
417
|
+
appServer,
|
|
418
|
+
findAnchor: (anchorId) => {
|
|
419
|
+
requirePermission(app, "transcriptRead");
|
|
420
|
+
return this.findAnchor(anchorId);
|
|
421
|
+
},
|
|
422
|
+
anchorIndex: (anchorId) => {
|
|
423
|
+
requirePermission(app, "transcriptRead");
|
|
424
|
+
return this.anchorIndex(anchorId);
|
|
425
|
+
},
|
|
426
|
+
anchorExists: (anchorId) => {
|
|
427
|
+
requirePermission(app, "transcriptRead");
|
|
428
|
+
return this.anchorExists(anchorId);
|
|
429
|
+
},
|
|
430
|
+
scrollToAnchor: (anchorId, options) => {
|
|
431
|
+
requirePermission(app, "transcriptNavigate");
|
|
432
|
+
return this.scrollToAnchor(anchorId, options);
|
|
433
|
+
},
|
|
434
|
+
highlightAnchor: (anchorId, options) => {
|
|
435
|
+
requirePermission(app, "transcriptNavigate");
|
|
436
|
+
return this.highlightAnchor(anchorId, options);
|
|
437
|
+
},
|
|
438
|
+
highlightRange: (range, options) => {
|
|
439
|
+
requirePermission(app, "transcriptNavigate");
|
|
440
|
+
return this.highlightRange(range, options);
|
|
441
|
+
},
|
|
442
|
+
acceptSelection: (selection, options) => this.acceptSelection(selection, options),
|
|
443
|
+
openOverlay: (overlay, options) => {
|
|
444
|
+
requirePermission(app, "overlayWrite");
|
|
445
|
+
return this.openOverlay({ ...overlay, appId: overlay?.appId || app.appId }, options);
|
|
446
|
+
},
|
|
447
|
+
closeOverlay: (overlayId, options) => {
|
|
448
|
+
requirePermission(app, "overlayWrite");
|
|
449
|
+
return this.closeOverlay(overlayId, options);
|
|
450
|
+
},
|
|
451
|
+
addComposerContext: (item, options) => {
|
|
452
|
+
requirePermission(app, "composerWrite");
|
|
453
|
+
return this.addComposerContext({ ...item, appId: item?.appId || app.appId }, options);
|
|
454
|
+
},
|
|
455
|
+
removeComposerContext: (itemId, options) => {
|
|
456
|
+
requirePermission(app, "composerWrite");
|
|
457
|
+
return this.removeComposerContext(itemId, options);
|
|
458
|
+
},
|
|
459
|
+
setComposerDraft: (draft, options) => {
|
|
460
|
+
requirePermission(app, "composerWrite");
|
|
461
|
+
return this.setComposerDraft(draft, options);
|
|
462
|
+
},
|
|
463
|
+
submitComposer: (draft, options) => {
|
|
464
|
+
requirePermission(app, "composerWrite");
|
|
465
|
+
return this.submitComposer(draft, options);
|
|
466
|
+
},
|
|
467
|
+
writeRuntimeFile: (file) => this.writeRuntimeFile(app, file),
|
|
468
|
+
attachRuntimeFiles: (files, options) => {
|
|
469
|
+
requirePermission(app, "composerWrite");
|
|
470
|
+
return this.attachRuntimeFiles(app, files, options);
|
|
471
|
+
},
|
|
472
|
+
requestStateBroadcast: () => this.touchAndBroadcast(),
|
|
473
|
+
httpError
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async handleRequest(req, res) {
|
|
478
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
479
|
+
this.applyCors(res);
|
|
480
|
+
if (req.method === "OPTIONS") {
|
|
481
|
+
res.writeHead(204).end();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (url.pathname.startsWith("/onewhack/") && !this.authorized(req)) {
|
|
485
|
+
this.fail(res, 401, "unauthorized", "missing or invalid OneWhack token");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (url.pathname === "/onewhack/manifest" && req.method === "GET") return this.ok(res, this.hostManifest());
|
|
490
|
+
if (url.pathname === "/onewhack/state" && req.method === "GET") return this.ok(res, this.publicState());
|
|
491
|
+
if (url.pathname === "/onewhack/events" && req.method === "GET") return this.handleEvents(req, res);
|
|
492
|
+
if (url.pathname === "/onewhack/apps" && req.method === "GET") return this.ok(res, { apps: this.appSummaries() });
|
|
493
|
+
if (url.pathname === "/onewhack/panel/open" && req.method === "POST") return this.openPanelEndpoint(req, res);
|
|
494
|
+
if (url.pathname === "/onewhack/transcript/scroll" && req.method === "POST") return this.scrollEndpoint(req, res);
|
|
495
|
+
if (url.pathname === "/onewhack/transcript/highlight" && req.method === "POST") return this.highlightEndpoint(req, res);
|
|
496
|
+
if (url.pathname === "/onewhack/transcript/highlight-range" && req.method === "POST") return this.highlightRangeEndpoint(req, res);
|
|
497
|
+
if (url.pathname === "/onewhack/selection" && req.method === "POST") return this.selectionEndpoint(req, res);
|
|
498
|
+
if (url.pathname === "/onewhack/overlay/open" && req.method === "POST") return this.overlayOpenEndpoint(req, res);
|
|
499
|
+
if (url.pathname === "/onewhack/overlay/close" && req.method === "POST") return this.overlayCloseEndpoint(req, res);
|
|
500
|
+
if (url.pathname === "/onewhack/composer/context" && req.method === "POST") return this.composerContextEndpoint(req, res);
|
|
501
|
+
if (url.pathname === "/onewhack/composer/context/remove" && req.method === "POST") return this.composerContextRemoveEndpoint(req, res);
|
|
502
|
+
if (url.pathname === "/onewhack/composer/draft" && req.method === "POST") return this.composerDraftEndpoint(req, res);
|
|
503
|
+
if (url.pathname === "/onewhack/composer/submit" && req.method === "POST") return this.composerSubmitEndpoint(req, res);
|
|
504
|
+
|
|
505
|
+
const appRoute = this.parseAppRoute(url.pathname);
|
|
506
|
+
if (appRoute && req.method === "GET" && appRoute.tail === "/manifest") return this.ok(res, this.appManifest(appRoute.appId));
|
|
507
|
+
if (appRoute && req.method === "GET" && appRoute.tail === "/state") return this.ok(res, this.appState(appRoute.appId));
|
|
508
|
+
if (appRoute && req.method === "POST" && appRoute.tail === "/actions") return this.actionEndpoint(appRoute.appId, req, res);
|
|
509
|
+
if (appRoute && req.method === "GET" && appRoute.tail.startsWith("/actions/")) return this.actionResultEndpoint(appRoute.appId, appRoute.tail, res);
|
|
510
|
+
|
|
511
|
+
return this.serveStatic(url, res);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
appSummaries() {
|
|
515
|
+
return [...this.apps.values()].map((app) => {
|
|
516
|
+
const appState = this.appState(app.appId);
|
|
517
|
+
return {
|
|
518
|
+
appId: app.appId,
|
|
519
|
+
name: app.name || app.appId,
|
|
520
|
+
status: appState.status,
|
|
521
|
+
entry: this.appEntryUrl(app.appId)
|
|
522
|
+
};
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
parseAppRoute(pathname) {
|
|
527
|
+
if (!pathname.startsWith("/onewhack/apps/")) return null;
|
|
528
|
+
const parts = pathname.split("/");
|
|
529
|
+
const appId = decodeURIComponent(parts[3] || "");
|
|
530
|
+
if (!appId) return null;
|
|
531
|
+
this.requireApp(appId);
|
|
532
|
+
return { appId, tail: `/${parts.slice(4).join("/")}` };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async actionEndpoint(appId, req, res) {
|
|
536
|
+
const app = this.requireApp(appId);
|
|
537
|
+
const body = await readJsonObject(req, "action request body");
|
|
538
|
+
if (!isPlainObject(body.action)) throw httpError(400, "bad_request", "action object is required");
|
|
539
|
+
const result = await this.handleAction(app, body.action);
|
|
540
|
+
return this.ok(res, result);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
actionResultEndpoint(appId, tail, res) {
|
|
544
|
+
this.requireApp(appId);
|
|
545
|
+
const actionId = decodeURIComponent(tail.slice("/actions/".length));
|
|
546
|
+
const result = this.actionResults.get(`${appId}:${actionId}`);
|
|
547
|
+
if (!result) return this.fail(res, 404, "not_found", "unknown action result");
|
|
548
|
+
return this.ok(res, result);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async handleAction(app, action) {
|
|
552
|
+
if (!isNonEmptyString(action.appId)) throw httpError(400, "bad_request", "action.appId is required");
|
|
553
|
+
if (action.appId !== app.appId) throw httpError(400, "bad_request", "action.appId must match route appId");
|
|
554
|
+
if (!isNonEmptyString(action.actionId)) throw httpError(400, "bad_request", "action.actionId is required");
|
|
555
|
+
if (!isNonEmptyString(action.type)) throw httpError(400, "bad_request", "action.type is required");
|
|
556
|
+
if (action.requestedAt !== undefined && !Number.isFinite(Date.parse(action.requestedAt))) {
|
|
557
|
+
throw httpError(400, "bad_request", "action.requestedAt must be an ISO timestamp");
|
|
558
|
+
}
|
|
559
|
+
const resultKey = `${action.appId}:${action.actionId}`;
|
|
560
|
+
if (this.actionResults.has(resultKey)) return this.actionResults.get(resultKey);
|
|
561
|
+
if (typeof app.handleAction !== "function") throw httpError(404, "not_found", `unknown action: ${action.type}`);
|
|
562
|
+
|
|
563
|
+
const result = await app.handleAction(action, this.appContext(app));
|
|
564
|
+
if (!result || typeof result.ok !== "boolean") {
|
|
565
|
+
throw httpError(500, "action_failed", `action handler returned an invalid result: ${action.type}`);
|
|
566
|
+
}
|
|
567
|
+
const normalized = {
|
|
568
|
+
actionId: action.actionId,
|
|
569
|
+
appId: app.appId,
|
|
570
|
+
ok: Boolean(result?.ok),
|
|
571
|
+
status: result?.status || (result?.ok ? "applied" : "failed"),
|
|
572
|
+
...(result?.error ? { error: result.error } : {}),
|
|
573
|
+
...(result?.data === undefined ? {} : { data: result.data }),
|
|
574
|
+
completedAt: result?.completedAt || new Date().toISOString()
|
|
575
|
+
};
|
|
576
|
+
this.actionResults.set(resultKey, normalized);
|
|
577
|
+
this.state.lastAction = normalized;
|
|
578
|
+
this.touchAndBroadcast("action", normalized);
|
|
579
|
+
return normalized;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async openPanelEndpoint(req, res) {
|
|
583
|
+
try {
|
|
584
|
+
const body = await readJsonObject(req, "panel open request body");
|
|
585
|
+
if (body.appId !== undefined) {
|
|
586
|
+
if (!isNonEmptyString(body.appId)) throw httpError(400, "bad_request", "appId must be a non-empty string");
|
|
587
|
+
this.setActivePanelApp(body.appId);
|
|
588
|
+
}
|
|
589
|
+
this.state.panel.status = "opening";
|
|
590
|
+
const result = await this.transcriptAdapter.openPanel?.(this);
|
|
591
|
+
if (result?.ok) {
|
|
592
|
+
this.state.panel.status = result.mode === "focused" ? "focused" : "open";
|
|
593
|
+
this.state.panel.lastOpenedAt = new Date().toISOString();
|
|
594
|
+
this.state.panel.error = null;
|
|
595
|
+
} else {
|
|
596
|
+
this.state.panel.status = "waiting";
|
|
597
|
+
this.state.panel.error = result?.error || "Open a Codex thread, then try opening the OneWhack panel again.";
|
|
598
|
+
}
|
|
599
|
+
this.touchAndBroadcast();
|
|
600
|
+
return this.ok(res, result || { ok: false, error: "panel unavailable" });
|
|
601
|
+
} catch (err) {
|
|
602
|
+
this.state.panel.status = "error";
|
|
603
|
+
this.state.panel.error = err.message;
|
|
604
|
+
this.touchAndBroadcast();
|
|
605
|
+
throw err;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async scrollEndpoint(req, res) {
|
|
610
|
+
const body = await readJsonObject(req, "scroll request body");
|
|
611
|
+
validateAnchorRequest(body);
|
|
612
|
+
if (body.behavior !== undefined && !["auto", "smooth"].includes(body.behavior)) {
|
|
613
|
+
throw httpError(400, "bad_request", "behavior must be auto or smooth");
|
|
614
|
+
}
|
|
615
|
+
if (body.block !== undefined && !["start", "center", "end", "nearest"].includes(body.block)) {
|
|
616
|
+
throw httpError(400, "bad_request", "block must be start, center, end, or nearest");
|
|
617
|
+
}
|
|
618
|
+
const result = await this.scrollToAnchor(body.anchorId, body);
|
|
619
|
+
if (!result?.ok) return this.fail(res, 404, "not_found", result?.error || "anchor not found", { anchorId: body.anchorId });
|
|
620
|
+
return this.ok(res, result);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async highlightEndpoint(req, res) {
|
|
624
|
+
const body = await readJsonObject(req, "highlight request body");
|
|
625
|
+
validateAnchorRequest(body);
|
|
626
|
+
if (body.durationMs !== undefined && (!Number.isFinite(Number(body.durationMs)) || Number(body.durationMs) < 0)) {
|
|
627
|
+
throw httpError(400, "bad_request", "durationMs must be a non-negative number");
|
|
628
|
+
}
|
|
629
|
+
const result = await this.highlightAnchor(body.anchorId, body);
|
|
630
|
+
if (!result?.ok) return this.fail(res, 404, "not_found", result?.error || "anchor not found", { anchorId: body.anchorId });
|
|
631
|
+
return this.ok(res, result);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async highlightRangeEndpoint(req, res) {
|
|
635
|
+
const body = await readJsonObject(req, "range highlight request body");
|
|
636
|
+
validateRangeRequest(body.range || body);
|
|
637
|
+
if (body.durationMs !== undefined && (!Number.isFinite(Number(body.durationMs)) || Number(body.durationMs) < 0)) {
|
|
638
|
+
throw httpError(400, "bad_request", "durationMs must be a non-negative number");
|
|
639
|
+
}
|
|
640
|
+
const result = await this.highlightRange(body.range || body, body);
|
|
641
|
+
if (!result?.ok) return this.fail(res, 404, "not_found", result?.error || "range not found", { anchorId: (body.range || body).anchorId });
|
|
642
|
+
return this.ok(res, result);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async selectionEndpoint(req, res) {
|
|
646
|
+
const body = await readJsonObject(req, "selection request body");
|
|
647
|
+
if (body.selection !== undefined && !isPlainObject(body.selection)) {
|
|
648
|
+
throw httpError(400, "bad_request", "selection must be an object");
|
|
649
|
+
}
|
|
650
|
+
if (body.selection?.source !== undefined && !["panel", "transcript", "adapter"].includes(body.selection.source)) {
|
|
651
|
+
throw httpError(400, "bad_request", "selection.source is invalid");
|
|
652
|
+
}
|
|
653
|
+
if (body.selection?.range !== undefined) validateRangeRequest({ ...body.selection.range, anchorId: body.selection.anchorId || body.selection.range.anchorId });
|
|
654
|
+
if (body.selection?.selectedAt !== undefined && !Number.isFinite(Date.parse(body.selection.selectedAt))) {
|
|
655
|
+
throw httpError(400, "bad_request", "selection.selectedAt must be an ISO timestamp");
|
|
656
|
+
}
|
|
657
|
+
return this.ok(res, this.setSelection(body.selection));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async overlayOpenEndpoint(req, res) {
|
|
661
|
+
const body = await readJsonObject(req, "overlay open request body");
|
|
662
|
+
const overlay = body.overlay || body;
|
|
663
|
+
return this.ok(res, this.openOverlay(overlay));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async overlayCloseEndpoint(req, res) {
|
|
667
|
+
const body = await readJsonObject(req, "overlay close request body");
|
|
668
|
+
if (body.overlayId !== undefined && !isNonEmptyString(body.overlayId)) {
|
|
669
|
+
throw httpError(400, "bad_request", "overlayId must be a non-empty string when present");
|
|
670
|
+
}
|
|
671
|
+
return this.ok(res, this.closeOverlay(body.overlayId));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async composerContextEndpoint(req, res) {
|
|
675
|
+
const body = await readJsonObject(req, "composer context request body");
|
|
676
|
+
return this.ok(res, this.addComposerContext(body.item || body));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async composerContextRemoveEndpoint(req, res) {
|
|
680
|
+
const body = await readJsonObject(req, "composer context remove request body");
|
|
681
|
+
if (!isNonEmptyString(body.itemId)) throw httpError(400, "bad_request", "itemId is required");
|
|
682
|
+
return this.ok(res, this.removeComposerContext(body.itemId));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async composerDraftEndpoint(req, res) {
|
|
686
|
+
const body = await readJsonObject(req, "composer draft request body");
|
|
687
|
+
return this.ok(res, await this.setComposerDraft(body.draft || body));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async composerSubmitEndpoint(req, res) {
|
|
691
|
+
const body = await readJsonObject(req, "composer submit request body");
|
|
692
|
+
return this.ok(res, await this.submitComposer(body.draft || body));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async scrollToAnchor(anchorId, options = {}) {
|
|
696
|
+
if (!this.anchorExists(anchorId)) return { ok: false, error: "anchor not found", anchorId };
|
|
697
|
+
const result = await this.transcriptAdapter.scrollToAnchor?.(anchorId, options, this);
|
|
698
|
+
return result || { ok: true, anchorId };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async highlightAnchor(anchorId, options = {}) {
|
|
702
|
+
if (!this.anchorExists(anchorId)) return { ok: false, error: "anchor not found", anchorId };
|
|
703
|
+
const result = await this.transcriptAdapter.highlightAnchor?.(anchorId, options, this);
|
|
704
|
+
return result || { ok: true, anchorId };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async highlightRange(range, options = {}) {
|
|
708
|
+
validateRangeRequest(range);
|
|
709
|
+
if (!this.anchorExists(range.anchorId)) return { ok: false, error: "anchor not found", anchorId: range.anchorId };
|
|
710
|
+
const normalized = normalizeRange(range);
|
|
711
|
+
const result = await this.transcriptAdapter.highlightRange?.(normalized, options, this);
|
|
712
|
+
if (result) return result;
|
|
713
|
+
const fallback = await this.highlightAnchor(normalized.anchorId, options);
|
|
714
|
+
return fallback?.ok ? { ...fallback, range: normalized } : fallback;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
setSelection(selection) {
|
|
718
|
+
if (!selection?.anchorId && !selection?.entryId) return { stale: false, selection: this.state.selection };
|
|
719
|
+
if (!this.isCandidateSelectionNewer(selection, this.state.selection)) {
|
|
720
|
+
return { stale: true, selection: this.state.selection };
|
|
721
|
+
}
|
|
722
|
+
return { stale: false, selection: this.acceptSelection(selection) };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
acceptSelection(selection, options = {}) {
|
|
726
|
+
this.touch();
|
|
727
|
+
this.state.selection = {
|
|
728
|
+
selectionId: selection.selectionId || `sel_${this.state.sequence}`,
|
|
729
|
+
sequence: this.state.sequence,
|
|
730
|
+
source: selection.source || "adapter",
|
|
731
|
+
appId: selection.appId || this.state.panel.activeAppId || undefined,
|
|
732
|
+
anchorId: selection.anchorId || null,
|
|
733
|
+
quote: selection.quote || selection.range?.text || null,
|
|
734
|
+
range: selection.range ? normalizeRange({ ...selection.range, anchorId: selection.anchorId || selection.range.anchorId }) : null,
|
|
735
|
+
rect: selection.rect ? normalizeRect(selection.rect) : null,
|
|
736
|
+
markerId: selection.markerId || null,
|
|
737
|
+
entryId: selection.entryId || (selection.anchorId ? `nav:${selection.anchorId}` : null),
|
|
738
|
+
appServer: selection.appServer || null,
|
|
739
|
+
correlationId: selection.correlationId || null,
|
|
740
|
+
selectedAt: selection.selectedAt || new Date().toISOString(),
|
|
741
|
+
acceptedAt: this.state.generatedAt
|
|
742
|
+
};
|
|
743
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
744
|
+
return this.state.selection;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
openOverlay(overlay, options = {}) {
|
|
748
|
+
validateOverlay(overlay);
|
|
749
|
+
this.touch();
|
|
750
|
+
this.state.overlay = cleanObject({
|
|
751
|
+
overlayId: overlay.overlayId || `overlay_${this.state.sequence}`,
|
|
752
|
+
appId: overlay.appId,
|
|
753
|
+
kind: overlay.kind || "form",
|
|
754
|
+
title: overlay.title || null,
|
|
755
|
+
anchorId: overlay.anchorId || overlay.range?.anchorId || this.state.selection?.anchorId || null,
|
|
756
|
+
range: overlay.range ? normalizeRange(overlay.range) : this.state.selection?.range || null,
|
|
757
|
+
anchorRect: overlay.anchorRect ? normalizeRect(overlay.anchorRect) : this.state.selection?.rect || null,
|
|
758
|
+
fields: overlay.fields || [],
|
|
759
|
+
actions: overlay.actions || [],
|
|
760
|
+
openedAt: this.state.generatedAt
|
|
761
|
+
});
|
|
762
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
763
|
+
return { ok: true, overlay: this.state.overlay };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
closeOverlay(overlayId, options = {}) {
|
|
767
|
+
if (overlayId && this.state.overlay?.overlayId !== overlayId) {
|
|
768
|
+
return { ok: false, error: "overlay not found", overlayId };
|
|
769
|
+
}
|
|
770
|
+
this.touch();
|
|
771
|
+
const closed = this.state.overlay;
|
|
772
|
+
this.state.overlay = null;
|
|
773
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
774
|
+
return { ok: true, overlay: closed };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
writeRuntimeFile(app, file) {
|
|
778
|
+
if (!this.runDir) throw httpError(500, "runtime_unavailable", "runtime directory is unavailable");
|
|
779
|
+
if (!isPlainObject(file)) throw httpError(400, "bad_request", "runtime file must be an object");
|
|
780
|
+
const content = String(file.content || "");
|
|
781
|
+
const directory = safePathSegment(file.directory || "files");
|
|
782
|
+
const filename = safeFilename(file.filename || `file-${this.state.sequence}.txt`);
|
|
783
|
+
const root = resolve(this.runDir, "apps", safePathSegment(app.appId), directory);
|
|
784
|
+
const filePath = resolve(root, filename);
|
|
785
|
+
if (filePath !== root && !filePath.startsWith(`${root}/`)) {
|
|
786
|
+
throw httpError(400, "bad_request", "runtime file path escapes app directory");
|
|
787
|
+
}
|
|
788
|
+
mkdirSync(root, { recursive: true });
|
|
789
|
+
writeFileSync(filePath, content);
|
|
790
|
+
return cleanObject({
|
|
791
|
+
fileId: file.fileId || `${app.appId}:${directory}/${filename}`,
|
|
792
|
+
appId: app.appId,
|
|
793
|
+
path: filePath,
|
|
794
|
+
relativePath: relative(this.runDir, filePath),
|
|
795
|
+
mimeType: file.mimeType || mimeType(filePath),
|
|
796
|
+
bytes: Buffer.byteLength(content),
|
|
797
|
+
createdAt: this.state.generatedAt
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async attachRuntimeFiles(app, files = [], options = {}) {
|
|
802
|
+
if (!this.runDir) throw httpError(500, "runtime_unavailable", "runtime directory is unavailable");
|
|
803
|
+
if (!Array.isArray(files)) throw httpError(400, "bad_request", "files must be an array");
|
|
804
|
+
const appRoot = resolve(this.runDir, "apps", safePathSegment(app.appId));
|
|
805
|
+
const normalized = files.map((file) => {
|
|
806
|
+
const path = resolve(typeof file === "string" ? file : file?.path || "");
|
|
807
|
+
if (!path || (path !== appRoot && !path.startsWith(`${appRoot}/`))) {
|
|
808
|
+
throw httpError(403, "forbidden", "apps may only attach their own runtime files");
|
|
809
|
+
}
|
|
810
|
+
if (!existsSync(path)) throw httpError(404, "not_found", `attachment file not found: ${path}`);
|
|
811
|
+
return cleanObject({
|
|
812
|
+
fileId: typeof file === "object" ? file.fileId : undefined,
|
|
813
|
+
appId: app.appId,
|
|
814
|
+
path,
|
|
815
|
+
relativePath: relative(this.runDir, path),
|
|
816
|
+
name: typeof file === "object" && file.name ? file.name : path.split("/").pop(),
|
|
817
|
+
mimeType: typeof file === "object" ? file.mimeType : mimeType(path),
|
|
818
|
+
body: readFileSync(path, "utf8"),
|
|
819
|
+
status: "queued"
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
const adapterResult = await this.transcriptAdapter.attachFiles?.(normalized, options, this);
|
|
823
|
+
if (!adapterResult || adapterResult.ok === false) {
|
|
824
|
+
return adapterResult || { ok: false, error: "file attachment unavailable" };
|
|
825
|
+
}
|
|
826
|
+
this.touch();
|
|
827
|
+
if (shouldTrackHostComposerAttachment(adapterResult)) {
|
|
828
|
+
const attachedAt = this.state.generatedAt;
|
|
829
|
+
this.state.composer.attachments = [
|
|
830
|
+
...this.state.composer.attachments,
|
|
831
|
+
...normalized.map((file) => ({ ...file, attachedAt }))
|
|
832
|
+
];
|
|
833
|
+
}
|
|
834
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
835
|
+
return { ok: true, files: normalized, adapter: adapterResult };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
addComposerContext(item, options = {}) {
|
|
839
|
+
validateComposerContextItem(item);
|
|
840
|
+
this.touch();
|
|
841
|
+
const normalized = cleanObject({
|
|
842
|
+
itemId: item.itemId || `ctx_${this.state.sequence}`,
|
|
843
|
+
appId: item.appId,
|
|
844
|
+
label: item.label,
|
|
845
|
+
body: item.body || item.quote || "",
|
|
846
|
+
anchorId: item.anchorId || item.range?.anchorId || null,
|
|
847
|
+
range: item.range ? normalizeRange(item.range) : null,
|
|
848
|
+
status: item.status || "queued",
|
|
849
|
+
createdAt: item.createdAt || this.state.generatedAt,
|
|
850
|
+
updatedAt: this.state.generatedAt
|
|
851
|
+
});
|
|
852
|
+
const index = this.state.composer.contextItems.findIndex((candidate) => candidate.itemId === normalized.itemId);
|
|
853
|
+
if (index >= 0) this.state.composer.contextItems[index] = normalized;
|
|
854
|
+
else this.state.composer.contextItems.push(normalized);
|
|
855
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
856
|
+
return { ok: true, item: normalized, contextItems: this.state.composer.contextItems };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
removeComposerContext(itemId, options = {}) {
|
|
860
|
+
this.touch();
|
|
861
|
+
const before = this.state.composer.contextItems.length;
|
|
862
|
+
this.state.composer.contextItems = this.state.composer.contextItems.filter((item) => item.itemId !== itemId);
|
|
863
|
+
const removed = before !== this.state.composer.contextItems.length;
|
|
864
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
865
|
+
return { ok: true, removed, itemId, contextItems: this.state.composer.contextItems };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
removeComposerAttachment(path, options = {}) {
|
|
869
|
+
this.touch();
|
|
870
|
+
const targetPath = resolve(String(path || ""));
|
|
871
|
+
const before = this.state.composer.attachments.length;
|
|
872
|
+
this.state.composer.attachments = this.state.composer.attachments.filter((item) => resolve(item.path) !== targetPath);
|
|
873
|
+
const removed = before !== this.state.composer.attachments.length;
|
|
874
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
875
|
+
return { ok: true, removed, path: targetPath, attachments: this.state.composer.attachments };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async setComposerDraft(draft, options = {}) {
|
|
879
|
+
validateComposerDraft(draft);
|
|
880
|
+
this.touch();
|
|
881
|
+
const mode = draft.mode || "replace";
|
|
882
|
+
const nextText = mergeComposerDraftText(this.state.composer.draft.text, draft.text || "", mode);
|
|
883
|
+
this.state.composer.draft = {
|
|
884
|
+
text: nextText,
|
|
885
|
+
mode,
|
|
886
|
+
contextItemIds: draft.contextItemIds || this.state.composer.contextItems.map((item) => item.itemId),
|
|
887
|
+
updatedAt: this.state.generatedAt
|
|
888
|
+
};
|
|
889
|
+
const adapterResult = await this.transcriptAdapter.setComposerDraft?.(this.state.composer.draft, options, this);
|
|
890
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
891
|
+
return adapterResult || { ok: true, draft: this.state.composer.draft };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async submitComposer(draft = {}, options = {}) {
|
|
895
|
+
const nextDraft = draft.text !== undefined ? await this.setComposerDraft(draft, { ...options, broadcast: false }) : { ok: true };
|
|
896
|
+
if (!nextDraft?.ok) return nextDraft;
|
|
897
|
+
this.touch();
|
|
898
|
+
const adapterResult = await this.transcriptAdapter.submitComposer?.(this.state.composer.draft, options, this);
|
|
899
|
+
if (adapterResult?.ok !== false) this.state.composer.lastSubmittedAt = this.state.generatedAt;
|
|
900
|
+
if (options.broadcast !== false) this.broadcast("state", this.publicState());
|
|
901
|
+
return adapterResult || { ok: false, error: "composer submit unavailable", draft: this.state.composer.draft };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
selectionActions() {
|
|
905
|
+
return [...this.apps.values()].flatMap((app) => {
|
|
906
|
+
const state = this.appState(app.appId);
|
|
907
|
+
return (state.selectionActions || []).map((action) => ({
|
|
908
|
+
...action,
|
|
909
|
+
appId: action.appId || app.appId
|
|
910
|
+
}));
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
isCandidateSelectionNewer(candidate, current) {
|
|
915
|
+
if (!candidate?.anchorId && !candidate?.entryId) return false;
|
|
916
|
+
const candidateTime = Date.parse(candidate.selectedAt || "");
|
|
917
|
+
const currentTime = Date.parse(current?.selectedAt || "");
|
|
918
|
+
return (Number.isFinite(candidateTime) ? candidateTime : 0) >= (Number.isFinite(currentTime) ? currentTime : 0);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
setDesktopStatus(next) {
|
|
922
|
+
this.state.desktop = cleanObject({ ...this.state.desktop, ...next });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
updateTranscript(transcript, options = {}) {
|
|
926
|
+
this.state.transcript = {
|
|
927
|
+
anchors: transcript.anchors || [],
|
|
928
|
+
visibleCount: transcript.visibleCount || 0,
|
|
929
|
+
annotationCount: transcript.annotationCount || 0,
|
|
930
|
+
scroll: transcript.scroll || null,
|
|
931
|
+
updatedAt: transcript.updatedAt || new Date().toISOString()
|
|
932
|
+
};
|
|
933
|
+
if (options.broadcast !== false) this.touchAndBroadcast();
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
findAnchor(anchorId) {
|
|
937
|
+
return this.state.transcript.anchors.find((anchor) => anchor.anchorId === anchorId) || null;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
anchorExists(anchorId) {
|
|
941
|
+
return Boolean(this.findAnchor(anchorId));
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
anchorIndex(anchorId) {
|
|
945
|
+
return this.state.transcript.anchors.findIndex((anchor) => anchor.anchorId === anchorId);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
requireApp(appId) {
|
|
949
|
+
const app = this.apps.get(appId);
|
|
950
|
+
if (!app) throw httpError(404, "not_found", "unknown app");
|
|
951
|
+
return app;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
serveStatic(url, res) {
|
|
955
|
+
let pathname = url.pathname;
|
|
956
|
+
if (pathname === "/") pathname = `/apps/${this.state.panel.activeAppId || ""}/`;
|
|
957
|
+
if (!pathname.startsWith("/apps/")) {
|
|
958
|
+
res.writeHead(404).end("not found");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const parts = pathname.split("/");
|
|
962
|
+
const appId = decodeURIComponent(parts[2] || "");
|
|
963
|
+
const app = this.apps.get(appId);
|
|
964
|
+
if (!app?.publicDir) {
|
|
965
|
+
res.writeHead(404).end("not found");
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
let relativePath = parts.slice(3).join("/");
|
|
969
|
+
if (!relativePath) relativePath = "index.html";
|
|
970
|
+
const rootPath = resolve(app.publicDir);
|
|
971
|
+
const filePath = resolve(rootPath, `./${relativePath}`);
|
|
972
|
+
if ((filePath !== rootPath && !filePath.startsWith(`${rootPath}/`)) || !existsSync(filePath)) {
|
|
973
|
+
res.writeHead(404).end("not found");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
res.writeHead(200, {
|
|
977
|
+
"content-type": mimeType(filePath),
|
|
978
|
+
"cache-control": "no-store"
|
|
979
|
+
});
|
|
980
|
+
createReadStream(filePath).pipe(res);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
handleEvents(req, res) {
|
|
984
|
+
res.writeHead(200, {
|
|
985
|
+
"content-type": "text/event-stream",
|
|
986
|
+
"cache-control": "no-store",
|
|
987
|
+
connection: "keep-alive"
|
|
988
|
+
});
|
|
989
|
+
this.sseClients.add(res);
|
|
990
|
+
res.write(`event: state\nid: ${this.state.sequence}\ndata: ${JSON.stringify(this.publicState())}\n\n`);
|
|
991
|
+
req.on("close", () => this.sseClients.delete(res));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
touch() {
|
|
995
|
+
this.state.sequence += 1;
|
|
996
|
+
this.state.generatedAt = new Date().toISOString();
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
touchAndBroadcast(event = "state", data = this.publicState()) {
|
|
1000
|
+
this.touch();
|
|
1001
|
+
this.broadcast(event, typeof data === "function" ? data() : data);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
broadcast(event = "state", data = this.publicState()) {
|
|
1005
|
+
const payload = `event: ${event}\nid: ${this.state.sequence}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
1006
|
+
for (const res of this.sseClients) res.write(payload);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
authorized(req) {
|
|
1010
|
+
if (this.localDevInsecure) return true;
|
|
1011
|
+
return req.headers.authorization === `Bearer ${this.token}`;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
ok(res, data, status = 200) {
|
|
1015
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
1016
|
+
res.end(JSON.stringify({ ok: true, data }));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
fail(res, status, code, message, detail) {
|
|
1020
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
1021
|
+
res.end(JSON.stringify({ ok: false, error: { code, message, ...(detail === undefined ? {} : { detail }) } }));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
applyCors(res) {
|
|
1025
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
1026
|
+
res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
|
|
1027
|
+
res.setHeader("access-control-allow-headers", "authorization,content-type");
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async function readJsonObject(req, label) {
|
|
1032
|
+
const chunks = [];
|
|
1033
|
+
let size = 0;
|
|
1034
|
+
for await (const chunk of req) {
|
|
1035
|
+
size += chunk.length;
|
|
1036
|
+
if (size > 1024 * 1024) throw httpError(400, "bad_request", "request body exceeds 1 MiB");
|
|
1037
|
+
chunks.push(chunk);
|
|
1038
|
+
}
|
|
1039
|
+
let parsed;
|
|
1040
|
+
try {
|
|
1041
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
1042
|
+
} catch {
|
|
1043
|
+
throw httpError(400, "bad_request", "invalid JSON request body");
|
|
1044
|
+
}
|
|
1045
|
+
if (!isPlainObject(parsed)) throw httpError(400, "bad_request", `${label} must be a JSON object`);
|
|
1046
|
+
return parsed;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function validateAnchorRequest(body) {
|
|
1050
|
+
if (!isNonEmptyString(body.anchorId)) throw httpError(400, "bad_request", "anchorId is required");
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function validateRangeRequest(range) {
|
|
1054
|
+
if (!isPlainObject(range)) throw httpError(400, "bad_request", "range must be an object");
|
|
1055
|
+
if (!isNonEmptyString(range.anchorId)) throw httpError(400, "bad_request", "range.anchorId is required");
|
|
1056
|
+
if (range.text !== undefined && typeof range.text !== "string") throw httpError(400, "bad_request", "range.text must be a string");
|
|
1057
|
+
if (range.quote !== undefined && typeof range.quote !== "string") throw httpError(400, "bad_request", "range.quote must be a string");
|
|
1058
|
+
for (const key of ["prefix", "suffix"]) {
|
|
1059
|
+
if (range[key] !== undefined && typeof range[key] !== "string") throw httpError(400, "bad_request", `range.${key} must be a string`);
|
|
1060
|
+
}
|
|
1061
|
+
for (const key of ["startOffset", "endOffset"]) {
|
|
1062
|
+
if (range[key] !== undefined && (!Number.isInteger(Number(range[key])) || Number(range[key]) < 0)) {
|
|
1063
|
+
throw httpError(400, "bad_request", `range.${key} must be a non-negative integer`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function normalizeRange(range) {
|
|
1069
|
+
validateRangeRequest(range);
|
|
1070
|
+
return cleanObject({
|
|
1071
|
+
anchorId: range.anchorId,
|
|
1072
|
+
text: range.text || range.quote || "",
|
|
1073
|
+
quote: range.quote || range.text || "",
|
|
1074
|
+
prefix: range.prefix || "",
|
|
1075
|
+
suffix: range.suffix || "",
|
|
1076
|
+
startOffset: range.startOffset === undefined ? undefined : Number(range.startOffset),
|
|
1077
|
+
endOffset: range.endOffset === undefined ? undefined : Number(range.endOffset),
|
|
1078
|
+
fingerprint: range.fingerprint || range.text || range.quote || ""
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function normalizeRect(rect) {
|
|
1083
|
+
if (!isPlainObject(rect)) return null;
|
|
1084
|
+
const normalized = {};
|
|
1085
|
+
for (const key of ["left", "top", "right", "bottom", "width", "height"]) {
|
|
1086
|
+
if (rect[key] !== undefined && Number.isFinite(Number(rect[key]))) normalized[key] = Number(rect[key]);
|
|
1087
|
+
}
|
|
1088
|
+
return Object.keys(normalized).length ? normalized : null;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function validateOverlay(overlay) {
|
|
1092
|
+
if (!isPlainObject(overlay)) throw httpError(400, "bad_request", "overlay must be an object");
|
|
1093
|
+
if (!isNonEmptyString(overlay.appId)) throw httpError(400, "bad_request", "overlay.appId is required");
|
|
1094
|
+
if (overlay.overlayId !== undefined && !isNonEmptyString(overlay.overlayId)) throw httpError(400, "bad_request", "overlay.overlayId must be a non-empty string");
|
|
1095
|
+
if (overlay.kind !== undefined && !["form", "menu", "notice"].includes(overlay.kind)) throw httpError(400, "bad_request", "overlay.kind is invalid");
|
|
1096
|
+
if (overlay.anchorId !== undefined && !isNonEmptyString(overlay.anchorId)) throw httpError(400, "bad_request", "overlay.anchorId must be a non-empty string");
|
|
1097
|
+
if (overlay.range !== undefined) validateRangeRequest(overlay.range);
|
|
1098
|
+
if (overlay.fields !== undefined && !Array.isArray(overlay.fields)) throw httpError(400, "bad_request", "overlay.fields must be an array");
|
|
1099
|
+
if (overlay.actions !== undefined && !Array.isArray(overlay.actions)) throw httpError(400, "bad_request", "overlay.actions must be an array");
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function validateComposerContextItem(item) {
|
|
1103
|
+
if (!isPlainObject(item)) throw httpError(400, "bad_request", "composer context item must be an object");
|
|
1104
|
+
if (!isNonEmptyString(item.appId)) throw httpError(400, "bad_request", "item.appId is required");
|
|
1105
|
+
if (item.itemId !== undefined && !isNonEmptyString(item.itemId)) throw httpError(400, "bad_request", "item.itemId must be a non-empty string");
|
|
1106
|
+
if (!isNonEmptyString(item.label)) throw httpError(400, "bad_request", "item.label is required");
|
|
1107
|
+
if (item.body !== undefined && typeof item.body !== "string") throw httpError(400, "bad_request", "item.body must be a string");
|
|
1108
|
+
if (item.quote !== undefined && typeof item.quote !== "string") throw httpError(400, "bad_request", "item.quote must be a string");
|
|
1109
|
+
if (item.anchorId !== undefined && !isNonEmptyString(item.anchorId)) throw httpError(400, "bad_request", "item.anchorId must be a non-empty string");
|
|
1110
|
+
if (item.range !== undefined) validateRangeRequest(item.range);
|
|
1111
|
+
if (item.status !== undefined && !["queued", "sent", "resolved"].includes(item.status)) throw httpError(400, "bad_request", "item.status is invalid");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function validateComposerDraft(draft) {
|
|
1115
|
+
if (!isPlainObject(draft)) throw httpError(400, "bad_request", "composer draft must be an object");
|
|
1116
|
+
if (draft.text !== undefined && typeof draft.text !== "string") throw httpError(400, "bad_request", "draft.text must be a string");
|
|
1117
|
+
if (draft.mode !== undefined && !["replace", "append", "prepend"].includes(draft.mode)) throw httpError(400, "bad_request", "draft.mode is invalid");
|
|
1118
|
+
if (draft.contextItemIds !== undefined && (!Array.isArray(draft.contextItemIds) || draft.contextItemIds.some((id) => !isNonEmptyString(id)))) {
|
|
1119
|
+
throw httpError(400, "bad_request", "draft.contextItemIds must be an array of non-empty strings");
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function mergeComposerDraftText(current, next, mode) {
|
|
1124
|
+
if (mode === "append") return current ? `${current}\n${next}` : next;
|
|
1125
|
+
if (mode === "prepend") return current ? `${next}\n${current}` : next;
|
|
1126
|
+
return next;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function shouldTrackHostComposerAttachment(adapterResult) {
|
|
1130
|
+
return adapterResult?.mode === "mock" || adapterResult?.trackHostComposerAttachment === true;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
export function createSessionToken() {
|
|
1134
|
+
return randomBytes(32).toString("base64url");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function requirePermission(app, permission) {
|
|
1138
|
+
if (app.permissions?.[permission] === false) {
|
|
1139
|
+
throw httpError(403, "forbidden", `${app.appId} lacks ${permission}`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function isNonEmptyString(value) {
|
|
1144
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function isPlainObject(value) {
|
|
1148
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function safePathSegment(value) {
|
|
1152
|
+
return String(value || "files")
|
|
1153
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
1154
|
+
.replace(/^-+|-+$/g, "")
|
|
1155
|
+
.slice(0, 80) || "files";
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function safeFilename(value) {
|
|
1159
|
+
const filename = safePathSegment(value);
|
|
1160
|
+
return filename.includes(".") ? filename : `${filename}.txt`;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function mimeType(filePath) {
|
|
1164
|
+
switch (extname(filePath)) {
|
|
1165
|
+
case ".html": return "text/html; charset=utf-8";
|
|
1166
|
+
case ".css": return "text/css; charset=utf-8";
|
|
1167
|
+
case ".js": return "text/javascript; charset=utf-8";
|
|
1168
|
+
case ".json": return "application/json; charset=utf-8";
|
|
1169
|
+
case ".md": return "text/markdown; charset=utf-8";
|
|
1170
|
+
case ".txt": return "text/plain; charset=utf-8";
|
|
1171
|
+
default: return "application/octet-stream";
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function cleanObject(value) {
|
|
1176
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
1177
|
+
}
|