@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
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";
@@ -77,6 +78,7 @@ import {
77
78
  buildRuntimeSelectionExtraArgs,
78
79
  mergeRuntimeExtraArgs,
79
80
  } from "./runtime-route-options.js";
81
+ import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
80
82
 
81
83
  /**
82
84
  * Information passed to {@link OnAgentInstalledHook} after a successful
@@ -164,7 +166,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
164
166
 
165
167
  case CONTROL_FRAME_TYPES.HELLO: {
166
168
  const params = (frame.params ?? {}) as unknown as HelloParams;
167
- const result = applyHelloIdentitySnapshot(params.agents);
169
+ const result = applyHelloIdentitySnapshot(params.agents, { gateway });
168
170
  daemonLog.debug("hello: identity snapshot applied", {
169
171
  frameId: frame.id,
170
172
  received: params.agents?.length ?? 0,
@@ -186,12 +188,19 @@ export function createProvisioner(opts: ProvisionerOptions): (
186
188
  displayName: params.displayName,
187
189
  bio: params.bio,
188
190
  });
191
+ const runtimeResult = applyAgentRuntimeSnapshot(params, { gateway });
192
+ const combined = {
193
+ changed: result.changed || runtimeResult.changed,
194
+ identity: result,
195
+ runtime: runtimeResult,
196
+ };
189
197
  daemonLog.info("update_agent applied", {
190
198
  agentId: params.agentId,
191
- changed: result.changed,
192
- skipped: result.skipped ?? null,
199
+ changed: combined.changed,
200
+ identitySkipped: result.skipped ?? null,
201
+ runtimeSkipped: runtimeResult.skipped ?? null,
193
202
  });
194
- return { ok: true, result };
203
+ return { ok: true, result: combined };
195
204
  }
196
205
 
197
206
  case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
@@ -419,6 +428,31 @@ export function createProvisioner(opts: ProvisionerOptions): (
419
428
  );
420
429
  }
421
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
+
422
456
  case "list_agent_files": {
423
457
  const params = (frame.params ?? {}) as unknown as ListAgentFilesParams;
424
458
  if (!params.agentId) {
@@ -2410,10 +2444,100 @@ interface HelloIdentityResult {
2410
2444
  skipped: number;
2411
2445
  }
2412
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
+
2413
2536
  /**
2414
2537
  * Reconcile every agent identity carried by the `hello.agents` snapshot
2415
- * against the on-disk `identity.md`. Best-effort: a malformed entry or a
2416
- * 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.
2417
2541
  *
2418
2542
  * Identity-snapshot semantics intentionally only touch the metadata
2419
2543
  * line + Bio body — Role/Boundaries paragraphs the user authored locally
@@ -2423,6 +2547,7 @@ interface HelloIdentityResult {
2423
2547
  */
2424
2548
  export function applyHelloIdentitySnapshot(
2425
2549
  snapshot: AgentIdentitySnapshot[] | undefined,
2550
+ ctx: RuntimeSnapshotCtx = {},
2426
2551
  ): HelloIdentityResult {
2427
2552
  const out: HelloIdentityResult = { updated: 0, skipped: 0 };
2428
2553
  if (!Array.isArray(snapshot)) return out;
@@ -2436,7 +2561,8 @@ export function applyHelloIdentitySnapshot(
2436
2561
  displayName: entry.displayName,
2437
2562
  bio: entry.bio,
2438
2563
  });
2439
- if (result.changed) out.updated += 1;
2564
+ const runtimeResult = applyAgentRuntimeSnapshot(entry, ctx);
2565
+ if (result.changed || runtimeResult.changed) out.updated += 1;
2440
2566
  else out.skipped += 1;
2441
2567
  } catch (err) {
2442
2568
  out.skipped += 1;
@@ -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
+ }