@addozhang/dsh-discord 0.6.4-rc.2 → 0.6.5-rc.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.
- package/README.md +11 -8
- package/README.zh.md +11 -8
- package/lib/dsh/host-asks.d.ts +20 -0
- package/lib/dsh/host-asks.js +103 -0
- package/lib/features/reconcile-bindings.d.ts +29 -0
- package/lib/features/reconcile-bindings.js +27 -0
- package/lib/index.js +23 -1
- package/lib/state/domain.d.ts +34 -3
- package/lib/state/domain.js +65 -1
- package/package.json +8 -9
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
|
|
|
20
20
|
|
|
21
21
|
- **Mention-driven sessions** — an authorized `@bot <task>` in a bound channel anchors a thread (your 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.
|
|
22
22
|
- **Stream rendering** — typing indicator, one head message edited in place, per-tool activity rows, fenced long-answer splitting, one-time finalize; the activity message is deleted when the turn ends.
|
|
23
|
-
- **Approvals & questions** — DSH asks become 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
|
+
- **Approvals & questions (dual surface)** — DSH asks become buttons, select menus, and a free-text modal, mirrored to the web/desktop panel at the same time: the first surface to answer wins and the other auto-retires (Discord carries the ask alone when no web client is connected). Ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
|
|
24
24
|
- **Session control** — `/steer`, `/stop`, and `/queue list|remove` with turn-ownership checks; `/project bind|list|info` and `/session resume` for guild↔workspace binding and history; `/guild forget` for operator cleanup.
|
|
25
25
|
- **Model selection** — `/model show` reads the session's live model directory; `/model select` walks an interactive provider → model → reasoning cascade, or applies a typed `provider/model` directly. Open to any authorized member by default; restrictable to Host operators.
|
|
26
26
|
- **Adapter status** — `/info` shows the adapter version, connection state, and project links (feedback, docs). Works in any channel.
|
|
@@ -36,16 +36,19 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
|
|
|
36
36
|
|
|
37
37
|
## Versions
|
|
38
38
|
|
|
39
|
-
Two channels ship in parallel, each
|
|
39
|
+
Two channels ship in parallel, each following the dsh host surface it was verified against. Pick the adapter and the dsh CLI from the same row:
|
|
40
40
|
|
|
41
41
|
| Channel | Adapter | dsh CLI | Install |
|
|
42
42
|
|---|---|---|---|
|
|
43
|
-
| **
|
|
44
|
-
| **
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
43
|
+
| **stable (0.2.1 line)** | `0.6.5` — npm `latest` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord` |
|
|
44
|
+
| **next (0.2.1 line)** | `>=0.6.5-rc.1` — npm `next` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
|
|
45
|
+
| Legacy (0.1.7 line) | `0.5.0` — exact version | `0.1.7-rc.1` — exact version | `dsh plugin --profile web add @addozhang/dsh-discord@0.5.0` |
|
|
46
|
+
|
|
47
|
+
- **Adapter `0.6.5+` ships no dsh-internal packages.** The `dsh-storage-domain` declaration helpers (`defineDomain`/`domainTable`) are mirrored inside the plugin, and the host's `storageDomain` service is consumed through a structural contract. Upgrading the dsh CLI or Desktop within the 0.2.1 line (including the exact-version plugin gate introduced in `0.2.1-rc.5`) therefore no longer disables this plugin. The host API surface is still guarded by the startup probe — a host lacking a required service surface refuses activation with one actionable diagnostic.
|
|
48
|
+
- **Existing installs of adapter `0.6.1`–`0.6.4`:** those versions carried `dsh-storage-domain@0.2.1-rc.3` as a runtime dependency. Since dsh `0.2.1-rc.5` the host disables any profile-tree plugin row whose version does not exactly match the runtime — the carried copy shadows the host's own, the `storageDomain` service vanishes, and the plugin stays pending (no workspaces or sessions in the UI; data is untouched). Upgrade the adapter to `0.6.5+` to fix it. If you manually added a `@deepseek-ai/dsh-storage-domain` entry to the profile's `package.json` as a stopgap, remove it too — it will trip the same gate on the next host upgrade.
|
|
49
|
+
- Earlier pairings are unchanged: adapter `0.6.0-rc.1` ↔ dsh `0.2.0-rc.2` (both sides are exact-version installs now that the dist-tags moved to 0.2.1), and adapter `0.5.0` pins the `0.1.7-rc.1` line symmetrically. Adapter `>=0.5.0-rc.4` (including `0.5.0`) speaks the 0.1.7 profile-backed settings forms and controller services and will not load on older hosts (the startup probe refuses with one actionable diagnostic); `0.5.0-rc.1`–`rc.3` target dsh `0.1.6-alpha`; adapter `0.4.x` talks to the former `apiProxy` surface, which `0.1.6-alpha` removed — it remains the pairing for the dsh `0.1.5` stable line, reachable by exact version (e.g. `@addozhang/dsh-discord@0.4.0`).
|
|
50
|
+
- To switch the dsh CLI line: `npm install -g @deepseek-ai/dsh@latest` (0.2.1-rc.x, pairs with adapter 0.6.5+) or `npm install -g @deepseek-ai/dsh@0.1.7-rc.1` (pairs with adapter 0.5.0).
|
|
51
|
+
- Right after a `next` release, the `@next` spec can resolve to a stale cached version. If the installed copy looks old, install by exact version instead (e.g. `@addozhang/dsh-discord@0.6.5-rc.1`) and verify the `version` field inside the profile's `node_modules`.
|
|
49
52
|
|
|
50
53
|
## Install
|
|
51
54
|
|
package/README.zh.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
- **@提及驱动会话** — 在已绑定的频道中,被授权的 `@机器人 <任务>` 会锚定一个线程(你的消息成为首帖)、创建 DSH 会话,并且至多提交一次。线程内的后续消息无需 @ 即可排队。消息附带的图片会一并送达:在严格的大小与主机白名单约束下从 Discord CDN 下载,并作为 image parts 提交给多模态模型。
|
|
22
22
|
- **流式渲染** — typing 指示、单条头消息原地编辑、逐工具活动行、代码围栏感知的长文分段、一次性收尾;Turn 结束时活动消息会被删除。
|
|
23
|
-
-
|
|
23
|
+
- **审批与提问(双面)** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗,并同时镜像到 web/桌面端:两端先答先得,另一端的控件与卡片自动失效(无 web 客户端时 Discord 单独承载)。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
|
|
24
24
|
- **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 与 `/session resume` 管理 Guild↔工作区绑定与历史会话;`/guild forget` 供操作员清理。
|
|
25
25
|
- **模型切换** — `/model show` 读取会话的实时模型目录;`/model select` 走交互式 provider → 模型 → 推理强度级联,也可直接填写 `provider/model` 应用。默认对所有授权成员开放,可收紧为仅 Host 操作员。
|
|
26
26
|
- **适配器状态** — `/info` 显示适配器版本、连接状态与项目链接(反馈/文档)。任意频道可用。
|
|
@@ -36,16 +36,19 @@
|
|
|
36
36
|
|
|
37
37
|
## 版本支持
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
两条通道并行发布,各自跟随经过真机验证的 dsh 宿主面。适配器与 dsh CLI 请取同一行:
|
|
40
40
|
|
|
41
41
|
| 通道 | 适配器 | dsh CLI | 安装命令 |
|
|
42
42
|
|---|---|---|---|
|
|
43
|
-
| **
|
|
44
|
-
| **
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
-
|
|
48
|
-
- `
|
|
43
|
+
| **stable(0.2.1 线)** | `0.6.5` — npm `latest` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord` |
|
|
44
|
+
| **next(0.2.1 线)** | `>=0.6.5-rc.1` — npm `next` | `0.2.1-rc.3`+ — npm `latest`/`next` | `dsh plugin --profile web add @addozhang/dsh-discord@next` |
|
|
45
|
+
| 旧线(0.1.7) | `0.5.0` — 精确版本号 | `0.1.7-rc.1` — 精确版本号 | `dsh plugin --profile web add @addozhang/dsh-discord@0.5.0` |
|
|
46
|
+
|
|
47
|
+
- **适配器 `0.6.5+` 不再随包携带任何 dsh 内部包**:`dsh-storage-domain` 的声明辅助(`defineDomain`/`domainTable`)已内联进插件,宿主的 `storageDomain` 服务按结构化契约消费。因此 dsh CLI / Desktop 在 0.2.1 线内升级(含 `0.2.1-rc.5` 起生效的插件精确版本兼容门)不会再禁用本插件。宿主 API 面仍由 startup 探针守护——宿主缺少所需服务面时拒绝激活并给出一条可操作诊断。
|
|
48
|
+
- **适配器 `0.6.1`–`0.6.4` 的存量安装注意:** 这些版本把 `dsh-storage-domain@0.2.1-rc.3` 作为运行时依赖随包携带。dsh `0.2.1-rc.5` 起宿主会禁用 profile 树中与运行时版本不精确匹配的插件行——被携带的副本盖住宿主副本后,`storageDomain` 服务随之消失,插件保持 pending(UI 里既无工作区也无会话,数据无损)。把适配器升级到 `0.6.5+` 即修复;若曾在 profile 的 `package.json` 里手工添加过 `@deepseek-ai/dsh-storage-domain` 条目(一种临时缓解),请一并移除——否则下一次宿主升级它会自己撞上同一道门禁。
|
|
49
|
+
- 更早的配对关系不变:适配器 `0.6.0-rc.1` ↔ dsh `0.2.0-rc.2`(dist-tags 已移到 0.2.1,两侧都只能精确版本号安装),适配器 `0.5.0` 同理反向钉在 `0.1.7-rc.1` 线;适配器 `>=0.5.0-rc.4`(含 `0.5.0`)使用 0.1.7 的 profile-backed settings forms 与控制器服务,在更旧的宿主上无法加载(startup 探针会给出一条可操作的诊断后拒绝激活);`0.5.0-rc.1`–`rc.3` 对应 dsh `0.1.6-alpha`;适配器 `0.4.x` 使用旧版 `apiProxy` 宿主面(该面已在 `0.1.6-alpha` 中移除)——它仍是 dsh `0.1.5` stable 线的配对,可用精确版本号安装(如 `@addozhang/dsh-discord@0.4.0`)。
|
|
50
|
+
- 切换 dsh CLI 通道:`npm install -g @deepseek-ai/dsh@latest`(0.2.1-rc.x,配适配器 0.6.5+)或 `npm install -g @deepseek-ai/dsh@0.1.7-rc.1`(配适配器 0.5.0)。
|
|
51
|
+
- `next` 刚发版后,`@next` 规格可能解析到过期的缓存版本。若装到的版本偏旧,请改用精确版本号安装(如 `@addozhang/dsh-discord@0.6.5-rc.1`),并核对 profile `node_modules` 中的 `version` 字段。
|
|
49
52
|
|
|
50
53
|
## 安装
|
|
51
54
|
|
package/lib/dsh/host-asks.d.ts
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
* renders the ask through the adapter's ask-wiring (buttons, ownership,
|
|
12
12
|
* expiry) and resolves with the user's answer; every other ask passes
|
|
13
13
|
* through to the original path (the web UI keeps working unchanged).
|
|
14
|
+
*
|
|
15
|
+
* Dual surface (16.105, 2026-10-30): a claimed ask is ALSO forwarded to
|
|
16
|
+
* the original entry under a relayed abort signal, so a connected web
|
|
17
|
+
* client renders the same card. The two surfaces race and the first
|
|
18
|
+
* decisive answer wins: a web answer settles the claimed ask and retires
|
|
19
|
+
* the Discord controls; a Discord answer aborts the relay and the web wait
|
|
20
|
+
* closes. The pre-binding passthrough direction stays single-surface (the
|
|
21
|
+
* 2026-10-26 decision on issue #11 — status quo).
|
|
14
22
|
*/
|
|
15
23
|
/** Narrow approval-request shape the service receives. */
|
|
16
24
|
export interface HostApprovalRequest {
|
|
@@ -68,6 +76,18 @@ export interface HostAskDeps {
|
|
|
68
76
|
questions: ReadonlyArray<Record<string, unknown>>;
|
|
69
77
|
}): void;
|
|
70
78
|
disableControl(key: string): Promise<void>;
|
|
79
|
+
/** Record a web-surface resolution and retire the Discord controls. */
|
|
80
|
+
onApprovalResolved(input: {
|
|
81
|
+
sessionId: string;
|
|
82
|
+
approvalId: string;
|
|
83
|
+
outcome?: string | undefined;
|
|
84
|
+
}): void;
|
|
85
|
+
/** Record a web-surface resolution and retire the Discord controls. */
|
|
86
|
+
onQuestionResolved(input: {
|
|
87
|
+
sessionId: string;
|
|
88
|
+
questionRpcId: string;
|
|
89
|
+
outcome: 'answered' | 'cancelled';
|
|
90
|
+
}): void;
|
|
71
91
|
};
|
|
72
92
|
approvalTimeoutMs(): number;
|
|
73
93
|
questionTimeoutMs(): number;
|
package/lib/dsh/host-asks.js
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
* renders the ask through the adapter's ask-wiring (buttons, ownership,
|
|
12
12
|
* expiry) and resolves with the user's answer; every other ask passes
|
|
13
13
|
* through to the original path (the web UI keeps working unchanged).
|
|
14
|
+
*
|
|
15
|
+
* Dual surface (16.105, 2026-10-30): a claimed ask is ALSO forwarded to
|
|
16
|
+
* the original entry under a relayed abort signal, so a connected web
|
|
17
|
+
* client renders the same card. The two surfaces race and the first
|
|
18
|
+
* decisive answer wins: a web answer settles the claimed ask and retires
|
|
19
|
+
* the Discord controls; a Discord answer aborts the relay and the web wait
|
|
20
|
+
* closes. The pre-binding passthrough direction stays single-surface (the
|
|
21
|
+
* 2026-10-26 decision on issue #11 — status quo).
|
|
14
22
|
*/
|
|
15
23
|
/** One claimed ask: a settle promise raced against the ask's abort signal. */
|
|
16
24
|
class PendingAsk {
|
|
@@ -48,6 +56,36 @@ class PendingAsk {
|
|
|
48
56
|
this.cancel();
|
|
49
57
|
}
|
|
50
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Relays the host's abort signal into an owned controller so the losing
|
|
61
|
+
* ask surface's wait can be closed independently: the web leg receives
|
|
62
|
+
* `relay.signal` instead of the host's own signal, and a Discord answer
|
|
63
|
+
* aborts it to settle the web card without touching the host's signal.
|
|
64
|
+
*/
|
|
65
|
+
class SignalRelay {
|
|
66
|
+
controller = new AbortController();
|
|
67
|
+
constructor(source) {
|
|
68
|
+
if (source === undefined)
|
|
69
|
+
return;
|
|
70
|
+
if (source.aborted) {
|
|
71
|
+
this.controller.abort(source.reason);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
source.addEventListener('abort', () => { this.controller.abort(source.reason); }, { once: true });
|
|
75
|
+
}
|
|
76
|
+
get signal() {
|
|
77
|
+
return this.controller.signal;
|
|
78
|
+
}
|
|
79
|
+
/** Close the relayed wait (a no-op when the source signal already did). */
|
|
80
|
+
abort(reason) {
|
|
81
|
+
this.controller.abort(reason);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The question waterfall's "no connected answerer" rejection shape. */
|
|
85
|
+
function isNoProvider(cause) {
|
|
86
|
+
return typeof cause === 'object' && cause !== null
|
|
87
|
+
&& cause.code === 'NO_PROVIDER';
|
|
88
|
+
}
|
|
51
89
|
/**
|
|
52
90
|
* Wrap both ask services. Thread-bound sessions get the Discord ask flow;
|
|
53
91
|
* everything else passes through untouched. Returns the settle ports the
|
|
@@ -57,6 +95,8 @@ class PendingAsk {
|
|
|
57
95
|
export function installAskServicePatches(approval, questions, deps) {
|
|
58
96
|
const pendingApprovals = new Map();
|
|
59
97
|
const pendingQuestions = new Map();
|
|
98
|
+
/** Live web-surface waits for claimed asks, keyed by ask id. */
|
|
99
|
+
const webRelays = new Map();
|
|
60
100
|
// Capture the current entries (own or prototype) before patching; dispose
|
|
61
101
|
// restores exactly these.
|
|
62
102
|
const originalRequest = approval.request.bind(approval);
|
|
@@ -73,6 +113,11 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
73
113
|
if (questions !== undefined && originalAsk !== undefined) {
|
|
74
114
|
questions.ask = originalAsk;
|
|
75
115
|
}
|
|
116
|
+
// Close every live web wait before cancelling the Discord surface:
|
|
117
|
+
// the relayed abort lets the original entries settle their cards.
|
|
118
|
+
for (const [, relay] of [...webRelays])
|
|
119
|
+
relay.abort();
|
|
120
|
+
webRelays.clear();
|
|
76
121
|
for (const [, pending] of [...pendingApprovals])
|
|
77
122
|
pending.dispose();
|
|
78
123
|
pendingApprovals.clear();
|
|
@@ -105,9 +150,40 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
105
150
|
});
|
|
106
151
|
const pending = new PendingAsk(pendingApprovals, approvalId, req.signal, () => 'cancelled');
|
|
107
152
|
pendingApprovals.set(approvalId, pending);
|
|
153
|
+
// Dual surface (16.105): the web surface races the Discord buttons
|
|
154
|
+
// through the original entry under a relayed signal. The first
|
|
155
|
+
// decisive outcome wins; 'unavailable' (no connected answerer, or an
|
|
156
|
+
// answerer failure the host normalizes fail-closed) and 'cancelled'
|
|
157
|
+
// (an abort racing the decision) never decide for the user.
|
|
158
|
+
const relay = new SignalRelay(req.signal);
|
|
159
|
+
webRelays.set(approvalId, relay);
|
|
160
|
+
void Promise.resolve()
|
|
161
|
+
.then(() => originalRequest({ ...req, signal: relay.signal }))
|
|
162
|
+
.then(outcome => {
|
|
163
|
+
if (outcome !== 'allowed-once' && outcome !== 'rejected') {
|
|
164
|
+
deps.log('discord_ask_dual_web_ignored', { kind: 'approval', approvalId, outcome });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!pending.resolve(outcome))
|
|
168
|
+
return;
|
|
169
|
+
deps.log('discord_ask_dual_web_won', { kind: 'approval', approvalId, outcome });
|
|
170
|
+
deps.askWiring.onApprovalResolved({ sessionId, approvalId, outcome });
|
|
171
|
+
})
|
|
172
|
+
.catch((cause) => {
|
|
173
|
+
// The real entry normalizes answerer failures to 'unavailable';
|
|
174
|
+
// a rejection here means the relay closed the wait (the Discord
|
|
175
|
+
// surface won) or the host aborted — both already settled.
|
|
176
|
+
if (relay.signal.aborted)
|
|
177
|
+
return;
|
|
178
|
+
deps.log('discord_ask_dual_web_failed', { kind: 'approval', approvalId, cause: String(cause) });
|
|
179
|
+
});
|
|
108
180
|
if (req.signal?.aborted === true)
|
|
109
181
|
pending.dispose();
|
|
110
182
|
void pending.promise.then(() => {
|
|
183
|
+
webRelays.delete(approvalId);
|
|
184
|
+
// Close the web wait when the Discord surface settled first (a
|
|
185
|
+
// no-op when the web answer already won or the host aborted).
|
|
186
|
+
relay.abort();
|
|
111
187
|
deps.onWaitState?.(threadId, false);
|
|
112
188
|
void deps.askWiring.disableControl(approvalId).catch((cause) => {
|
|
113
189
|
deps.log('discord_ask_control_disable_failed', { key: approvalId, cause: String(cause) });
|
|
@@ -144,9 +220,36 @@ export function installAskServicePatches(approval, questions, deps) {
|
|
|
144
220
|
const cancelledAnswer = { answers: [] };
|
|
145
221
|
const pending = new PendingAsk(pendingQuestions, rpcId, req.signal, () => cancelledAnswer);
|
|
146
222
|
pendingQuestions.set(rpcId, pending);
|
|
223
|
+
// Dual surface (16.105): mirror the batch to the web surface under
|
|
224
|
+
// the relayed signal; the first complete answer batch wins.
|
|
225
|
+
const relay = new SignalRelay(req.signal);
|
|
226
|
+
webRelays.set(rpcId, relay);
|
|
227
|
+
void Promise.resolve()
|
|
228
|
+
.then(() => originalAsk({ ...req, signal: relay.signal }))
|
|
229
|
+
.then(answer => {
|
|
230
|
+
if (!pending.resolve(answer))
|
|
231
|
+
return;
|
|
232
|
+
deps.log('discord_ask_dual_web_won', { kind: 'question', rpcId });
|
|
233
|
+
deps.askWiring.onQuestionResolved({ sessionId, questionRpcId: rpcId, outcome: 'answered' });
|
|
234
|
+
})
|
|
235
|
+
.catch((cause) => {
|
|
236
|
+
// NO_PROVIDER = no connected web client: the Discord surface
|
|
237
|
+
// carries the ask alone (the pre-dual-surface behavior).
|
|
238
|
+
if (isNoProvider(cause)) {
|
|
239
|
+
deps.log('discord_ask_dual_web_absent', { kind: 'question', rpcId });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (relay.signal.aborted)
|
|
243
|
+
return;
|
|
244
|
+
deps.log('discord_ask_dual_web_failed', { kind: 'question', rpcId, cause: String(cause) });
|
|
245
|
+
});
|
|
147
246
|
if (req.signal?.aborted === true)
|
|
148
247
|
pending.dispose();
|
|
149
248
|
void pending.promise.then(() => {
|
|
249
|
+
webRelays.delete(rpcId);
|
|
250
|
+
// Close the web wait when the Discord surface settled first (a
|
|
251
|
+
// no-op when the web answer already won or the host aborted).
|
|
252
|
+
relay.abort();
|
|
150
253
|
deps.onWaitState?.(threadId, false);
|
|
151
254
|
void deps.askWiring.disableControl(rpcId).catch((cause) => {
|
|
152
255
|
deps.log('discord_ask_control_disable_failed', { key: rpcId, cause: String(cause) });
|
|
@@ -73,3 +73,32 @@ export interface BindingReconciliationPlan {
|
|
|
73
73
|
}
|
|
74
74
|
/** Plan one reconciliation pass. Pure: callers decide how actions are applied. */
|
|
75
75
|
export declare function planBindingReconciliation(input: BindingReconciliationInput): BindingReconciliationPlan;
|
|
76
|
+
/**
|
|
77
|
+
* One direct channel probe for a thread binding the guild's active-thread
|
|
78
|
+
* listing did not account for (16.104). The shapes mirror the REST client's
|
|
79
|
+
* outcome vocabulary; `completed` needs no body — existence is the verdict.
|
|
80
|
+
*/
|
|
81
|
+
export type ThreadChannelProbeOutcome = {
|
|
82
|
+
outcome: 'completed';
|
|
83
|
+
} | {
|
|
84
|
+
outcome: 'rejected';
|
|
85
|
+
status: number;
|
|
86
|
+
} | {
|
|
87
|
+
outcome: 'unknown';
|
|
88
|
+
};
|
|
89
|
+
export type ThreadChannelProbe = (threadId: string) => Promise<ThreadChannelProbeOutcome>;
|
|
90
|
+
/**
|
|
91
|
+
* Refine facts for thread bindings absent from `/guilds/{id}/threads/active`.
|
|
92
|
+
* That listing drops both deleted threads and Discord-archived ones, so
|
|
93
|
+
* absence alone proves nothing (the pre-16.104 planner kept such threads
|
|
94
|
+
* forever — deleting a bound channel orphans every thread binding under it,
|
|
95
|
+
* permanently excluding those sessions from `/session resume`). One
|
|
96
|
+
* `GET /channels/{threadId}` each settles it: 200 → `ok` (alive, including
|
|
97
|
+
* Discord-archived — the thread remains the binding's live surface and a
|
|
98
|
+
* post un-archives it), 404 → `missing` (verified deletion → retire),
|
|
99
|
+
* anything else → `unknown` (keep-blocked, exactly the inconclusive
|
|
100
|
+
* posture the reconciliation spec mandates). Probes run sequentially; a
|
|
101
|
+
* retired binding leaves the table, so the probe set only ever shrinks to
|
|
102
|
+
* the genuinely alive or persistently unreachable.
|
|
103
|
+
*/
|
|
104
|
+
export declare function refineUnlistedThreadFacts(threadIds: readonly string[], probe: ThreadChannelProbe): Promise<Record<string, 'ok' | 'missing' | 'unknown'>>;
|
|
@@ -52,3 +52,30 @@ export function planBindingReconciliation(input) {
|
|
|
52
52
|
});
|
|
53
53
|
return { channelActions, threadActions };
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Refine facts for thread bindings absent from `/guilds/{id}/threads/active`.
|
|
57
|
+
* That listing drops both deleted threads and Discord-archived ones, so
|
|
58
|
+
* absence alone proves nothing (the pre-16.104 planner kept such threads
|
|
59
|
+
* forever — deleting a bound channel orphans every thread binding under it,
|
|
60
|
+
* permanently excluding those sessions from `/session resume`). One
|
|
61
|
+
* `GET /channels/{threadId}` each settles it: 200 → `ok` (alive, including
|
|
62
|
+
* Discord-archived — the thread remains the binding's live surface and a
|
|
63
|
+
* post un-archives it), 404 → `missing` (verified deletion → retire),
|
|
64
|
+
* anything else → `unknown` (keep-blocked, exactly the inconclusive
|
|
65
|
+
* posture the reconciliation spec mandates). Probes run sequentially; a
|
|
66
|
+
* retired binding leaves the table, so the probe set only ever shrinks to
|
|
67
|
+
* the genuinely alive or persistently unreachable.
|
|
68
|
+
*/
|
|
69
|
+
export async function refineUnlistedThreadFacts(threadIds, probe) {
|
|
70
|
+
const facts = {};
|
|
71
|
+
for (const threadId of threadIds) {
|
|
72
|
+
const probed = await probe(threadId);
|
|
73
|
+
if (probed.outcome === 'completed')
|
|
74
|
+
facts[threadId] = 'ok';
|
|
75
|
+
else if (probed.outcome === 'rejected' && probed.status === 404)
|
|
76
|
+
facts[threadId] = 'missing';
|
|
77
|
+
else
|
|
78
|
+
facts[threadId] = 'unknown';
|
|
79
|
+
}
|
|
80
|
+
return facts;
|
|
81
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { handleSelectInput, handleModalSubmit } from './features/question-routin
|
|
|
21
21
|
import { createCopy, permissionPresetLabel } from './i18n.js';
|
|
22
22
|
import { createInteractionRouter } from './features/interaction-router.js';
|
|
23
23
|
import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
|
|
24
|
-
import { planBindingReconciliation } from './features/reconcile-bindings.js';
|
|
24
|
+
import { planBindingReconciliation, refineUnlistedThreadFacts } from './features/reconcile-bindings.js';
|
|
25
25
|
import { guildKeysToForget, sweepExpired } from './state/retention.js';
|
|
26
26
|
import { staleUncertainIntents } from './features/reconcile-intents.js';
|
|
27
27
|
import { createHostEventRouter } from './dsh/host-events.js';
|
|
@@ -673,6 +673,28 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
673
673
|
if (facts[channelId] === undefined)
|
|
674
674
|
facts[channelId] = 'missing';
|
|
675
675
|
}
|
|
676
|
+
// The active-thread listing drops Discord-archived threads too,
|
|
677
|
+
// so an unlisted thread binding is *absent evidence*, not
|
|
678
|
+
// deletion — one direct channel probe each settles it (16.104):
|
|
679
|
+
// 404 retires the orphan (deleting a bound channel kills every
|
|
680
|
+
// thread under it), 200 keeps it (archived threads are still the
|
|
681
|
+
// binding's live surface), anything else stays unverified.
|
|
682
|
+
const unlistedThreads = [...threadKeyById.keys()].filter(threadId => facts[threadId] === undefined);
|
|
683
|
+
if (unlistedThreads.length > 0) {
|
|
684
|
+
const refined = await refineUnlistedThreadFacts(unlistedThreads, async (threadId) => {
|
|
685
|
+
const probed = await rest.request('GET', `/channels/${threadId}`);
|
|
686
|
+
rpcLog('discord_reconcile_thread_probe', {
|
|
687
|
+
threadId,
|
|
688
|
+
outcome: probed.outcome === 'rejected' ? `http-${String(probed.status)}` : probed.outcome,
|
|
689
|
+
});
|
|
690
|
+
if (probed.outcome === 'completed')
|
|
691
|
+
return { outcome: 'completed' };
|
|
692
|
+
if (probed.outcome === 'rejected')
|
|
693
|
+
return { outcome: 'rejected', status: probed.status };
|
|
694
|
+
return { outcome: 'unknown' };
|
|
695
|
+
});
|
|
696
|
+
Object.assign(facts, refined);
|
|
697
|
+
}
|
|
676
698
|
}
|
|
677
699
|
// Default surface (16.34/16.37): the adapter's category and the
|
|
678
700
|
// general control channel are ensured on every READY. Bound home
|
package/lib/state/domain.d.ts
CHANGED
|
@@ -5,7 +5,38 @@
|
|
|
5
5
|
* scan space from parsed parts. The spec is declared once with
|
|
6
6
|
* `defineDomain`, which fails loud at module load on any naming or version
|
|
7
7
|
* misconfiguration.
|
|
8
|
+
*
|
|
9
|
+
* 16.106: `defineDomain`/`domainTable` are mirrored here instead of imported.
|
|
10
|
+
* Shipping `@deepseek-ai/dsh-storage-domain` as a plugin dependency made pnpm
|
|
11
|
+
* hoist a plugin-owned copy over the profile tree, and the host's
|
|
12
|
+
* exact-version compatibility gate (0.2.1-rc.5+) then disabled that row on
|
|
13
|
+
* every host patch release — cascading `storageDomain` away and pending the
|
|
14
|
+
* workspace/session graph. `dsh-base` already provides the row at the host's
|
|
15
|
+
* exact version, and the host's `open()` consumes a spec purely structurally
|
|
16
|
+
* (property reads plus `.parse`/`.safeParse` on the caller's own zod
|
|
17
|
+
* schemas), so an inlined spec is indistinguishable from a package-built one.
|
|
18
|
+
* The mirror is kept honest by `test/state-domain.test.ts`, which checks it
|
|
19
|
+
* against the real package (a devDependency) accept-for-accept and
|
|
20
|
+
* reject-for-reject. Types still come from the package — compile-time only.
|
|
8
21
|
*/
|
|
22
|
+
import type { DomainSpec, DomainTableSpec } from '@deepseek-ai/dsh-storage-domain';
|
|
23
|
+
import type { ZodType } from 'zod';
|
|
24
|
+
/**
|
|
25
|
+
* Declare one table. Mirrored from `@deepseek-ai/dsh-storage-domain`: the
|
|
26
|
+
* runtime value is the plain `{ valueSchema }` pair — the key type is a
|
|
27
|
+
* compile-time phantom that never exists at runtime.
|
|
28
|
+
*/
|
|
29
|
+
export declare function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>;
|
|
30
|
+
/**
|
|
31
|
+
* Identity helper that pins a spec's literal types and validates its fields.
|
|
32
|
+
* Mirrored from `@deepseek-ai/dsh-storage-domain` (0.2.1 line): misconfiguration
|
|
33
|
+
* fails loud at the owning package's module load, before any medium is
|
|
34
|
+
* touched — a domain or table name outside `UNIT_NAME_RE`, a version that is
|
|
35
|
+
* not a non-negative integer, or a global schema that accepts `null` all
|
|
36
|
+
* throw. Keep the checks and messages in sync with upstream;
|
|
37
|
+
* `test/state-domain.test.ts` drift-guards the equivalence.
|
|
38
|
+
*/
|
|
39
|
+
export declare function defineDomain<S extends DomainSpec>(spec: S): S;
|
|
9
40
|
/**
|
|
10
41
|
* Storage-legal domain name (`UNIT_NAME_RE` allows only lowercase, digits,
|
|
11
42
|
* and underscores) and the current format version; a medium stamped with a
|
|
@@ -42,13 +73,13 @@ export declare const discordDomainSpec: {
|
|
|
42
73
|
name: string;
|
|
43
74
|
version: number;
|
|
44
75
|
tables: {
|
|
45
|
-
channel_bindings:
|
|
76
|
+
channel_bindings: DomainTableSpec<"channel", {
|
|
46
77
|
workspaceId: string;
|
|
47
78
|
revision: number;
|
|
48
79
|
boundBy: string;
|
|
49
80
|
boundAtMs: number;
|
|
50
81
|
}>;
|
|
51
|
-
thread_bindings:
|
|
82
|
+
thread_bindings: DomainTableSpec<"thread", {
|
|
52
83
|
sessionId: string;
|
|
53
84
|
workspaceId: string;
|
|
54
85
|
revision: number;
|
|
@@ -56,7 +87,7 @@ export declare const discordDomainSpec: {
|
|
|
56
87
|
createdAtMs: number;
|
|
57
88
|
renderedSeq?: number | undefined;
|
|
58
89
|
}>;
|
|
59
|
-
inbound_intents:
|
|
90
|
+
inbound_intents: DomainTableSpec<"intent", {
|
|
60
91
|
contentHash: string;
|
|
61
92
|
state: "claimed" | "unknown" | "succeeded" | "failed";
|
|
62
93
|
claimedAtMs: number;
|
package/lib/state/domain.js
CHANGED
|
@@ -5,9 +5,73 @@
|
|
|
5
5
|
* scan space from parsed parts. The spec is declared once with
|
|
6
6
|
* `defineDomain`, which fails loud at module load on any naming or version
|
|
7
7
|
* misconfiguration.
|
|
8
|
+
*
|
|
9
|
+
* 16.106: `defineDomain`/`domainTable` are mirrored here instead of imported.
|
|
10
|
+
* Shipping `@deepseek-ai/dsh-storage-domain` as a plugin dependency made pnpm
|
|
11
|
+
* hoist a plugin-owned copy over the profile tree, and the host's
|
|
12
|
+
* exact-version compatibility gate (0.2.1-rc.5+) then disabled that row on
|
|
13
|
+
* every host patch release — cascading `storageDomain` away and pending the
|
|
14
|
+
* workspace/session graph. `dsh-base` already provides the row at the host's
|
|
15
|
+
* exact version, and the host's `open()` consumes a spec purely structurally
|
|
16
|
+
* (property reads plus `.parse`/`.safeParse` on the caller's own zod
|
|
17
|
+
* schemas), so an inlined spec is indistinguishable from a package-built one.
|
|
18
|
+
* The mirror is kept honest by `test/state-domain.test.ts`, which checks it
|
|
19
|
+
* against the real package (a devDependency) accept-for-accept and
|
|
20
|
+
* reject-for-reject. Types still come from the package — compile-time only.
|
|
8
21
|
*/
|
|
9
|
-
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
|
|
10
22
|
import { ChannelBindingRecord, InboundIntentRecordSchema, ThreadBindingRecord } from './records.js';
|
|
23
|
+
/**
|
|
24
|
+
* Storage-legal unit name, mirrored from `@deepseek-ai/dsh-storage`
|
|
25
|
+
* (`UNIT_NAME_RE`): lowercase, digits, underscores, leading letter.
|
|
26
|
+
*/
|
|
27
|
+
const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/;
|
|
28
|
+
/**
|
|
29
|
+
* Declare one table. Mirrored from `@deepseek-ai/dsh-storage-domain`: the
|
|
30
|
+
* runtime value is the plain `{ valueSchema }` pair — the key type is a
|
|
31
|
+
* compile-time phantom that never exists at runtime.
|
|
32
|
+
*/
|
|
33
|
+
export function domainTable(schema) {
|
|
34
|
+
return { valueSchema: schema };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Identity helper that pins a spec's literal types and validates its fields.
|
|
38
|
+
* Mirrored from `@deepseek-ai/dsh-storage-domain` (0.2.1 line): misconfiguration
|
|
39
|
+
* fails loud at the owning package's module load, before any medium is
|
|
40
|
+
* touched — a domain or table name outside `UNIT_NAME_RE`, a version that is
|
|
41
|
+
* not a non-negative integer, or a global schema that accepts `null` all
|
|
42
|
+
* throw. Keep the checks and messages in sync with upstream;
|
|
43
|
+
* `test/state-domain.test.ts` drift-guards the equivalence.
|
|
44
|
+
*/
|
|
45
|
+
export function defineDomain(spec) {
|
|
46
|
+
const candidate = spec;
|
|
47
|
+
if (!UNIT_NAME_RE.test(candidate.name))
|
|
48
|
+
throw new Error(`domain name '${candidate.name}' must match ${String(UNIT_NAME_RE)}`);
|
|
49
|
+
if (!Number.isInteger(candidate.version) || candidate.version < 0)
|
|
50
|
+
throw new Error(`domain '${candidate.name}' version must be a non-negative integer, got ${String(candidate.version)}`);
|
|
51
|
+
for (const compat of candidate.compatibleVersions ?? []) {
|
|
52
|
+
if (!Number.isInteger(compat) || compat < 0 || compat >= candidate.version) {
|
|
53
|
+
throw new Error(`domain '${candidate.name}' compatibleVersions entries must be non-negative integers below version ${String(candidate.version)}, got ${String(compat)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (candidate.layout !== undefined) {
|
|
57
|
+
const layout = candidate.layout;
|
|
58
|
+
if (layout !== 'single' && layout !== 'per-record')
|
|
59
|
+
throw new Error(`domain '${candidate.name}' layout must be 'single' or 'per-record', got ${layout}`);
|
|
60
|
+
}
|
|
61
|
+
if (candidate.invalidRecords !== undefined) {
|
|
62
|
+
const policy = candidate.invalidRecords;
|
|
63
|
+
if (policy !== 'backup-and-skip')
|
|
64
|
+
throw new Error(`domain '${candidate.name}' invalidRecords must be 'backup-and-skip' when present, got ${policy}`);
|
|
65
|
+
}
|
|
66
|
+
for (const table of Object.keys(candidate.tables)) {
|
|
67
|
+
if (!UNIT_NAME_RE.test(table))
|
|
68
|
+
throw new Error(`domain '${candidate.name}' table name '${table}' must match ${String(UNIT_NAME_RE)}`);
|
|
69
|
+
}
|
|
70
|
+
if (candidate.global !== undefined && candidate.global.schema.safeParse(null).success) {
|
|
71
|
+
throw new Error(`domain '${candidate.name}' global schema must not accept null: null is the medium's "never written" sentinel, so a stored null could not round-trip`);
|
|
72
|
+
}
|
|
73
|
+
return spec;
|
|
74
|
+
}
|
|
11
75
|
/**
|
|
12
76
|
* Storage-legal domain name (`UNIT_NAME_RE` allows only lowercase, digits,
|
|
13
77
|
* and underscores) and the current format version; a medium stamped with a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addozhang/dsh-discord",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5-rc.1",
|
|
4
4
|
"description": "Discord adapter for DeepSeek Harness · DeepSeek Harness 的 Discord 适配器",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@deepseek-ai/cordis": "4.0.5",
|
|
60
|
-
"@deepseek-ai/dsh-client-store": "0.2.1-rc.
|
|
61
|
-
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.
|
|
62
|
-
"@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.
|
|
63
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.
|
|
64
|
-
"@deepseek-ai/dsh-credentials": "0.2.1-rc.
|
|
65
|
-
"@deepseek-ai/dsh-settings": "0.2.1-rc.
|
|
66
|
-
"@deepseek-ai/dsh-storage-domain": "0.2.1-rc.
|
|
60
|
+
"@deepseek-ai/dsh-client-store": "0.2.1-rc.5",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.5",
|
|
62
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.5",
|
|
63
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.5",
|
|
64
|
+
"@deepseek-ai/dsh-credentials": "0.2.1-rc.5",
|
|
65
|
+
"@deepseek-ai/dsh-settings": "0.2.1-rc.5",
|
|
66
|
+
"@deepseek-ai/dsh-storage-domain": "0.2.1-rc.5",
|
|
67
67
|
"@eslint/js": "9.39.1",
|
|
68
68
|
"@types/node": "24.10.1",
|
|
69
69
|
"@types/react": "18.3.1",
|
|
@@ -78,7 +78,6 @@
|
|
|
78
78
|
"vitest": "4.1.11"
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
|
-
"@deepseek-ai/dsh-storage-domain": "0.2.1-rc.3",
|
|
82
81
|
"@deepseek-ai/schemastery": "3.18.5",
|
|
83
82
|
"zod": "^4.4.3"
|
|
84
83
|
},
|