@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,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
  }
package/src/provision.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type RuntimeProbeResult,
27
27
  type StoredBotCordCredentials,
28
28
  type UpdateAgentParams,
29
+ type GatewayInboundFrame,
29
30
  } from "@botcord/protocol-core";
30
31
  import type { Gateway } from "./gateway/index.js";
31
32
  import type { GatewayInboundMessage } from "./gateway/index.js";
@@ -72,6 +73,12 @@ import {
72
73
  import { log as daemonLog } from "./log.js";
73
74
  import { discoverAgentCredentials } from "./agent-discovery.js";
74
75
  import { resolveMemoryDir } from "./working-memory.js";
76
+ import { discoverRuntimeModelCatalog } from "./runtime-models.js";
77
+ import {
78
+ buildRuntimeSelectionExtraArgs,
79
+ mergeRuntimeExtraArgs,
80
+ } from "./runtime-route-options.js";
81
+ import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
75
82
 
76
83
  /**
77
84
  * Information passed to {@link OnAgentInstalledHook} after a successful
@@ -159,7 +166,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
159
166
 
160
167
  case CONTROL_FRAME_TYPES.HELLO: {
161
168
  const params = (frame.params ?? {}) as unknown as HelloParams;
162
- const result = applyHelloIdentitySnapshot(params.agents);
169
+ const result = applyHelloIdentitySnapshot(params.agents, { gateway });
163
170
  daemonLog.debug("hello: identity snapshot applied", {
164
171
  frameId: frame.id,
165
172
  received: params.agents?.length ?? 0,
@@ -181,12 +188,19 @@ export function createProvisioner(opts: ProvisionerOptions): (
181
188
  displayName: params.displayName,
182
189
  bio: params.bio,
183
190
  });
191
+ const runtimeResult = applyAgentRuntimeSnapshot(params, { gateway });
192
+ const combined = {
193
+ changed: result.changed || runtimeResult.changed,
194
+ identity: result,
195
+ runtime: runtimeResult,
196
+ };
184
197
  daemonLog.info("update_agent applied", {
185
198
  agentId: params.agentId,
186
- changed: result.changed,
187
- skipped: result.skipped ?? null,
199
+ changed: combined.changed,
200
+ identitySkipped: result.skipped ?? null,
201
+ runtimeSkipped: runtimeResult.skipped ?? null,
188
202
  });
189
- return { ok: true, result };
203
+ return { ok: true, result: combined };
190
204
  }
191
205
 
192
206
  case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
@@ -414,6 +428,31 @@ export function createProvisioner(opts: ProvisionerOptions): (
414
428
  );
415
429
  }
416
430
 
431
+ case "cloud_gateway_runtime_inbound": {
432
+ const params = (frame.params ?? {}) as { frame?: unknown };
433
+ const runtimeFrame = params.frame as GatewayInboundFrame | undefined;
434
+ if (!runtimeFrame || typeof runtimeFrame !== "object") {
435
+ return {
436
+ ok: false,
437
+ error: {
438
+ code: "bad_params",
439
+ message: "cloud_gateway_runtime_inbound requires params.frame",
440
+ },
441
+ };
442
+ }
443
+ const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame);
444
+ return result.accepted
445
+ ? { ok: true, result }
446
+ : {
447
+ ok: false,
448
+ result,
449
+ error: result.error ?? {
450
+ code: "runtime_inbound_rejected",
451
+ message: "cloud gateway runtime inbound was rejected",
452
+ },
453
+ };
454
+ }
455
+
417
456
  case "list_agent_files": {
418
457
  const params = (frame.params ?? {}) as unknown as ListAgentFilesParams;
419
458
  if (!params.agentId) {
@@ -1057,6 +1096,11 @@ function upsertManagedRouteForCredentials(
1057
1096
  runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
1058
1097
  cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
1059
1098
  };
1099
+ const extraArgs = mergeRuntimeExtraArgs(
1100
+ cfg.defaultRoute.extraArgs,
1101
+ buildRuntimeSelectionExtraArgs(synthRoute.runtime, credentials),
1102
+ );
1103
+ if (extraArgs) synthRoute.extraArgs = extraArgs;
1060
1104
  if (synthRoute.runtime === "openclaw-acp") {
1061
1105
  const profile = (cfg.openclawGateways ?? []).find(
1062
1106
  (g) => g.name === credentials.openclawGateway,
@@ -1169,6 +1213,10 @@ async function materializeCredentials(
1169
1213
  if (c.token) record.token = c.token;
1170
1214
  if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
1171
1215
  if (runtime) record.runtime = runtime;
1216
+ const runtimeSelection = pickRuntimeSelection(params);
1217
+ if (runtimeSelection.runtimeModel) record.runtimeModel = runtimeSelection.runtimeModel;
1218
+ if (runtimeSelection.reasoningEffort) record.reasoningEffort = runtimeSelection.reasoningEffort;
1219
+ if (typeof runtimeSelection.thinking === "boolean") record.thinking = runtimeSelection.thinking;
1172
1220
  record.cwd = cwd;
1173
1221
  const openclawSel = pickOpenclawSelection(params);
1174
1222
  if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
@@ -1203,6 +1251,10 @@ async function materializeCredentials(
1203
1251
  tokenExpiresAt: reg.expiresAt,
1204
1252
  };
1205
1253
  if (runtime) record.runtime = runtime;
1254
+ const runtimeSelection = pickRuntimeSelection(params);
1255
+ if (runtimeSelection.runtimeModel) record.runtimeModel = runtimeSelection.runtimeModel;
1256
+ if (runtimeSelection.reasoningEffort) record.reasoningEffort = runtimeSelection.reasoningEffort;
1257
+ if (typeof runtimeSelection.thinking === "boolean") record.thinking = runtimeSelection.thinking;
1206
1258
  record.cwd = cwd;
1207
1259
  const openclawSel = pickOpenclawSelection(params);
1208
1260
  if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
@@ -1784,6 +1836,10 @@ export function collectRuntimeSnapshot(opts: { force?: boolean } = {}): ListRunt
1784
1836
  // style used above.
1785
1837
  if (entry.result.version) record.version = entry.result.version;
1786
1838
  if (entry.result.path) record.path = entry.result.path;
1839
+ const catalog = discoverRuntimeModelCatalog(entry);
1840
+ const models = catalog.models;
1841
+ if (models?.length) record.models = models.slice(0, RUNTIME_MODELS_CAP);
1842
+ if (catalog.parameters?.length) record.parameters = catalog.parameters.slice(0, RUNTIME_PARAMETERS_CAP);
1787
1843
  // Gateway's probe surface doesn't expose an `error` string today — it
1788
1844
  // already swallows throws into `{available: false}`. We leave the wire
1789
1845
  // field blank in that case and let callers treat `!available` as reason
@@ -1841,6 +1897,8 @@ export function attachRuntimeHealth(
1841
1897
 
1842
1898
  /** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
1843
1899
  export const RUNTIME_ENDPOINTS_CAP = 32;
1900
+ export const RUNTIME_MODELS_CAP = 128;
1901
+ export const RUNTIME_PARAMETERS_CAP = 64;
1844
1902
 
1845
1903
  /** Injection seam for L2 + L3 endpoint probes — kept testable + side-effect-free. */
1846
1904
  export type WsEndpointProbeFn = (args: {
@@ -2386,10 +2444,100 @@ interface HelloIdentityResult {
2386
2444
  skipped: number;
2387
2445
  }
2388
2446
 
2447
+ interface RuntimeSnapshotResult {
2448
+ changed: boolean;
2449
+ skipped?: string;
2450
+ routeUpdated?: boolean;
2451
+ }
2452
+
2453
+ interface RuntimeSnapshotCtx {
2454
+ gateway?: Gateway;
2455
+ }
2456
+
2457
+ function hasOwnField(obj: object, key: string): boolean {
2458
+ return Object.prototype.hasOwnProperty.call(obj, key);
2459
+ }
2460
+
2461
+ function cleanNullableString(value: string | null | undefined): string | undefined {
2462
+ if (typeof value !== "string") return undefined;
2463
+ const trimmed = value.trim();
2464
+ return trimmed || undefined;
2465
+ }
2466
+
2467
+ function applyAgentRuntimeSnapshot(
2468
+ snapshot: AgentIdentitySnapshot,
2469
+ ctx: RuntimeSnapshotCtx = {},
2470
+ ): RuntimeSnapshotResult {
2471
+ const hasRuntimeFields = (
2472
+ hasOwnField(snapshot, "runtime") ||
2473
+ hasOwnField(snapshot, "runtimeModel") ||
2474
+ hasOwnField(snapshot, "reasoningEffort") ||
2475
+ hasOwnField(snapshot, "thinking")
2476
+ );
2477
+ if (!hasRuntimeFields) return { changed: false, skipped: "no_runtime_fields" };
2478
+
2479
+ const credentialsFile = defaultCredentialsFile(snapshot.agentId);
2480
+ if (!existsSync(credentialsFile)) {
2481
+ return { changed: false, skipped: "credentials_missing" };
2482
+ }
2483
+
2484
+ const credentials = loadStoredCredentials(credentialsFile);
2485
+ let changed = false;
2486
+
2487
+ if (hasOwnField(snapshot, "runtime")) {
2488
+ const runtime = cleanNullableString(snapshot.runtime);
2489
+ if (runtime !== credentials.runtime) {
2490
+ if (runtime) credentials.runtime = runtime;
2491
+ else delete credentials.runtime;
2492
+ changed = true;
2493
+ }
2494
+ }
2495
+
2496
+ if (hasOwnField(snapshot, "runtimeModel")) {
2497
+ const runtimeModel = cleanNullableString(snapshot.runtimeModel);
2498
+ if (runtimeModel !== credentials.runtimeModel) {
2499
+ if (runtimeModel) credentials.runtimeModel = runtimeModel;
2500
+ else delete credentials.runtimeModel;
2501
+ changed = true;
2502
+ }
2503
+ }
2504
+
2505
+ if (hasOwnField(snapshot, "reasoningEffort")) {
2506
+ const reasoningEffort = cleanNullableString(snapshot.reasoningEffort);
2507
+ if (reasoningEffort !== credentials.reasoningEffort) {
2508
+ if (reasoningEffort) credentials.reasoningEffort = reasoningEffort;
2509
+ else delete credentials.reasoningEffort;
2510
+ changed = true;
2511
+ }
2512
+ }
2513
+
2514
+ if (hasOwnField(snapshot, "thinking")) {
2515
+ if (typeof snapshot.thinking === "boolean") {
2516
+ if (credentials.thinking !== snapshot.thinking) {
2517
+ credentials.thinking = snapshot.thinking;
2518
+ changed = true;
2519
+ }
2520
+ } else if (typeof credentials.thinking === "boolean") {
2521
+ delete credentials.thinking;
2522
+ changed = true;
2523
+ }
2524
+ }
2525
+
2526
+ if (!changed) return { changed: false };
2527
+
2528
+ writeCredentialsFile(credentialsFile, credentials);
2529
+ if (ctx.gateway) {
2530
+ upsertManagedRouteForCredentials(credentials, loadConfig(), ctx.gateway);
2531
+ return { changed: true, routeUpdated: true };
2532
+ }
2533
+ return { changed: true };
2534
+ }
2535
+
2389
2536
  /**
2390
2537
  * Reconcile every agent identity carried by the `hello.agents` snapshot
2391
- * against the on-disk `identity.md`. Best-effort: a malformed entry or a
2392
- * file-system error for one agent never aborts the rest.
2538
+ * against the on-disk `identity.md` and credentials runtime selectors.
2539
+ * Best-effort: a malformed entry or a file-system error for one agent never
2540
+ * aborts the rest.
2393
2541
  *
2394
2542
  * Identity-snapshot semantics intentionally only touch the metadata
2395
2543
  * line + Bio body — Role/Boundaries paragraphs the user authored locally
@@ -2399,6 +2547,7 @@ interface HelloIdentityResult {
2399
2547
  */
2400
2548
  export function applyHelloIdentitySnapshot(
2401
2549
  snapshot: AgentIdentitySnapshot[] | undefined,
2550
+ ctx: RuntimeSnapshotCtx = {},
2402
2551
  ): HelloIdentityResult {
2403
2552
  const out: HelloIdentityResult = { updated: 0, skipped: 0 };
2404
2553
  if (!Array.isArray(snapshot)) return out;
@@ -2412,7 +2561,8 @@ export function applyHelloIdentitySnapshot(
2412
2561
  displayName: entry.displayName,
2413
2562
  bio: entry.bio,
2414
2563
  });
2415
- if (result.changed) out.updated += 1;
2564
+ const runtimeResult = applyAgentRuntimeSnapshot(entry, ctx);
2565
+ if (result.changed || runtimeResult.changed) out.updated += 1;
2416
2566
  else out.skipped += 1;
2417
2567
  } catch (err) {
2418
2568
  out.skipped += 1;
@@ -2511,20 +2661,34 @@ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadRes
2511
2661
  */
2512
2662
  function readAgentRuntimesFromCredentials(
2513
2663
  agentIds: string[],
2514
- ): Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> {
2515
- const out: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> = {};
2664
+ ): Record<string, { runtime?: string; runtimeModel?: string; reasoningEffort?: string; thinking?: boolean; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> {
2665
+ const out: Record<string, { runtime?: string; runtimeModel?: string; reasoningEffort?: string; thinking?: boolean; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string }> = {};
2516
2666
  for (const id of agentIds) {
2517
2667
  const file = defaultCredentialsFile(id);
2518
2668
  try {
2519
2669
  if (!existsSync(file)) continue;
2520
2670
  const creds = loadStoredCredentials(file);
2521
- const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string } = {};
2671
+ const entry: { runtime?: string; runtimeModel?: string; reasoningEffort?: string; thinking?: boolean; cwd?: string; openclawGateway?: string; openclawAgent?: string; hermesProfile?: string } = {};
2522
2672
  if (creds.runtime) entry.runtime = creds.runtime;
2673
+ if (creds.runtimeModel) entry.runtimeModel = creds.runtimeModel;
2674
+ if (creds.reasoningEffort) entry.reasoningEffort = creds.reasoningEffort;
2675
+ if (typeof creds.thinking === "boolean") entry.thinking = creds.thinking;
2523
2676
  if (creds.cwd) entry.cwd = creds.cwd;
2524
2677
  if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
2525
2678
  if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
2526
2679
  if (creds.hermesProfile) entry.hermesProfile = creds.hermesProfile;
2527
- if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent || entry.hermesProfile) out[id] = entry;
2680
+ if (
2681
+ entry.runtime ||
2682
+ entry.runtimeModel ||
2683
+ entry.reasoningEffort ||
2684
+ typeof entry.thinking === "boolean" ||
2685
+ entry.cwd ||
2686
+ entry.openclawGateway ||
2687
+ entry.openclawAgent ||
2688
+ entry.hermesProfile
2689
+ ) {
2690
+ out[id] = entry;
2691
+ }
2528
2692
  } catch {
2529
2693
  // best-effort — skip agents with unreadable credentials
2530
2694
  }
@@ -2769,6 +2933,33 @@ function pickRuntime(params: ProvisionAgentParams): string | undefined {
2769
2933
  return undefined;
2770
2934
  }
2771
2935
 
2936
+ function pickRuntimeSelection(
2937
+ params: ProvisionAgentParams,
2938
+ ): { runtimeModel?: string; reasoningEffort?: string; thinking?: boolean } {
2939
+ const out: { runtimeModel?: string; reasoningEffort?: string; thinking?: boolean } = {};
2940
+ const runtimeModel = pickString(params.runtimeModel, params.credentials?.runtimeModel);
2941
+ const reasoningEffort = pickString(
2942
+ params.reasoningEffort,
2943
+ params.credentials?.reasoningEffort,
2944
+ );
2945
+ if (runtimeModel) out.runtimeModel = runtimeModel;
2946
+ if (reasoningEffort) out.reasoningEffort = reasoningEffort;
2947
+ if (typeof params.thinking === "boolean") {
2948
+ out.thinking = params.thinking;
2949
+ } else if (typeof params.credentials?.thinking === "boolean") {
2950
+ out.thinking = params.credentials.thinking;
2951
+ }
2952
+ return out;
2953
+ }
2954
+
2955
+ function pickString(...values: Array<string | undefined>): string | undefined {
2956
+ for (const value of values) {
2957
+ const trimmed = value?.trim();
2958
+ if (trimmed) return trimmed;
2959
+ }
2960
+ return undefined;
2961
+ }
2962
+
2772
2963
  function assertKnownRuntime(runtime: string): void {
2773
2964
  const mod = getAdapterModule(runtime);
2774
2965
  if (!mod) {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Build a compact, deterministic recovery block from recent Hub room messages.
3
+ * Used when a runtime-native session is discarded and the same turn is retried
4
+ * in a fresh session.
5
+ */
6
+ import { BotCordClient, loadStoredCredentials } from "@botcord/protocol-core";
7
+ import { sanitizeUntrustedContent } from "./gateway/index.js";
8
+ import type { GatewayInboundMessage } from "./gateway/index.js";
9
+
10
+ interface CachedClient {
11
+ client: BotCordClient;
12
+ credentialsPath: string;
13
+ }
14
+
15
+ export interface RecentRoomMessagesRecoveryOptions {
16
+ credentialPathByAgentId: Map<string, string>;
17
+ defaultCredentialsPath?: string;
18
+ hubBaseUrl?: string;
19
+ limit?: number;
20
+ log?: {
21
+ warn: (msg: string, meta?: Record<string, unknown>) => void;
22
+ };
23
+ }
24
+
25
+ interface RoomMessage {
26
+ from?: string;
27
+ from_name?: string;
28
+ text?: string;
29
+ type?: string;
30
+ ts?: string;
31
+ topic_id?: string | null;
32
+ topic_title?: string | null;
33
+ }
34
+
35
+ const DEFAULT_RECENT_LIMIT = 20;
36
+ const MAX_MESSAGE_TEXT_CHARS = 1200;
37
+
38
+ function stripNewlines(s: string): string {
39
+ return s.replace(/[\r\n]+/g, " ");
40
+ }
41
+
42
+ function messageLabel(m: RoomMessage): string {
43
+ const name = typeof m.from_name === "string" && m.from_name.trim()
44
+ ? m.from_name
45
+ : typeof m.from === "string" && m.from.trim()
46
+ ? m.from
47
+ : "unknown";
48
+ return sanitizeUntrustedContent(stripNewlines(name));
49
+ }
50
+
51
+ function formatRecentMessages(messages: RoomMessage[]): string {
52
+ if (messages.length === 0) return "[Recent Room Messages]\n(none)";
53
+ const chronological = [...messages].reverse();
54
+ const lines = ["[Recent Room Messages]"];
55
+ for (const m of chronological) {
56
+ const text = typeof m.text === "string" ? m.text.trim() : "";
57
+ if (!text) continue;
58
+ const ts = typeof m.ts === "string" ? m.ts : "";
59
+ const topic = typeof m.topic_title === "string" && m.topic_title.trim()
60
+ ? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_title))}`
61
+ : typeof m.topic_id === "string" && m.topic_id
62
+ ? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_id))}`
63
+ : "";
64
+ const safeText = sanitizeUntrustedContent(
65
+ text.length > MAX_MESSAGE_TEXT_CHARS
66
+ ? `${text.slice(0, MAX_MESSAGE_TEXT_CHARS)}...`
67
+ : text,
68
+ );
69
+ lines.push(`- ${ts ? `${ts} ` : ""}${messageLabel(m)}${topic}: ${safeText}`);
70
+ }
71
+ return lines.join("\n");
72
+ }
73
+
74
+ export function createRecentRoomMessagesRecoveryBuilder(
75
+ opts: RecentRoomMessagesRecoveryOptions,
76
+ ): (message: GatewayInboundMessage) => Promise<string | null> {
77
+ const clients = new Map<string, CachedClient>();
78
+ const limit = opts.limit ?? DEFAULT_RECENT_LIMIT;
79
+
80
+ function getClient(accountId: string): BotCordClient | null {
81
+ const existing = clients.get(accountId);
82
+ if (existing) return existing.client;
83
+
84
+ const credsPath =
85
+ opts.credentialPathByAgentId.get(accountId) ?? opts.defaultCredentialsPath;
86
+ if (!credsPath) {
87
+ opts.log?.warn("daemon.recovery-context.no-credentials", { accountId });
88
+ return null;
89
+ }
90
+
91
+ try {
92
+ const creds = loadStoredCredentials(credsPath);
93
+ const client = new BotCordClient({
94
+ hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
95
+ agentId: creds.agentId,
96
+ keyId: creds.keyId,
97
+ privateKey: creds.privateKey,
98
+ ...(creds.token ? { token: creds.token } : {}),
99
+ ...(creds.tokenExpiresAt !== undefined
100
+ ? { tokenExpiresAt: creds.tokenExpiresAt }
101
+ : {}),
102
+ });
103
+ clients.set(accountId, { client, credentialsPath: credsPath });
104
+ return client;
105
+ } catch (err) {
106
+ opts.log?.warn("daemon.recovery-context.client-init-failed", {
107
+ accountId,
108
+ credsPath,
109
+ error: err instanceof Error ? err.message : String(err),
110
+ });
111
+ return null;
112
+ }
113
+ }
114
+
115
+ return async (message) => {
116
+ const client = getClient(message.accountId);
117
+ if (!client) return null;
118
+ try {
119
+ const body = await client.roomMessages(message.conversation.id, { limit });
120
+ const messages = Array.isArray(body?.messages) ? body.messages as RoomMessage[] : [];
121
+ return formatRecentMessages(messages);
122
+ } catch (err) {
123
+ opts.log?.warn("daemon.recovery-context.fetch-failed", {
124
+ accountId: message.accountId,
125
+ roomId: message.conversation.id,
126
+ error: err instanceof Error ? err.message : String(err),
127
+ });
128
+ return null;
129
+ }
130
+ };
131
+ }