@addozhang/dsh-discord 0.2.3 → 0.4.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/README.md +4 -3
- package/README.zh.md +4 -3
- package/lib/compose.d.ts +20 -0
- package/lib/compose.js +8 -3
- package/lib/discord/commands.js +6 -0
- package/lib/dsh/api-proxy-face.d.ts +59 -9
- package/lib/dsh/api-proxy-face.js +67 -1
- package/lib/features/image-collection.d.ts +11 -0
- package/lib/features/image-collection.js +22 -8
- package/lib/features/image-download.d.ts +6 -0
- package/lib/features/image-download.js +21 -0
- package/lib/features/interaction-router.d.ts +42 -0
- package/lib/features/interaction-router.js +79 -6
- package/lib/features/project-list.d.ts +1 -0
- package/lib/features/prompt-submission.d.ts +9 -0
- package/lib/features/prompt-submission.js +7 -1
- package/lib/features/session-mainline.d.ts +42 -1
- package/lib/features/session-mainline.js +29 -3
- package/lib/features/session-resume.d.ts +64 -30
- package/lib/features/session-resume.js +108 -35
- package/lib/gateway/inbound.d.ts +14 -0
- package/lib/gateway/inbound.js +41 -0
- package/lib/i18n.d.ts +14 -0
- package/lib/i18n.js +32 -0
- package/lib/index.js +128 -5
- package/package.json +13 -2
- package/lib/features/image-submission.d.ts +0 -67
- package/lib/features/image-submission.js +0 -57
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
[](https://github.com/addozhang/dsh-discord/actions/workflows/ci.yml)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
[](./package.json)
|
|
9
|
+
[](https://dshfind.com/en/plugins/addozhang/dsh-discord?ref=badge)
|
|
9
10
|
|
|
10
11
|
A Discord-first adapter for [DeepSeek Harness](https://github.com/deepseek-ai): run DSH sessions from a Discord guild — mention the bot to open a task thread, steer and stop turns, answer approvals and questions inline, and watch the answer stream in.
|
|
11
12
|
|
|
@@ -17,7 +18,7 @@ This is a function/namespace plugin (`inject: ['apiProxy', 'credentials', 'setti
|
|
|
17
18
|
|
|
18
19
|
## Features
|
|
19
20
|
|
|
20
|
-
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (the author's message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention.
|
|
21
|
+
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (the author's message becomes the first post), creates the DSH session, and submits the prompt at most once. Follow-ups inside the thread queue without a mention. Attached images ride along: they are downloaded from the Discord CDN within strict size/host bounds and submitted as image parts for multimodal models.
|
|
21
22
|
- **Stream rendering** — typing indicators, a single edited head message, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
|
|
22
23
|
- **Approvals & questions** — DSH ask frames become Discord buttons, select menus, and a free-text modal; ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
|
|
23
24
|
|
|
@@ -109,6 +110,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
109
110
|
| `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
|
|
110
111
|
| `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
|
|
111
112
|
| `/model show` / `select` | session thread | show the live model directory; `select` without arguments walks the interactive provider → model → reasoning cascade (any authorized member by default) |
|
|
113
|
+
| `/session resume` | project channel | pick one of this workspace's past sessions (autocomplete: title and age, newest first) and resume it into a new thread of this channel; blank, already-bound, subagent, and archived sessions are never offered |
|
|
112
114
|
| `/guild forget` | any channel | operator-only removal of adapter records |
|
|
113
115
|
|
|
114
116
|
## Design notes
|
|
@@ -122,7 +124,6 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
122
124
|
|
|
123
125
|
## Known Limitations and Deferred Work
|
|
124
126
|
|
|
125
|
-
- **`/session new|resume` is not registered** — the selector and cold-adoption modules are implemented and unit-tested, but the Host RPC face cannot back them yet (`sessions.list` v1 returns bare ids; no `session.inspect`). They return with the next milestone.
|
|
126
127
|
- **`/preset`, `/skill`, and `/host` stay deregistered** — their control modules are implemented and unit-tested and return when the router wires them (the `/preset` thread-context guard rides along).
|
|
127
128
|
- **Verbosity is a single global setting** (the DSH ecosystem has per-channel precedent).
|
|
128
129
|
- **Deferred after a Kimaki parity pass**: reconcile-interactions wiring, typing pause during ask waits, fail-closed binding/session-owner store wiring, and credential-rotation watching.
|
|
@@ -132,7 +133,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
132
133
|
|
|
133
134
|
```sh
|
|
134
135
|
pnpm install --ignore-scripts
|
|
135
|
-
pnpm test #
|
|
136
|
+
pnpm test # 683 tests incl. gateway/REST twin E2E
|
|
136
137
|
pnpm typecheck
|
|
137
138
|
pnpm lint
|
|
138
139
|
pnpm build # lib + client bundle
|
package/README.zh.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
[](https://github.com/addozhang/dsh-discord/actions/workflows/ci.yml)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
[](./package.json)
|
|
9
|
+
[](https://dshfind.com/en/plugins/addozhang/dsh-discord?ref=badge)
|
|
9
10
|
|
|
10
11
|
[DeepSeek Harness](https://github.com/deepseek-ai) 的 Discord 优先适配器:在 Discord 服务器中运行 DSH 会话——@机器人即可开启任务线程、插话与停止运行、在线审批与回答问题,并实时观看回答流式输出。
|
|
11
12
|
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
|
|
18
19
|
## 功能
|
|
19
20
|
|
|
20
|
-
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @
|
|
21
|
+
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
|
|
21
22
|
- **流式渲染** — typing 指示、单条头消息编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
|
|
22
23
|
- **审批与提问** — DSH ask 帧渲染为 Discord 按钮、下拉菜单与自由文本弹窗;所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
|
|
23
24
|
|
|
@@ -109,6 +110,7 @@ dsh-discord:
|
|
|
109
110
|
| `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
|
|
110
111
|
| `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
|
|
111
112
|
| `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
|
|
113
|
+
| `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现 |
|
|
112
114
|
| `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
|
|
113
115
|
|
|
114
116
|
|
|
@@ -123,7 +125,6 @@ dsh-discord:
|
|
|
123
125
|
|
|
124
126
|
## 已知限制与推迟项
|
|
125
127
|
|
|
126
|
-
- **`/session new|resume` 未注册** — 选择器与冷收养模块已实现并通过单元测试,但 Host RPC 面尚不支持(`sessions.list` v1 只返回裸 id;缺少 `session.inspect`)。将在下个里程碑回归。
|
|
127
128
|
- **`/preset`、`/skill`、`/host` 保持注销状态** — 控制模块已实现并通过单元测试,待路由接线时回归(`/preset` 的会话线程守卫一并处理)。
|
|
128
129
|
- **verbosity 为全局设置**(DSH 生态有按频道设置的先例)。
|
|
129
130
|
- **经 Kimaki 对齐后有意推迟**:reconcile-interactions 接线、ask 等待期暂停 typing、fail-closed 绑定/会话属主 store 接线、凭据轮换监听。
|
|
@@ -133,7 +134,7 @@ dsh-discord:
|
|
|
133
134
|
|
|
134
135
|
```sh
|
|
135
136
|
pnpm install --ignore-scripts
|
|
136
|
-
pnpm test #
|
|
137
|
+
pnpm test # 683 tests incl. gateway/REST twin E2E
|
|
137
138
|
pnpm typecheck
|
|
138
139
|
pnpm lint
|
|
139
140
|
pnpm build # lib + client bundle
|
package/lib/compose.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface SessionMainlinePort {
|
|
|
31
31
|
authorId: string;
|
|
32
32
|
workspaceId: string;
|
|
33
33
|
prompt: string;
|
|
34
|
+
/** Declared image attachments carried by the message (16.50). */
|
|
35
|
+
images: ReadonlyArray<{
|
|
36
|
+
url: string;
|
|
37
|
+
filename: string;
|
|
38
|
+
declaredSize: number;
|
|
39
|
+
contentType: string;
|
|
40
|
+
}>;
|
|
34
41
|
}): Promise<{
|
|
35
42
|
outcome: 'admitted';
|
|
36
43
|
threadId: string;
|
|
@@ -47,6 +54,9 @@ export interface SessionMainlinePort {
|
|
|
47
54
|
outcome: 'prompt-rejected';
|
|
48
55
|
} | {
|
|
49
56
|
outcome: 'prompt-unknown';
|
|
57
|
+
} | {
|
|
58
|
+
outcome: 'image-failed';
|
|
59
|
+
reason: string;
|
|
50
60
|
}>;
|
|
51
61
|
continueInThread(request: {
|
|
52
62
|
applicationId: string;
|
|
@@ -55,6 +65,13 @@ export interface SessionMainlinePort {
|
|
|
55
65
|
sessionId: string;
|
|
56
66
|
messageId: string;
|
|
57
67
|
prompt: string;
|
|
68
|
+
/** Declared image attachments carried by the message (16.50). */
|
|
69
|
+
images: ReadonlyArray<{
|
|
70
|
+
url: string;
|
|
71
|
+
filename: string;
|
|
72
|
+
declaredSize: number;
|
|
73
|
+
contentType: string;
|
|
74
|
+
}>;
|
|
58
75
|
}): Promise<{
|
|
59
76
|
outcome: 'queued';
|
|
60
77
|
} | {
|
|
@@ -65,6 +82,9 @@ export interface SessionMainlinePort {
|
|
|
65
82
|
outcome: 'rejected';
|
|
66
83
|
} | {
|
|
67
84
|
outcome: 'unknown';
|
|
85
|
+
} | {
|
|
86
|
+
outcome: 'image-failed';
|
|
87
|
+
reason: string;
|
|
68
88
|
}>;
|
|
69
89
|
}
|
|
70
90
|
export interface BindingsProbe {
|
package/lib/compose.js
CHANGED
|
@@ -18,10 +18,13 @@ export const GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json';
|
|
|
18
18
|
export function routeEvent(deps, event, decision) {
|
|
19
19
|
if (event.kind === 'message') {
|
|
20
20
|
const prompt = event.content.trim();
|
|
21
|
+
const images = event.images;
|
|
22
|
+
// Text and images are independent task carriers: either alone admits.
|
|
23
|
+
const hasTask = prompt !== '' || images.length > 0;
|
|
21
24
|
// Adapter-owned thread: ordinary continuation, no mention required.
|
|
22
25
|
const sessionId = deps.bindings.sessionForThread(event.guildId, event.channelId);
|
|
23
26
|
if (sessionId !== undefined) {
|
|
24
|
-
if (
|
|
27
|
+
if (!hasTask)
|
|
25
28
|
return;
|
|
26
29
|
const request = {
|
|
27
30
|
applicationId: deps.applicationId(),
|
|
@@ -30,10 +33,11 @@ export function routeEvent(deps, event, decision) {
|
|
|
30
33
|
sessionId,
|
|
31
34
|
messageId: event.messageId,
|
|
32
35
|
prompt,
|
|
36
|
+
images,
|
|
33
37
|
};
|
|
34
38
|
void deps.mainline.continueInThread(request)
|
|
35
39
|
.then((result) => {
|
|
36
|
-
if (result.outcome === 'rejected' || result.outcome === 'unknown') {
|
|
40
|
+
if (result.outcome === 'rejected' || result.outcome === 'unknown' || result.outcome === 'image-failed') {
|
|
37
41
|
deps.logger?.warn('discord_continuation_not_queued', { ...result, messageId: event.messageId });
|
|
38
42
|
}
|
|
39
43
|
})
|
|
@@ -63,7 +67,7 @@ export function routeEvent(deps, event, decision) {
|
|
|
63
67
|
}
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
|
-
if (
|
|
70
|
+
if (!hasTask)
|
|
67
71
|
return;
|
|
68
72
|
void deps.mainline.admitMention({
|
|
69
73
|
applicationId: deps.applicationId(),
|
|
@@ -73,6 +77,7 @@ export function routeEvent(deps, event, decision) {
|
|
|
73
77
|
authorId: event.authorId,
|
|
74
78
|
workspaceId,
|
|
75
79
|
prompt,
|
|
80
|
+
images,
|
|
76
81
|
})
|
|
77
82
|
.then((result) => {
|
|
78
83
|
if (result.outcome !== 'admitted') {
|
package/lib/discord/commands.js
CHANGED
|
@@ -35,6 +35,12 @@ export const MILESTONE_ONE_COMMANDS = [
|
|
|
35
35
|
// → reasoning cascade (16.35); typing `provider/model` applies directly.
|
|
36
36
|
{ name: 'select', options: [{ name: 'model', required: false }, { name: 'reasoning', required: false }] },
|
|
37
37
|
]),
|
|
38
|
+
grouped('session', 'Resume a DSH session into a new thread', [
|
|
39
|
+
// Typing filters live candidates by session title (autocomplete);
|
|
40
|
+
// /session new is deliberately absent — the @mention IS the new-session
|
|
41
|
+
// path (design.md §13).
|
|
42
|
+
{ name: 'resume', options: [{ name: 'session', required: true, autocomplete: true }] },
|
|
43
|
+
]),
|
|
38
44
|
grouped('guild', 'Guild-scoped adapter operations', [
|
|
39
45
|
{ name: 'forget' },
|
|
40
46
|
]),
|
|
@@ -34,6 +34,10 @@ export interface DshApiProxyFace {
|
|
|
34
34
|
content: Array<{
|
|
35
35
|
type: 'text';
|
|
36
36
|
text: string;
|
|
37
|
+
} | {
|
|
38
|
+
type: 'image';
|
|
39
|
+
mediaType: string;
|
|
40
|
+
data: string;
|
|
37
41
|
}>;
|
|
38
42
|
}>): Promise<RpcResponseShape<{
|
|
39
43
|
accepted: true;
|
|
@@ -62,9 +66,7 @@ export interface DshApiProxyFace {
|
|
|
62
66
|
list(request: RpcRequestShape<{
|
|
63
67
|
cursor?: string;
|
|
64
68
|
}>): Promise<RpcResponseShape<{
|
|
65
|
-
items:
|
|
66
|
-
sessionId: string;
|
|
67
|
-
}>;
|
|
69
|
+
items: SessionSummaryShape[];
|
|
68
70
|
}>>;
|
|
69
71
|
models(request: RpcRequestShape<{
|
|
70
72
|
sessionId: string;
|
|
@@ -79,6 +81,29 @@ export interface DshApiProxyFace {
|
|
|
79
81
|
}>>;
|
|
80
82
|
};
|
|
81
83
|
}
|
|
84
|
+
/** Defensive read of the title projection in a list row's values. */
|
|
85
|
+
export interface SessionProjectionsShape {
|
|
86
|
+
values?: {
|
|
87
|
+
title?: unknown;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** The per-session summary `sessions.list` returns (rc.2 rich rows). */
|
|
91
|
+
export interface SessionSummaryShape {
|
|
92
|
+
sessionId: string;
|
|
93
|
+
updatedAt: number;
|
|
94
|
+
running: boolean;
|
|
95
|
+
blank: boolean;
|
|
96
|
+
cwd?: string;
|
|
97
|
+
agentPreset?: string;
|
|
98
|
+
origin?: 'subagent';
|
|
99
|
+
projections?: SessionProjectionsShape;
|
|
100
|
+
}
|
|
101
|
+
/** The complete provider/model/reasoning selection (dsh-agent ModelSelection). */
|
|
102
|
+
export interface ModelSelectionShape {
|
|
103
|
+
provider: string;
|
|
104
|
+
model: string;
|
|
105
|
+
reasoningEffort?: string;
|
|
106
|
+
}
|
|
82
107
|
/** One reasoning effort a model's adapter advertises (sessions.d.ts). */
|
|
83
108
|
import type { DshModelPort } from '../features/model-control.js';
|
|
84
109
|
export interface ModelReasoningEffortShape {
|
|
@@ -115,12 +140,6 @@ export interface SessionModelsShape {
|
|
|
115
140
|
message: string;
|
|
116
141
|
}>;
|
|
117
142
|
}
|
|
118
|
-
/** The complete provider/model/reasoning selection (dsh-agent ModelSelection). */
|
|
119
|
-
export interface ModelSelectionShape {
|
|
120
|
-
provider: string;
|
|
121
|
-
model: string;
|
|
122
|
-
reasoningEffort?: string;
|
|
123
|
-
}
|
|
124
143
|
/** Signature-layer narrow request form (RpcId brand erased at this seam). */
|
|
125
144
|
export interface RpcRequestShape<P> {
|
|
126
145
|
rpcId: string;
|
|
@@ -200,10 +219,16 @@ export type PromptOutcome = {
|
|
|
200
219
|
* the turn may or may not have been admitted, so callers must not resubmit.
|
|
201
220
|
* `options.rpcId` pins the adapter-owned stable request id, which the Host
|
|
202
221
|
* records on the durable `user/message` (`source.rpcId`) for reconciliation.
|
|
222
|
+
* Images (16.50) encode as ordered `image` parts after the text part — the
|
|
223
|
+
* rc.2 `session.prompt` content is parts-shaped at this seam.
|
|
203
224
|
*/
|
|
204
225
|
export declare function promptSession(dsh: DshApiProxyFace, request: {
|
|
205
226
|
sessionId: string;
|
|
206
227
|
prompt: string;
|
|
228
|
+
images?: ReadonlyArray<{
|
|
229
|
+
mediaType: string;
|
|
230
|
+
base64: string;
|
|
231
|
+
}>;
|
|
207
232
|
}, options?: ApiProxyFaceOptions & {
|
|
208
233
|
rpcId?: string;
|
|
209
234
|
}): Promise<PromptOutcome>;
|
|
@@ -246,6 +271,31 @@ export type SessionIdListOutcome = {
|
|
|
246
271
|
};
|
|
247
272
|
/** List durable Session ids (`session.list`, v1 returns everything). */
|
|
248
273
|
export declare function listSessionIds(dsh: DshApiProxyFace, options?: ApiProxyFaceOptions): Promise<SessionIdListOutcome>;
|
|
274
|
+
/** A list row narrowed to what the /session resume surface renders. */
|
|
275
|
+
export interface SessionResumeRow {
|
|
276
|
+
sessionId: string;
|
|
277
|
+
title: string | undefined;
|
|
278
|
+
updatedAt: number;
|
|
279
|
+
running: boolean;
|
|
280
|
+
blank: boolean;
|
|
281
|
+
cwd: string | undefined;
|
|
282
|
+
origin: 'subagent' | undefined;
|
|
283
|
+
}
|
|
284
|
+
export type SessionSummariesOutcome = {
|
|
285
|
+
outcome: 'completed';
|
|
286
|
+
sessions: SessionResumeRow[];
|
|
287
|
+
} | {
|
|
288
|
+
outcome: 'failed';
|
|
289
|
+
} | {
|
|
290
|
+
outcome: 'unknown';
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* The rich `sessions.list` for the /session resume surface: titles ride each
|
|
294
|
+
* row's projection values (absence = the session has no title yet), blank
|
|
295
|
+
* sessions are flagged, and rows arrive updatedAt-descending. Defensive
|
|
296
|
+
* narrowing: the wire is untrusted, extra/missing fields never throw.
|
|
297
|
+
*/
|
|
298
|
+
export declare function listSessionSummaries(dsh: DshApiProxyFace, options?: ApiProxyFaceOptions): Promise<SessionSummariesOutcome>;
|
|
249
299
|
export type CancelOutcome = {
|
|
250
300
|
outcome: 'accepted';
|
|
251
301
|
} | {
|
|
@@ -101,7 +101,19 @@ export function createWorkspaceCatalogPort(dsh, options = {}) {
|
|
|
101
101
|
workspaces: items.map(workspace => ({
|
|
102
102
|
id: workspace.workspaceId,
|
|
103
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 } : {}),
|
|
104
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
|
+
: [],
|
|
105
117
|
};
|
|
106
118
|
}
|
|
107
119
|
log?.('discord_workspace_list_rejected', {
|
|
@@ -162,6 +174,8 @@ export async function readWorkspaceDetail(dsh, reference, options = {}) {
|
|
|
162
174
|
* the turn may or may not have been admitted, so callers must not resubmit.
|
|
163
175
|
* `options.rpcId` pins the adapter-owned stable request id, which the Host
|
|
164
176
|
* records on the durable `user/message` (`source.rpcId`) for reconciliation.
|
|
177
|
+
* Images (16.50) encode as ordered `image` parts after the text part — the
|
|
178
|
+
* rc.2 `session.prompt` content is parts-shaped at this seam.
|
|
165
179
|
*/
|
|
166
180
|
export async function promptSession(dsh, request, options = {}) {
|
|
167
181
|
return submitPromptTurn(dsh, { ...request, mode: 'queue' }, options);
|
|
@@ -181,7 +195,10 @@ async function submitPromptTurn(dsh, request, options) {
|
|
|
181
195
|
response = await withRpcTimeout(dsh.sessions.prompt(mintRequest({
|
|
182
196
|
sessionId: request.sessionId,
|
|
183
197
|
mode: request.mode,
|
|
184
|
-
content: [
|
|
198
|
+
content: [
|
|
199
|
+
{ type: 'text', text: request.prompt },
|
|
200
|
+
...(request.images ?? []).map(image => ({ type: 'image', mediaType: image.mediaType, data: image.base64 })),
|
|
201
|
+
],
|
|
185
202
|
}, options.rpcId)), timeoutMs);
|
|
186
203
|
}
|
|
187
204
|
catch (cause) {
|
|
@@ -271,6 +288,55 @@ export async function listSessionIds(dsh, options = {}) {
|
|
|
271
288
|
log?.('discord_session_list_rejected', { code: result.error.code });
|
|
272
289
|
return { outcome: 'failed' };
|
|
273
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* The rich `sessions.list` for the /session resume surface: titles ride each
|
|
293
|
+
* row's projection values (absence = the session has no title yet), blank
|
|
294
|
+
* sessions are flagged, and rows arrive updatedAt-descending. Defensive
|
|
295
|
+
* narrowing: the wire is untrusted, extra/missing fields never throw.
|
|
296
|
+
*/
|
|
297
|
+
export async function listSessionSummaries(dsh, options = {}) {
|
|
298
|
+
const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
299
|
+
const log = options.log;
|
|
300
|
+
let response;
|
|
301
|
+
try {
|
|
302
|
+
response = await withRpcTimeout(dsh.sessions.list(mintRequest({})), timeoutMs);
|
|
303
|
+
}
|
|
304
|
+
catch (cause) {
|
|
305
|
+
if (cause instanceof RpcTimeoutError) {
|
|
306
|
+
log?.('discord_session_summaries_timeout', { timeoutMs });
|
|
307
|
+
return { outcome: 'unknown' };
|
|
308
|
+
}
|
|
309
|
+
log?.('discord_session_summaries_threw', { cause: String(cause) });
|
|
310
|
+
return { outcome: 'unknown' };
|
|
311
|
+
}
|
|
312
|
+
const result = response?.result;
|
|
313
|
+
if (result === undefined || !result.ok || !Array.isArray(result.value.items)) {
|
|
314
|
+
log?.('discord_session_summaries_malformed');
|
|
315
|
+
return { outcome: 'failed' };
|
|
316
|
+
}
|
|
317
|
+
const sessions = [];
|
|
318
|
+
// The wire is untrusted: narrow every row defensively before use.
|
|
319
|
+
const items = Array.isArray(result.value.items) ? result.value.items : [];
|
|
320
|
+
for (const item of items) {
|
|
321
|
+
if (typeof item !== 'object' || item === null)
|
|
322
|
+
continue;
|
|
323
|
+
const row = item;
|
|
324
|
+
if (typeof row.sessionId !== 'string' || row.sessionId === '')
|
|
325
|
+
continue;
|
|
326
|
+
const values = row.projections?.values;
|
|
327
|
+
const title = typeof values?.title === 'string' && values.title !== '' ? values.title : undefined;
|
|
328
|
+
sessions.push({
|
|
329
|
+
sessionId: row.sessionId,
|
|
330
|
+
title,
|
|
331
|
+
updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
|
|
332
|
+
running: row.running ?? false,
|
|
333
|
+
blank: row.blank === true,
|
|
334
|
+
cwd: typeof row.cwd === 'string' ? row.cwd : undefined,
|
|
335
|
+
origin: row.origin === 'subagent' ? 'subagent' : undefined,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return { outcome: 'completed', sessions };
|
|
339
|
+
}
|
|
274
340
|
/** Cancel the session's active turn (`session.cancel`); DSH preserves the pending inbox. */
|
|
275
341
|
export async function cancelSessionViaProxy(dsh, request, options = {}) {
|
|
276
342
|
const timeoutMs = options.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
@@ -37,10 +37,21 @@ export interface ImageAttachment {
|
|
|
37
37
|
declaredSize: number;
|
|
38
38
|
contentType: string;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The production download port (16.50): every fetch rides the safe boundary
|
|
42
|
+
* — allowlisted Discord CDN hosts over HTTPS, one revalidated redirect hop,
|
|
43
|
+
* supported media types only.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createSafeImageDownloadPort(): ImageDownloadPort;
|
|
40
46
|
export type ImageCollectionResult = {
|
|
41
47
|
outcome: 'collected';
|
|
42
48
|
images: number;
|
|
43
49
|
totalBytes: number;
|
|
50
|
+
/** The downloaded images in attachment order (16.50): callers encode them for submission. */
|
|
51
|
+
downloaded: ReadonlyArray<{
|
|
52
|
+
mediaType: string;
|
|
53
|
+
body: Uint8Array;
|
|
54
|
+
}>;
|
|
44
55
|
} | {
|
|
45
56
|
outcome: 'too-large';
|
|
46
57
|
reason: 'declared' | 'actual' | 'aggregate';
|
|
@@ -8,10 +8,22 @@
|
|
|
8
8
|
* bounded in practice by Discord CDN attachment limits and the aggregate
|
|
9
9
|
* cap, not by streaming. Refusals are plain values.
|
|
10
10
|
*/
|
|
11
|
+
import { createHttpFetchPort, fetchSafeImage } from './image-download.js';
|
|
11
12
|
/** Per-image cap (design: bounded by DSH-advertised limits; adapter-capped). */
|
|
12
13
|
export const MAX_IMAGE_BYTES = 8 * 1_024 * 1_024;
|
|
13
14
|
/** Aggregate cap across all images of one message. */
|
|
14
15
|
export const MAX_AGGREGATE_IMAGE_BYTES = 24 * 1_024 * 1_024;
|
|
16
|
+
/**
|
|
17
|
+
* The production download port (16.50): every fetch rides the safe boundary
|
|
18
|
+
* — allowlisted Discord CDN hosts over HTTPS, one revalidated redirect hop,
|
|
19
|
+
* supported media types only.
|
|
20
|
+
*/
|
|
21
|
+
export function createSafeImageDownloadPort() {
|
|
22
|
+
const http = createHttpFetchPort();
|
|
23
|
+
return {
|
|
24
|
+
download: request => fetchSafeImage(http, { url: request.url }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
15
27
|
/**
|
|
16
28
|
* Wrap the safe download boundary with byte-count enforcement: the body size
|
|
17
29
|
* is checked against the remaining aggregate budget before it is accepted,
|
|
@@ -35,6 +47,7 @@ export function collectImages(port, request) {
|
|
|
35
47
|
let remainingAggregate = MAX_AGGREGATE_IMAGE_BYTES;
|
|
36
48
|
let images = 0;
|
|
37
49
|
let totalBytes = 0;
|
|
50
|
+
const downloaded = [];
|
|
38
51
|
const inner = port;
|
|
39
52
|
async function run() {
|
|
40
53
|
for (const attachment of request.attachments) {
|
|
@@ -50,30 +63,31 @@ export function collectImages(port, request) {
|
|
|
50
63
|
const timeout = new Promise((resolve) => {
|
|
51
64
|
timer = setTimeout(() => { resolve('timeout'); }, request.timeoutMs);
|
|
52
65
|
});
|
|
53
|
-
const
|
|
66
|
+
const downloadedImage = await Promise.race([
|
|
54
67
|
bounded.download({ url: attachment.url }).then((result) => result),
|
|
55
68
|
timeout,
|
|
56
69
|
]).finally(() => {
|
|
57
70
|
if (timer !== undefined)
|
|
58
71
|
clearTimeout(timer);
|
|
59
72
|
});
|
|
60
|
-
if (
|
|
73
|
+
if (downloadedImage === 'timeout')
|
|
61
74
|
return { outcome: 'timeout' };
|
|
62
|
-
if (
|
|
63
|
-
if (
|
|
75
|
+
if (downloadedImage.outcome === 'downloaded') {
|
|
76
|
+
if (downloadedImage.body.byteLength > MAX_IMAGE_BYTES) {
|
|
64
77
|
return { outcome: 'too-large', reason: 'actual' };
|
|
65
78
|
}
|
|
66
79
|
images += 1;
|
|
67
|
-
totalBytes +=
|
|
68
|
-
remainingAggregate -=
|
|
80
|
+
totalBytes += downloadedImage.body.byteLength;
|
|
81
|
+
remainingAggregate -= downloadedImage.body.byteLength;
|
|
82
|
+
downloaded.push({ mediaType: downloadedImage.mediaType, body: downloadedImage.body });
|
|
69
83
|
continue;
|
|
70
84
|
}
|
|
71
|
-
if (
|
|
85
|
+
if (downloadedImage.outcome === 'http-error' && downloadedImage.status === 413) {
|
|
72
86
|
return { outcome: 'too-large', reason: 'actual' };
|
|
73
87
|
}
|
|
74
88
|
return { outcome: 'download-failed' };
|
|
75
89
|
}
|
|
76
|
-
return { outcome: 'collected', images, totalBytes };
|
|
90
|
+
return { outcome: 'collected', images, totalBytes, downloaded };
|
|
77
91
|
}
|
|
78
92
|
return run();
|
|
79
93
|
}
|
|
@@ -46,3 +46,9 @@ export type SafeImageResult = {
|
|
|
46
46
|
export declare function fetchSafeImage(port: HttpFetchPort, request: {
|
|
47
47
|
url: string;
|
|
48
48
|
}): Promise<SafeImageResult>;
|
|
49
|
+
/**
|
|
50
|
+
* The production fetch boundary over the platform `fetch` (16.50). Redirects
|
|
51
|
+
* are NOT followed here — `fetchSafeImage` revalidates every hop itself —
|
|
52
|
+
* and the body is only read for a terminal 200 response.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createHttpFetchPort(): HttpFetchPort;
|
|
@@ -75,3 +75,24 @@ function isParseable(rawUrl) {
|
|
|
75
75
|
export function fetchSafeImage(port, request) {
|
|
76
76
|
return fetchOnce(port, MAX_REDIRECTS, request);
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The production fetch boundary over the platform `fetch` (16.50). Redirects
|
|
80
|
+
* are NOT followed here — `fetchSafeImage` revalidates every hop itself —
|
|
81
|
+
* and the body is only read for a terminal 200 response.
|
|
82
|
+
*/
|
|
83
|
+
export function createHttpFetchPort() {
|
|
84
|
+
return {
|
|
85
|
+
async fetch(url) {
|
|
86
|
+
const response = await fetch(url, { redirect: 'manual' });
|
|
87
|
+
const location = response.headers.get('location');
|
|
88
|
+
const contentType = response.headers.get('content-type');
|
|
89
|
+
const body = response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : undefined;
|
|
90
|
+
return {
|
|
91
|
+
status: response.status,
|
|
92
|
+
contentType: contentType ?? undefined,
|
|
93
|
+
location: location ?? undefined,
|
|
94
|
+
body,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -85,6 +85,48 @@ export interface InteractionRouterDeps {
|
|
|
85
85
|
purgeChannelBinding: (guildId: string, channelId: string) => Promise<void>;
|
|
86
86
|
/** The session's live model directory + selection mutation (/model surface). */
|
|
87
87
|
model: DshModelPort;
|
|
88
|
+
/**
|
|
89
|
+
* /session resume autocomplete candidates: rich Host rows filtered to
|
|
90
|
+
* non-blank, unbound sessions, title-filtered, newest-first, capped at 25
|
|
91
|
+
* (16.44).
|
|
92
|
+
*/
|
|
93
|
+
resumeCandidates: (query: string, workspacePath?: string, archivedSessionIds?: ReadonlySet<string>) => Promise<{
|
|
94
|
+
outcome: 'ok';
|
|
95
|
+
options: Array<{
|
|
96
|
+
label: string;
|
|
97
|
+
value: string;
|
|
98
|
+
description?: string;
|
|
99
|
+
}>;
|
|
100
|
+
} | {
|
|
101
|
+
outcome: 'unavailable';
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* Cold-adopt a session into a NEW thread of the bound project channel:
|
|
105
|
+
* anchor post → message-anchored thread creation → durable thread→session
|
|
106
|
+
* binding. Already-bound sessions resolve to their existing thread
|
|
107
|
+
* (16.44).
|
|
108
|
+
*/
|
|
109
|
+
resumeSession: (input: {
|
|
110
|
+
sessionId: string;
|
|
111
|
+
workspaceId: string;
|
|
112
|
+
guildId: string;
|
|
113
|
+
parentChannelId: string;
|
|
114
|
+
actorId: string;
|
|
115
|
+
}) => Promise<{
|
|
116
|
+
outcome: 'started';
|
|
117
|
+
threadId: string;
|
|
118
|
+
} | {
|
|
119
|
+
outcome: 'already-bound';
|
|
120
|
+
threadId: string;
|
|
121
|
+
} | {
|
|
122
|
+
outcome: 'refused-control-channel';
|
|
123
|
+
} | {
|
|
124
|
+
outcome: 'refused-subagent';
|
|
125
|
+
} | {
|
|
126
|
+
outcome: 'refused-archived';
|
|
127
|
+
} | {
|
|
128
|
+
outcome: 'failed';
|
|
129
|
+
}>;
|
|
88
130
|
/**
|
|
89
131
|
* Whether /model select stays Host-operator-only (default). Single-user
|
|
90
132
|
* deployments flip this so any authorized member can switch (16.42).
|