@addozhang/dsh-discord 0.3.0 → 0.5.0-alpha.1

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 (49) hide show
  1. package/README.md +13 -13
  2. package/README.zh.md +13 -13
  3. package/cordis.patch.yml +1 -1
  4. package/lib/client/card-controller.d.ts +1 -1
  5. package/lib/client/card-form.d.ts +2 -1
  6. package/lib/client/index.d.ts +2 -2
  7. package/lib/client/slot-contract.d.ts +7 -0
  8. package/lib/client/snapshot-store.d.ts +1 -1
  9. package/lib/compose.d.ts +22 -2
  10. package/lib/compose.js +9 -4
  11. package/lib/dsh/host-asks.d.ts +90 -0
  12. package/lib/dsh/host-asks.js +143 -0
  13. package/lib/dsh/host-events.d.ts +42 -0
  14. package/lib/dsh/host-events.js +234 -0
  15. package/lib/dsh/host-face.d.ts +418 -0
  16. package/lib/dsh/host-face.js +598 -0
  17. package/lib/features/adapter-status.d.ts +15 -2
  18. package/lib/features/adapter-status.js +100 -4
  19. package/lib/features/approval-store.d.ts +1 -1
  20. package/lib/features/image-collection.d.ts +11 -0
  21. package/lib/features/image-collection.js +22 -8
  22. package/lib/features/image-download.d.ts +6 -0
  23. package/lib/features/image-download.js +21 -0
  24. package/lib/features/interaction-router.d.ts +2 -2
  25. package/lib/features/model-control.d.ts +1 -1
  26. package/lib/features/project-list.d.ts +1 -1
  27. package/lib/features/project-list.js +1 -1
  28. package/lib/features/prompt-submission.d.ts +9 -0
  29. package/lib/features/prompt-submission.js +7 -1
  30. package/lib/features/session-mainline.d.ts +42 -1
  31. package/lib/features/session-mainline.js +29 -3
  32. package/lib/features/session-resume.d.ts +1 -1
  33. package/lib/gateway/inbound.d.ts +14 -0
  34. package/lib/gateway/inbound.js +41 -0
  35. package/lib/i18n.d.ts +1 -0
  36. package/lib/i18n.js +2 -0
  37. package/lib/index.js +142 -65
  38. package/lib/settings.js +5 -2
  39. package/lib/startup.d.ts +1 -1
  40. package/lib/startup.js +14 -7
  41. package/lib/stream/live.d.ts +0 -37
  42. package/lib/stream/live.js +10 -58
  43. package/lib/stream/tool-view.d.ts +12 -2
  44. package/lib/stream/tool-view.js +32 -2
  45. package/package.json +10 -12
  46. package/lib/dsh/api-proxy-face.d.ts +0 -390
  47. package/lib/dsh/api-proxy-face.js +0 -510
  48. package/lib/features/image-submission.d.ts +0 -67
  49. package/lib/features/image-submission.js +0 -57
