@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,68 +1,10 @@
1
1
  /**
2
- * Sanitize untrusted inbound content before handing it off to a local runtime.
3
- *
4
- * Copied from `packages/daemon/src/sanitize.ts` so the gateway channel adapter
5
- * does not depend back on the daemon package. Keep these two files in sync —
6
- * any new structural marker added in one place should be mirrored in the other.
7
- *
8
- * Neutralizes:
9
- * - BotCord structural markers the channel itself emits (so peers can't forge them).
10
- * - Common LLM prompt-injection patterns (<system>, [INST], <<SYS>>, <|im_start|>, etc.).
11
- * - Wrapper XML tags the channel uses to frame inbound content
12
- * (<agent-message>, <human-message>, <room-rule>).
2
+ * Thin re-export `sanitizeUntrustedContent` / `sanitizeSenderName` live
3
+ * in `@botcord/protocol-core` so the daemon channel adapters and the
4
+ * `gateway-ingress` provider adapters use one canonical implementation.
5
+ * Existing imports of this module keep working unchanged.
13
6
  */
14
-
15
- export function sanitizeUntrustedContent(text: string): string {
16
- let s = text;
17
- s = s.replace(
18
- /<\/?a[\s]*g[\s]*e[\s]*n[\s]*t[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi,
19
- "[⚠ stripped: agent-message tag]",
20
- );
21
- s = s.replace(
22
- /<\/?h[\s]*u[\s]*m[\s]*a[\s]*n[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi,
23
- "[⚠ stripped: human-message tag]",
24
- );
25
- s = s.replace(
26
- /<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi,
27
- "[⚠ stripped: room-rule tag]",
28
- );
29
-
30
- return s
31
- .split(/\r?\n/)
32
- .map((line) => {
33
- let l = line;
34
- l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
35
- l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
36
- l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
37
- l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
38
- l = l.replace(/^\[BotCord\s+([^\]\r\n]+)\]/i, (_m, label) => {
39
- const head = String(label).split(":")[0].trim() || String(label).trim();
40
- return `[⚠ fake: BotCord ${head}]`;
41
- });
42
- l = l.replace(/^\[(System|SYSTEM|Assistant|ASSISTANT|User|USER)\]/, "[⚠ fake: $1]");
43
- l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
44
- l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
45
- l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
46
- l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
47
- l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
48
- l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
49
- return l;
50
- })
51
- .join("\n");
52
- }
53
-
54
- /**
55
- * Sanitize a sender label so it's safe to embed inside
56
- * `<agent-message sender="...">`. Must not contain newlines, structural
57
- * markers, or characters that could break the XML attribute boundary.
58
- */
59
- export function sanitizeSenderName(name: string): string {
60
- return name
61
- .replace(/[\n\r]/g, " ")
62
- .replace(/\[/g, "⟦")
63
- .replace(/\]/g, "⟧")
64
- .replace(/"/g, "'")
65
- .replace(/</g, "<")
66
- .replace(/>/g, ">")
67
- .slice(0, 100);
68
- }
7
+ export {
8
+ sanitizeUntrustedContent,
9
+ sanitizeSenderName,
10
+ } from "@botcord/protocol-core";
@@ -1,29 +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: string, limit: number): string[] {
14
- if (limit <= 0) return [text];
15
- if (text.length === 0) return [""];
16
- if (text.length <= limit) return [text];
17
-
18
- const out: string[] = [];
19
- let remaining = text;
20
- while (remaining.length > limit) {
21
- let cut = remaining.lastIndexOf("\n", limit);
22
- if (cut <= 0) cut = limit;
23
- out.push(remaining.slice(0, cut));
24
- // Drop the leading newline so the next chunk doesn't start with a blank line.
25
- remaining = remaining.slice(cut).replace(/^\n/, "");
26
- }
27
- if (remaining.length > 0) out.push(remaining);
28
- return out;
29
- }
7
+ export { splitText } from "@botcord/protocol-core";
@@ -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
  }
@@ -260,6 +260,9 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
260
260
  auto_approve: opts.trustLevel !== "public",
261
261
  archived: false,
262
262
  };
263
+ const selection = parseDeepseekRuntimeSelection(opts.extraArgs);
264
+ if (selection.model) body.model = selection.model;
265
+ if (selection.reasoningEffort) body.reasoning_effort = selection.reasoningEffort;
263
266
  if (opts.systemContext) body.system_prompt = opts.systemContext;
264
267
  const res = await this.requestJson<any>(`${baseUrl}/v1/threads`, {
265
268
  method: "POST",
@@ -306,18 +309,22 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
306
309
  });
307
310
  let turnId = "";
