@addozhang/dsh-discord 0.5.0-alpha.2 → 0.5.0-rc.2
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 +2 -2
- package/README.zh.md +2 -2
- package/lib/dsh/host-events.d.ts +13 -1
- package/lib/dsh/host-events.js +47 -1
- package/lib/i18n.d.ts +3 -0
- package/lib/i18n.js +6 -0
- package/lib/index.js +77 -14
- package/lib/state/bindings.d.ts +13 -0
- package/lib/state/bindings.js +14 -0
- package/lib/state/domain.d.ts +1 -0
- package/lib/state/records.d.ts +1 -0
- package/lib/state/records.js +7 -0
- package/lib/stream/live.d.ts +18 -2
- package/lib/stream/live.js +79 -2
- package/lib/stream/outbound.d.ts +3 -1
- package/lib/stream/outbound.js +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
108
108
|
| `/queue list`, `/queue remove` | session thread | inspect and trim the pending queue |
|
|
109
109
|
| `/steer`, `/stop` | session thread | steer or cancel the running turn (owner only) |
|
|
110
110
|
| `/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) |
|
|
111
|
-
| `/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 |
|
|
111
|
+
| `/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. The thread renders the session's history — answers and tool summaries plus the user's inputs as quoted echo lines (plugin/system injections are never echoed). After an adapter restart, threads catch up only what they missed: a persisted render watermark suppresses already-delivered history, and bound sessions re-subscribe on reconnect so turns prompted from the web UI still land in their thread |
|
|
112
112
|
| `/guild forget` | any channel | operator-only removal of adapter records |
|
|
113
113
|
|
|
114
114
|
## Design notes
|
|
@@ -132,7 +132,7 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
132
132
|
|
|
133
133
|
```sh
|
|
134
134
|
pnpm install --ignore-scripts
|
|
135
|
-
pnpm test #
|
|
135
|
+
pnpm test # 729 tests incl. gateway/REST twin E2E
|
|
136
136
|
pnpm typecheck
|
|
137
137
|
pnpm lint
|
|
138
138
|
pnpm build # lib + client bundle
|
package/README.zh.md
CHANGED
|
@@ -108,7 +108,7 @@ dsh-discord:
|
|
|
108
108
|
| `/queue list`, `/queue remove` | 会话线程 | 查看与移除待处理队列 |
|
|
109
109
|
| `/steer`, `/stop` | 会话线程 | 插话或取消运行中的 Turn(仅属主) |
|
|
110
110
|
| `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
|
|
111
|
-
| `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent
|
|
111
|
+
| `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现。线程会渲染会话历史——回答与工具摘要之外,用户输入以引用行回显(插件/系统注入永不回显)。适配器重启后线程只补齐错过部分:持久化的渲染水位抑制已投递过的历史,已绑定会话在重连时自动重订阅,因此从 web UI 发起的回合也会落进对应线程 |
|
|
112
112
|
| `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
|
|
113
113
|
|
|
114
114
|
|
|
@@ -133,7 +133,7 @@ dsh-discord:
|
|
|
133
133
|
|
|
134
134
|
```sh
|
|
135
135
|
pnpm install --ignore-scripts
|
|
136
|
-
pnpm test #
|
|
136
|
+
pnpm test # 729 tests incl. gateway/REST twin E2E
|
|
137
137
|
pnpm typecheck
|
|
138
138
|
pnpm lint
|
|
139
139
|
pnpm build # lib + client bundle
|
package/lib/dsh/host-events.d.ts
CHANGED
|
@@ -28,6 +28,18 @@ export interface HostSessionFollowFace {
|
|
|
28
28
|
}, signal: AbortSignal): AsyncIterable<unknown>;
|
|
29
29
|
control(signal: AbortSignal): AsyncIterable<unknown>;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Durable catch-up seeding for one tracked session (replay-fence D2):
|
|
33
|
+
* `floor` raises the in-memory watermark so snapshot records the thread
|
|
34
|
+
* already rendered are dropped before they reach the renderer;
|
|
35
|
+
* `suppressOpeningSnapshot` swallows the FIRST opening snapshot whole
|
|
36
|
+
* (watermark advances, nothing delivers) — the one-time migration for
|
|
37
|
+
* bindings that predate the persisted watermark.
|
|
38
|
+
*/
|
|
39
|
+
export interface TrackSeedOptions {
|
|
40
|
+
floor?: number;
|
|
41
|
+
suppressOpeningSnapshot?: boolean;
|
|
42
|
+
}
|
|
31
43
|
export interface HostEventRouterOptions {
|
|
32
44
|
log?: (event: string, detail?: unknown) => void;
|
|
33
45
|
}
|
|
@@ -37,6 +49,6 @@ export interface HostEventRouterOptions {
|
|
|
37
49
|
* tracked session owns one follow loop whose frames join the shared queue.
|
|
38
50
|
*/
|
|
39
51
|
export declare function createHostEventRouter(services: HostSessionFollowFace, options?: HostEventRouterOptions): {
|
|
40
|
-
track(sessionId: string): void;
|
|
52
|
+
track(sessionId: string, seed?: TrackSeedOptions): void;
|
|
41
53
|
stream(signal: AbortSignal): AsyncIterable<unknown>;
|
|
42
54
|
};
|
package/lib/dsh/host-events.js
CHANGED
|
@@ -75,6 +75,17 @@ function createFrameQueue() {
|
|
|
75
75
|
function isRecord(value) {
|
|
76
76
|
return typeof value === 'object' && value !== null;
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Extract one journal record's seq through both envelope shapes (double
|
|
80
|
+
* `{type:'event', event:{seq}}` and flat `{seq}`); undefined when absent.
|
|
81
|
+
*/
|
|
82
|
+
function journalSeq(record) {
|
|
83
|
+
if (!isRecord(record))
|
|
84
|
+
return undefined;
|
|
85
|
+
const inner = isRecord(record['event']) ? record['event'] : record;
|
|
86
|
+
const seq = inner['seq'];
|
|
87
|
+
return typeof seq === 'number' ? seq : undefined;
|
|
88
|
+
}
|
|
78
89
|
/** Defensive text extraction from a queued message's JSON content parts. */
|
|
79
90
|
function queueItemSummary(content) {
|
|
80
91
|
if (!Array.isArray(content))
|
|
@@ -99,6 +110,8 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
99
110
|
const tracked = new Map();
|
|
100
111
|
/** Last delivered durable seq per session: the replay-dedupe watermark. */
|
|
101
112
|
const watermark = new Map();
|
|
113
|
+
/** Sessions whose NEXT opening snapshot is swallowed whole (legacy bindings). */
|
|
114
|
+
const suppressOnce = new Set();
|
|
102
115
|
let consumer;
|
|
103
116
|
let rootSignal;
|
|
104
117
|
const startLoop = (sessionId, attempt = 0) => {
|
|
@@ -139,6 +152,25 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
139
152
|
: undefined;
|
|
140
153
|
if (batch === undefined)
|
|
141
154
|
continue;
|
|
155
|
+
// Legacy-binding migration (D2): swallow the first opening
|
|
156
|
+
// snapshot whole — the thread already rendered that history in
|
|
157
|
+
// a pre-feature process; re-delivering it would replay the very
|
|
158
|
+
// duplication this fence exists to stop. The watermark still
|
|
159
|
+
// advances, so later live frames deliver untouched.
|
|
160
|
+
if (frame['type'] === 'snapshot' && suppressOnce.delete(sessionId)) {
|
|
161
|
+
let maxSeq = watermark.get(sessionId) ?? 0;
|
|
162
|
+
for (const record of batch) {
|
|
163
|
+
const seq = journalSeq(record);
|
|
164
|
+
if (seq !== undefined && seq > maxSeq)
|
|
165
|
+
maxSeq = seq;
|
|
166
|
+
}
|
|
167
|
+
if (maxSeq > 0)
|
|
168
|
+
watermark.set(sessionId, maxSeq);
|
|
169
|
+
if (process.env['DSH_DISCORD_TRACE'] === '1') {
|
|
170
|
+
console.error(`[dsh-discord:trace] snapshot-suppressed session=${sessionId.slice(0, 8)} watermark=${String(maxSeq)}`);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
142
174
|
const through = watermark.get(sessionId) ?? 0;
|
|
143
175
|
let delivered = through;
|
|
144
176
|
for (const record of batch) {
|
|
@@ -169,6 +201,11 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
169
201
|
type: 'session/event',
|
|
170
202
|
sessionId,
|
|
171
203
|
event: { type: inner.type, data: (typeof inner.data === 'object' && inner.data !== null ? inner.data : {}) },
|
|
204
|
+
// Carrier + seq (replay-fence D4): the renderer's watermark
|
|
205
|
+
// tracking and catch-up/live distinction ride these; pure
|
|
206
|
+
// additions every existing consumer can ignore.
|
|
207
|
+
carrier: frame['type'] === 'snapshot' ? 'snapshot' : 'live',
|
|
208
|
+
...(typeof inner.seq === 'number' ? { seq: inner.seq } : {}),
|
|
172
209
|
});
|
|
173
210
|
}
|
|
174
211
|
if (delivered > through)
|
|
@@ -253,11 +290,20 @@ export function createHostEventRouter(services, options = {}) {
|
|
|
253
290
|
})();
|
|
254
291
|
};
|
|
255
292
|
return {
|
|
256
|
-
track(sessionId) {
|
|
293
|
+
track(sessionId, seed) {
|
|
257
294
|
if (sessionId === '' || tracked.has(sessionId))
|
|
258
295
|
return;
|
|
259
296
|
if (rootSignal?.aborted)
|
|
260
297
|
return;
|
|
298
|
+
if (seed !== undefined) {
|
|
299
|
+
// Seed BEFORE the loop opens: the opening snapshot races the very
|
|
300
|
+
// first watermark read.
|
|
301
|
+
if (seed.floor !== undefined && seed.floor > (watermark.get(sessionId) ?? 0)) {
|
|
302
|
+
watermark.set(sessionId, seed.floor);
|
|
303
|
+
}
|
|
304
|
+
if (seed.suppressOpeningSnapshot === true)
|
|
305
|
+
suppressOnce.add(sessionId);
|
|
306
|
+
}
|
|
261
307
|
startLoop(sessionId);
|
|
262
308
|
},
|
|
263
309
|
stream(signal) {
|
package/lib/i18n.d.ts
CHANGED
|
@@ -90,6 +90,9 @@ declare const zh: {
|
|
|
90
90
|
progressWriting: string;
|
|
91
91
|
progressApprovalWait: string;
|
|
92
92
|
progressTurnSummary: (total: number, failed: number, breakdown: string) => string;
|
|
93
|
+
userInputLabel: string;
|
|
94
|
+
userInputNonText: string;
|
|
95
|
+
userInputTruncated: string;
|
|
93
96
|
modelNeedsThread: string;
|
|
94
97
|
modelShowUnavailable: string;
|
|
95
98
|
modelShowHeader: (sel: string, groups: number) => string;
|
package/lib/i18n.js
CHANGED
|
@@ -100,6 +100,9 @@ const zh = {
|
|
|
100
100
|
progressTurnSummary: (total, failed, breakdown) => failed > 0
|
|
101
101
|
? `⚙️ 本轮 ${String(total)} 次工具调用 · ${String(failed)} 失败 ✗(${breakdown})`
|
|
102
102
|
: `⚙️ 本轮 ${String(total)} 次工具调用 ✓(${breakdown})`,
|
|
103
|
+
userInputLabel: '💬 用户输入',
|
|
104
|
+
userInputNonText: '(非文本消息)',
|
|
105
|
+
userInputTruncated: '(已截断)',
|
|
103
106
|
// ── /model show / select ─────────────────────────────────────────────
|
|
104
107
|
modelNeedsThread: '⚠️ /model 需要在已绑定 Session 的任务线程中使用(先在项目频道 @ 机器人)。',
|
|
105
108
|
modelShowUnavailable: '⚠️ 模型目录暂时不可用,请稍后重试。',
|
|
@@ -229,6 +232,9 @@ const en = {
|
|
|
229
232
|
progressTurnSummary: (total, failed, breakdown) => failed > 0
|
|
230
233
|
? `⚙️ ${String(total)} tool calls this turn · ${String(failed)} failed ✗ (${breakdown})`
|
|
231
234
|
: `⚙️ ${String(total)} tool calls this turn ✓ (${breakdown})`,
|
|
235
|
+
userInputLabel: '💬 User input',
|
|
236
|
+
userInputNonText: '(non-text message)',
|
|
237
|
+
userInputTruncated: '(truncated)',
|
|
232
238
|
// ── /model show / select ─────────────────────────────────────────────
|
|
233
239
|
modelNeedsThread: '⚠️ /model needs a thread bound to a Session (mention the bot in a project channel first).',
|
|
234
240
|
modelShowUnavailable: '⚠️ The model catalog is temporarily unavailable; try again later.',
|
package/lib/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { guildKeysToForget, sweepExpired } from './state/retention.js';
|
|
|
24
24
|
import { createHostEventRouter } from './dsh/host-events.js';
|
|
25
25
|
import { listSessionIds, listSessionSummaries } from './dsh/host-face.js';
|
|
26
26
|
import { installAskServicePatches } from './dsh/host-asks.js';
|
|
27
|
-
import { createBindingStore } from './state/bindings.js';
|
|
27
|
+
import { createBindingStore, renderWatermarkSeed } from './state/bindings.js';
|
|
28
28
|
import { createIntentStore } from './state/intents.js';
|
|
29
29
|
import { createTurnTracker } from './features/turn-ownership.js';
|
|
30
30
|
import { createThreadCreationFlow } from './features/thread-creation.js';
|
|
@@ -173,6 +173,61 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
173
173
|
const threadBindingStore = createBindingStore(threadTable);
|
|
174
174
|
const intents = createIntentStore(intentTable);
|
|
175
175
|
const turnTracker = createTurnTracker();
|
|
176
|
+
// Replay-fence (replay-fence-and-user-input D1/D2): the process start
|
|
177
|
+
// mark separates "binding created in-process" (fresh thread, render full
|
|
178
|
+
// history) from "binding from an earlier process" (its thread already
|
|
179
|
+
// shows that history; catch-up must not re-deliver it).
|
|
180
|
+
const processStartMs = Date.now();
|
|
181
|
+
/**
|
|
182
|
+
* The full reverse lookup the watermark paths need: binding key, parsed
|
|
183
|
+
* scope, and record for one session's owning thread (bindings are 1:1).
|
|
184
|
+
*/
|
|
185
|
+
const threadBindingForSession = (sessionId) => {
|
|
186
|
+
for (const [key, record] of threadTable.entries()) {
|
|
187
|
+
if (record.sessionId !== sessionId)
|
|
188
|
+
continue;
|
|
189
|
+
const scope = parseThreadBindingKey(key);
|
|
190
|
+
if (scope !== undefined)
|
|
191
|
+
return { key, scope, record };
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Durable catch-up seeding (D2), derived per record by
|
|
197
|
+
* `renderWatermarkSeed` — see state/bindings.ts.
|
|
198
|
+
*/
|
|
199
|
+
const trackSeedForRecord = (record) => renderWatermarkSeed(record, processStartMs);
|
|
200
|
+
const trackSeedFor = (sessionId) => {
|
|
201
|
+
const found = threadBindingForSession(sessionId);
|
|
202
|
+
return found === undefined ? undefined : trackSeedForRecord(found.record);
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Advance the owning thread's durable render watermark (D5): one
|
|
206
|
+
* revision-fenced write per turn boundary. A lost fence race is
|
|
207
|
+
* abandoned and retried at the next boundary — the watermark is a
|
|
208
|
+
* monotonic upper bound, so one lost write only widens the crash
|
|
209
|
+
* window's bounded re-render suffix.
|
|
210
|
+
*/
|
|
211
|
+
const persistRenderedSeq = (sessionId, renderedSeq) => {
|
|
212
|
+
const found = threadBindingForSession(sessionId);
|
|
213
|
+
if (found === undefined)
|
|
214
|
+
return Promise.resolve();
|
|
215
|
+
const { key, record } = found;
|
|
216
|
+
if (renderedSeq <= (record.renderedSeq ?? 0))
|
|
217
|
+
return Promise.resolve();
|
|
218
|
+
return threadBindingStore.bind(key, {
|
|
219
|
+
sessionId: record.sessionId,
|
|
220
|
+
workspaceId: record.workspaceId,
|
|
221
|
+
createdBy: record.createdBy,
|
|
222
|
+
createdAtMs: record.createdAtMs,
|
|
223
|
+
renderedSeq,
|
|
224
|
+
}, { expectedRevision: record.revision }).then(outcome => {
|
|
225
|
+
if (!outcome.ok)
|
|
226
|
+
rpcLog('discord_render_watermark_skipped', { sessionId, error: outcome.error });
|
|
227
|
+
}).catch((cause) => {
|
|
228
|
+
rpcLog('discord_render_watermark_threw', { sessionId, cause: String(cause) });
|
|
229
|
+
});
|
|
230
|
+
};
|
|
176
231
|
// Approvals: process-local backing (minutes-lived; DSH replays pending
|
|
177
232
|
// asks on mux reopen). turnActors maps submitted request ids to their
|
|
178
233
|
// Discord authors — the ownership fact for approval/question clicks.
|
|
@@ -308,7 +363,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
308
363
|
...(request.images === undefined ? {} : { images: request.images }),
|
|
309
364
|
}, { log: rpcLog, rpcId: request.requestId });
|
|
310
365
|
if (submitted.outcome === 'accepted')
|
|
311
|
-
hostEvents.track(request.sessionId);
|
|
366
|
+
hostEvents.track(request.sessionId, trackSeedFor(request.sessionId));
|
|
312
367
|
return submitted;
|
|
313
368
|
},
|
|
314
369
|
};
|
|
@@ -655,6 +710,16 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
655
710
|
rpcLog('discord_reconcile_thread_retired', { threadId: action.threadId, reason: action.reason });
|
|
656
711
|
}
|
|
657
712
|
}
|
|
713
|
+
// Replay fence (16.70): re-arm follow subscriptions for every
|
|
714
|
+
// binding that survived reconciliation. Without a Discord-side
|
|
715
|
+
// acquisition event this process (create/prompt/steer/resume), a
|
|
716
|
+
// web-origin turn on a bound session would render into nothing —
|
|
717
|
+
// the seeded floor keeps the opening snapshot from re-delivering
|
|
718
|
+
// history the thread already shows, so only the missed suffix
|
|
719
|
+
// (bounded by the last persisted turn boundary) catches up.
|
|
720
|
+
for (const [, record] of threadTable.entries()) {
|
|
721
|
+
hostEvents.track(record.sessionId, trackSeedForRecord(record));
|
|
722
|
+
}
|
|
658
723
|
rpcLog('discord_reconcile_done', {});
|
|
659
724
|
}
|
|
660
725
|
finally {
|
|
@@ -689,16 +754,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
689
754
|
}).catch((cause) => { rpcLog('discord_question_expiry_threw', String(cause)); });
|
|
690
755
|
}, 30_000);
|
|
691
756
|
ctx.effect(() => () => { clearInterval(expiryTimer); }, 'discord interaction expiry sweep');
|
|
692
|
-
const threadForSession = (sessionId) =>
|
|
693
|
-
for (const [key, record] of threadTable.entries()) {
|
|
694
|
-
if (record.sessionId !== sessionId)
|
|
695
|
-
continue;
|
|
696
|
-
const scope = parseThreadBindingKey(key);
|
|
697
|
-
if (scope !== undefined)
|
|
698
|
-
return scope.threadId;
|
|
699
|
-
}
|
|
700
|
-
return undefined;
|
|
701
|
-
};
|
|
757
|
+
const threadForSession = (sessionId) => threadBindingForSession(sessionId)?.scope.threadId;
|
|
702
758
|
// DSH ask answerers (0.1.6 composed-approval model): the waterfalls
|
|
703
759
|
// dispatch on the BASE TREE's event bus (the profile composes one event
|
|
704
760
|
// tree per bundle and a waterfall only enumerates its own registry), so
|
|
@@ -781,7 +837,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
781
837
|
steer: async (sessionId, prompt) => {
|
|
782
838
|
const steered = await steerSession(dsh, { sessionId, prompt }, { log: rpcLog });
|
|
783
839
|
if (steered.outcome === 'accepted')
|
|
784
|
-
hostEvents.track(sessionId);
|
|
840
|
+
hostEvents.track(sessionId, trackSeedFor(sessionId));
|
|
785
841
|
return steered;
|
|
786
842
|
},
|
|
787
843
|
removeQueueItem: (sessionId, itemId) => removeQueueItemViaProxy(dsh, { sessionId, itemId }, { log: rpcLog }),
|
|
@@ -1093,12 +1149,19 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
1093
1149
|
approvalWait: copy.progressApprovalWait,
|
|
1094
1150
|
turnSummary: copy.progressTurnSummary,
|
|
1095
1151
|
}),
|
|
1152
|
+
userEchoCopy: () => ({
|
|
1153
|
+
label: copy.userInputLabel,
|
|
1154
|
+
nonText: copy.userInputNonText,
|
|
1155
|
+
truncated: copy.userInputTruncated,
|
|
1156
|
+
}),
|
|
1096
1157
|
onQueueSnapshot: (sessionId, items) => { queueSnapshots.set(sessionId, items); },
|
|
1097
|
-
onTurnEnded: (sessionId) => {
|
|
1158
|
+
onTurnEnded: (sessionId, info) => {
|
|
1098
1159
|
const turn = turnTracker.active(sessionId);
|
|
1099
1160
|
if (turn !== undefined)
|
|
1100
1161
|
turnTracker.complete(turn.requestId);
|
|
1101
1162
|
rpcLog('discord_turn_ended', { sessionId, hadActiveTurn: turn !== undefined });
|
|
1163
|
+
if (info !== undefined)
|
|
1164
|
+
void persistRenderedSeq(sessionId, info.renderedSeq);
|
|
1102
1165
|
},
|
|
1103
1166
|
});
|
|
1104
1167
|
// The ask patches steer the same renderer's progress line (3.2 wiring).
|
package/lib/state/bindings.d.ts
CHANGED
|
@@ -44,3 +44,16 @@ export declare function createBindingStore<V extends {
|
|
|
44
44
|
export type ChannelBindingStore = BindingStore<ChannelBinding>;
|
|
45
45
|
/** Convenience alias for the thread-binding table's store. */
|
|
46
46
|
export type ThreadBindingStore = BindingStore<ThreadBinding>;
|
|
47
|
+
/** Catch-up seeding options for the event router's `track()` (replay-fence D2). */
|
|
48
|
+
export interface RenderWatermarkSeed {
|
|
49
|
+
floor?: number;
|
|
50
|
+
suppressOpeningSnapshot?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The durable catch-up seed for one thread binding (replay-fence-and-user-input
|
|
54
|
+
* D2): a persisted `renderedSeq` floors the watermark; a binding created
|
|
55
|
+
* before this process that never persisted one (pre-feature) suppresses its
|
|
56
|
+
* first opening snapshot whole; a binding born in-process seeds nothing so a
|
|
57
|
+
* fresh thread (resume adopt) renders the session's full history.
|
|
58
|
+
*/
|
|
59
|
+
export declare function renderWatermarkSeed(record: ThreadBinding, processStartMs: number): RenderWatermarkSeed | undefined;
|
package/lib/state/bindings.js
CHANGED
|
@@ -53,3 +53,17 @@ export function createBindingStore(table) {
|
|
|
53
53
|
withKey: (key, op) => enqueue(key, op),
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The durable catch-up seed for one thread binding (replay-fence-and-user-input
|
|
58
|
+
* D2): a persisted `renderedSeq` floors the watermark; a binding created
|
|
59
|
+
* before this process that never persisted one (pre-feature) suppresses its
|
|
60
|
+
* first opening snapshot whole; a binding born in-process seeds nothing so a
|
|
61
|
+
* fresh thread (resume adopt) renders the session's full history.
|
|
62
|
+
*/
|
|
63
|
+
export function renderWatermarkSeed(record, processStartMs) {
|
|
64
|
+
if (record.renderedSeq !== undefined)
|
|
65
|
+
return { floor: record.renderedSeq };
|
|
66
|
+
if (record.createdAtMs < processStartMs)
|
|
67
|
+
return { suppressOpeningSnapshot: true };
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
package/lib/state/domain.d.ts
CHANGED
|
@@ -54,6 +54,7 @@ export declare const discordDomainSpec: {
|
|
|
54
54
|
revision: number;
|
|
55
55
|
createdBy: string;
|
|
56
56
|
createdAtMs: number;
|
|
57
|
+
renderedSeq?: number | undefined;
|
|
57
58
|
}>;
|
|
58
59
|
inbound_intents: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<"intent", {
|
|
59
60
|
contentHash: string;
|
package/lib/state/records.d.ts
CHANGED
|
@@ -33,5 +33,6 @@ export declare const ThreadBindingRecord: z.ZodObject<{
|
|
|
33
33
|
revision: z.ZodNumber;
|
|
34
34
|
createdBy: z.ZodString;
|
|
35
35
|
createdAtMs: z.ZodNumber;
|
|
36
|
+
renderedSeq: z.ZodOptional<z.ZodNumber>;
|
|
36
37
|
}, z.core.$strict>;
|
|
37
38
|
export type ThreadBinding = z.infer<typeof ThreadBindingRecord>;
|
package/lib/state/records.js
CHANGED
|
@@ -27,4 +27,11 @@ export const ThreadBindingRecord = z.strictObject({
|
|
|
27
27
|
revision: z.number().int().min(1),
|
|
28
28
|
createdBy: z.string().min(1),
|
|
29
29
|
createdAtMs: z.number().int().min(0),
|
|
30
|
+
/**
|
|
31
|
+
* Highest journal seq this thread has consumed (the durable render
|
|
32
|
+
* watermark, replay-fence-and-user-input D1). Absent on records written
|
|
33
|
+
* before the feature or before the thread rendered anything; advanced at
|
|
34
|
+
* turn boundaries through the revision fence.
|
|
35
|
+
*/
|
|
36
|
+
renderedSeq: z.number().int().min(0).optional(),
|
|
30
37
|
});
|
package/lib/stream/live.d.ts
CHANGED
|
@@ -17,6 +17,10 @@ export type LiveFrame = {
|
|
|
17
17
|
type: string;
|
|
18
18
|
data: Record<string, unknown>;
|
|
19
19
|
};
|
|
20
|
+
/** Journal seq of the carried record (absent on seq-less records). */
|
|
21
|
+
seq?: number;
|
|
22
|
+
/** Which follow carrier delivered the record (replay-fence D4). */
|
|
23
|
+
carrier?: 'snapshot' | 'live';
|
|
20
24
|
view?: unknown;
|
|
21
25
|
} | {
|
|
22
26
|
type: 'session/subscribed';
|
|
@@ -107,8 +111,20 @@ export interface LiveRenderDeps {
|
|
|
107
111
|
approvalWait: string;
|
|
108
112
|
turnSummary: (total: number, failed: number, breakdown: string) => string;
|
|
109
113
|
};
|
|
110
|
-
/**
|
|
111
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Localized user-echo copy, resolved live (language can change).
|
|
116
|
+
* Unset: user/message records never echo (the pre-feature behavior).
|
|
117
|
+
*/
|
|
118
|
+
userEchoCopy?: () => {
|
|
119
|
+
label: string;
|
|
120
|
+
nonText: string;
|
|
121
|
+
truncated: string;
|
|
122
|
+
};
|
|
123
|
+
/** Turn ownership release on turn/end; `info` carries the consumed watermark. */
|
|
124
|
+
onTurnEnded?: (sessionId: string, info?: {
|
|
125
|
+
threadId: string;
|
|
126
|
+
renderedSeq: number;
|
|
127
|
+
}) => void;
|
|
112
128
|
}
|
|
113
129
|
export declare function startLiveRender(deps: LiveRenderDeps): {
|
|
114
130
|
dispose(): void;
|
package/lib/stream/live.js
CHANGED
|
@@ -21,6 +21,12 @@ import { discordChannelNameKey, safeTitle } from '../policy/disclosure.js';
|
|
|
21
21
|
const DEFAULT_ACTIVITY_COALESCE_MS = 1_000;
|
|
22
22
|
/** Row budget: a presentation title is truncated before it reaches Discord. */
|
|
23
23
|
const ACTIVITY_TITLE_MAX = 80;
|
|
24
|
+
/**
|
|
25
|
+
* Echo budget for one user/message (replay-fence D6): history catch-up
|
|
26
|
+
* mirrors, it does not replay wholesale — beyond this the text truncates
|
|
27
|
+
* with a marker and the Session log remains the source of truth.
|
|
28
|
+
*/
|
|
29
|
+
const USER_ECHO_MAX = 500;
|
|
24
30
|
/**
|
|
25
31
|
* Wire-level live-path tracing (`DSH_DISCORD_TRACE=1` → stderr). Default
|
|
26
32
|
* silent like the rest of the adapter; the live path's drops (unrecognized
|
|
@@ -53,6 +59,15 @@ function assistantText(message) {
|
|
|
53
59
|
.map(block => block.text)
|
|
54
60
|
.join('');
|
|
55
61
|
}
|
|
62
|
+
/** Extract one user message's text (text parts only, newline-joined). */
|
|
63
|
+
function userTextParts(content) {
|
|
64
|
+
if (!Array.isArray(content))
|
|
65
|
+
return '';
|
|
66
|
+
return content
|
|
67
|
+
.filter((block) => typeof block === 'object' && block !== null && block.type === 'text')
|
|
68
|
+
.map(block => block.text)
|
|
69
|
+
.join('\n');
|
|
70
|
+
}
|
|
56
71
|
/** The callId of a tool/result event (block-carried, defensive). */
|
|
57
72
|
function resultCallId(data) {
|
|
58
73
|
if (typeof data['callId'] === 'string')
|
|
@@ -119,6 +134,8 @@ export function startLiveRender(deps) {
|
|
|
119
134
|
toolNames: new Map(),
|
|
120
135
|
toolTitles: new Map(),
|
|
121
136
|
lastTitle: undefined,
|
|
137
|
+
lastSeq: 0,
|
|
138
|
+
caughtUp: false,
|
|
122
139
|
};
|
|
123
140
|
runtimes.set(threadId, runtime);
|
|
124
141
|
return runtime;
|
|
@@ -270,13 +287,63 @@ export function startLiveRender(deps) {
|
|
|
270
287
|
await flush;
|
|
271
288
|
};
|
|
272
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* User-input echo (replay-fence D6): mirror one human `user/message` into
|
|
292
|
+
* the thread as one quoted bot message. Filter matrix:
|
|
293
|
+
* - `source.kind !== 'user'` (plugin/system injections): never renders,
|
|
294
|
+
* and none of its content is disclosed.
|
|
295
|
+
* - Discord-originated input (`source.rpcId` = `discord:<messageId>`):
|
|
296
|
+
* already the user's own message in the thread — echoes only during a
|
|
297
|
+
* fresh runtime's initial catch-up (resume into a new thread); live
|
|
298
|
+
* frames and post-catch-up snapshots skip it.
|
|
299
|
+
*/
|
|
300
|
+
function echoUserInput(threadId, runtime, data, event) {
|
|
301
|
+
const copy = deps.userEchoCopy?.();
|
|
302
|
+
if (copy === undefined)
|
|
303
|
+
return;
|
|
304
|
+
const source = data['source'];
|
|
305
|
+
if (typeof source !== 'object' || source === null)
|
|
306
|
+
return;
|
|
307
|
+
const { kind, rpcId } = source;
|
|
308
|
+
if (kind !== 'user')
|
|
309
|
+
return;
|
|
310
|
+
const fromDiscord = typeof rpcId === 'string' && rpcId.startsWith('discord:');
|
|
311
|
+
if (fromDiscord && (event.carrier === 'live' || runtime.caughtUp))
|
|
312
|
+
return;
|
|
313
|
+
const text = userTextParts(data['content']);
|
|
314
|
+
const body = text === ''
|
|
315
|
+
? copy.nonText
|
|
316
|
+
: text.length <= USER_ECHO_MAX
|
|
317
|
+
? text
|
|
318
|
+
: `${truncateText(text, USER_ECHO_MAX)} ${copy.truncated}`;
|
|
319
|
+
const quoted = body.split('\n').map(line => `> ${line}`).join('\n');
|
|
320
|
+
const payload = buildOutboundMessage({ kind: 'user', content: `${copy.label}\n${quoted}` });
|
|
321
|
+
void deps.delivery.send({ channelId: threadId, content: payload.content }).then(sent => {
|
|
322
|
+
if (sent.outcome !== 'completed') {
|
|
323
|
+
deps.log?.('discord_live_user_echo_failed', { threadId, outcome: sent.outcome });
|
|
324
|
+
}
|
|
325
|
+
}).catch((cause) => {
|
|
326
|
+
deps.log?.('discord_live_user_echo_threw', { threadId, cause: String(cause) });
|
|
327
|
+
});
|
|
328
|
+
}
|
|
273
329
|
function handleSessionEvent(sessionId, threadId, runtime, event, frameView) {
|
|
274
330
|
const data = event.data;
|
|
275
331
|
if (TRACE)
|
|
276
332
|
trace('handleSessionEvent', event.type, 'keys:', Object.keys(data).join(','));
|
|
333
|
+
// Watermark bookkeeping BEFORE any branch (replay-fence D5): a record
|
|
334
|
+
// that falls through the switch is still consumed — the persisted
|
|
335
|
+
// watermark must never claim less than what was delivered.
|
|
336
|
+
if (typeof event.seq === 'number' && event.seq > runtime.lastSeq)
|
|
337
|
+
runtime.lastSeq = event.seq;
|
|
338
|
+
if (event.carrier === 'live')
|
|
339
|
+
runtime.caughtUp = true;
|
|
277
340
|
const turnId = typeof data['turn'] === 'number' ? String(data['turn']) : undefined;
|
|
278
341
|
const stepId = typeof data['step'] === 'number' ? String(data['step']) : undefined;
|
|
279
342
|
switch (event.type) {
|
|
343
|
+
case 'user/message': {
|
|
344
|
+
echoUserInput(threadId, runtime, data, event);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
280
347
|
case 'turn/start': {
|
|
281
348
|
if (typeof turnId !== 'string')
|
|
282
349
|
return;
|
|
@@ -453,7 +520,10 @@ export function startLiveRender(deps) {
|
|
|
453
520
|
deps.log?.('discord_live_activity_delete_threw', { threadId, cause: String(cause) });
|
|
454
521
|
});
|
|
455
522
|
});
|
|
456
|
-
|
|
523
|
+
// Watermark persist rides the same boundary (replay-fence D5): the
|
|
524
|
+
// consumed high-water seq at turn end becomes the durable floor the
|
|
525
|
+
// next process's catch-up seeds from.
|
|
526
|
+
deps.onTurnEnded?.(sessionId, { threadId, renderedSeq: runtime.lastSeq });
|
|
457
527
|
return;
|
|
458
528
|
}
|
|
459
529
|
default:
|
|
@@ -546,7 +616,14 @@ export function startLiveRender(deps) {
|
|
|
546
616
|
trace('drop: session/event without event wrapper', JSON.stringify(frame).slice(0, 200));
|
|
547
617
|
return;
|
|
548
618
|
}
|
|
549
|
-
|
|
619
|
+
const frameSeq = frame['seq'];
|
|
620
|
+
const frameCarrier = frame['carrier'];
|
|
621
|
+
handleSessionEvent(sessionId, threadId, runtimeFor(threadId), {
|
|
622
|
+
type: eventWrapper.type,
|
|
623
|
+
data: eventWrapper.data ?? {},
|
|
624
|
+
...(typeof frameSeq === 'number' ? { seq: frameSeq } : {}),
|
|
625
|
+
...(frameCarrier === 'snapshot' || frameCarrier === 'live' ? { carrier: frameCarrier } : {}),
|
|
626
|
+
}, frame['view']);
|
|
550
627
|
}
|
|
551
628
|
async function runLoop() {
|
|
552
629
|
// Bounded reopen loop: the mux stream is the live accelerator; a dropped
|
package/lib/stream/outbound.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
/** Discord message flags the adapter always sets (silent delivery). */
|
|
10
10
|
export declare const OUTBOUND_MESSAGE_FLAGS: number;
|
|
11
|
-
export type OutboundContentKind = 'assistant' | 'tool' | 'title' | 'error';
|
|
11
|
+
export type OutboundContentKind = 'assistant' | 'tool' | 'title' | 'error' | 'user';
|
|
12
12
|
export interface OutboundMessage {
|
|
13
13
|
content: string;
|
|
14
14
|
flags: number;
|
|
@@ -20,6 +20,8 @@ export interface OutboundMessage {
|
|
|
20
20
|
/**
|
|
21
21
|
* Build one outbound payload. Titles are additionally length-capped (they
|
|
22
22
|
* render into headers and badges); all content is mention-neutralized.
|
|
23
|
+
* `user` rides the tool path: suppression only, no table wrapping — echoed
|
|
24
|
+
* input is quoted verbatim, never reflowed.
|
|
23
25
|
*/
|
|
24
26
|
export declare function buildOutboundMessage(input: {
|
|
25
27
|
kind: OutboundContentKind;
|
package/lib/stream/outbound.js
CHANGED
|
@@ -14,6 +14,8 @@ export const OUTBOUND_MESSAGE_FLAGS = DISCORD_SUPPRESS_NOTIFICATIONS_FLAG;
|
|
|
14
14
|
/**
|
|
15
15
|
* Build one outbound payload. Titles are additionally length-capped (they
|
|
16
16
|
* render into headers and badges); all content is mention-neutralized.
|
|
17
|
+
* `user` rides the tool path: suppression only, no table wrapping — echoed
|
|
18
|
+
* input is quoted verbatim, never reflowed.
|
|
17
19
|
*/
|
|
18
20
|
export function buildOutboundMessage(input) {
|
|
19
21
|
const content = input.kind === 'title'
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addozhang/dsh-discord",
|
|
3
|
-
"version": "0.5.0-
|
|
4
|
-
"description": "Discord
|
|
3
|
+
"version": "0.5.0-rc.2",
|
|
4
|
+
"description": "Discord adapter for DeepSeek Harness · DeepSeek Harness 的 Discord 适配器",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"packageManager": "pnpm@11.0.8",
|