@@ -1,510 +0,0 @@
1
- /**
2
- * The typed face over the Host's in-process `ctx.apiProxy` service
3
- * (ApiProxyService, the transport-agnostic gateway's direct implementation).
4
- * Its domain methods speak the narrow RPC signature: `RpcRequest<P>` in,
5
- * `RpcResponse<T>` out, business errors on `result.ok === false` — they never
6
- * throw for business outcomes. Two guarantees are added here:
7
- *
8
- * 1. Boundedness — a Host that never answers must not wedge an interaction
9
- * handler (or a Discord ephemeral) forever; every call races a timeout and
10
- * resolves to an unobservable outcome instead.
11
- * 2. Observability — every terminal outcome is reported through the injected
12
- * log sink, so a silent-void call can never again be misread as a hang.
13
- */
14
- import { parseWorkspaceReference } from '../policy/disclosure.js';
15
- /** Raised when the Host did not answer within the bounded window. */
16
- export class RpcTimeoutError extends Error {
17
- constructor(timeoutMs) {
18
- super(`dsh apiProxy call did not answer within ${String(timeoutMs)}ms`);
19
- this.name = 'RpcTimeoutError';
20
- }
21
- }
22
- /** Race one apiProxy promise against a bounded window. */
23
- export function withRpcTimeout(promise, timeoutMs) {
24
- let timer;
25
- const timeout = new Promise((_, reject) => {
26
- timer = setTimeout(() => { reject(new RpcTimeoutError(timeoutMs)); }, timeoutMs);
27
- });
28
- return Promise.race([promise, timeout]).finally(() => {
29
- if (timer !== undefined)
30
- clearTimeout(timer);
31
- });
32
- }
33
- /** Default bounded window for unary catalog reads (local, no model work). */
34
- const CATALOG_TIMEOUT_MS = 5_000;
35
- /** Default bounded window for prompt admission (the Host may enqueue). */
36
- const PROMPT_TIMEOUT_MS = 30_000;
37
- /** Mint a request envelope the way every client shape does. */
38
- function mintRequest(payload, rpcId) {
39
- return { rpcId: rpcId ?? crypto.randomUUID(), payload };
40
- }
41
- /**
42
- * The bind flow's catalog verifier: resolves an opaque `ws:` reference
43
- * against the live workspace list. A well-formed reference the catalog no
44
- * longer knows — and any malformed one — resolve `stale` (fail-closed, no
45
- * write can follow); a Host error is `failed`; a timeout is `unknown`.
46
- */
47
- export function createWorkspaceResolver(dsh, options = {}) {
48
- const port = createWorkspaceCatalogPort(dsh, options);
49
- return {
50
- async resolve(reference) {
51
- const catalog = await port.listWorkspaces();
52
- if (catalog.outcome !== 'completed') {
53
- return catalog.outcome === 'unknown' ? { outcome: 'unknown' } : { outcome: 'failed' };
54
- }
55
- const id = parseWorkspaceReference(reference);
56
- const found = id === undefined
57
- ? undefined
58
- : catalog.workspaces.find(workspace => workspace.id === id);
59
- return found === undefined
60
- ? { outcome: 'stale' }
61
- : { outcome: 'found', workspace: { id: found.id, title: found.title } };
62
- },
63
- };
64
- }
65
- /**
66
- * The `/project list` catalog port satisfied by the in-process apiProxy.
67
- * Outcomes follow the port contract: a definitive Host error is `failed`
68
- * (sanitized before Discord), while a timeout or unreadable body is
69
- * `unknown` — delivery was not observed, so no retry is implied.
70
- */
71
- export function createWorkspaceCatalogPort(dsh, options = {}) {
72
- const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
73
- const log = options.log;
74
- return {
75
- async listWorkspaces() {
76
- let response;
77
- try {
78
- response = await withRpcTimeout(dsh.workspace.list(mintRequest({})), timeoutMs);
79
- }
80
- catch (cause) {
81
- if (cause instanceof RpcTimeoutError) {
82
- log?.('discord_workspace_list_timeout', { timeoutMs });
83
- return { outcome: 'unknown' };
84
- }
85
- log?.('discord_workspace_list_threw', { cause: String(cause) });
86
- return { outcome: 'failed' };
87
- }
88
- // A malformed envelope is a definitive, sanitized failure: the Host
89
- // answered, so a retry is safe and semantics stay observable-free.
90
- const result = response?.result;
91
- if (result === undefined) {
92
- log?.('discord_workspace_list_malformed');
93
- return { outcome: 'failed' };
94
- }
95
- if (result.ok) {
96
- const items = Array.isArray(result.value.items)
97
- ? result.value.items
98
- : [];
99
- return {
100
- outcome: 'completed',
101
- workspaces: items.map(workspace => ({
102
- id: workspace.workspaceId,
103
- title: workspace.title,
104
- // The registered path rides every workspace.* row (Host
105
- // WorkspaceView); /session resume scopes candidates by it and
106
- // /project autocomplete abbreviates it — dropping it here once
107
- // silently emptied the resume list everywhere (16.46).
108
- ...(typeof workspace.path === 'string' ? { path: workspace.path } : {}),
109
- })),
110
- // The registry's archived set rides the workspace.list value
111
- // (sessions.list rows carry NO archived marker): /session resume
112
- // subtracts it — resuming an archived session dead-ends in a
113
- // thread whose turns never run (16.49).
114
- archivedSessionIds: Array.isArray(result.value.archivedSessionIds)
115
- ? result.value.archivedSessionIds.filter((id) => typeof id === 'string')
116
- : [],
117
- };
118
- }
119
- log?.('discord_workspace_list_rejected', {
120
- code: result.error.code,
121
- message: result.error.message,
122
- });
123
- return { outcome: 'failed' };
124
- },
125
- };
126
- }
127
- /**
128
- * Read one Workspace's full view (title plus canonical path). The path is
129
- * for the administrator-only ephemeral info response — the disclosure
130
- * policy owns whether it ever renders; this face only carries it in memory.
131
- */
132
- export async function readWorkspaceDetail(dsh, reference, options = {}) {
133
- const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
134
- const log = options.log;
135
- let response;
136
- try {
137
- response = await withRpcTimeout(dsh.workspace.list(mintRequest({})), timeoutMs);
138
- }
139
- catch (cause) {
140
- if (cause instanceof RpcTimeoutError) {
141
- log?.('discord_workspace_detail_timeout', { timeoutMs });
142
- return { outcome: 'unknown' };
143
- }
144
- log?.('discord_workspace_detail_threw', { cause: String(cause) });
145
- return { outcome: 'failed' };
146
- }
147
- const result = response?.result;
148
- if (result === undefined) {
149
- log?.('discord_workspace_detail_malformed');
150
- return { outcome: 'failed' };
151
- }
152
- if (!result.ok) {
153
- log?.('discord_workspace_detail_rejected', { code: result.error.code });
154
- return { outcome: 'failed' };
155
- }
156
- const id = parseWorkspaceReference(reference) ?? (reference === '' ? undefined : reference);
157
- const found = Array.isArray(result.value.items)
158
- ? result.value.items.find(workspace => workspace.workspaceId === id)
159
- : undefined;
160
- if (found === undefined)
161
- return { outcome: 'stale' };
162
- return {
163
- outcome: 'found',
164
- workspace: {
165
- id: found.workspaceId,
166
- title: found.title,
167
- path: typeof found.path === 'string' ? found.path : undefined,
168
- },
169
- };
170
- }
171
- /**
172
- * Submit one prompt turn through the in-process apiProxy. A definitive Host
173
- * error is a rejection carrying the sanitized code; a timeout is `unknown` —
174
- * the turn may or may not have been admitted, so callers must not resubmit.
175
- * `options.rpcId` pins the adapter-owned stable request id, which the Host
176
- * records on the durable `user/message` (`source.rpcId`) for reconciliation.
177
- */
178
- export async function promptSession(dsh, request, options = {}) {
179
- return submitPromptTurn(dsh, { ...request, mode: 'queue' }, options);
180
- }
181
- /**
182
- * Steer the session's active turn: `session.prompt` with `mode: 'steer'`,
183
- * carrying the same stable request-id discipline as the queue path.
184
- */
185
- export async function steerSession(dsh, request, options = {}) {
186
- return submitPromptTurn(dsh, { ...request, mode: 'steer' }, options);
187
- }
188
- async function submitPromptTurn(dsh, request, options) {
189
- const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
190
- const log = options.log;
191
- let response;
192
- try {
193
- response = await withRpcTimeout(dsh.sessions.prompt(mintRequest({
194
- sessionId: request.sessionId,
195
- mode: request.mode,
196
- content: [{ type: 'text', text: request.prompt }],
197
- }, options.rpcId)), timeoutMs);
198
- }
199
- catch (cause) {
200
- if (cause instanceof RpcTimeoutError) {
201
- log?.('discord_prompt_submit_timeout', { timeoutMs, sessionId: request.sessionId });
202
- return { outcome: 'unknown' };
203
- }
204
- log?.('discord_prompt_submit_threw', { cause: String(cause), sessionId: request.sessionId });
205
- return { outcome: 'unknown' };
206
- }
207
- const result = response?.result;
208
- if (result === undefined) {
209
- // An unreadable body leaves admission unobservable: the turn MAY have
210
- // been admitted, so the at-most-once discipline forbids resubmission.
211
- log?.('discord_prompt_submit_malformed', { sessionId: request.sessionId });
212
- return { outcome: 'unknown' };
213
- }
214
- if (result.ok) {
215
- // The wire schema pins accepted to literal true; the Host's own zod
216
- // parse enforces it before this seam ever sees the body.
217
- return { outcome: 'accepted' };
218
- }
219
- log?.('discord_prompt_submit_rejected', {
220
- code: result.error.code,
221
- sessionId: request.sessionId,
222
- });
223
- return { outcome: 'rejected', reason: result.error.code };
224
- }
225
- /**
226
- * Create one DSH Session against a preallocated id (design.md §10): DSH
227
- * adopts the same session id idempotently, so an uncertain response never
228
- * forks a second Session. Same outcome discipline as the prompt path.
229
- */
230
- export async function createSessionViaProxy(dsh, request, options = {}) {
231
- const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
232
- const log = options.log;
233
- let response;
234
- try {
235
- response = await withRpcTimeout(dsh.sessions.create(mintRequest({
236
- workspaceId: request.workspaceId,
237
- sessionId: request.sessionId,
238
- })), timeoutMs);
239
- }
240
- catch (cause) {
241
- if (cause instanceof RpcTimeoutError) {
242
- log?.('discord_session_create_timeout', { timeoutMs, sessionId: request.sessionId });
243
- return { outcome: 'unknown' };
244
- }
245
- log?.('discord_session_create_threw', { cause: String(cause), sessionId: request.sessionId });
246
- return { outcome: 'unknown' };
247
- }
248
- const result = response?.result;
249
- if (result === undefined) {
250
- log?.('discord_session_create_malformed', { sessionId: request.sessionId });
251
- return { outcome: 'unknown' };
252
- }
253
- if (result.ok) {
254
- return { outcome: 'completed', sessionId: result.value.sessionId };
255
- }
256
- log?.('discord_session_create_rejected', { code: result.error.code, sessionId: request.sessionId });
257
- return { outcome: 'rejected', reason: result.error.code };
258
- }
259
- /** List durable Session ids (`session.list`, v1 returns everything). */
260
- export async function listSessionIds(dsh, options = {}) {
261
- const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
262
- const log = options.log;
263
- let response;
264
- try {
265
- response = await withRpcTimeout(dsh.sessions.list(mintRequest({})), timeoutMs);
266
- }
267
- catch (cause) {
268
- if (cause instanceof RpcTimeoutError) {
269
- log?.('discord_session_list_timeout', { timeoutMs });
270
- return { outcome: 'unknown' };
271
- }
272
- log?.('discord_session_list_threw', { cause: String(cause) });
273
- return { outcome: 'unknown' };
274
- }
275
- const result = response?.result;
276
- if (result === undefined) {
277
- log?.('discord_session_list_malformed');
278
- return { outcome: 'failed' };
279
- }
280
- if (result.ok) {
281
- return { outcome: 'completed', ids: Array.isArray(result.value.items) ? result.value.items.map(item => item.sessionId) : [] };
282
- }
283
- log?.('discord_session_list_rejected', { code: result.error.code });
284
- return { outcome: 'failed' };
285
- }
286
- /**
287
- * The rich `sessions.list` for the /session resume surface: titles ride each
288
- * row's projection values (absence = the session has no title yet), blank
289
- * sessions are flagged, and rows arrive updatedAt-descending. Defensive
290
- * narrowing: the wire is untrusted, extra/missing fields never throw.
291
- */
292
- export async function listSessionSummaries(dsh, options = {}) {
293
- const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
294
- const log = options.log;
295
- let response;
296
- try {
297
- response = await withRpcTimeout(dsh.sessions.list(mintRequest({})), timeoutMs);
298
- }
299
- catch (cause) {
300
- if (cause instanceof RpcTimeoutError) {
301
- log?.('discord_session_summaries_timeout', { timeoutMs });
302
- return { outcome: 'unknown' };
303
- }
304
- log?.('discord_session_summaries_threw', { cause: String(cause) });
305
- return { outcome: 'unknown' };
306
- }
307
- const result = response?.result;
308
- if (result === undefined || !result.ok || !Array.isArray(result.value.items)) {
309
- log?.('discord_session_summaries_malformed');
310
- return { outcome: 'failed' };
311
- }
312
- const sessions = [];
313
- // The wire is untrusted: narrow every row defensively before use.
314
- const items = Array.isArray(result.value.items) ? result.value.items : [];
315
- for (const item of items) {
316
- if (typeof item !== 'object' || item === null)
317
- continue;
318
- const row = item;
319
- if (typeof row.sessionId !== 'string' || row.sessionId === '')
320
- continue;
321
- const values = row.projections?.values;
322
- const title = typeof values?.title === 'string' && values.title !== '' ? values.title : undefined;
323
- sessions.push({
324
- sessionId: row.sessionId,
325
- title,
326
- updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
327
- running: row.running ?? false,
328
- blank: row.blank === true,
329
- cwd: typeof row.cwd === 'string' ? row.cwd : undefined,
330
- origin: row.origin === 'subagent' ? 'subagent' : undefined,
331
- });
332
- }
333
- return { outcome: 'completed', sessions };
334
- }
335
- /** Cancel the session's active turn (`session.cancel`); DSH preserves the pending inbox. */
336
- export async function cancelSessionViaProxy(dsh, request, options = {}) {
337
- const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
338
- const log = options.log;
339
- let response;
340
- try {
341
- response = await withRpcTimeout(dsh.sessions.cancel(mintRequest({ sessionId: request.sessionId })), timeoutMs);
342
- }
343
- catch (cause) {
344
- if (cause instanceof RpcTimeoutError) {
345
- log?.('discord_session_cancel_timeout', { timeoutMs, sessionId: request.sessionId });
346
- return { outcome: 'unknown' };
347
- }
348
- log?.('discord_session_cancel_threw', { cause: String(cause), sessionId: request.sessionId });
349
- return { outcome: 'unknown' };
350
- }
351
- const result = response?.result;
352
- if (result === undefined) {
353
- log?.('discord_session_cancel_malformed', { sessionId: request.sessionId });
354
- return { outcome: 'unknown' };
355
- }
356
- if (result.ok)
357
- return { outcome: 'accepted' };
358
- log?.('discord_session_cancel_rejected', { code: result.error.code, sessionId: request.sessionId });
359
- return { outcome: 'rejected', reason: result.error.code };
360
- }
361
- /** Remove one pending inbox item (`session.updateQueue`, action remove). */
362
- export async function removeQueueItemViaProxy(dsh, request, options = {}) {
363
- const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
364
- const log = options.log;
365
- let response;
366
- try {
367
- response = await withRpcTimeout(dsh.sessions.updateQueue(mintRequest({
368
- sessionId: request.sessionId,
369
- itemId: request.itemId,
370
- action: { kind: 'remove' },
371
- })), timeoutMs);
372
- }
373
- catch (cause) {
374
- if (cause instanceof RpcTimeoutError) {
375
- log?.('discord_queue_remove_timeout', { timeoutMs, sessionId: request.sessionId });
376
- return { outcome: 'unknown' };
377
- }
378
- log?.('discord_queue_remove_threw', { cause: String(cause), sessionId: request.sessionId });
379
- return { outcome: 'unknown' };
380
- }
381
- const result = response?.result;
382
- if (result === undefined) {
383
- log?.('discord_queue_remove_malformed', { sessionId: request.sessionId });
384
- return { outcome: 'unknown' };
385
- }
386
- if (result.ok)
387
- return { outcome: 'accepted' };
388
- log?.('discord_queue_remove_rejected', { code: result.error.code, sessionId: request.sessionId });
389
- return { outcome: 'rejected', reason: result.error.code };
390
- }
391
- /**
392
- * The respond face for answerable server-requests (approvals, questions).
393
- * Builds the full ClientResponse envelope — {type: 'client-response',
394
- * rpcId, result: {ok: true, value}} is the wire contract; posting the bare
395
- * payload is silently ignored by the Host (rpcId never resolves) — and
396
- * maps the RpcReceipt onto the port outcome. Like every call in this module
397
- * it is bounded (a Host that never resolves the receipt must not wedge an
398
- * interaction handler or an expiry sweep forever) and never throws: a Host
399
- * rejection or timeout resolves to `unknown` — the response may or may not
400
- * have landed, and the callers park the ask `unresolved` on exactly that.
401
- */
402
- export function createClientRespondPort(dsh, options = {}) {
403
- const log = options.log;
404
- const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
405
- return {
406
- async respond(rpcId, value) {
407
- let receipt;
408
- try {
409
- const response = await withRpcTimeout(dsh.respond({
410
- type: 'client-response',
411
- rpcId,
412
- result: { ok: true, value },
413
- }), timeoutMs);
414
- receipt = response;
415
- }
416
- catch (cause) {
417
- const reason = cause instanceof RpcTimeoutError ? 'discord_client_respond_timeout' : 'discord_client_respond_threw';
418
- log?.(reason, { rpcId, ...(cause instanceof RpcTimeoutError ? { timeoutMs } : { cause: String(cause) }) });
419
- return { outcome: 'unknown' };
420
- }
421
- const table = receipt;
422
- log?.('discord_client_respond_receipt', { rpcId, receipt: table ?? null });
423
- if (table?.accepted === true)
424
- return { outcome: 'confirmed' };
425
- if (table?.accepted === false) {
426
- return { outcome: 'rejected', reason: typeof table.reason === 'string' ? table.reason : 'respond-refused' };
427
- }
428
- return { outcome: 'unknown' };
429
- },
430
- };
431
- }
432
- /** Bounded window for the per-session model directory read. */
433
- const MODELS_TIMEOUT_MS = 10_000;
434
- /**
435
- * The session's detached model directory: the live selection, whether the
436
- * current route still serves, and the per-provider catalog groups the
437
- * /model cascade browses.
438
- */
439
- export async function sessionModels(dsh, request, options = {}) {
440
- const timeoutMs = options.timeoutMs ?? MODELS_TIMEOUT_MS;
441
- let response;
442
- try {
443
- response = await withRpcTimeout(dsh.sessions.models(mintRequest({ sessionId: request.sessionId })), timeoutMs);
444
- }
445
- catch (cause) {
446
- if (cause instanceof RpcTimeoutError) {
447
- options.log?.('discord_models_timeout', { sessionId: request.sessionId });
448
- return { outcome: 'unknown' };
449
- }
450
- options.log?.('discord_models_threw', { cause: String(cause), sessionId: request.sessionId });
451
- return { outcome: 'unknown' };
452
- }
453
- const result = response?.result;
454
- if (result === undefined || !result.ok) {
455
- options.log?.('discord_models_malformed', {
456
- sessionId: request.sessionId,
457
- code: result?.ok === false ? result.error.code : 'malformed',
458
- });
459
- return { outcome: 'failed' };
460
- }
461
- return { outcome: 'completed', models: result.value };
462
- }
463
- /**
464
- * Select the complete model selection for one session (session.selectModel):
465
- * the session switches immediately and the Host records the choice as the
466
- * default for sessions that have not logged their own — the response only
467
- * proves the session switch, so callers must not claim the persistence
468
- * outcome (design.md §7).
469
- */
470
- export async function selectSessionModel(dsh, request, options = {}) {
471
- const timeoutMs = options.timeoutMs ?? PROMPT_TIMEOUT_MS;
472
- let response;
473
- try {
474
- response = await withRpcTimeout(dsh.sessions.selectModel(mintRequest({
475
- sessionId: request.sessionId,
476
- provider: request.provider,
477
- model: request.model,
478
- ...(request.reasoningEffort === undefined ? {} : { reasoningEffort: request.reasoningEffort }),
479
- })), timeoutMs);
480
- }
481
- catch (cause) {
482
- if (cause instanceof RpcTimeoutError) {
483
- options.log?.('discord_model_select_timeout', { sessionId: request.sessionId });
484
- return { outcome: 'unknown' };
485
- }
486
- options.log?.('discord_model_select_threw', { cause: String(cause), sessionId: request.sessionId });
487
- return { outcome: 'unknown' };
488
- }
489
- const result = response?.result;
490
- if (result === undefined) {
491
- options.log?.('discord_model_select_malformed', { sessionId: request.sessionId });
492
- return { outcome: 'unknown' };
493
- }
494
- if (result.ok) {
495
- return { outcome: 'completed', selected: result.value.selected };
496
- }
497
- options.log?.('discord_model_select_rejected', { code: result.error.code, sessionId: request.sessionId });
498
- return { outcome: 'rejected', reason: result.error.code };
499
- }
500
- /**
501
- * The /model surface over the real session RPCs: the per-session live
502
- * directory (`session.models`) and the guarded selection mutation
503
- * (`session.selectModel`) — the shapes model-control reasons about.
504
- */
505
- export function createModelPort(dsh, options = {}) {
506
- return {
507
- models: sessionId => sessionModels(dsh, { sessionId }, options),
508
- selectModel: request => selectSessionModel(dsh, request, options),
509
- };
510
- }
@@ -1,67 +0,0 @@
1
- /**
2
- * Image submission through `session.prompt` (design.md §12, task 12.3).
3
- * Mixed text/images encode into ordered prompt parts; the model's modality
4
- * is checked BEFORE any DSH call; and the request id claims an intent so a
5
- * duplicate Discord delivery never resubmits. Host outcomes map to values
6
- * with the intent state kept honest (failed on rejection, unknown preserved
7
- * for reconciliation).
8
- */
9
- import { type IntentStore } from '../state/intents.js';
10
- export type PromptPart = {
11
- type: 'text';
12
- text: string;
13
- } | {
14
- type: 'image';
15
- mediaType: string;
16
- data: string;
17
- };
18
- export interface DshImagePromptPort {
19
- submit(request: {
20
- requestId: string;
21
- sessionId: string;
22
- parts: readonly PromptPart[];
23
- }): Promise<{
24
- outcome: 'accepted';
25
- } | {
26
- outcome: 'rejected';
27
- } | {
28
- outcome: 'unknown';
29
- }>;
30
- }
31
- export interface ImageSubmissionDeps {
32
- prompts: DshImagePromptPort;
33
- intents: IntentStore;
34
- /** Whether the session's current model accepts image parts. */
35
- modality: () => boolean;
36
- nowMs: () => number;
37
- }
38
- export type ImageSubmissionResult = {
39
- outcome: 'accepted';
40
- } | {
41
- outcome: 'rejected';
42
- } | {
43
- outcome: 'unknown';
44
- } | {
45
- outcome: 'already-submitted';
46
- } | {
47
- outcome: 'conflict';
48
- } | {
49
- outcome: 'refused';
50
- reason: 'unsupported-modality';
51
- };
52
- /** Encode text plus images into ordered prompt parts. */
53
- export declare function encodePromptParts(text: string, images: ReadonlyArray<{
54
- mediaType: string;
55
- base64: string;
56
- }>): PromptPart[];
57
- export declare function createImageSubmissionFlow(deps: ImageSubmissionDeps): {
58
- submit(request: {
59
- requestId: string;
60
- sessionId: string;
61
- text: string;
62
- images: ReadonlyArray<{
63
- mediaType: string;
64
- base64: string;
65
- }>;
66
- }): Promise<ImageSubmissionResult>;
67
- };
@@ -1,57 +0,0 @@
1
- /**
2
- * Image submission through `session.prompt` (design.md §12, task 12.3).
3
- * Mixed text/images encode into ordered prompt parts; the model's modality
4
- * is checked BEFORE any DSH call; and the request id claims an intent so a
5
- * duplicate Discord delivery never resubmits. Host outcomes map to values
6
- * with the intent state kept honest (failed on rejection, unknown preserved
7
- * for reconciliation).
8
- */
9
- import { hashPayload } from '../state/intents.js';
10
- /** Encode text plus images into ordered prompt parts. */
11
- export function encodePromptParts(text, images) {
12
- const parts = [];
13
- const trimmed = text.trim();
14
- if (trimmed !== '')
15
- parts.push({ type: 'text', text: trimmed });
16
- for (const image of images) {
17
- parts.push({ type: 'image', mediaType: image.mediaType, data: image.base64 });
18
- }
19
- return parts;
20
- }
21
- export function createImageSubmissionFlow(deps) {
22
- return {
23
- async submit(request) {
24
- const parts = encodePromptParts(request.text, request.images);
25
- // Modality gate: refuse BEFORE the intent claim so an explicit user
26
- // retry with a text-only message is a clean new attempt.
27
- if (request.images.length > 0 && !deps.modality()) {
28
- return { outcome: 'refused', reason: 'unsupported-modality' };
29
- }
30
- const contentHash = await hashPayload({ sessionId: request.sessionId, parts });
31
- const claim = await deps.intents.claim({
32
- messageId: request.requestId,
33
- contentHash,
34
- claimedAtMs: deps.nowMs(),
35
- });
36
- if (claim.outcome === 'conflict')
37
- return { outcome: 'conflict' };
38
- if (claim.outcome === 'duplicate')
39
- return { outcome: 'already-submitted' };
40
- const submitted = await deps.prompts.submit({
41
- requestId: request.requestId,
42
- sessionId: request.sessionId,
43
- parts,
44
- });
45
- if (submitted.outcome === 'accepted') {
46
- await deps.intents.resolve(request.requestId, 'succeeded', deps.nowMs());
47
- return { outcome: 'accepted' };
48
- }
49
- if (submitted.outcome === 'rejected') {
50
- await deps.intents.resolve(request.requestId, 'failed', deps.nowMs());
51
- return { outcome: 'rejected' };
52
- }
53
- await deps.intents.resolve(request.requestId, 'unknown', deps.nowMs());
54
- return { outcome: 'unknown' };
55
- },
56
- };
57
- }