@botcord/daemon 0.2.77 → 0.2.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/agent-discovery.d.ts +6 -0
  2. package/dist/agent-discovery.js +6 -0
  3. package/dist/attention-policy-fetcher.d.ts +14 -0
  4. package/dist/attention-policy-fetcher.js +59 -0
  5. package/dist/cloud-daemon.js +8 -0
  6. package/dist/cloud-gateway-runtime.d.ts +29 -0
  7. package/dist/cloud-gateway-runtime.js +122 -0
  8. package/dist/daemon-config-map.d.ts +6 -0
  9. package/dist/daemon-config-map.js +5 -4
  10. package/dist/daemon.d.ts +3 -0
  11. package/dist/daemon.js +32 -7
  12. package/dist/gateway/channels/botcord.js +29 -9
  13. package/dist/gateway/channels/login-session.d.ts +12 -0
  14. package/dist/gateway/channels/login-session.js +20 -2
  15. package/dist/gateway/channels/sanitize.d.ts +5 -18
  16. package/dist/gateway/channels/sanitize.js +5 -54
  17. package/dist/gateway/channels/text-split.d.ts +5 -11
  18. package/dist/gateway/channels/text-split.js +5 -31
  19. package/dist/gateway/dispatcher.d.ts +7 -1
  20. package/dist/gateway/dispatcher.js +88 -8
  21. package/dist/gateway/gateway.d.ts +16 -1
  22. package/dist/gateway/gateway.js +21 -0
  23. package/dist/gateway/policy-resolver.js +17 -9
  24. package/dist/gateway/runtimes/deepseek-tui.js +86 -19
  25. package/dist/gateway/types.d.ts +12 -57
  26. package/dist/gateway-control.js +18 -9
  27. package/dist/provision.d.ts +9 -3
  28. package/dist/provision.js +181 -9
  29. package/dist/room-recovery-context.d.ts +11 -0
  30. package/dist/room-recovery-context.js +97 -0
  31. package/dist/runtime-models.d.ts +17 -0
  32. package/dist/runtime-models.js +953 -0
  33. package/dist/runtime-route-options.d.ts +7 -0
  34. package/dist/runtime-route-options.js +45 -0
  35. package/package.json +2 -2
  36. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  37. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  38. package/src/__tests__/daemon-config-map.test.ts +26 -1
  39. package/src/__tests__/gateway-control.test.ts +136 -0
  40. package/src/__tests__/policy-resolver.test.ts +20 -0
  41. package/src/__tests__/provision.test.ts +124 -0
  42. package/src/__tests__/runtime-discovery.test.ts +68 -9
  43. package/src/__tests__/runtime-models.test.ts +333 -0
  44. package/src/agent-discovery.ts +9 -0
  45. package/src/attention-policy-fetcher.ts +87 -0
  46. package/src/cloud-daemon.ts +8 -0
  47. package/src/cloud-gateway-runtime.ts +171 -0
  48. package/src/daemon-config-map.ts +17 -4
  49. package/src/daemon.ts +38 -9
  50. package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
  51. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +207 -1
  52. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  53. package/src/gateway/channels/botcord.ts +32 -8
  54. package/src/gateway/channels/login-session.ts +20 -2
  55. package/src/gateway/channels/sanitize.ts +8 -66
  56. package/src/gateway/channels/text-split.ts +5 -27
  57. package/src/gateway/dispatcher.ts +123 -27
  58. package/src/gateway/gateway.ts +29 -0
  59. package/src/gateway/policy-resolver.ts +20 -9
  60. package/src/gateway/runtimes/deepseek-tui.ts +86 -19
  61. package/src/gateway/types.ts +31 -59
  62. package/src/gateway-control.ts +21 -9
  63. package/src/provision.ts +202 -11
  64. package/src/room-recovery-context.ts +131 -0
  65. package/src/runtime-models.ts +972 -0
  66. package/src/runtime-route-options.ts +52 -0
@@ -1,33 +1,7 @@
1
1
  /**
2
- * Split a long message into chunks <= `limit` characters each. Prefers to cut
3
- * on newline boundaries so multi-paragraph replies don't fragment mid-line.
4
- *
5
- * Shared by third-party channel adapters (Telegram, WeChat) which both have a
6
- * per-message size cap from upstream and no native streaming. WeChat caller
7
- * passes a smaller `limit` (~1800), Telegram a larger one (~4000, since the
8
- * raw Telegram limit is 4096).
9
- *
10
- * Empty input returns `[""]` so callers can iterate uniformly without a length
11
- * check.
2
+ * Thin re-export `splitText` lives in `@botcord/protocol-core` so the
3
+ * daemon channel adapters and the `gateway-ingress` provider adapters use
4
+ * one canonical implementation. Existing imports of this module keep
5
+ * working unchanged.
12
6
  */
