@botcord/daemon 0.2.78 → 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 (50) hide show
  1. package/dist/attention-policy-fetcher.d.ts +14 -0
  2. package/dist/attention-policy-fetcher.js +59 -0
  3. package/dist/cloud-daemon.js +8 -0
  4. package/dist/cloud-gateway-runtime.d.ts +29 -0
  5. package/dist/cloud-gateway-runtime.js +122 -0
  6. package/dist/daemon.js +21 -6
  7. package/dist/gateway/channels/botcord.js +29 -9
  8. package/dist/gateway/channels/login-session.d.ts +12 -0
  9. package/dist/gateway/channels/login-session.js +20 -2
  10. package/dist/gateway/channels/sanitize.d.ts +5 -18
  11. package/dist/gateway/channels/sanitize.js +5 -54
  12. package/dist/gateway/channels/text-split.d.ts +5 -11
  13. package/dist/gateway/channels/text-split.js +5 -31
  14. package/dist/gateway/dispatcher.d.ts +7 -1
  15. package/dist/gateway/dispatcher.js +88 -8
  16. package/dist/gateway/gateway.d.ts +16 -1
  17. package/dist/gateway/gateway.js +21 -0
  18. package/dist/gateway/policy-resolver.js +17 -9
  19. package/dist/gateway/runtimes/deepseek-tui.js +31 -12
  20. package/dist/gateway/types.d.ts +12 -57
  21. package/dist/gateway-control.js +18 -9
  22. package/dist/provision.d.ts +7 -3
  23. package/dist/provision.js +115 -8
  24. package/dist/room-recovery-context.d.ts +11 -0
  25. package/dist/room-recovery-context.js +97 -0
  26. package/package.json +2 -2
  27. package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
  28. package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
  29. package/src/__tests__/gateway-control.test.ts +136 -0
  30. package/src/__tests__/policy-resolver.test.ts +20 -0
  31. package/src/__tests__/provision.test.ts +65 -0
  32. package/src/attention-policy-fetcher.ts +87 -0
  33. package/src/cloud-daemon.ts +8 -0
  34. package/src/cloud-gateway-runtime.ts +171 -0
  35. package/src/daemon.ts +23 -6
  36. package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
  37. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +177 -0
  38. package/src/gateway/__tests__/dispatcher.test.ts +56 -0
  39. package/src/gateway/channels/botcord.ts +32 -8
  40. package/src/gateway/channels/login-session.ts +20 -2
  41. package/src/gateway/channels/sanitize.ts +8 -66
  42. package/src/gateway/channels/text-split.ts +5 -27
  43. package/src/gateway/dispatcher.ts +123 -27
  44. package/src/gateway/gateway.ts +29 -0
  45. package/src/gateway/policy-resolver.ts +20 -9
  46. package/src/gateway/runtimes/deepseek-tui.ts +37 -12
  47. package/src/gateway/types.ts +31 -59
  48. package/src/gateway-control.ts +21 -9
  49. package/src/provision.ts +133 -7
  50. package/src/room-recovery-context.ts +131 -0
@@ -23,6 +23,7 @@ import type {
23
23
  OutboundObserver,
24
24
  QueueMode,
25
25
  RuntimeAdapter,
26
+ RuntimeRecoveryContextBuilder,
26
27
  RuntimeRunResult,
27
28
  RuntimeCircuitBreakerSnapshot,
28
29
  RuntimeStatusEvent,
@@ -182,6 +183,31 @@ function extractCloudRunBudget(msg: GatewayInboundMessage): CloudRunBudgetCaps |
182
183
  return out.maxWallTimeMs !== undefined || out.maxToolCalls !== undefined ? out : undefined;
183
184
  }
184
185
 
