@botcord/daemon 0.2.78 → 0.2.80
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.
- package/dist/attention-policy-fetcher.d.ts +14 -0
- package/dist/attention-policy-fetcher.js +59 -0
- package/dist/cloud-daemon.js +8 -0
- package/dist/cloud-gateway-runtime.d.ts +29 -0
- package/dist/cloud-gateway-runtime.js +122 -0
- package/dist/daemon-singleton.d.ts +13 -0
- package/dist/daemon-singleton.js +68 -0
- package/dist/daemon.js +21 -6
- package/dist/gateway/channels/botcord.d.ts +1 -0
- package/dist/gateway/channels/botcord.js +62 -17
- package/dist/gateway/channels/login-session.d.ts +12 -0
- package/dist/gateway/channels/login-session.js +20 -2
- package/dist/gateway/channels/sanitize.d.ts +5 -18
- package/dist/gateway/channels/sanitize.js +5 -54
- package/dist/gateway/channels/text-split.d.ts +5 -11
- package/dist/gateway/channels/text-split.js +5 -31
- package/dist/gateway/dispatcher.d.ts +7 -1
- package/dist/gateway/dispatcher.js +88 -8
- package/dist/gateway/gateway.d.ts +16 -1
- package/dist/gateway/gateway.js +21 -0
- package/dist/gateway/policy-resolver.js +17 -9
- package/dist/gateway/runtimes/deepseek-tui.js +56 -13
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- package/dist/index.js +8 -3
- package/dist/provision.d.ts +7 -3
- package/dist/provision.js +115 -8
- package/dist/room-recovery-context.d.ts +11 -0
- package/dist/room-recovery-context.js +97 -0
- package/dist/status-render.d.ts +4 -0
- package/dist/status-render.js +14 -1
- package/package.json +2 -2
- package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
- package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
- package/src/__tests__/daemon-singleton.test.ts +32 -0
- package/src/__tests__/gateway-control.test.ts +136 -0
- package/src/__tests__/policy-resolver.test.ts +20 -0
- package/src/__tests__/provision.test.ts +65 -0
- package/src/__tests__/status-render.test.ts +23 -0
- package/src/attention-policy-fetcher.ts +87 -0
- package/src/cloud-daemon.ts +8 -0
- package/src/cloud-gateway-runtime.ts +171 -0
- package/src/daemon-singleton.ts +85 -0
- package/src/daemon.ts +23 -6
- package/src/gateway/__tests__/botcord-channel.test.ts +211 -5
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +263 -0
- package/src/gateway/__tests__/dispatcher.test.ts +56 -0
- package/src/gateway/channels/botcord.ts +69 -17
- package/src/gateway/channels/login-session.ts +20 -2
- package/src/gateway/channels/sanitize.ts +8 -66
- package/src/gateway/channels/text-split.ts +5 -27
- package/src/gateway/dispatcher.ts +123 -27
- package/src/gateway/gateway.ts +29 -0
- package/src/gateway/policy-resolver.ts +20 -9
- package/src/gateway/runtimes/deepseek-tui.ts +63 -13
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/index.ts +9 -2
- package/src/provision.ts +133 -7
- package/src/room-recovery-context.ts +131 -0
- package/src/status-render.ts +14 -1
package/dist/gateway/types.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { GatewayLogger } from "./log.js";
|
|
2
|
+
import type { GatewayInboundEnvelope as CanonicalGatewayInboundEnvelope, GatewayInboundMessage as CanonicalGatewayInboundMessage, GatewayOutboundAttachment as CanonicalGatewayOutboundAttachment, GatewayOutboundMessage as CanonicalGatewayOutboundMessage, RuntimeGatewayProvider } from "@botcord/protocol-core";
|
|
3
|
+
export type GatewayInboundMessage = CanonicalGatewayInboundMessage;
|
|
4
|
+
export type GatewayInboundEnvelope = CanonicalGatewayInboundEnvelope;
|
|
5
|
+
export type GatewayOutboundAttachment = CanonicalGatewayOutboundAttachment;
|
|
6
|
+
export type GatewayOutboundMessage = CanonicalGatewayOutboundMessage;
|
|
2
7
|
/** Set of predicates matched against a normalized inbound message to pick a route. */
|
|
3
8
|
export interface RouteMatch {
|
|
4
9
|
channel?: string;
|
|
@@ -69,41 +74,6 @@ export interface GatewayConfig {
|
|
|
69
74
|
managedRoutes?: GatewayRoute[];
|
|
70
75
|
streamBlocks?: boolean;
|
|
71
76
|
}
|
|
72
|
-
/** Normalized inbound message produced by a channel adapter for the dispatcher. */
|
|
73
|
-
export interface GatewayInboundMessage {
|
|
74
|
-
id: string;
|
|
75
|
-
/** Channel adapter id (`ChannelAdapter.id`), not channel type. */
|
|
76
|
-
channel: string;
|
|
77
|
-
accountId: string;
|
|
78
|
-
conversation: {
|
|
79
|
-
id: string;
|
|
80
|
-
kind: "direct" | "group";
|
|
81
|
-
title?: string;
|
|
82
|
-
threadId?: string | null;
|
|
83
|
-
};
|
|
84
|
-
sender: {
|
|
85
|
-
id: string;
|
|
86
|
-
name?: string;
|
|
87
|
-
kind: "user" | "agent" | "system";
|
|
88
|
-
};
|
|
89
|
-
text?: string;
|
|
90
|
-
raw: unknown;
|
|
91
|
-
replyTo?: string | null;
|
|
92
|
-
mentioned?: boolean;
|
|
93
|
-
receivedAt: number;
|
|
94
|
-
trace?: {
|
|
95
|
-
id: string;
|
|
96
|
-
streamable?: boolean;
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
/** Inbound envelope wrapping a normalized message with optional upstream ack callbacks. */
|
|
100
|
-
export interface GatewayInboundEnvelope {
|
|
101
|
-
message: GatewayInboundMessage;
|
|
102
|
-
ack?: {
|
|
103
|
-
accept(): Promise<void>;
|
|
104
|
-
reject?(reason: string): Promise<void>;
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
77
|
/**
|
|
108
78
|
* Channel-agnostic hook that produces a system-context string for a turn.
|
|
109
79
|
* Called before every `runtime.run(...)`; returned value is passed through
|
|
@@ -137,33 +107,18 @@ export interface MemoryContextSnapshot {
|
|
|
137
107
|
version: string;
|
|
138
108
|
}
|
|
139
109
|
export type MemoryContextBuilder = (message: GatewayInboundMessage) => Promise<MemoryContextSnapshot | null | undefined> | MemoryContextSnapshot | null | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Optional hook used after a runtime session is discarded and retried fresh.
|
|
112
|
+
* The daemon implementation can pull recent room messages from Hub; gateway
|
|
113
|
+
* core treats the returned string as opaque recovery context.
|
|
114
|
+
*/
|
|
115
|
+
export type RuntimeRecoveryContextBuilder = (message: GatewayInboundMessage) => Promise<string | null | undefined> | string | null | undefined;
|
|
140
116
|
/**
|
|
141
117
|
* Optional hook fired after the dispatcher dispatches a reply to a channel.
|
|
142
118
|
* Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
|
|
143
119
|
* are caught and logged so observer failures never break the turn.
|
|
144
120
|
*/
|
|
145
121
|
export type OutboundObserver = (message: GatewayOutboundMessage) => Promise<void> | void;
|
|
146
|
-
/** Outbound reply payload passed to `ChannelAdapter.send()`. */
|
|
147
|
-
export interface GatewayOutboundAttachment {
|
|
148
|
-
/** Local daemon-readable file path. */
|
|
149
|
-
filePath?: string;
|
|
150
|
-
/** In-memory bytes, primarily for tests and in-process tool callers. */
|
|
151
|
-
data?: Uint8Array;
|
|
152
|
-
filename?: string;
|
|
153
|
-
contentType?: string;
|
|
154
|
-
kind?: "image" | "file" | "video";
|
|
155
|
-
}
|
|
156
|
-
export interface GatewayOutboundMessage {
|
|
157
|
-
channel: string;
|
|
158
|
-
accountId: string;
|
|
159
|
-
conversationId: string;
|
|
160
|
-
threadId?: string | null;
|
|
161
|
-
type?: "message" | "error";
|
|
162
|
-
text: string;
|
|
163
|
-
attachments?: GatewayOutboundAttachment[];
|
|
164
|
-
replyTo?: string | null;
|
|
165
|
-
traceId?: string | null;
|
|
166
|
-
}
|
|
167
122
|
/** Per-channel status snapshot exposed for `status`/`doctor` style output. */
|
|
168
123
|
export interface ChannelStatusSnapshot {
|
|
169
124
|
channel: string;
|
|
@@ -176,7 +131,7 @@ export interface ChannelStatusSnapshot {
|
|
|
176
131
|
lastStopAt?: number;
|
|
177
132
|
lastError?: string | null;
|
|
178
133
|
/** Third-party provider id when this channel is not the built-in BotCord. */
|
|
179
|
-
provider?:
|
|
134
|
+
provider?: RuntimeGatewayProvider;
|
|
180
135
|
/** Last time the adapter polled the upstream provider (ms epoch). */
|
|
181
136
|
lastPollAt?: number;
|
|
182
137
|
/** Last time the adapter accepted an inbound message (ms epoch). */
|
package/dist/gateway-control.js
CHANGED
|
@@ -108,13 +108,16 @@ export function createGatewayControl(ctx) {
|
|
|
108
108
|
if (!loginId) {
|
|
109
109
|
return badParams("upsert_gateway: wechat requires loginId");
|
|
110
110
|
}
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
111
|
+
const resolved = sessions.resolve(loginId);
|
|
112
|
+
if (resolved.state !== "live") {
|
|
113
113
|
return {
|
|
114
114
|
ok: false,
|
|
115
|
-
error:
|
|
115
|
+
error: resolved.state === "missing"
|
|
116
|
+
? { code: "login_missing", message: `wechat login session "${loginId}" not found` }
|
|
117
|
+
: { code: "login_expired", message: `wechat login session "${loginId}" expired` },
|
|
116
118
|
};
|
|
117
119
|
}
|
|
120
|
+
const session = resolved.session;
|
|
118
121
|
if (session.provider !== "wechat") {
|
|
119
122
|
return badParams(`upsert_gateway: login session provider "${session.provider}" != "wechat"`);
|
|
120
123
|
}
|
|
@@ -143,13 +146,16 @@ export function createGatewayControl(ctx) {
|
|
|
143
146
|
if (!loginId) {
|
|
144
147
|
return badParams("upsert_gateway: feishu requires loginId");
|
|
145
148
|
}
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
149
|
+
const resolved = sessions.resolve(loginId);
|
|
150
|
+
if (resolved.state !== "live") {
|
|
148
151
|
return {
|
|
149
152
|
ok: false,
|
|
150
|
-
error:
|
|
153
|
+
error: resolved.state === "missing"
|
|
154
|
+
? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
|
|
155
|
+
: { code: "login_expired", message: `feishu login session "${loginId}" expired` },
|
|
151
156
|
};
|
|
152
157
|
}
|
|
158
|
+
const session = resolved.session;
|
|
153
159
|
if (session.provider !== "feishu") {
|
|
154
160
|
return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
|
|
155
161
|
}
|
|
@@ -659,13 +665,16 @@ export function createGatewayControl(ctx) {
|
|
|
659
665
|
if (!params.accountId || typeof params.accountId !== "string") {
|
|
660
666
|
return badParams("gateway_recent_senders: accountId is required");
|
|
661
667
|
}
|
|
662
|
-
const
|
|
663
|
-
if (
|
|
668
|
+
const resolved = sessions.resolve(params.loginId);
|
|
669
|
+
if (resolved.state !== "live") {
|
|
664
670
|
return {
|
|
665
671
|
ok: false,
|
|
666
|
-
error:
|
|
672
|
+
error: resolved.state === "missing"
|
|
673
|
+
? { code: "login_missing", message: `wechat login session "${params.loginId}" not found` }
|
|
674
|
+
: { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
|
|
667
675
|
};
|
|
668
676
|
}
|
|
677
|
+
const session = resolved.session;
|
|
669
678
|
if (session.provider !== "wechat") {
|
|
670
679
|
return badParams("gateway_recent_senders: provider does not match login session");
|
|
671
680
|
}
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir, hostname } from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { augmentProcessPath } from "./path-env.js";
|
|
7
7
|
import { loadConfig, saveConfig, initDefaultConfig, resolveConfiguredAgentIds, SNAPSHOT_PATH, CONFIG_FILE_PATH, CONFIG_MISSING, } from "./config.js";
|
|
8
|
-
import { ensureNoOtherDaemonFromPidFile, pidAlive, readPid, removePidFile, stopDaemonFromPidFileForRestart, writeCurrentPid, } from "./daemon-singleton.js";
|
|
8
|
+
import { ensureNoOtherDaemonFromPidFile, findOtherDaemonProcesses, pidAlive, readPid, removePidFile, stopDaemonFromPidFileForRestart, stopOtherDaemonProcessesForRestart, writeCurrentPid, } from "./daemon-singleton.js";
|
|
9
9
|
import { resolveBootAgents } from "./agent-discovery.js";
|
|
10
10
|
import { defaultTranscriptRoot, resolveTranscriptEnabled, transcriptAgentRoot, transcriptFilePath, } from "./gateway/index.js";
|
|
11
11
|
import { startDaemon } from "./daemon.js";
|
|
@@ -469,6 +469,7 @@ async function cmdStart(args) {
|
|
|
469
469
|
if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
|
|
470
470
|
await ensureUserAuthForStart(args);
|
|
471
471
|
await stopDaemonFromPidFileForRestart({ logger: log });
|
|
472
|
+
await stopOtherDaemonProcessesForRestart({ logger: log });
|
|
472
473
|
}
|
|
473
474
|
else {
|
|
474
475
|
const existing = ensureNoOtherDaemonFromPidFile();
|
|
@@ -487,7 +488,7 @@ async function cmdStart(args) {
|
|
|
487
488
|
env: { ...process.env, BOTCORD_DAEMON_CHILD: "1" },
|
|
488
489
|
});
|
|
489
490
|
child.unref();
|
|
490
|
-
const deadline = Date.now() +
|
|
491
|
+
const deadline = Date.now() + 5_000;
|
|
491
492
|
let observed = null;
|
|
492
493
|
while (Date.now() < deadline) {
|
|
493
494
|
const p = readPid();
|
|
@@ -498,7 +499,7 @@ async function cmdStart(args) {
|
|
|
498
499
|
await new Promise((r) => setTimeout(r, 50));
|
|
499
500
|
}
|
|
500
501
|
if (!observed) {
|
|
501
|
-
console.error(`daemon did not record pid within
|
|
502
|
+
console.error(`daemon did not record pid within 5000ms (expected child pid ${child.pid})`);
|
|
502
503
|
process.exit(1);
|
|
503
504
|
}
|
|
504
505
|
console.log(`daemon started (pid ${observed})`);
|
|
@@ -539,6 +540,7 @@ async function cmdStartCloud(_args) {
|
|
|
539
540
|
hubUrl: cloudConfig.hubUrl,
|
|
540
541
|
});
|
|
541
542
|
await stopDaemonFromPidFileForRestart({ logger: log });
|
|
543
|
+
await stopOtherDaemonProcessesForRestart({ logger: log });
|
|
542
544
|
writeCurrentPid();
|
|
543
545
|
// Cloud daemons always start with an empty in-memory config — every
|
|
544
546
|
// agent + route arrives over the control plane. We synthesize the
|
|
@@ -628,6 +630,7 @@ async function cmdStatus(args) {
|
|
|
628
630
|
const file = readSnapshotFile();
|
|
629
631
|
const now = Date.now();
|
|
630
632
|
const snapshotAgeMs = file ? now - file.writtenAt : null;
|
|
633
|
+
const daemonProcesses = findOtherDaemonProcesses().filter((p) => p.pid !== pid);
|
|
631
634
|
if (args.flags.json === true) {
|
|
632
635
|
const payload = {
|
|
633
636
|
pid,
|
|
@@ -652,6 +655,7 @@ async function cmdStatus(args) {
|
|
|
652
655
|
snapshotWrittenAt: file?.writtenAt ?? null,
|
|
653
656
|
snapshotAgeMs,
|
|
654
657
|
snapshotPath: SNAPSHOT_PATH,
|
|
658
|
+
daemonProcesses,
|
|
655
659
|
};
|
|
656
660
|
console.log(JSON.stringify(payload, null, 2));
|
|
657
661
|
return;
|
|
@@ -664,6 +668,7 @@ async function cmdStatus(args) {
|
|
|
664
668
|
configPath,
|
|
665
669
|
snapshot: file?.snapshot ?? null,
|
|
666
670
|
snapshotAgeMs,
|
|
671
|
+
daemonProcesses,
|
|
667
672
|
};
|
|
668
673
|
console.log(renderStatus(input, now));
|
|
669
674
|
if (userAuth) {
|
package/dist/provision.d.ts
CHANGED
|
@@ -182,10 +182,14 @@ interface HelloIdentityResult {
|
|
|
182
182
|
updated: number;
|
|
183
183
|
skipped: number;
|
|
184
184
|
}
|
|
185
|
+
interface RuntimeSnapshotCtx {
|
|
186
|
+
gateway?: Gateway;
|
|
187
|
+
}
|
|
185
188
|
/**
|
|
186
189
|
* Reconcile every agent identity carried by the `hello.agents` snapshot
|
|
187
|
-
* against the on-disk `identity.md
|
|
188
|
-
* file-system error for one agent never
|
|
190
|
+
* against the on-disk `identity.md` and credentials runtime selectors.
|
|
191
|
+
* Best-effort: a malformed entry or a file-system error for one agent never
|
|
192
|
+
* aborts the rest.
|
|
189
193
|
*
|
|
190
194
|
* Identity-snapshot semantics intentionally only touch the metadata
|
|
191
195
|
* line + Bio body — Role/Boundaries paragraphs the user authored locally
|
|
@@ -193,7 +197,7 @@ interface HelloIdentityResult {
|
|
|
193
197
|
* (agent provisioned on a different daemon, or workspace cleared) are
|
|
194
198
|
* silently skipped.
|
|
195
199
|
*/
|
|
196
|
-
export declare function applyHelloIdentitySnapshot(snapshot: AgentIdentitySnapshot[] | undefined): HelloIdentityResult;
|
|
200
|
+
export declare function applyHelloIdentitySnapshot(snapshot: AgentIdentitySnapshot[] | undefined, ctx?: RuntimeSnapshotCtx): HelloIdentityResult;
|
|
197
201
|
interface ReloadResult {
|
|
198
202
|
reloaded: true;
|
|
199
203
|
added: string[];
|
package/dist/provision.js
CHANGED
|
@@ -20,6 +20,7 @@ import { discoverAgentCredentials } from "./agent-discovery.js";
|
|
|
20
20
|
import { resolveMemoryDir } from "./working-memory.js";
|
|
21
21
|
import { discoverRuntimeModelCatalog } from "./runtime-models.js";
|
|
22
22
|
import { buildRuntimeSelectionExtraArgs, mergeRuntimeExtraArgs, } from "./runtime-route-options.js";
|
|
23
|
+
import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
|
|
23
24
|
/**
|
|
24
25
|
* Build a dispatcher function that routes a `ControlFrame` to the right
|
|
25
26
|
* handler. Returned function signature matches
|
|
@@ -41,7 +42,7 @@ export function createProvisioner(opts) {
|
|
|
41
42
|
return { ok: true, result: { pong: true, ts: Date.now() } };
|
|
42
43
|
case CONTROL_FRAME_TYPES.HELLO: {
|
|
43
44
|
const params = (frame.params ?? {});
|
|
44
|
-
const result = applyHelloIdentitySnapshot(params.agents);
|
|
45
|
+
const result = applyHelloIdentitySnapshot(params.agents, { gateway });
|
|
45
46
|
daemonLog.debug("hello: identity snapshot applied", {
|
|
46
47
|
frameId: frame.id,
|
|
47
48
|
received: params.agents?.length ?? 0,
|
|
@@ -62,12 +63,19 @@ export function createProvisioner(opts) {
|
|
|
62
63
|
displayName: params.displayName,
|
|
63
64
|
bio: params.bio,
|
|
64
65
|
});
|
|
66
|
+
const runtimeResult = applyAgentRuntimeSnapshot(params, { gateway });
|
|
67
|
+
const combined = {
|
|
68
|
+
changed: result.changed || runtimeResult.changed,
|
|
69
|
+
identity: result,
|
|
70
|
+
runtime: runtimeResult,
|
|
71
|
+
};
|
|
65
72
|
daemonLog.info("update_agent applied", {
|
|
66
73
|
agentId: params.agentId,
|
|
67
|
-
changed:
|
|
68
|
-
|
|
74
|
+
changed: combined.changed,
|
|
75
|
+
identitySkipped: result.skipped ?? null,
|
|
76
|
+
runtimeSkipped: runtimeResult.skipped ?? null,
|
|
69
77
|
});
|
|
70
|
-
return { ok: true, result };
|
|
78
|
+
return { ok: true, result: combined };
|
|
71
79
|
}
|
|
72
80
|
case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
|
|
73
81
|
const params = (frame.params ?? {});
|
|
@@ -266,6 +274,30 @@ export function createProvisioner(opts) {
|
|
|
266
274
|
return v.ack;
|
|
267
275
|
return gatewayControl.handleSend(v.params);
|
|
268
276
|
}
|
|
277
|
+
case "cloud_gateway_runtime_inbound": {
|
|
278
|
+
const params = (frame.params ?? {});
|
|
279
|
+
const runtimeFrame = params.frame;
|
|
280
|
+
if (!runtimeFrame || typeof runtimeFrame !== "object") {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
error: {
|
|
284
|
+
code: "bad_params",
|
|
285
|
+
message: "cloud_gateway_runtime_inbound requires params.frame",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame);
|
|
290
|
+
return result.accepted
|
|
291
|
+
? { ok: true, result }
|
|
292
|
+
: {
|
|
293
|
+
ok: false,
|
|
294
|
+
result,
|
|
295
|
+
error: result.error ?? {
|
|
296
|
+
code: "runtime_inbound_rejected",
|
|
297
|
+
message: "cloud gateway runtime inbound was rejected",
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
269
301
|
case "list_agent_files": {
|
|
270
302
|
const params = (frame.params ?? {});
|
|
271
303
|
if (!params.agentId) {
|
|
@@ -1998,10 +2030,84 @@ function openclawBindingIndex() {
|
|
|
1998
2030
|
}
|
|
1999
2031
|
return out;
|
|
2000
2032
|
}
|
|
2033
|
+
function hasOwnField(obj, key) {
|
|
2034
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
2035
|
+
}
|
|
2036
|
+
function cleanNullableString(value) {
|
|
2037
|
+
if (typeof value !== "string")
|
|
2038
|
+
return undefined;
|
|
2039
|
+
const trimmed = value.trim();
|
|
2040
|
+
return trimmed || undefined;
|
|
2041
|
+
}
|
|
2042
|
+
function applyAgentRuntimeSnapshot(snapshot, ctx = {}) {
|
|
2043
|
+
const hasRuntimeFields = (hasOwnField(snapshot, "runtime") ||
|
|
2044
|
+
hasOwnField(snapshot, "runtimeModel") ||
|
|
2045
|
+
hasOwnField(snapshot, "reasoningEffort") ||
|
|
2046
|
+
hasOwnField(snapshot, "thinking"));
|
|
2047
|
+
if (!hasRuntimeFields)
|
|
2048
|
+
return { changed: false, skipped: "no_runtime_fields" };
|
|
2049
|
+
const credentialsFile = defaultCredentialsFile(snapshot.agentId);
|
|
2050
|
+
if (!existsSync(credentialsFile)) {
|
|
2051
|
+
return { changed: false, skipped: "credentials_missing" };
|
|
2052
|
+
}
|
|
2053
|
+
const credentials = loadStoredCredentials(credentialsFile);
|
|
2054
|
+
let changed = false;
|
|
2055
|
+
if (hasOwnField(snapshot, "runtime")) {
|
|
2056
|
+
const runtime = cleanNullableString(snapshot.runtime);
|
|
2057
|
+
if (runtime !== credentials.runtime) {
|
|
2058
|
+
if (runtime)
|
|
2059
|
+
credentials.runtime = runtime;
|
|
2060
|
+
else
|
|
2061
|
+
delete credentials.runtime;
|
|
2062
|
+
changed = true;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
if (hasOwnField(snapshot, "runtimeModel")) {
|
|
2066
|
+
const runtimeModel = cleanNullableString(snapshot.runtimeModel);
|
|
2067
|
+
if (runtimeModel !== credentials.runtimeModel) {
|
|
2068
|
+
if (runtimeModel)
|
|
2069
|
+
credentials.runtimeModel = runtimeModel;
|
|
2070
|
+
else
|
|
2071
|
+
delete credentials.runtimeModel;
|
|
2072
|
+
changed = true;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
if (hasOwnField(snapshot, "reasoningEffort")) {
|
|
2076
|
+
const reasoningEffort = cleanNullableString(snapshot.reasoningEffort);
|
|
2077
|
+
if (reasoningEffort !== credentials.reasoningEffort) {
|
|
2078
|
+
if (reasoningEffort)
|
|
2079
|
+
credentials.reasoningEffort = reasoningEffort;
|
|
2080
|
+
else
|
|
2081
|
+
delete credentials.reasoningEffort;
|
|
2082
|
+
changed = true;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
if (hasOwnField(snapshot, "thinking")) {
|
|
2086
|
+
if (typeof snapshot.thinking === "boolean") {
|
|
2087
|
+
if (credentials.thinking !== snapshot.thinking) {
|
|
2088
|
+
credentials.thinking = snapshot.thinking;
|
|
2089
|
+
changed = true;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
else if (typeof credentials.thinking === "boolean") {
|
|
2093
|
+
delete credentials.thinking;
|
|
2094
|
+
changed = true;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
if (!changed)
|
|
2098
|
+
return { changed: false };
|
|
2099
|
+
writeCredentialsFile(credentialsFile, credentials);
|
|
2100
|
+
if (ctx.gateway) {
|
|
2101
|
+
upsertManagedRouteForCredentials(credentials, loadConfig(), ctx.gateway);
|
|
2102
|
+
return { changed: true, routeUpdated: true };
|
|
2103
|
+
}
|
|
2104
|
+
return { changed: true };
|
|
2105
|
+
}
|
|
2001
2106
|
/**
|
|
2002
2107
|
* Reconcile every agent identity carried by the `hello.agents` snapshot
|
|
2003
|
-
* against the on-disk `identity.md
|
|
2004
|
-
* file-system error for one agent never
|
|
2108
|
+
* against the on-disk `identity.md` and credentials runtime selectors.
|
|
2109
|
+
* Best-effort: a malformed entry or a file-system error for one agent never
|
|
2110
|
+
* aborts the rest.
|
|
2005
2111
|
*
|
|
2006
2112
|
* Identity-snapshot semantics intentionally only touch the metadata
|
|
2007
2113
|
* line + Bio body — Role/Boundaries paragraphs the user authored locally
|
|
@@ -2009,7 +2115,7 @@ function openclawBindingIndex() {
|
|
|
2009
2115
|
* (agent provisioned on a different daemon, or workspace cleared) are
|
|
2010
2116
|
* silently skipped.
|
|
2011
2117
|
*/
|
|
2012
|
-
export function applyHelloIdentitySnapshot(snapshot) {
|
|
2118
|
+
export function applyHelloIdentitySnapshot(snapshot, ctx = {}) {
|
|
2013
2119
|
const out = { updated: 0, skipped: 0 };
|
|
2014
2120
|
if (!Array.isArray(snapshot))
|
|
2015
2121
|
return out;
|
|
@@ -2023,7 +2129,8 @@ export function applyHelloIdentitySnapshot(snapshot) {
|
|
|
2023
2129
|
displayName: entry.displayName,
|
|
2024
2130
|
bio: entry.bio,
|
|
2025
2131
|
});
|
|
2026
|
-
|
|
2132
|
+
const runtimeResult = applyAgentRuntimeSnapshot(entry, ctx);
|
|
2133
|
+
if (result.changed || runtimeResult.changed)
|
|
2027
2134
|
out.updated += 1;
|
|
2028
2135
|
else
|
|
2029
2136
|
out.skipped += 1;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
2
|
+
export interface RecentRoomMessagesRecoveryOptions {
|
|
3
|
+
credentialPathByAgentId: Map<string, string>;
|
|
4
|
+
defaultCredentialsPath?: string;
|
|
5
|
+
hubBaseUrl?: string;
|
|
6
|
+
limit?: number;
|
|
7
|
+
log?: {
|
|
8
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare function createRecentRoomMessagesRecoveryBuilder(opts: RecentRoomMessagesRecoveryOptions): (message: GatewayInboundMessage) => Promise<string | null>;
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
const DEFAULT_RECENT_LIMIT = 20;
|
|
9
|
+
const MAX_MESSAGE_TEXT_CHARS = 1200;
|
|
10
|
+
function stripNewlines(s) {
|
|
11
|
+
return s.replace(/[\r\n]+/g, " ");
|
|
12
|
+
}
|
|
13
|
+
function messageLabel(m) {
|
|
14
|
+
const name = typeof m.from_name === "string" && m.from_name.trim()
|
|
15
|
+
? m.from_name
|
|
16
|
+
: typeof m.from === "string" && m.from.trim()
|
|
17
|
+
? m.from
|
|
18
|
+
: "unknown";
|
|
19
|
+
return sanitizeUntrustedContent(stripNewlines(name));
|
|
20
|
+
}
|
|
21
|
+
function formatRecentMessages(messages) {
|
|
22
|
+
if (messages.length === 0)
|
|
23
|
+
return "[Recent Room Messages]\n(none)";
|
|
24
|
+
const chronological = [...messages].reverse();
|
|
25
|
+
const lines = ["[Recent Room Messages]"];
|
|
26
|
+
for (const m of chronological) {
|
|
27
|
+
const text = typeof m.text === "string" ? m.text.trim() : "";
|
|
28
|
+
if (!text)
|
|
29
|
+
continue;
|
|
30
|
+
const ts = typeof m.ts === "string" ? m.ts : "";
|
|
31
|
+
const topic = typeof m.topic_title === "string" && m.topic_title.trim()
|
|
32
|
+
? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_title))}`
|
|
33
|
+
: typeof m.topic_id === "string" && m.topic_id
|
|
34
|
+
? ` topic=${sanitizeUntrustedContent(stripNewlines(m.topic_id))}`
|
|
35
|
+
: "";
|
|
36
|
+
const safeText = sanitizeUntrustedContent(text.length > MAX_MESSAGE_TEXT_CHARS
|
|
37
|
+
? `${text.slice(0, MAX_MESSAGE_TEXT_CHARS)}...`
|
|
38
|
+
: text);
|
|
39
|
+
lines.push(`- ${ts ? `${ts} ` : ""}${messageLabel(m)}${topic}: ${safeText}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
export function createRecentRoomMessagesRecoveryBuilder(opts) {
|
|
44
|
+
const clients = new Map();
|
|
45
|
+
const limit = opts.limit ?? DEFAULT_RECENT_LIMIT;
|
|
46
|
+
function getClient(accountId) {
|
|
47
|
+
const existing = clients.get(accountId);
|
|
48
|
+
if (existing)
|
|
49
|
+
return existing.client;
|
|
50
|
+
const credsPath = opts.credentialPathByAgentId.get(accountId) ?? opts.defaultCredentialsPath;
|
|
51
|
+
if (!credsPath) {
|
|
52
|
+
opts.log?.warn("daemon.recovery-context.no-credentials", { accountId });
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const creds = loadStoredCredentials(credsPath);
|
|
57
|
+
const client = new BotCordClient({
|
|
58
|
+
hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
|
|
59
|
+
agentId: creds.agentId,
|
|
60
|
+
keyId: creds.keyId,
|
|
61
|
+
privateKey: creds.privateKey,
|
|
62
|
+
...(creds.token ? { token: creds.token } : {}),
|
|
63
|
+
...(creds.tokenExpiresAt !== undefined
|
|
64
|
+
? { tokenExpiresAt: creds.tokenExpiresAt }
|
|
65
|
+
: {}),
|
|
66
|
+
});
|
|
67
|
+
clients.set(accountId, { client, credentialsPath: credsPath });
|
|
68
|
+
return client;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
opts.log?.warn("daemon.recovery-context.client-init-failed", {
|
|
72
|
+
accountId,
|
|
73
|
+
credsPath,
|
|
74
|
+
error: err instanceof Error ? err.message : String(err),
|
|
75
|
+
});
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return async (message) => {
|
|
80
|
+
const client = getClient(message.accountId);
|
|
81
|
+
if (!client)
|
|
82
|
+
return null;
|
|
83
|
+
try {
|
|
84
|
+
const body = await client.roomMessages(message.conversation.id, { limit });
|
|
85
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
86
|
+
return formatRecentMessages(messages);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
opts.log?.warn("daemon.recovery-context.fetch-failed", {
|
|
90
|
+
accountId: message.accountId,
|
|
91
|
+
roomId: message.conversation.id,
|
|
92
|
+
error: err instanceof Error ? err.message : String(err),
|
|
93
|
+
});
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/status-render.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ export declare const STALE_THRESHOLD_MS = 30000;
|
|
|
5
5
|
export interface StatusRenderInput {
|
|
6
6
|
pid: number | null;
|
|
7
7
|
alive: boolean;
|
|
8
|
+
daemonProcesses?: Array<{
|
|
9
|
+
pid: number;
|
|
10
|
+
command?: string;
|
|
11
|
+
}> | null;
|
|
8
12
|
/**
|
|
9
13
|
* Effective list of agent ids the daemon is bound to. Single-agent installs
|
|
10
14
|
* show one entry; multi-agent configs show all. `agentId` (scalar) is kept
|
package/dist/status-render.js
CHANGED
|
@@ -71,10 +71,23 @@ function renderRuntimeCircuitBreakers(snap, now) {
|
|
|
71
71
|
export function renderStatus(input, now = Date.now()) {
|
|
72
72
|
const lines = [];
|
|
73
73
|
if (input.pid === null) {
|
|
74
|
-
|
|
74
|
+
const extras = input.daemonProcesses ?? [];
|
|
75
|
+
if (extras.length > 0) {
|
|
76
|
+
lines.push("daemon: no pid file");
|
|
77
|
+
lines.push(`warning: ${extras.length} daemon process${extras.length === 1 ? "" : "es"} detected without pid file`);
|
|
78
|
+
lines.push(`pids: ${extras.map((p) => p.pid).join(", ")}`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push("daemon: stopped");
|
|
82
|
+
}
|
|
75
83
|
return lines.join("\n");
|
|
76
84
|
}
|
|
77
85
|
lines.push(`daemon: pid ${input.pid} (${input.alive ? "alive" : "not alive"})`);
|
|
86
|
+
const extras = (input.daemonProcesses ?? []).filter((p) => p.pid !== input.pid);
|
|
87
|
+
if (extras.length > 0) {
|
|
88
|
+
lines.push(`warning: ${extras.length} additional daemon process${extras.length === 1 ? "" : "es"} detected`);
|
|
89
|
+
lines.push(`extra pids: ${extras.map((p) => p.pid).join(", ")}`);
|
|
90
|
+
}
|
|
78
91
|
const agents = input.agents && input.agents.length > 0
|
|
79
92
|
? input.agents
|
|
80
93
|
: input.agentId
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botcord/daemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.80",
|
|
4
4
|
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@botcord/cli": "^0.1.7",
|
|
31
|
-
"@botcord/protocol-core": "^0.2.
|
|
31
|
+
"@botcord/protocol-core": "^0.2.10",
|
|
32
32
|
"@larksuiteoapi/node-sdk": "^1.63.1",
|
|
33
33
|
"ws": "^8.20.1"
|
|
34
34
|
},
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { generateKeypair } from "@botcord/protocol-core";
|
|
6
|
+
import { createAttentionPolicyFetcher } from "../attention-policy-fetcher.js";
|
|
7
|
+
|
|
8
|
+
function writeCredentials(agentId: string): string {
|
|
9
|
+
const dir = mkdtempSync(path.join(tmpdir(), "botcord-policy-"));
|
|
10
|
+
const file = path.join(dir, `${agentId}.json`);
|
|
11
|
+
const keys = generateKeypair();
|
|
12
|
+
writeFileSync(
|
|
13
|
+
file,
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
version: 1,
|
|
16
|
+
hubUrl: "https://hub.test",
|
|
17
|
+
agentId,
|
|
18
|
+
keyId: "key_1",
|
|
19
|
+
privateKey: keys.privateKey,
|
|
20
|
+
publicKey: keys.publicKey,
|
|
21
|
+
savedAt: new Date().toISOString(),
|
|
22
|
+
token: "jwt",
|
|
23
|
+
tokenExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("createAttentionPolicyFetcher", () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("fetches effective room attention policy with agent credentials", async () => {
|
|
35
|
+
const credentialsPath = writeCredentials("ag_policy");
|
|
36
|
+
const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
37
|
+
expect(String(url)).toBe(
|
|
38
|
+
"https://hub.test/hub/attention-policy?room_id=rm_1",
|
|
39
|
+
);
|
|
40
|
+
expect((init?.headers as Record<string, string>).Authorization).toBe(
|
|
41
|
+
"Bearer jwt",
|
|
42
|
+
);
|
|
43
|
+
return new Response(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
mode: "mention_only",
|
|
46
|
+
keywords: [],
|
|
47
|
+
allowedSenderIds: [],
|
|
48
|
+
}),
|
|
49
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
53
|
+
|
|
54
|
+
const fetchPolicy = createAttentionPolicyFetcher({
|
|
55
|
+
credentialPathByAgentId: new Map([["ag_policy", credentialsPath]]),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
fetchPolicy({ agentId: "ag_policy", roomId: "rm_1" }),
|
|
60
|
+
).resolves.toEqual({
|
|
61
|
+
mode: "mention_only",
|
|
62
|
+
keywords: [],
|
|
63
|
+
allowedSenderIds: [],
|
|
64
|
+
});
|
|
65
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
});
|