@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 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 # 683 tests incl. gateway/REST twin E2E
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 # 683 tests incl. gateway/REST twin E2E
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
@@ -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
  };
@@ -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).
@@ -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;
@@ -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
+ }
@@ -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;
@@ -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>;
@@ -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
  });
@@ -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
- /** Turn ownership release on turn/end. */
111
- onTurnEnded?: (sessionId: string) => void;
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;
@@ -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
- deps.onTurnEnded?.(sessionId);
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
- handleSessionEvent(sessionId, threadId, runtimeFor(threadId), { type: eventWrapper.type, data: eventWrapper.data ?? {} }, frame['view']);
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
@@ -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;
@@ -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-alpha.2",
4
- "description": "Discord-first adapter for DeepSeek Harness",
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",