13
- export function splitText(text, limit) {
14
- if (limit <= 0)
15
- return [text];
16
- if (text.length === 0)
17
- return [""];
18
- if (text.length <= limit)
19
- return [text];
20
- const out = [];
21
- let remaining = text;
22
- while (remaining.length > limit) {
23
- let cut = remaining.lastIndexOf("\n", limit);
24
- if (cut <= 0)
25
- cut = limit;
26
- out.push(remaining.slice(0, cut));
27
- // Drop the leading newline so the next chunk doesn't start with a blank line.
28
- remaining = remaining.slice(cut).replace(/^\n/, "");
29
- }
30
- if (remaining.length > 0)
31
- out.push(remaining);
32
- return out;
33
- }
7
+ export { splitText } from "@botcord/protocol-core";
@@ -1,7 +1,7 @@
1
1
  import type { GatewayLogger } from "./log.js";
2
2
  import { type SessionStore } from "./session-store.js";
3
3
  import { type TranscriptWriter } from "./transcript.js";
4
- import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
4
+ import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRecoveryContextBuilder, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
5
5
  /** Factory signature for building a runtime adapter at turn dispatch time. */
6
6
  export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
7
7
  /** Constructor options for `Dispatcher`. */
@@ -33,6 +33,11 @@ export interface DispatcherOptions {
33
33
  * keep following stale memory.
34
34
  */
35
35
  buildMemoryContext?: MemoryContextBuilder;
36
+ /**
37
+ * Optional hook that returns recent room context for a fresh-session retry
38
+ * after a runtime resume session becomes unrecoverable.
39
+ */
40
+ buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
36
41
  /**
37
42
  * Optional side-effect hook invoked after ack, before the turn runs.
38
43
  * Intended for bookkeeping (e.g. activity tracking). Errors are logged
@@ -116,6 +121,7 @@ export declare class Dispatcher {
116
121
  private readonly runtimeAuthFailureCooldownMs;
117
122
  private readonly buildSystemContext?;
118
123
  private readonly buildMemoryContext?;
124
+ private readonly buildRuntimeRecoveryContext?;
119
125
  private readonly onInbound?;
120
126
  private readonly onOutbound?;
121
127
  private readonly onTurnComplete?;
@@ -124,6 +124,25 @@ function extractCloudRunBudget(msg) {
124
124
  }
125
125
  return out.maxWallTimeMs !== undefined || out.maxToolCalls !== undefined ? out : undefined;
126
126
  }
127
+ function looksLikeRecoverableSessionFailure(error) {
128
+ return /compact|compaction|context|token limit|maximum context|too many tokens|conversation found|session .*not found|resume/i
129
+ .test(error);
130
+ }
131
+ function buildRuntimeRecoveryPrompt(args) {
132
+ return [
133
+ "[BotCord Runtime Recovery Notice]",
134
+ "The previous Codex runtime session for this room became unrecoverable while resuming or compacting context.",
135
+ `Previous runtime error: ${truncate(args.error, 1000)}`,
136
+ "You are now running in a fresh Codex session.",
137
+ "Use the recent room messages below, current filesystem state, and available BotCord memory/context tools to reconstruct the active task.",
138
+ "Continue the original user request without asking the user to repeat information unless it is missing from those sources.",
139
+ "",
140
+ args.recoveryContext?.trim() || "[Recent Room Messages]\n(unavailable)",
141
+ "",
142
+ "[Current User Turn]",
143
+ args.userTurn,
144
+ ].join("\n");
145
+ }
127
146
  /**
128
147
  * Reason carried on `AbortController.abort()` when a cancel-previous wave
129
148
  * is taking over the slot. Distinguishing this from a timeout abort lets
@@ -164,6 +183,7 @@ export class Dispatcher {
164
183
  runtimeAuthFailureCooldownMs;
165
184
  buildSystemContext;
166
185
  buildMemoryContext;
186
+ buildRuntimeRecoveryContext;
167
187
  onInbound;
168
188
  onOutbound;
169
189
  onTurnComplete;
@@ -195,6 +215,7 @@ export class Dispatcher {
195
215
  opts.runtimeAuthFailureCooldownMs ?? DEFAULT_RUNTIME_AUTH_FAILURE_COOLDOWN_MS;
196
216
  this.buildSystemContext = opts.buildSystemContext;
197
217
  this.buildMemoryContext = opts.buildMemoryContext;
218
+ this.buildRuntimeRecoveryContext = opts.buildRuntimeRecoveryContext;
198
219
  this.onInbound = opts.onInbound;
199
220
  this.onOutbound = opts.onOutbound;
200
221
  this.onTurnComplete = opts.onTurnComplete;
@@ -1269,12 +1290,13 @@ export class Dispatcher {
1269
1290
  const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
1270
1291
  let result;
1271
1292
  let threw;
1293
+ let activeSessionId = sessionId;
1272
1294
  const turnStartedAt = Date.now();
1273
1295
  try {
1274
1296
  try {
1275
- result = await runtime.run({
1276
- text: runtimeText,
1277
- sessionId,
1297
+ const runRuntime = (textForRun, sessionIdForRun) => runtime.run({
1298
+ text: textForRun,
1299
+ sessionId: sessionIdForRun,
1278
1300
  cwd: route.cwd,
1279
1301
  accountId: msg.accountId,
1280
1302
  hubUrl: this.resolveHubUrl?.(msg.accountId),
@@ -1296,6 +1318,64 @@ export class Dispatcher {
1296
1318
  gateway: route.gateway,
1297
1319
  ...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
1298
1320
  });
1321
+ result = await runRuntime(runtimeText, sessionId);
1322
+ const firstError = result.error ?? "";
1323
+ const firstReply = (result.text || "").trim();
1324
+ const shouldRetryFresh = route.runtime === "codex" &&
1325
+ !!sessionId &&
1326
+ !!firstError &&
1327
+ !firstReply &&
1328
+ !looksLikeRuntimeAuthFailure(firstError) &&
1329
+ looksLikeRecoverableSessionFailure(firstError) &&
1330
+ !controller.signal.aborted &&
1331
+ !slot.timedOut &&
1332
+ !slot.budgetExceeded;
1333
+ if (shouldRetryFresh) {
1334
+ try {
1335
+ await this.sessionStore.delete(key);
1336
+ this.log.info("dispatcher: dropped unrecoverable runtime session before fresh retry", {
1337
+ key,
1338
+ prevRuntimeSessionId: sessionId,
1339
+ runtime: route.runtime,
1340
+ error: firstError,
1341
+ });
1342
+ }
1343
+ catch (err) {
1344
+ this.log.warn("dispatcher: session-store.delete failed before fresh retry", {
1345
+ key,
1346
+ error: err instanceof Error ? err.message : String(err),
1347
+ });
1348
+ }
1349
+ let recoveryContext;
1350
+ if (this.buildRuntimeRecoveryContext) {
1351
+ try {
1352
+ recoveryContext = await this.buildRuntimeRecoveryContext(msg);
1353
+ }
1354
+ catch (err) {
1355
+ this.log.warn("dispatcher: buildRuntimeRecoveryContext threw — retrying without recent room context", {
1356
+ agentId: msg.accountId,
1357
+ roomId: msg.conversation.id,
1358
+ topicId: msg.conversation.threadId ?? null,
1359
+ turnId,
1360
+ error: err instanceof Error ? err.message : String(err),
1361
+ });
1362
+ }
1363
+ }
1364
+ activeSessionId = null;
1365
+ runtimeText = buildRuntimeRecoveryPrompt({
1366
+ userTurn: text,
1367
+ error: firstError,
1368
+ recoveryContext,
1369
+ });
1370
+ this.log.info("dispatcher: retrying codex turn in a fresh session with recovery context", {
1371
+ agentId: msg.accountId,
1372
+ roomId: msg.conversation.id,
1373
+ topicId: msg.conversation.threadId ?? null,
1374
+ turnId,
1375
+ queueKey,
1376
+ });
1377
+ result = await runRuntime(runtimeText, null);
1378
+ }
1299
1379
  }
1300
1380
  catch (err) {
1301
1381
  threw = err;
@@ -1477,12 +1557,12 @@ export class Dispatcher {
1477
1557
  // even when the adapter echoes that id back
1478
1558
  // result.newSessionId truthy → upsert the entry
1479
1559
  // otherwise → no-op (e.g. codex intentionally never persists)
1480
- if (sessionId && effectiveError && !replyText) {
1560
+ if (activeSessionId && effectiveError && !replyText) {
1481
1561
  try {
1482
1562
  await this.sessionStore.delete(key);
1483
1563
  this.log.info("dispatcher: dropped stale runtime session", {
1484
1564
  key,
1485
- prevRuntimeSessionId: sessionId,
1565
+ prevRuntimeSessionId: activeSessionId,
1486
1566
  nextRuntimeSessionId: result.newSessionId || null,
1487
1567
  error: effectiveError,
1488
1568
  });
@@ -1509,7 +1589,7 @@ export class Dispatcher {
1509
1589
  updatedAt: Date.now(),
1510
1590
  };
1511
1591
  try {
1512
- const prevRuntimeSessionId = sessionId;
1592
+ const prevRuntimeSessionId = activeSessionId;
1513
1593
  await this.sessionStore.set(session);
1514
1594
  this.log.debug("dispatcher: persisted runtime session", {
1515
1595
  key,
@@ -1524,12 +1604,12 @@ export class Dispatcher {
1524
1604
  });
1525
1605
  }
1526
1606
  }
1527
- else if (sessionId && effectiveError) {
1607
+ else if (activeSessionId && effectiveError) {
1528
1608
  try {
1529
1609
  await this.sessionStore.delete(key);
1530
1610
  this.log.info("dispatcher: dropped stale runtime session", {
1531
1611
  key,
1532
- prevRuntimeSessionId: sessionId,
1612
+ prevRuntimeSessionId: activeSessionId,
1533
1613
  error: effectiveError,
1534
1614
  });
1535
1615
  }
@@ -2,7 +2,7 @@ import { type ChannelBackoffOptions } from "./channel-manager.js";
2
2
  import { type DispatcherOptions, type RuntimeFactory } from "./dispatcher.js";
3
3
  import { type GatewayLogger } from "./log.js";
4
4
  import { type TranscriptWriter } from "./transcript.js";
5
- import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayOutboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, MemoryContextBuilder, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
5
+ import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayOutboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeRecoveryContextBuilder, SystemContextBuilder, UserTurnBuilder } from "./types.js";
6
6
  /** Constructor options for `Gateway`. */
7
7
  export interface GatewayBootOptions {
8
8
  config: GatewayConfig;
@@ -25,6 +25,11 @@ export interface GatewayBootOptions {
25
25
  * resumed runtime sessions get an explicit prompt when memory changes.
26
26
  */
27
27
  buildMemoryContext?: MemoryContextBuilder;
28
+ /**
29
+ * Recent room context provider used by dispatcher when it must discard a
30
+ * broken runtime session and retry the same turn in a fresh session.
31
+ */
32
+ buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
28
33
  /**
29
34
  * Observer called after the dispatcher acks each inbound message. Useful
30
35
  * for activity tracking or metrics. Errors are logged and swallowed.
@@ -137,6 +142,16 @@ export declare class Gateway {
137
142
  * routing, queueing, transcript, and runtime behavior as channel messages.
138
143
  */
139
144
  injectInbound(message: GatewayInboundMessage): Promise<void>;
145
+ /**
146
+ * Inject an inbound message while routing replies through a caller-owned
147
+ * channel adapter. Cloud gateway runtime sessions use this to execute a
148
+ * provider message without loading provider credentials inside the sandbox:
149
+ * the temporary adapter captures the runtime's final reply and the always-on
150
+ * ingress service performs the provider send.
151
+ */
152
+ injectInboundThrough(message: GatewayInboundMessage, channel: ChannelAdapter, ack?: {
153
+ accept: () => Promise<void>;
154
+ }): Promise<void>;
140
155
  /**
141
156
  * Send a daemon-control initiated outbound message through a registered
142
157
  * channel. Used by proactive third-party gateway sends where the runtime
@@ -69,6 +69,7 @@ export class Gateway {
69
69
  turnTimeoutMs: opts.turnTimeoutMs,
70
70
  buildSystemContext: opts.buildSystemContext,
71
71
  buildMemoryContext: opts.buildMemoryContext,
72
+ buildRuntimeRecoveryContext: opts.buildRuntimeRecoveryContext,
72
73
  onInbound: opts.onInbound,
73
74
  composeUserTurn: opts.composeUserTurn,
74
75
  onOutbound: opts.onOutbound,
@@ -178,6 +179,26 @@ export class Gateway {
178
179
  async injectInbound(message) {
179
180
  await this.dispatcher.handle({ message });
180
181
  }
182
+ /**
183
+ * Inject an inbound message while routing replies through a caller-owned
184
+ * channel adapter. Cloud gateway runtime sessions use this to execute a
185
+ * provider message without loading provider credentials inside the sandbox:
186
+ * the temporary adapter captures the runtime's final reply and the always-on
187
+ * ingress service performs the provider send.
188
+ */
189
+ async injectInboundThrough(message, channel, ack) {
190
+ const previous = this.channelMap.get(channel.id);
191
+ this.channelMap.set(channel.id, channel);
192
+ try {
193
+ await this.dispatcher.handle({ message, ...(ack ? { ack } : {}) });
194
+ }
195
+ finally {
196
+ if (previous)
197
+ this.channelMap.set(channel.id, previous);
198
+ else
199
+ this.channelMap.delete(channel.id);
200
+ }
201
+ }
181
202
  /**
182
203
  * Send a daemon-control initiated outbound message through a registered
183
204
  * channel. Used by proactive third-party gateway sends where the runtime
@@ -24,16 +24,24 @@
24
24
  const DEFAULT_TTL_MS = 5 * 60 * 1000;
25
25
  const FETCH_FAILED = Symbol("fetch_failed");
26
26
  /**
27
- * Force DM rooms (`rm_dm_*`) to `mode: "always"` per design §4.2 — UI never
27
+ * Force direct conversations to `mode: "always"` per design §4.2 — UI never
28
28
  * lets the user mute a DM, but a stale cache from before a UX bug is cheap
29
- * to defend against here.
29
+ * to defend against here. Third-party 1:1 gateway chats have the same
30
+ * expectation: they do not carry BotCord mention metadata, so applying a
31
+ * global mention-only policy would silently drop ordinary direct messages.
30
32
  */
31
- function maybeForceDm(roomId, policy) {
32
- if (roomId && roomId.startsWith("rm_dm_") && policy.mode !== "always") {
33
+ function maybeForceDirectConversation(roomId, policy) {
34
+ if (roomId && isDirectConversation(roomId) && policy.mode !== "always") {
33
35
  return { ...policy, mode: "always" };
34
36
  }
35
37
  return policy;
36
38
  }
39
+ function isDirectConversation(roomId) {
40
+ return (roomId.startsWith("rm_dm_") ||
41
+ roomId.startsWith("telegram:user:") ||
42
+ roomId.startsWith("wechat:user:") ||
43
+ roomId.startsWith("feishu:user:"));
44
+ }
37
45
  function defaultPolicy() {
38
46
  return { mode: "always", keywords: [] };
39
47
  }
@@ -65,10 +73,10 @@ export class PolicyResolver {
65
73
  return defaultPolicy();
66
74
  const policy = fetched ?? defaultPolicy();
67
75
  this.cache.set(cacheKey(agentId, roomId), {
68
- policy: maybeForceDm(roomId, policy),
76
+ policy: maybeForceDirectConversation(roomId, policy),
69
77
  expiresAt: now + this.ttlMs,
70
78
  });
71
- return maybeForceDm(roomId, policy);
79
+ return maybeForceDirectConversation(roomId, policy);
72
80
  }
73
81
  // 3. No room override known — inherit from the cached agent-wide global.
74
82
  // Without this layer, group messages collapsed to mode=always whenever
@@ -77,7 +85,7 @@ export class PolicyResolver {
77
85
  const globalKey = cacheKey(agentId, null);
78
86
  const globalHit = this.cache.get(globalKey);
79
87
  if (globalHit && globalHit.expiresAt > now) {
80
- return maybeForceDm(roomId, globalHit.policy);
88
+ return maybeForceDirectConversation(roomId, globalHit.policy);
81
89
  }
82
90
  // 4. Cold start for global.
83
91
  const fetched = await this.safeFetch(() => this.fetchGlobal(agentId));
@@ -85,7 +93,7 @@ export class PolicyResolver {
85
93
  return defaultPolicy();
86
94
  const policy = fetched ?? defaultPolicy();
87
95
  this.cache.set(globalKey, { policy, expiresAt: now + this.ttlMs });
88
- return maybeForceDm(roomId, policy);
96
+ return maybeForceDirectConversation(roomId, policy);
89
97
  }
90
98
  async safeFetch(fn) {
91
99
  try {
@@ -113,7 +121,7 @@ export class PolicyResolver {
113
121
  put(agentId, roomId, policy) {
114
122
  const key = cacheKey(agentId, roomId);
115
123
  this.cache.set(key, {
116
- policy: maybeForceDm(roomId, policy),
124
+ policy: maybeForceDirectConversation(roomId, policy),
117
125
  expiresAt: Date.now() + this.ttlMs,
118
126
  });
119
127
  }
@@ -202,6 +202,11 @@ export class DeepseekTuiAdapter {
202
202
  auto_approve: opts.trustLevel !== "public",
203
203
  archived: false,
204
204
  };
205
+ const selection = parseDeepseekRuntimeSelection(opts.extraArgs);
206
+ if (selection.model)
207
+ body.model = selection.model;
208
+ if (selection.reasoningEffort)
209
+ body.reasoning_effort = selection.reasoningEffort;
205
210
  if (opts.systemContext)
206
211
  body.system_prompt = opts.systemContext;
207
212
  const res = await this.requestJson(`${baseUrl}/v1/threads`, {
@@ -236,16 +241,22 @@ export class DeepseekTuiAdapter {
236
241
  });
237
242
  let turnId = "";
238
243
  try {
244
+ const selection = parseDeepseekRuntimeSelection(opts.extraArgs);
245
+ const body = {
246
+ prompt: opts.text,
247
+ mode: "agent",
248
+ allow_shell: opts.trustLevel !== "public",
249
+ trust_mode: opts.trustLevel !== "public",
250
+ auto_approve: opts.trustLevel !== "public",
251
+ };
252
+ if (selection.model)
253
+ body.model = selection.model;
254
+ if (selection.reasoningEffort)
255
+ body.reasoning_effort = selection.reasoningEffort;
239
256
  const started = await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`, {
240
257
  method: "POST",
241
258
  headers,
242
- body: JSON.stringify({
243
- prompt: opts.text,
244
- mode: "agent",
245
- allow_shell: opts.trustLevel !== "public",
246
- trust_mode: opts.trustLevel !== "public",
247
- auto_approve: opts.trustLevel !== "public",
248
- }),
259
+ body: JSON.stringify(body),
249
260
  signal,
250
261
  });
251
262
  turnId = stringField(started?.turn, "id") ?? stringField(started, "turn_id") ?? "";
@@ -305,14 +316,17 @@ export class DeepseekTuiAdapter {
305
316
  if (eventName === "message.delta") {
306
317
  append(stringField(payload, "content") ?? "");
307
318
  }
308
- else if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
309
- append(stringField(payload.payload, "delta") ?? "");
319
+ else if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
320
+ append(extractDeepseekDelta(payload));
310
321
  }
311
322
  if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
312
323
  opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
313
324
  }
314
- else if (eventName === "tool.started" || isToolStarted(payload)) {
315
- const label = stringField(payload, "name") ?? stringField(payload?.payload?.tool, "name") ?? "tool";
325
+ else if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
326
+ const label = stringField(payload, "name") ??
327
+ stringField(payload?.tool, "name") ??
328
+ stringField(payload?.payload?.tool, "name") ??
329
+ "tool";
316
330
  opts.onStatus?.({ kind: "thinking", phase: "updated", label });
317
331
  }
318
332
  else if (isDeepseekTerminalEvent(eventName, payload)) {
@@ -374,15 +388,18 @@ function normalizeDeepseekEvent(eventName, payload, seq) {
374
388
  if (eventName === "message.delta") {
375
389
  return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
376
390
  }
377
- if (eventName === "tool.started" || isToolStarted(payload)) {
391
+ if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
378
392
  return { raw: { event: eventName, payload }, kind: "tool_use", seq };
379
393
  }
380
- if (eventName === "tool.completed" || isToolCompleted(payload)) {
394
+ if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
381
395
  return { raw: { event: eventName, payload }, kind: "tool_result", seq };
382
396
  }
383
- if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
397
+ if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
384
398
  return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
385
399
  }
400
+ if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
401
+ return { raw: { event: eventName, payload }, kind: "thinking", seq };
402
+ }
386
403
  if (eventName === "turn.started" || eventName === "status" || embeddedDeepseekEvent(payload) === "turn.started") {
387
404
  return { raw: { event: eventName, payload }, kind: "system", seq };
388
405
  }
@@ -405,14 +422,27 @@ function isDeepseekTerminalEvent(eventName, payload) {
405
422
  embedded === "turn.done" ||
406
423
  embedded === "done");
407
424
  }
408
- function isToolStarted(payload) {
409
- return payload?.event === "item.started" && !!payload?.payload?.tool;
425
+ function isToolStarted(eventName, payload) {
426
+ return ((eventName === "item.started" && (!!payload?.tool || payload?.item?.kind === "tool_call")) ||
427
+ (payload?.event === "item.started" && !!payload?.payload?.tool));
410
428
  }
411
- function isToolCompleted(payload) {
412
- const kind = payload?.payload?.item?.kind;
413
- return ((payload?.event === "item.completed" || payload?.event === "item.failed") &&
429
+ function isToolCompleted(eventName, payload) {
430
+ const kind = payload?.payload?.item?.kind ?? payload?.item?.kind;
431
+ return ((eventName === "item.completed" ||
432
+ eventName === "item.failed" ||
433
+ payload?.event === "item.completed" ||
434
+ payload?.event === "item.failed") &&
414
435
  (kind === "tool_call" || kind === "file_change" || kind === "command_execution"));
415
436
  }
437
+ function isAgentMessageDelta(payload) {
438
+ return payload?.kind === "agent_message" || payload?.payload?.kind === "agent_message";
439
+ }
440
+ function isAgentReasoningItem(payload) {
441
+ return payload?.item?.kind === "agent_reasoning" || payload?.payload?.item?.kind === "agent_reasoning";
442
+ }
443
+ function extractDeepseekDelta(payload) {
444
+ return stringField(payload, "delta") ?? stringField(payload?.payload, "delta") ?? "";
445
+ }
416
446
  function extractDeepseekError(eventName, payload) {
417
447
  if (eventName === "error") {
418
448
  return (stringField(payload, "message") ??
@@ -457,6 +487,43 @@ function parseSseFrame(raw) {
457
487
  function authHeaders(token) {
458
488
  return token ? { authorization: `Bearer ${token}` } : {};
459
489
  }
490
+ function parseDeepseekRuntimeSelection(extraArgs) {
491
+ const out = {};
492
+ if (!extraArgs?.length)
493
+ return out;
494
+ for (let i = 0; i < extraArgs.length; i += 1) {
495
+ const arg = extraArgs[i];
496
+ if (arg === "--model") {
497
+ const value = nextArgValue(extraArgs, i);
498
+ if (value !== undefined) {
499
+ out.model = value;
500
+ i += 1;
501
+ }
502
+ }
503
+ else if (arg.startsWith("--model=")) {
504
+ out.model = arg.slice("--model=".length);
505
+ }
506
+ else if (arg === "--reasoning-effort") {
507
+ const value = nextArgValue(extraArgs, i);
508
+ if (value !== undefined) {
509
+ out.reasoningEffort = value;
510
+ i += 1;
511
+ }
512
+ }
513
+ else if (arg.startsWith("--reasoning-effort=")) {
514
+ out.reasoningEffort = arg.slice("--reasoning-effort=".length);
515
+ }
516
+ }
517
+ return out;
518
+ }
519
+ function nextArgValue(args, index) {
520
+ const next = args[index + 1];
521
+ if (typeof next !== "string")
522
+ return undefined;
523
+ if (!next.startsWith("-"))
524
+ return next;
525
+ return /^-\d/.test(next) ? next : undefined;
526
+ }
460
527
  function poolKey(opts) {
461
528
  return opts.accountId || "default";
462
529
  }
@@ -1,4 +1,9 @@
1
1
  import type { GatewayLogger } from "./log.js";
2
+ import type { GatewayInboundEnvelope as CanonicalGatewayInboundEnvelope, GatewayInboundMessage as CanonicalGatewayInboundMessage, GatewayOutboundAttachment as CanonicalGatewayOutboundAttachment, GatewayOutboundMessage as CanonicalGatewayOutboundMessage, RuntimeGatewayProvider } from "@botcord/protocol-core";
3
+ export type GatewayInboundMessage = CanonicalGatewayInboundMessage;
4
+ export type GatewayInboundEnvelope = CanonicalGatewayInboundEnvelope;
5
+ export type GatewayOutboundAttachment = CanonicalGatewayOutboundAttachment;
6
+ export type GatewayOutboundMessage = CanonicalGatewayOutboundMessage;
2
7
  /** Set of predicates matched against a normalized inbound message to pick a route. */
3
8
  export interface RouteMatch {
4
9
  channel?: string;
@@ -69,41 +74,6 @@ export interface GatewayConfig {
69
74
  managedRoutes?: GatewayRoute[];
70
75
  streamBlocks?: boolean;
71
76
  }
72
- /** Normalized inbound message produced by a channel adapter for the dispatcher. */
73
- export interface GatewayInboundMessage {
74
- id: string;
75
- /** Channel adapter id (`ChannelAdapter.id`), not channel type. */
76
- channel: string;
77
- accountId: string;
78
- conversation: {
79
- id: string;
80
- kind: "direct" | "group";
81
- title?: string;
82
- threadId?: string | null;
83
- };
84
- sender: {
85
- id: string;
86
- name?: string;
87
- kind: "user" | "agent" | "system";
88
- };
89
- text?: string;
90
- raw: unknown;
91
- replyTo?: string | null;
92
- mentioned?: boolean;
93
- receivedAt: number;
94
- trace?: {
95
- id: string;
96
- streamable?: boolean;
97
- };
98
- }
99
- /** Inbound envelope wrapping a normalized message with optional upstream ack callbacks. */
100
- export interface GatewayInboundEnvelope {
101
- message: GatewayInboundMessage;
102
- ack?: {
103
- accept(): Promise<void>;
104
- reject?(reason: string): Promise<void>;
105
- };
106
- }
107
77
  /**
108
78
  * Channel-agnostic hook that produces a system-context string for a turn.
109
79
  * Called before every `runtime.run(...)`; returned value is passed through
@@ -137,33 +107,18 @@ export interface MemoryContextSnapshot {
137
107
  version: string;
138
108
  }
139
109
  export type MemoryContextBuilder = (message: GatewayInboundMessage) => Promise<MemoryContextSnapshot | null | undefined> | MemoryContextSnapshot | null | undefined;
110
+ /**
111
+ * Optional hook used after a runtime session is discarded and retried fresh.
112
+ * The daemon implementation can pull recent room messages from Hub; gateway
113
+ * core treats the returned string as opaque recovery context.
114
+ */
115
+ export type RuntimeRecoveryContextBuilder = (message: GatewayInboundMessage) => Promise<string | null | undefined> | string | null | undefined;
140
116
  /**
141
117
  * Optional hook fired after the dispatcher dispatches a reply to a channel.
142
118
  * Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
143
119
  * are caught and logged so observer failures never break the turn.
144
120
  */
145
121
  export type OutboundObserver = (message: GatewayOutboundMessage) => Promise<void> | void;
146
- /** Outbound reply payload passed to `ChannelAdapter.send()`. */
147
- export interface GatewayOutboundAttachment {
148
- /** Local daemon-readable file path. */
149
- filePath?: string;
150
- /** In-memory bytes, primarily for tests and in-process tool callers. */
151
- data?: Uint8Array;
152
- filename?: string;
153
- contentType?: string;
154
- kind?: "image" | "file" | "video";
155
- }
156
- export interface GatewayOutboundMessage {
157
- channel: string;
158
- accountId: string;
159
- conversationId: string;
160
- threadId?: string | null;
161
- type?: "message" | "error";
162
- text: string;
163
- attachments?: GatewayOutboundAttachment[];
164
- replyTo?: string | null;
165
- traceId?: string | null;
166
- }
167
122
  /** Per-channel status snapshot exposed for `status`/`doctor` style output. */
168
123
  export interface ChannelStatusSnapshot {
169
124
  channel: string;
@@ -176,7 +131,7 @@ export interface ChannelStatusSnapshot {
176
131
  lastStopAt?: number;
177
132
  lastError?: string | null;
178
133
  /** Third-party provider id when this channel is not the built-in BotCord. */
179
- provider?: "wechat" | "telegram" | "feishu";
134
+ provider?: RuntimeGatewayProvider;
180
135
  /** Last time the adapter polled the upstream provider (ms epoch). */
181
136
  lastPollAt?: number;
182
137
  /** Last time the adapter accepted an inbound message (ms epoch). */