186
+ function looksLikeRecoverableSessionFailure(error: string): boolean {
187
+ return /compact|compaction|context|token limit|maximum context|too many tokens|conversation found|session .*not found|resume/i
188
+ .test(error);
189
+ }
190
+
191
+ function buildRuntimeRecoveryPrompt(args: {
192
+ userTurn: string;
193
+ error: string;
194
+ recoveryContext?: string | null;
195
+ }): string {
196
+ return [
197
+ "[BotCord Runtime Recovery Notice]",
198
+ "The previous Codex runtime session for this room became unrecoverable while resuming or compacting context.",
199
+ `Previous runtime error: ${truncate(args.error, 1000)}`,
200
+ "You are now running in a fresh Codex session.",
201
+ "Use the recent room messages below, current filesystem state, and available BotCord memory/context tools to reconstruct the active task.",
202
+ "Continue the original user request without asking the user to repeat information unless it is missing from those sources.",
203
+ "",
204
+ args.recoveryContext?.trim() || "[Recent Room Messages]\n(unavailable)",
205
+ "",
206
+ "[Current User Turn]",
207
+ args.userTurn,
208
+ ].join("\n");
209
+ }
210
+
185
211
  /** Factory signature for building a runtime adapter at turn dispatch time. */
186
212
  export type RuntimeFactory = (
187
213
  runtimeId: string,
@@ -217,6 +243,11 @@ export interface DispatcherOptions {
217
243
  * keep following stale memory.
218
244
  */
219
245
  buildMemoryContext?: MemoryContextBuilder;
246
+ /**
247
+ * Optional hook that returns recent room context for a fresh-session retry
248
+ * after a runtime resume session becomes unrecoverable.
249
+ */
250
+ buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
220
251
  /**
221
252
  * Optional side-effect hook invoked after ack, before the turn runs.
222
253
  * Intended for bookkeeping (e.g. activity tracking). Errors are logged
@@ -381,6 +412,7 @@ export class Dispatcher {
381
412
  private readonly runtimeAuthFailureCooldownMs: number;
382
413
  private readonly buildSystemContext?: SystemContextBuilder;
383
414
  private readonly buildMemoryContext?: MemoryContextBuilder;
415
+ private readonly buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
384
416
  private readonly onInbound?: InboundObserver;
385
417
  private readonly onOutbound?: OutboundObserver;
386
418
  private readonly onTurnComplete?: DispatcherOptions["onTurnComplete"];
@@ -415,6 +447,7 @@ export class Dispatcher {
415
447
  opts.runtimeAuthFailureCooldownMs ?? DEFAULT_RUNTIME_AUTH_FAILURE_COOLDOWN_MS;
416
448
  this.buildSystemContext = opts.buildSystemContext;
417
449
  this.buildMemoryContext = opts.buildMemoryContext;
450
+ this.buildRuntimeRecoveryContext = opts.buildRuntimeRecoveryContext;
418
451
  this.onInbound = opts.onInbound;
419
452
  this.onOutbound = opts.onOutbound;
420
453
  this.onTurnComplete = opts.onTurnComplete;
@@ -1604,33 +1637,96 @@ export class Dispatcher {
1604
1637
  const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
1605
1638
  let result: RuntimeRunResult | undefined;
1606
1639
  let threw: unknown;
1640
+ let activeSessionId: string | null = sessionId;
1607
1641
  const turnStartedAt = Date.now();
1608
1642
  try {
1609
1643
  try {
1610
- result = await runtime.run({
1611
- text: runtimeText,
1612
- sessionId,
1613
- cwd: route.cwd,
1614
- accountId: msg.accountId,
1615
- hubUrl: this.resolveHubUrl?.(msg.accountId),
1616
- extraArgs: route.extraArgs,
1617
- signal: controller.signal,
1618
- trustLevel,
1619
- systemContext,
1620
- onBlock,
1621
- onStatus,
1622
- context: {
1623
- turnId,
1624
- messageId: msg.id,
1644
+ const runRuntime = (textForRun: string, sessionIdForRun: string | null) =>
1645
+ runtime.run({
1646
+ text: textForRun,
1647
+ sessionId: sessionIdForRun,
1648
+ cwd: route.cwd,
1649
+ accountId: msg.accountId,
1650
+ hubUrl: this.resolveHubUrl?.(msg.accountId),
1651
+ extraArgs: route.extraArgs,
1652
+ signal: controller.signal,
1653
+ trustLevel,
1654
+ systemContext,
1655
+ onBlock,
1656
+ onStatus,
1657
+ context: {
1658
+ turnId,
1659
+ messageId: msg.id,
1660
+ roomId: msg.conversation.id,
1661
+ topicId: msg.conversation.threadId ?? null,
1662
+ channel: msg.channel,
1663
+ conversationKind: msg.conversation.kind,
1664
+ },
1665
+ ...(cloudRunBudget ? { budget: cloudRunBudget } : {}),
1666
+ gateway: route.gateway,
1667
+ ...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
1668
+ });
1669
+
1670
+ result = await runRuntime(runtimeText, sessionId);
1671
+ const firstError = result.error ?? "";
1672
+ const firstReply = (result.text || "").trim();
1673
+ const shouldRetryFresh =
1674
+ route.runtime === "codex" &&
1675
+ !!sessionId &&
1676
+ !!firstError &&
1677
+ !firstReply &&
1678
+ !looksLikeRuntimeAuthFailure(firstError) &&
1679
+ looksLikeRecoverableSessionFailure(firstError) &&
1680
+ !controller.signal.aborted &&
1681
+ !slot.timedOut &&
1682
+ !slot.budgetExceeded;
1683
+
1684
+ if (shouldRetryFresh) {
1685
+ try {
1686
+ await this.sessionStore.delete(key);
1687
+ this.log.info("dispatcher: dropped unrecoverable runtime session before fresh retry", {
1688
+ key,
1689
+ prevRuntimeSessionId: sessionId,
1690
+ runtime: route.runtime,
1691
+ error: firstError,
1692
+ });
1693
+ } catch (err) {
1694
+ this.log.warn("dispatcher: session-store.delete failed before fresh retry", {
1695
+ key,
1696
+ error: err instanceof Error ? err.message : String(err),
1697
+ });
1698
+ }
1699
+
1700
+ let recoveryContext: string | null | undefined;
1701
+ if (this.buildRuntimeRecoveryContext) {
1702
+ try {
1703
+ recoveryContext = await this.buildRuntimeRecoveryContext(msg);
1704
+ } catch (err) {
1705
+ this.log.warn("dispatcher: buildRuntimeRecoveryContext threw — retrying without recent room context", {
1706
+ agentId: msg.accountId,
1707
+ roomId: msg.conversation.id,
1708
+ topicId: msg.conversation.threadId ?? null,
1709
+ turnId,
1710
+ error: err instanceof Error ? err.message : String(err),
1711
+ });
1712
+ }
1713
+ }
1714
+
1715
+ activeSessionId = null;
1716
+ runtimeText = buildRuntimeRecoveryPrompt({
1717
+ userTurn: text,
1718
+ error: firstError,
1719
+ recoveryContext,
1720
+ });
1721
+ this.log.info("dispatcher: retrying codex turn in a fresh session with recovery context", {
1722
+ agentId: msg.accountId,
1625
1723
  roomId: msg.conversation.id,
1626
1724
  topicId: msg.conversation.threadId ?? null,
1627
- channel: msg.channel,
1628
- conversationKind: msg.conversation.kind,
1629
- },
1630
- ...(cloudRunBudget ? { budget: cloudRunBudget } : {}),
1631
- gateway: route.gateway,
1632
- ...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
1633
- });
1725
+ turnId,
1726
+ queueKey,
1727
+ });
1728
+ result = await runRuntime(runtimeText, null);
1729
+ }
1634
1730
  } catch (err) {
1635
1731
  threw = err;
1636
1732
  } finally {
@@ -1814,12 +1910,12 @@ export class Dispatcher {
1814
1910
  // even when the adapter echoes that id back
1815
1911
  // result.newSessionId truthy → upsert the entry
1816
1912
  // otherwise → no-op (e.g. codex intentionally never persists)
1817
- if (sessionId && effectiveError && !replyText) {
1913
+ if (activeSessionId && effectiveError && !replyText) {
1818
1914
  try {
1819
1915
  await this.sessionStore.delete(key);
1820
1916
  this.log.info("dispatcher: dropped stale runtime session", {
1821
1917
  key,
1822
- prevRuntimeSessionId: sessionId,
1918
+ prevRuntimeSessionId: activeSessionId,
1823
1919
  nextRuntimeSessionId: result.newSessionId || null,
1824
1920
  error: effectiveError,
1825
1921
  });
@@ -1844,7 +1940,7 @@ export class Dispatcher {
1844
1940
  updatedAt: Date.now(),
1845
1941
  };
1846
1942
  try {
1847
- const prevRuntimeSessionId = sessionId;
1943
+ const prevRuntimeSessionId = activeSessionId;
1848
1944
  await this.sessionStore.set(session);
1849
1945
  this.log.debug("dispatcher: persisted runtime session", {
1850
1946
  key,
@@ -1857,12 +1953,12 @@ export class Dispatcher {
1857
1953
  error: err instanceof Error ? err.message : String(err),
1858
1954
  });
1859
1955
  }
1860
- } else if (sessionId && effectiveError) {
1956
+ } else if (activeSessionId && effectiveError) {
1861
1957
  try {
1862
1958
  await this.sessionStore.delete(key);
1863
1959
  this.log.info("dispatcher: dropped stale runtime session", {
1864
1960
  key,
1865
- prevRuntimeSessionId: sessionId,
1961
+ prevRuntimeSessionId: activeSessionId,
1866
1962
  error: effectiveError,
1867
1963
  });
1868
1964
  } catch (err) {
@@ -23,6 +23,7 @@ import type {
23
23
  InboundObserver,
24
24
  MemoryContextBuilder,
25
25
  OutboundObserver,
26
+ RuntimeRecoveryContextBuilder,
26
27
  SystemContextBuilder,
27
28
  UserTurnBuilder,
28
29
  } from "./types.js";
@@ -49,6 +50,11 @@ export interface GatewayBootOptions {
49
50
  * resumed runtime sessions get an explicit prompt when memory changes.
50
51
  */
51
52
  buildMemoryContext?: MemoryContextBuilder;
53
+ /**
54
+ * Recent room context provider used by dispatcher when it must discard a
55
+ * broken runtime session and retry the same turn in a fresh session.
56
+ */
57
+ buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
52
58
  /**
53
59
  * Observer called after the dispatcher acks each inbound message. Useful
54
60
  * for activity tracking or metrics. Errors are logged and swallowed.
@@ -178,6 +184,7 @@ export class Gateway {
178
184
  turnTimeoutMs: opts.turnTimeoutMs,
179
185
  buildSystemContext: opts.buildSystemContext,
180
186
  buildMemoryContext: opts.buildMemoryContext,
187
+ buildRuntimeRecoveryContext: opts.buildRuntimeRecoveryContext,
181
188
  onInbound: opts.onInbound,
182
189
  composeUserTurn: opts.composeUserTurn,
183
190
  onOutbound: opts.onOutbound,
@@ -295,6 +302,28 @@ export class Gateway {
295
302
  await this.dispatcher.handle({ message });
296
303
  }
297
304
 
305
+ /**
306
+ * Inject an inbound message while routing replies through a caller-owned
307
+ * channel adapter. Cloud gateway runtime sessions use this to execute a
308
+ * provider message without loading provider credentials inside the sandbox:
309
+ * the temporary adapter captures the runtime's final reply and the always-on
310
+ * ingress service performs the provider send.
311
+ */
312
+ async injectInboundThrough(
313
+ message: GatewayInboundMessage,
314
+ channel: ChannelAdapter,
315
+ ack?: { accept: () => Promise<void> },
316
+ ): Promise<void> {
317
+ const previous = this.channelMap.get(channel.id);
318
+ this.channelMap.set(channel.id, channel);
319
+ try {
320
+ await this.dispatcher.handle({ message, ...(ack ? { ack } : {}) });
321
+ } finally {
322
+ if (previous) this.channelMap.set(channel.id, previous);
323
+ else this.channelMap.delete(channel.id);
324
+ }
325
+ }
326
+
298
327
  /**
299
328
  * Send a daemon-control initiated outbound message through a registered
300
329
  * channel. Used by proactive third-party gateway sends where the runtime
@@ -65,20 +65,31 @@ const DEFAULT_TTL_MS = 5 * 60 * 1000;
65
65
  const FETCH_FAILED = Symbol("fetch_failed");
66
66
 
67
67
  /**
68
- * Force DM rooms (`rm_dm_*`) to `mode: "always"` per design §4.2 — UI never
68
+ * Force direct conversations to `mode: "always"` per design §4.2 — UI never
69
69
  * lets the user mute a DM, but a stale cache from before a UX bug is cheap
70
- * to defend against here.
70
+ * to defend against here. Third-party 1:1 gateway chats have the same
71
+ * expectation: they do not carry BotCord mention metadata, so applying a
72
+ * global mention-only policy would silently drop ordinary direct messages.
71
73
  */
72
- function maybeForceDm(
74
+ function maybeForceDirectConversation(
73
75
  roomId: string | null,
74
76
  policy: DaemonAttentionPolicy,
75
77
  ): DaemonAttentionPolicy {
76
- if (roomId && roomId.startsWith("rm_dm_") && policy.mode !== "always") {
78
+ if (roomId && isDirectConversation(roomId) && policy.mode !== "always") {
77
79
  return { ...policy, mode: "always" };
78
80
  }
79
81
  return policy;
80
82
  }
81
83
 
84
+ function isDirectConversation(roomId: string): boolean {
85
+ return (
86
+ roomId.startsWith("rm_dm_") ||
87
+ roomId.startsWith("telegram:user:") ||
88
+ roomId.startsWith("wechat:user:") ||
89
+ roomId.startsWith("feishu:user:")
90
+ );
91
+ }
92
+
82
93
  function defaultPolicy(): DaemonAttentionPolicy {
83
94
  return { mode: "always", keywords: [] };
84
95
  }
@@ -115,10 +126,10 @@ export class PolicyResolver implements PolicyResolverLike {
115
126
  if (fetched === FETCH_FAILED) return defaultPolicy();
116
127
  const policy = fetched ?? defaultPolicy();
117
128
  this.cache.set(cacheKey(agentId, roomId), {
118
- policy: maybeForceDm(roomId, policy),
129
+ policy: maybeForceDirectConversation(roomId, policy),
119
130
  expiresAt: now + this.ttlMs,
120
131
  });
121
- return maybeForceDm(roomId, policy);
132
+ return maybeForceDirectConversation(roomId, policy);
122
133
  }
123
134
 
124
135
  // 3. No room override known — inherit from the cached agent-wide global.
@@ -128,7 +139,7 @@ export class PolicyResolver implements PolicyResolverLike {
128
139
  const globalKey = cacheKey(agentId, null);
129
140
  const globalHit = this.cache.get(globalKey);
130
141
  if (globalHit && globalHit.expiresAt > now) {
131
- return maybeForceDm(roomId, globalHit.policy);
142
+ return maybeForceDirectConversation(roomId, globalHit.policy);
132
143
  }
133
144
 
134
145
  // 4. Cold start for global.
@@ -136,7 +147,7 @@ export class PolicyResolver implements PolicyResolverLike {
136
147
  if (fetched === FETCH_FAILED) return defaultPolicy();
137
148
  const policy = fetched ?? defaultPolicy();
138
149
  this.cache.set(globalKey, { policy, expiresAt: now + this.ttlMs });
139
- return maybeForceDm(roomId, policy);
150
+ return maybeForceDirectConversation(roomId, policy);
140
151
  }
141
152
 
142
153
  private async safeFetch(
@@ -168,7 +179,7 @@ export class PolicyResolver implements PolicyResolverLike {
168
179
  put(agentId: string, roomId: string | null, policy: DaemonAttentionPolicy): void {
169
180
  const key = cacheKey(agentId, roomId);
170
181
  this.cache.set(key, {
171
- policy: maybeForceDm(roomId, policy),
182
+ policy: maybeForceDirectConversation(roomId, policy),
172
183
  expiresAt: Date.now() + this.ttlMs,
173
184
  });
174
185
  }
@@ -383,13 +383,17 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
383
383
  if (extractedError) errorText = extractedError;
384
384
  if (eventName === "message.delta") {
385
385
  append(stringField(payload, "content") ?? "");
386
- } else if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
387
- append(stringField(payload.payload, "delta") ?? "");
386
+ } else if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
387
+ append(extractDeepseekDelta(payload));
388
388
  }
389
389
  if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
390
390
  opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
391
- } else if (eventName === "tool.started" || isToolStarted(payload)) {
392
- const label = stringField(payload, "name") ?? stringField(payload?.payload?.tool, "name") ?? "tool";
391
+ } else if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
392
+ const label =
393
+ stringField(payload, "name") ??
394
+ stringField(payload?.tool, "name") ??
395
+ stringField(payload?.payload?.tool, "name") ??
396
+ "tool";
393
397
  opts.onStatus?.({ kind: "thinking", phase: "updated", label });
394
398
  } else if (isDeepseekTerminalEvent(eventName, payload)) {
395
399
  opts.onStatus?.({ kind: "thinking", phase: "stopped" });
@@ -449,15 +453,18 @@ function normalizeDeepseekEvent(eventName: string, payload: any, seq: number): S
449
453
  if (eventName === "message.delta") {
450
454
  return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
451
455
  }
452
- if (eventName === "tool.started" || isToolStarted(payload)) {
456
+ if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
453
457
  return { raw: { event: eventName, payload }, kind: "tool_use", seq };
454
458
  }
455
- if (eventName === "tool.completed" || isToolCompleted(payload)) {
459
+ if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
456
460
  return { raw: { event: eventName, payload }, kind: "tool_result", seq };
457
461
  }
458
- if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
462
+ if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
459
463
  return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
460
464
  }
465
+ if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
466
+ return { raw: { event: eventName, payload }, kind: "thinking", seq };
467
+ }
461
468
  if (eventName === "turn.started" || eventName === "status" || embeddedDeepseekEvent(payload) === "turn.started") {
462
469
  return { raw: { event: eventName, payload }, kind: "system", seq };
463
470
  }
@@ -485,18 +492,36 @@ function isDeepseekTerminalEvent(eventName: string, payload: any): boolean {
485
492
  );
486
493
  }
487
494
 
488
- function isToolStarted(payload: any): boolean {
489
- return payload?.event === "item.started" && !!payload?.payload?.tool;
495
+ function isToolStarted(eventName: string, payload: any): boolean {
496
+ return (
497
+ (eventName === "item.started" && (!!payload?.tool || payload?.item?.kind === "tool_call")) ||
498
+ (payload?.event === "item.started" && !!payload?.payload?.tool)
499
+ );
490
500
  }
491
501
 
492
- function isToolCompleted(payload: any): boolean {
493
- const kind = payload?.payload?.item?.kind;
502
+ function isToolCompleted(eventName: string, payload: any): boolean {
503
+ const kind = payload?.payload?.item?.kind ?? payload?.item?.kind;
494
504
  return (
495
- (payload?.event === "item.completed" || payload?.event === "item.failed") &&
505
+ (eventName === "item.completed" ||
506
+ eventName === "item.failed" ||
507
+ payload?.event === "item.completed" ||
508
+ payload?.event === "item.failed") &&
496
509
  (kind === "tool_call" || kind === "file_change" || kind === "command_execution")
497
510
  );
498
511
  }
499
512
 
513
+ function isAgentMessageDelta(payload: any): boolean {
514
+ return payload?.kind === "agent_message" || payload?.payload?.kind === "agent_message";
515
+ }
516
+
517
+ function isAgentReasoningItem(payload: any): boolean {
518
+ return payload?.item?.kind === "agent_reasoning" || payload?.payload?.item?.kind === "agent_reasoning";
519
+ }
520
+
521
+ function extractDeepseekDelta(payload: any): string {
522
+ return stringField(payload, "delta") ?? stringField(payload?.payload, "delta") ?? "";
523
+ }
524
+
500
525
  function extractDeepseekError(eventName: string, payload: any): string | undefined {
501
526
  if (eventName === "error") {
502
527
  return (
@@ -1,4 +1,20 @@
1
1
  import type { GatewayLogger } from "./log.js";
2
+ import type {
3
+ GatewayInboundEnvelope as CanonicalGatewayInboundEnvelope,
4
+ GatewayInboundMessage as CanonicalGatewayInboundMessage,
5
+ GatewayOutboundAttachment as CanonicalGatewayOutboundAttachment,
6
+ GatewayOutboundMessage as CanonicalGatewayOutboundMessage,
7
+ RuntimeGatewayProvider,
8
+ } from "@botcord/protocol-core";
9
+
10
+ // Canonical gateway message shapes live in `@botcord/protocol-core` so the
11
+ // `gateway-ingress` provider adapters can import the same types without
12
+ // pulling the entire daemon. Daemon re-exports the canonical shapes here so
13
+ // every existing import (`from "./gateway/index.js"`) keeps working.
14
+ export type GatewayInboundMessage = CanonicalGatewayInboundMessage;
15
+ export type GatewayInboundEnvelope = CanonicalGatewayInboundEnvelope;
16
+ export type GatewayOutboundAttachment = CanonicalGatewayOutboundAttachment;
17
+ export type GatewayOutboundMessage = CanonicalGatewayOutboundMessage;
2
18
 
3
19
  // ---------------------------------------------------------------------------
4
20
  // Routing (§9)
@@ -89,42 +105,9 @@ export interface GatewayConfig {
89
105
  // Inbound / outbound message shape (§7.3, §7.4, §7.5)
90
106
  // ---------------------------------------------------------------------------
91
107
 
92
- /** Normalized inbound message produced by a channel adapter for the dispatcher. */
93
- export interface GatewayInboundMessage {
94
- id: string;
95
- /** Channel adapter id (`ChannelAdapter.id`), not channel type. */
96
- channel: string;
97
- accountId: string;
98
- conversation: {
99
- id: string;
100
- kind: "direct" | "group";
101
- title?: string;
102
- threadId?: string | null;
103
- };
104
- sender: {
105
- id: string;
106
- name?: string;
107
- kind: "user" | "agent" | "system";
108
- };
109
- text?: string;
110
- raw: unknown;
111
- replyTo?: string | null;
112
- mentioned?: boolean;
113
- receivedAt: number;
114
- trace?: {
115
- id: string;
116
- streamable?: boolean;
117
- };
118
- }
119
-
120
- /** Inbound envelope wrapping a normalized message with optional upstream ack callbacks. */
121
- export interface GatewayInboundEnvelope {
122
- message: GatewayInboundMessage;
123
- ack?: {
124
- accept(): Promise<void>;
125
- reject?(reason: string): Promise<void>;
126
- };
127
- }
108
+ // `GatewayInboundMessage` and `GatewayInboundEnvelope` are re-exported from
109
+ // `@botcord/protocol-core` at the top of this file. The wire-level subset is
110
+ // `RuntimeGatewayInboundPayload` in protocol-core/runtime-frame.ts.
128
111
 
129
112
  /**
130
113
  * Channel-agnostic hook that produces a system-context string for a turn.
@@ -170,6 +153,15 @@ export type MemoryContextBuilder = (
170
153
  message: GatewayInboundMessage,
171
154
  ) => Promise<MemoryContextSnapshot | null | undefined> | MemoryContextSnapshot | null | undefined;
172
155
 
156
+ /**
157
+ * Optional hook used after a runtime session is discarded and retried fresh.
158
+ * The daemon implementation can pull recent room messages from Hub; gateway
159
+ * core treats the returned string as opaque recovery context.
160
+ */
161
+ export type RuntimeRecoveryContextBuilder = (
162
+ message: GatewayInboundMessage,
163
+ ) => Promise<string | null | undefined> | string | null | undefined;
164
+
173
165
  /**
174
166
  * Optional hook fired after the dispatcher dispatches a reply to a channel.
175
167
  * Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
@@ -179,28 +171,8 @@ export type OutboundObserver = (
179
171
  message: GatewayOutboundMessage,
180
172
  ) => Promise<void> | void;
181
173
 
182
- /** Outbound reply payload passed to `ChannelAdapter.send()`. */
183
- export interface GatewayOutboundAttachment {
184
- /** Local daemon-readable file path. */
185
- filePath?: string;
186
- /** In-memory bytes, primarily for tests and in-process tool callers. */
187
- data?: Uint8Array;
188
- filename?: string;
189
- contentType?: string;
190
- kind?: "image" | "file" | "video";
191
- }
192
-
193
- export interface GatewayOutboundMessage {
194
- channel: string;
195
- accountId: string;
196
- conversationId: string;
197
- threadId?: string | null;
198
- type?: "message" | "error";
199
- text: string;
200
- attachments?: GatewayOutboundAttachment[];
201
- replyTo?: string | null;
202
- traceId?: string | null;
203
- }
174
+ // `GatewayOutboundAttachment` and `GatewayOutboundMessage` are re-exported from
175
+ // `@botcord/protocol-core` at the top of this file.
204
176
 
205
177
  // ---------------------------------------------------------------------------
206
178
  // Status (§14)
@@ -218,7 +190,7 @@ export interface ChannelStatusSnapshot {
218
190
  lastStopAt?: number;
219
191
  lastError?: string | null;
220
192
  /** Third-party provider id when this channel is not the built-in BotCord. */
221
- provider?: "wechat" | "telegram" | "feishu";
193
+ provider?: RuntimeGatewayProvider;
222
194
  /** Last time the adapter polled the upstream provider (ms epoch). */
223
195
  lastPollAt?: number;
224
196
  /** Last time the adapter accepted an inbound message (ms epoch). */
@@ -313,13 +313,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
313
313
  if (!loginId) {
314
314
  return badParams("upsert_gateway: wechat requires loginId");
315
315
  }
316
- const session = sessions.get(loginId);
317
- if (!session) {
316
+ const resolved = sessions.resolve(loginId);
317
+ if (resolved.state !== "live") {
318
318
  return {
319
319
  ok: false,
320
- error: { code: "login_expired", message: `wechat login session "${loginId}" not found or expired` },
320
+ error:
321
+ resolved.state === "missing"
322
+ ? { code: "login_missing", message: `wechat login session "${loginId}" not found` }
323
+ : { code: "login_expired", message: `wechat login session "${loginId}" expired` },
321
324
  };
322
325
  }
326
+ const session = resolved.session!;
323
327
  if (session.provider !== "wechat") {
324
328
  return badParams(`upsert_gateway: login session provider "${session.provider}" != "wechat"`);
325
329
  }
@@ -347,13 +351,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
347
351
  if (!loginId) {
348
352
  return badParams("upsert_gateway: feishu requires loginId");
349
353
  }
350
- const session = sessions.get(loginId);
351
- if (!session) {
354
+ const resolved = sessions.resolve(loginId);
355
+ if (resolved.state !== "live") {
352
356
  return {
353
357
  ok: false,
354
- error: { code: "login_expired", message: `feishu login session "${loginId}" not found or expired` },
358
+ error:
359
+ resolved.state === "missing"
360
+ ? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
361
+ : { code: "login_expired", message: `feishu login session "${loginId}" expired` },
355
362
  };
356
363
  }
364
+ const session = resolved.session!;
357
365
  if (session.provider !== "feishu") {
358
366
  return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
359
367
  }
@@ -869,13 +877,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
869
877
  if (!params.accountId || typeof params.accountId !== "string") {
870
878
  return badParams("gateway_recent_senders: accountId is required");
871
879
  }
872
- const session = sessions.get(params.loginId);
873
- if (!session) {
880
+ const resolved = sessions.resolve(params.loginId);
881
+ if (resolved.state !== "live") {
874
882
  return {
875
883
  ok: false,
876
- error: { code: "login_expired", message: `wechat login session "${params.loginId}" not found or expired` },
884
+ error:
885
+ resolved.state === "missing"
886
+ ? { code: "login_missing", message: `wechat login session "${params.loginId}" not found` }
887
+ : { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
877
888
  };
878
889
  }
890
+ const session = resolved.session!;
879
891
  if (session.provider !== "wechat") {
880
892
  return badParams("gateway_recent_senders: provider does not match login session");
881
893
  }