308
311
  try {
312
+ const selection = parseDeepseekRuntimeSelection(opts.extraArgs);
313
+ const body: Record<string, unknown> = {
314
+ prompt: opts.text,
315
+ mode: "agent",
316
+ allow_shell: opts.trustLevel !== "public",
317
+ trust_mode: opts.trustLevel !== "public",
318
+ auto_approve: opts.trustLevel !== "public",
319
+ };
320
+ if (selection.model) body.model = selection.model;
321
+ if (selection.reasoningEffort) body.reasoning_effort = selection.reasoningEffort;
309
322
  const started = await this.requestJson<any>(
310
323
  `${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`,
311
324
  {
312
325
  method: "POST",
313
326
  headers,
314
- body: JSON.stringify({
315
- prompt: opts.text,
316
- mode: "agent",
317
- allow_shell: opts.trustLevel !== "public",
318
- trust_mode: opts.trustLevel !== "public",
319
- auto_approve: opts.trustLevel !== "public",
320
- }),
327
+ body: JSON.stringify(body),
321
328
  signal,
322
329
  },
323
330
  );
@@ -376,13 +383,17 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
376
383
  if (extractedError) errorText = extractedError;
377
384
  if (eventName === "message.delta") {
378
385
  append(stringField(payload, "content") ?? "");
379
- } else if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
380
- append(stringField(payload.payload, "delta") ?? "");
386
+ } else if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
387
+ append(extractDeepseekDelta(payload));
381
388
  }
382
389
  if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
383
390
  opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
384
- } else if (eventName === "tool.started" || isToolStarted(payload)) {
385
- 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";
386
397
  opts.onStatus?.({ kind: "thinking", phase: "updated", label });
387
398
  } else if (isDeepseekTerminalEvent(eventName, payload)) {
388
399
  opts.onStatus?.({ kind: "thinking", phase: "stopped" });
@@ -442,15 +453,18 @@ function normalizeDeepseekEvent(eventName: string, payload: any, seq: number): S
442
453
  if (eventName === "message.delta") {
443
454
  return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
444
455
  }
445
- if (eventName === "tool.started" || isToolStarted(payload)) {
456
+ if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
446
457
  return { raw: { event: eventName, payload }, kind: "tool_use", seq };
447
458
  }
448
- if (eventName === "tool.completed" || isToolCompleted(payload)) {
459
+ if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
449
460
  return { raw: { event: eventName, payload }, kind: "tool_result", seq };
450
461
  }
451
- if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
462
+ if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
452
463
  return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
453
464
  }
465
+ if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
466
+ return { raw: { event: eventName, payload }, kind: "thinking", seq };
467
+ }
454
468
  if (eventName === "turn.started" || eventName === "status" || embeddedDeepseekEvent(payload) === "turn.started") {
455
469
  return { raw: { event: eventName, payload }, kind: "system", seq };
456
470
  }
@@ -478,18 +492,36 @@ function isDeepseekTerminalEvent(eventName: string, payload: any): boolean {
478
492
  );
479
493
  }
480
494
 
481
- function isToolStarted(payload: any): boolean {
482
- 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
+ );
483
500
  }
484
501
 
485
- function isToolCompleted(payload: any): boolean {
486
- const kind = payload?.payload?.item?.kind;
502
+ function isToolCompleted(eventName: string, payload: any): boolean {
503
+ const kind = payload?.payload?.item?.kind ?? payload?.item?.kind;
487
504
  return (
488
- (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") &&
489
509
  (kind === "tool_call" || kind === "file_change" || kind === "command_execution")
490
510
  );
491
511
  }
492
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
+
493
525
  function extractDeepseekError(eventName: string, payload: any): string | undefined {
494
526
  if (eventName === "error") {
495
527
  return (
@@ -535,6 +567,41 @@ function authHeaders(token: string): HeadersInit {
535
567
  return token ? { authorization: `Bearer ${token}` } : {};
536
568
  }
537
569
 
570
+ function parseDeepseekRuntimeSelection(
571
+ extraArgs: string[] | undefined,
572
+ ): { model?: string; reasoningEffort?: string } {
573
+ const out: { model?: string; reasoningEffort?: string } = {};
574
+ if (!extraArgs?.length) return out;
575
+ for (let i = 0; i < extraArgs.length; i += 1) {
576
+ const arg = extraArgs[i]!;
577
+ if (arg === "--model") {
578
+ const value = nextArgValue(extraArgs, i);
579
+ if (value !== undefined) {
580
+ out.model = value;
581
+ i += 1;
582
+ }
583
+ } else if (arg.startsWith("--model=")) {
584
+ out.model = arg.slice("--model=".length);
585
+ } else if (arg === "--reasoning-effort") {
586
+ const value = nextArgValue(extraArgs, i);
587
+ if (value !== undefined) {
588
+ out.reasoningEffort = value;
589
+ i += 1;
590
+ }
591
+ } else if (arg.startsWith("--reasoning-effort=")) {
592
+ out.reasoningEffort = arg.slice("--reasoning-effort=".length);
593
+ }
594
+ }
595
+ return out;
596
+ }
597
+
598
+ function nextArgValue(args: string[], index: number): string | undefined {
599
+ const next = args[index + 1];
600
+ if (typeof next !== "string") return undefined;
601
+ if (!next.startsWith("-")) return next;
602
+ return /^-\d/.test(next) ? next : undefined;
603
+ }
604
+
538
605
  function poolKey(opts: RuntimeRunOptions): string {
539
606
  return opts.accountId || "default";
540
607
  }