@botcord/daemon 0.2.4 → 0.2.6
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/agent-discovery.d.ts +7 -3
- package/dist/agent-discovery.js +9 -1
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -10
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/control-channel.d.ts +1 -4
- package/dist/control-channel.js +1 -4
- package/dist/daemon-config-map.d.ts +29 -12
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +52 -5
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +66 -1
- package/dist/gateway/dispatcher.js +583 -56
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +286 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/provision.d.ts +73 -3
- package/dist/provision.js +373 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/turn-text.js +20 -1
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/dist/user-auth.js +0 -2
- package/dist/working-memory.js +1 -1
- package/package.json +2 -1
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +160 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +12 -4
- package/src/agent-workspace.ts +173 -9
- package/src/config.ts +132 -4
- package/src/control-channel.ts +1 -4
- package/src/daemon-config-map.ts +156 -12
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +440 -2
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +681 -58
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +295 -30
- package/src/mention-scan.ts +38 -0
- package/src/provision.ts +446 -20
- package/src/system-context.ts +41 -9
- package/src/turn-text.ts +22 -1
- package/src/url-utils.ts +17 -0
- package/src/user-auth.ts +0 -2
- package/src/working-memory.ts +1 -1
|
@@ -1,6 +1,69 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { resolveAgentIds } from "./config.js";
|
|
2
5
|
import { agentWorkspaceDir } from "./agent-workspace.js";
|
|
3
6
|
import { log as daemonLog } from "./log.js";
|
|
7
|
+
function expandHome(p) {
|
|
8
|
+
if (p === "~")
|
|
9
|
+
return homedir();
|
|
10
|
+
if (p.startsWith("~/"))
|
|
11
|
+
return path.join(homedir(), p.slice(2));
|
|
12
|
+
return p;
|
|
13
|
+
}
|
|
14
|
+
/** Resolve one profile's token (inline > tokenFile). Failures are swallowed
|
|
15
|
+
* into `tokenError`; `resolvedToken` is left undefined. Logs at warn for ops
|
|
16
|
+
* visibility. */
|
|
17
|
+
export function prepareGatewayProfile(p) {
|
|
18
|
+
const prepared = { ...p };
|
|
19
|
+
if (p.token && p.token.length > 0) {
|
|
20
|
+
prepared.resolvedToken = p.token;
|
|
21
|
+
}
|
|
22
|
+
else if (p.tokenFile && p.tokenFile.length > 0) {
|
|
23
|
+
try {
|
|
24
|
+
prepared.resolvedToken = readFileSync(expandHome(p.tokenFile), "utf8").trim();
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
prepared.tokenError = err?.message ?? String(err);
|
|
28
|
+
daemonLog.warn("daemon.config.openclaw.tokenfile_failed", {
|
|
29
|
+
gateway: p.name,
|
|
30
|
+
tokenFile: p.tokenFile,
|
|
31
|
+
error: prepared.tokenError,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return prepared;
|
|
36
|
+
}
|
|
37
|
+
/** Build a name → prepared-profile map for a config's gateway registry. */
|
|
38
|
+
export function prepareGatewayProfiles(profiles) {
|
|
39
|
+
const out = new Map();
|
|
40
|
+
if (!profiles)
|
|
41
|
+
return out;
|
|
42
|
+
for (const p of profiles)
|
|
43
|
+
out.set(p.name, prepareGatewayProfile(p));
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function resolveGateway(profiles, gatewayName, agentOverride, where) {
|
|
47
|
+
if (!gatewayName) {
|
|
48
|
+
daemonLog.warn("daemon.config.openclaw.missing_gateway", { where });
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const profile = profiles.get(gatewayName);
|
|
52
|
+
if (!profile) {
|
|
53
|
+
daemonLog.warn("daemon.config.openclaw.unknown_gateway", { where, gateway: gatewayName });
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const resolved = {
|
|
57
|
+
name: profile.name,
|
|
58
|
+
url: profile.url,
|
|
59
|
+
};
|
|
60
|
+
if (profile.resolvedToken)
|
|
61
|
+
resolved.token = profile.resolvedToken;
|
|
62
|
+
const agent = agentOverride ?? profile.defaultAgent;
|
|
63
|
+
if (agent)
|
|
64
|
+
resolved.openclawAgent = agent;
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
4
67
|
/**
|
|
5
68
|
* Historical channel id used when the daemon bound a single agent. Kept as a
|
|
6
69
|
* named export for any downstream reader that still references it; no new
|
|
@@ -29,7 +92,7 @@ function mapTrustLevel(level) {
|
|
|
29
92
|
* legacy alias and its canonical field are present, the canonical field
|
|
30
93
|
* wins and a warning is logged.
|
|
31
94
|
*/
|
|
32
|
-
function mapRoute(r) {
|
|
95
|
+
function mapRoute(r, profiles, index) {
|
|
33
96
|
const match = {};
|
|
34
97
|
if (r.match.channel)
|
|
35
98
|
match.channel = r.match.channel;
|
|
@@ -66,13 +129,17 @@ function mapRoute(r) {
|
|
|
66
129
|
if (typeof r.match.mentioned === "boolean")
|
|
67
130
|
match.mentioned = r.match.mentioned;
|
|
68
131
|
const rawTrust = r.trustLevel;
|
|
69
|
-
|
|
132
|
+
const out = {
|
|
70
133
|
match,
|
|
71
134
|
runtime: r.adapter,
|
|
72
135
|
cwd: r.cwd,
|
|
73
136
|
extraArgs: r.extraArgs,
|
|
74
137
|
trustLevel: mapTrustLevel(rawTrust),
|
|
75
138
|
};
|
|
139
|
+
if (r.adapter === "openclaw-acp") {
|
|
140
|
+
out.gateway = resolveGateway(profiles, r.gateway, r.openclawAgent, `routes[${index}]`);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
76
143
|
}
|
|
77
144
|
/**
|
|
78
145
|
* Convert the daemon's on-disk config into a gateway runtime config. Only
|
|
@@ -100,6 +167,7 @@ export function toGatewayConfig(cfg, opts = {}) {
|
|
|
100
167
|
}));
|
|
101
168
|
// DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
|
|
102
169
|
// defensively so future config extensions can propagate without a shape bump.
|
|
170
|
+
const profiles = prepareGatewayProfiles(cfg.openclawGateways);
|
|
103
171
|
const rawDefaultTrust = cfg.defaultRoute
|
|
104
172
|
.trustLevel;
|
|
105
173
|
const defaultRoute = {
|
|
@@ -110,14 +178,18 @@ export function toGatewayConfig(cfg, opts = {}) {
|
|
|
110
178
|
// (direct → cancel-previous, group → serial).
|
|
111
179
|
trustLevel: mapTrustLevel(rawDefaultTrust),
|
|
112
180
|
};
|
|
113
|
-
|
|
181
|
+
if (cfg.defaultRoute.adapter === "openclaw-acp") {
|
|
182
|
+
const dr = cfg.defaultRoute;
|
|
183
|
+
defaultRoute.gateway = resolveGateway(profiles, dr.gateway, dr.openclawAgent, "defaultRoute");
|
|
184
|
+
}
|
|
185
|
+
const routes = (cfg.routes ?? []).map((r, i) => mapRoute(r, profiles, i));
|
|
114
186
|
// Synthesize a per-agent route for every bound agent and hand it to the
|
|
115
187
|
// gateway via the managed-routes bucket (plan §10.1). User-authored
|
|
116
188
|
// `cfg.routes[]` stay untouched. Match priority (see router.ts):
|
|
117
189
|
// `routes[] with explicit accountId → managedRoutes → other routes[] →
|
|
118
190
|
// defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
|
|
119
191
|
// chosen runtime — only routes that name the agent by `accountId` do.
|
|
120
|
-
const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute);
|
|
192
|
+
const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute, profiles);
|
|
121
193
|
return {
|
|
122
194
|
channels,
|
|
123
195
|
defaultRoute,
|
|
@@ -140,15 +212,40 @@ export function toGatewayConfig(cfg, opts = {}) {
|
|
|
140
212
|
* Exported so `reload_config` and `provisionAgent` hot-add can share the
|
|
141
213
|
* same synthesis logic (plan §10.5).
|
|
142
214
|
*/
|
|
143
|
-
export function buildManagedRoutes(agentIds, agentRuntimes, defaultRoute) {
|
|
215
|
+
export function buildManagedRoutes(agentIds, agentRuntimes, defaultRoute, openclawProfiles) {
|
|
144
216
|
const out = new Map();
|
|
217
|
+
// Lazy-build profile map when caller didn't pass one (legacy callers).
|
|
218
|
+
const profiles = openclawProfiles ?? new Map();
|
|
145
219
|
for (const agentId of agentIds) {
|
|
146
220
|
const meta = agentRuntimes[agentId] ?? {};
|
|
147
|
-
|
|
221
|
+
const runtime = meta.runtime ?? defaultRoute.runtime;
|
|
222
|
+
const route = {
|
|
148
223
|
match: { accountId: agentId },
|
|
149
|
-
runtime
|
|
224
|
+
runtime,
|
|
150
225
|
cwd: meta.cwd || agentWorkspaceDir(agentId),
|
|
151
|
-
|
|
226
|
+
// Inherit defaultRoute's extraArgs so synthesized per-agent routes
|
|
227
|
+
// pick up operator-wide flags (e.g. `--permission-mode bypassPermissions`)
|
|
228
|
+
// that would otherwise apply only to agents listed in `cfg.routes[]`.
|
|
229
|
+
...(defaultRoute.extraArgs ? { extraArgs: defaultRoute.extraArgs.slice() } : {}),
|
|
230
|
+
};
|
|
231
|
+
if (runtime === "openclaw-acp") {
|
|
232
|
+
// Per RFC §3.4: prefer credentials, fall back to defaultRoute.gateway.
|
|
233
|
+
const gatewayName = meta.openclawGateway ?? defaultRoute.gateway?.name;
|
|
234
|
+
const agentOverride = meta.openclawAgent;
|
|
235
|
+
const resolved = gatewayName
|
|
236
|
+
? resolveGateway(profiles, gatewayName, agentOverride, `managedRoute[${agentId}]`)
|
|
237
|
+
: defaultRoute.gateway;
|
|
238
|
+
if (!resolved) {
|
|
239
|
+
// No usable gateway — skip the managed route so defaultRoute can take over.
|
|
240
|
+
daemonLog.warn("daemon.config.openclaw.managed_route_skipped", {
|
|
241
|
+
agentId,
|
|
242
|
+
gatewayName,
|
|
243
|
+
});
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
route.gateway = resolved;
|
|
247
|
+
}
|
|
248
|
+
out.set(agentId, route);
|
|
152
249
|
}
|
|
153
250
|
return out;
|
|
154
251
|
}
|
package/dist/daemon.d.ts
CHANGED
package/dist/daemon.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { CONTROL_FRAME_TYPES } from "@botcord/protocol-core";
|
|
2
|
-
import { Gateway, createBotCordChannel, sanitizeUntrustedContent, } from "./gateway/index.js";
|
|
1
|
+
import { CONTROL_FRAME_TYPES, shouldWake, } from "@botcord/protocol-core";
|
|
2
|
+
import { Gateway, createBotCordChannel, resolveTranscriptEnabled, sanitizeUntrustedContent, } from "./gateway/index.js";
|
|
3
3
|
import { ActivityTracker } from "./activity-tracker.js";
|
|
4
4
|
import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
|
|
5
5
|
import { resolveBootAgents } from "./agent-discovery.js";
|
|
@@ -15,6 +15,8 @@ import { createRoomContextFetcher } from "./room-context-fetcher.js";
|
|
|
15
15
|
import { buildLoopRiskPrompt, loopRiskSessionKey, recordInboundText as recordLoopRiskInbound, recordOutboundText as recordLoopRiskOutbound, } from "./loop-risk.js";
|
|
16
16
|
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
17
17
|
import { UserAuthManager } from "./user-auth.js";
|
|
18
|
+
import { PolicyResolver } from "./gateway/policy-resolver.js";
|
|
19
|
+
import { scanMention } from "./mention-scan.js";
|
|
18
20
|
/**
|
|
19
21
|
* Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
|
|
20
22
|
* long-running CLI turns behave the same way under the gateway core.
|
|
@@ -124,6 +126,17 @@ export async function startDaemon(opts) {
|
|
|
124
126
|
const agentIds = boot.agents.map((a) => a.agentId);
|
|
125
127
|
const { credentialPathByAgentId, agentRuntimes } = backfillBootAgents(boot.agents, { logger });
|
|
126
128
|
const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
|
|
129
|
+
// Per-agent hub URL — read from each credential file at boot. Used to
|
|
130
|
+
// populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
|
|
131
|
+
// `botcord` CLI talks to the same hub the agent is registered against,
|
|
132
|
+
// even when a single daemon hosts agents from different hubs.
|
|
133
|
+
const hubUrlByAgentId = new Map();
|
|
134
|
+
for (const a of boot.agents) {
|
|
135
|
+
if (a.hubUrl)
|
|
136
|
+
hubUrlByAgentId.set(a.agentId, a.hubUrl);
|
|
137
|
+
}
|
|
138
|
+
const fallbackHubUrl = opts.hubBaseUrl;
|
|
139
|
+
const resolveHubUrl = (accountId) => hubUrlByAgentId.get(accountId) ?? fallbackHubUrl;
|
|
127
140
|
// ActivityTracker lives at the daemon layer (not the gateway core). We
|
|
128
141
|
// expose it to the gateway via (a) the `buildSystemContext` hook so the
|
|
129
142
|
// cross-room digest reflects current activity, and (b) the `onInbound`
|
|
@@ -206,6 +219,35 @@ export async function startDaemon(opts) {
|
|
|
206
219
|
text: out.text,
|
|
207
220
|
});
|
|
208
221
|
};
|
|
222
|
+
// Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
|
|
223
|
+
// the optional `defaultAttention` / `attentionKeywords` carried by
|
|
224
|
+
// `provision_agent`, refreshed in-place by the `policy_updated` control
|
|
225
|
+
// frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
|
|
226
|
+
// leaves it absent so the resolver collapses to per-agent state.
|
|
227
|
+
const policyResolver = new PolicyResolver({
|
|
228
|
+
fetchGlobal: async (_agentId) => undefined,
|
|
229
|
+
});
|
|
230
|
+
// Display-name lookup for the mention text-fallback. Populated from boot
|
|
231
|
+
// credentials; multi-agent daemons can reuse the same map via accountId.
|
|
232
|
+
const displayNameByAgent = new Map();
|
|
233
|
+
for (const a of boot.agents) {
|
|
234
|
+
if (a.displayName)
|
|
235
|
+
displayNameByAgent.set(a.agentId, a.displayName);
|
|
236
|
+
}
|
|
237
|
+
// Attention gate: compose `messages.mentioned` (sender-supplied — distrust)
|
|
238
|
+
// with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
|
|
239
|
+
// effective policy, then defer to the protocol-core `shouldWake` decision.
|
|
240
|
+
const attentionGate = async (msg) => {
|
|
241
|
+
const policy = await policyResolver.resolve(msg.accountId, msg.conversation.id);
|
|
242
|
+
const localMention = scanMention(msg.text, {
|
|
243
|
+
agentId: msg.accountId,
|
|
244
|
+
displayName: displayNameByAgent.get(msg.accountId),
|
|
245
|
+
});
|
|
246
|
+
return shouldWake(policy, {
|
|
247
|
+
mentioned: msg.mentioned === true || localMention,
|
|
248
|
+
text: msg.text,
|
|
249
|
+
});
|
|
250
|
+
};
|
|
209
251
|
const gateway = new Gateway({
|
|
210
252
|
config: gwConfig,
|
|
211
253
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
@@ -225,6 +267,9 @@ export async function startDaemon(opts) {
|
|
|
225
267
|
onInbound,
|
|
226
268
|
onOutbound,
|
|
227
269
|
composeUserTurn: composeBotCordUserTurn,
|
|
270
|
+
attentionGate,
|
|
271
|
+
resolveHubUrl,
|
|
272
|
+
transcriptEnabled: resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, opts.config.transcript?.enabled === true),
|
|
228
273
|
});
|
|
229
274
|
logger.info("daemon starting", {
|
|
230
275
|
agents: agentIds,
|
|
@@ -239,7 +284,7 @@ export async function startDaemon(opts) {
|
|
|
239
284
|
logger.warn("daemon starting with no channels", {
|
|
240
285
|
source: boot.source,
|
|
241
286
|
credentialsDir: boot.credentialsDir,
|
|
242
|
-
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon
|
|
287
|
+
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon start --agent <ag_xxx>` (only seeds config on first run)",
|
|
243
288
|
});
|
|
244
289
|
}
|
|
245
290
|
await gateway.start();
|
|
@@ -256,7 +301,7 @@ export async function startDaemon(opts) {
|
|
|
256
301
|
userId: userAuth.current.userId,
|
|
257
302
|
hubUrl: userAuth.current.hubUrl,
|
|
258
303
|
});
|
|
259
|
-
const provisioner = createProvisioner({ gateway });
|
|
304
|
+
const provisioner = createProvisioner({ gateway, policyResolver });
|
|
260
305
|
controlChannel = new ControlChannel({
|
|
261
306
|
auth: userAuth,
|
|
262
307
|
handle: provisioner,
|
|
@@ -329,10 +374,12 @@ export function backfillBootAgents(agents, opts) {
|
|
|
329
374
|
for (const a of agents) {
|
|
330
375
|
if (a.credentialsFile)
|
|
331
376
|
credentialPathByAgentId.set(a.agentId, a.credentialsFile);
|
|
332
|
-
if (a.runtime || a.cwd) {
|
|
377
|
+
if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
|
|
333
378
|
agentRuntimes[a.agentId] = {
|
|
334
379
|
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
335
380
|
...(a.cwd ? { cwd: a.cwd } : {}),
|
|
381
|
+
...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
|
|
382
|
+
...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
|
|
336
383
|
};
|
|
337
384
|
}
|
|
338
385
|
// Seed files are written only when missing (see `ensureAgentWorkspace`),
|
package/dist/doctor.d.ts
CHANGED
|
@@ -25,9 +25,35 @@ export interface DoctorHttpResult {
|
|
|
25
25
|
status?: number;
|
|
26
26
|
error?: string;
|
|
27
27
|
}
|
|
28
|
+
/** One endpoint probe entry, mirrored from `RuntimeEndpointProbe`. */
|
|
29
|
+
export interface DoctorRuntimeEndpoint {
|
|
30
|
+
name: string;
|
|
31
|
+
url: string;
|
|
32
|
+
reachable: boolean;
|
|
33
|
+
version?: string;
|
|
34
|
+
error?: string;
|
|
35
|
+
agents?: Array<{
|
|
36
|
+
id: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
workspace?: string;
|
|
39
|
+
model?: {
|
|
40
|
+
name?: string;
|
|
41
|
+
provider?: string;
|
|
42
|
+
};
|
|
43
|
+
}>;
|
|
44
|
+
/**
|
|
45
|
+
* Optional warning surfaced by the doctor: e.g. botcord plugin loaded on
|
|
46
|
+
* the gateway (would form a daemon → openclaw → botcord → Hub loop).
|
|
47
|
+
*/
|
|
48
|
+
warnings?: string[];
|
|
49
|
+
}
|
|
50
|
+
/** Augmented runtime entry that may carry endpoint probe results. */
|
|
51
|
+
export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
|
|
52
|
+
endpoints?: DoctorRuntimeEndpoint[];
|
|
53
|
+
}
|
|
28
54
|
/** Input for the rendered doctor output. */
|
|
29
55
|
export interface DoctorInput {
|
|
30
|
-
runtimes:
|
|
56
|
+
runtimes: DoctorRuntimeEntry[];
|
|
31
57
|
channels: ChannelProbeResult[];
|
|
32
58
|
}
|
|
33
59
|
/** Per-channel config entry accepted by {@link probeChannel}. */
|
package/dist/doctor.js
CHANGED
|
@@ -152,8 +152,29 @@ export function renderDoctor(input) {
|
|
|
152
152
|
version: Math.max(7, ...rows.map((r) => r.version.length)),
|
|
153
153
|
};
|
|
154
154
|
lines.push(`${pad("RUNTIME", widths.runtime)} ${pad("NAME", widths.name)} ${pad("STATUS", widths.status)} ${pad("VERSION", widths.version)} PATH`);
|
|
155
|
-
for (
|
|
155
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
156
|
+
const r = rows[i];
|
|
157
|
+
const e = input.runtimes[i];
|
|
156
158
|
lines.push(`${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`);
|
|
159
|
+
if (e.endpoints && e.endpoints.length > 0) {
|
|
160
|
+
for (const ep of e.endpoints) {
|
|
161
|
+
const mark = ep.reachable ? "✓" : "✗";
|
|
162
|
+
const detail = ep.reachable
|
|
163
|
+
? ep.version ?? "ok"
|
|
164
|
+
: ep.error ?? "unreachable";
|
|
165
|
+
lines.push(` gateway ${pad(`"${ep.name}"`, 16)} ${pad(ep.url, 40)} ${mark} ${detail}`);
|
|
166
|
+
if (ep.agents && ep.agents.length > 0) {
|
|
167
|
+
// RFC §3.8.4: list by `id` (stable key); show display name when distinct.
|
|
168
|
+
lines.push(` agents (id): ${ep.agents
|
|
169
|
+
.map((a) => (a.name && a.name !== a.id ? `${a.id} (${a.name})` : a.id))
|
|
170
|
+
.join(", ")}`);
|
|
171
|
+
}
|
|
172
|
+
if (ep.warnings) {
|
|
173
|
+
for (const w of ep.warnings)
|
|
174
|
+
lines.push(` WARN: ${w}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
157
178
|
}
|
|
158
179
|
const available = input.runtimes.filter((e) => e.result.available).length;
|
|
159
180
|
lines.push(`\n${available}/${input.runtimes.length} runtimes available`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface BundledCliBin {
|
|
2
|
+
/** Directory containing the `botcord` symlink — safe to prepend to PATH. */
|
|
3
|
+
binDir: string;
|
|
4
|
+
/** Absolute path to the CLI's JS entry — for direct spawn (not via PATH). */
|
|
5
|
+
binPath: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the bundled `@botcord/cli` package and return both the
|
|
9
|
+
* `<install-root>/node_modules/.bin` directory (for PATH injection so
|
|
10
|
+
* `botcord` shows up to runtimes) and the absolute JS entry (for callers
|
|
11
|
+
* that want to spawn the CLI directly without depending on the symlink).
|
|
12
|
+
*
|
|
13
|
+
* Returns `null` when `@botcord/cli` is not installed alongside the daemon
|
|
14
|
+
* — callers should fall back to whatever `botcord` is on the user's PATH.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveBundledCliBin(): BundledCliBin | null;
|
|
17
|
+
/** Test-only: clear the cached resolution. */
|
|
18
|
+
export declare function __resetBundledCliBinCache(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Return env additions that point a runtime CLI subprocess at the right
|
|
21
|
+
* BotCord identity:
|
|
22
|
+
* - `BOTCORD_HUB` — hub URL the agent is registered against
|
|
23
|
+
* - `BOTCORD_AGENT_ID` — default `--agent` for `botcord ...` invocations
|
|
24
|
+
* - `PATH` — prepended with the bundled CLI's `.bin` dir so
|
|
25
|
+
* `botcord` resolves to the version daemon shipped
|
|
26
|
+
* with (avoiding protocol-core drift). Falls
|
|
27
|
+
* through to whatever the user already has on PATH
|
|
28
|
+
* when the bundled CLI can't be resolved.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildCliEnv(opts: {
|
|
31
|
+
hubUrl?: string;
|
|
32
|
+
accountId?: string;
|
|
33
|
+
basePath?: string | undefined;
|
|
34
|
+
}): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { consoleLogger } from "./log.js";
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
// Tri-state cache: `undefined` means "not yet attempted"; `null` means
|
|
7
|
+
// "attempted and unavailable" (don't retry, don't re-log).
|
|
8
|
+
let cached;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the bundled `@botcord/cli` package and return both the
|
|
11
|
+
* `<install-root>/node_modules/.bin` directory (for PATH injection so
|
|
12
|
+
* `botcord` shows up to runtimes) and the absolute JS entry (for callers
|
|
13
|
+
* that want to spawn the CLI directly without depending on the symlink).
|
|
14
|
+
*
|
|
15
|
+
* Returns `null` when `@botcord/cli` is not installed alongside the daemon
|
|
16
|
+
* — callers should fall back to whatever `botcord` is on the user's PATH.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveBundledCliBin() {
|
|
19
|
+
if (cached !== undefined)
|
|
20
|
+
return cached;
|
|
21
|
+
try {
|
|
22
|
+
const pkgJsonPath = require.resolve("@botcord/cli/package.json");
|
|
23
|
+
const pkgRoot = path.dirname(pkgJsonPath);
|
|
24
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
|
|
25
|
+
const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.botcord;
|
|
26
|
+
if (!binRel) {
|
|
27
|
+
consoleLogger.warn("cli-resolver: @botcord/cli has no bin.botcord entry");
|
|
28
|
+
cached = null;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const binPath = path.resolve(pkgRoot, binRel);
|
|
32
|
+
// PATH must point at `<install-root>/node_modules/.bin` (where npm puts
|
|
33
|
+
// the `botcord` shim), not the package's own `dist/` — there is no
|
|
34
|
+
// executable named `botcord` inside the package directory.
|
|
35
|
+
const binDir = path.resolve(pkgRoot, "..", "..", ".bin");
|
|
36
|
+
cached = { binDir, binPath };
|
|
37
|
+
return cached;
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
consoleLogger.warn("cli-resolver: bundled @botcord/cli not resolvable; runtimes will fall back to PATH", { error: err instanceof Error ? err.message : String(err) });
|
|
41
|
+
cached = null;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Test-only: clear the cached resolution. */
|
|
46
|
+
export function __resetBundledCliBinCache() {
|
|
47
|
+
cached = undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Return env additions that point a runtime CLI subprocess at the right
|
|
51
|
+
* BotCord identity:
|
|
52
|
+
* - `BOTCORD_HUB` — hub URL the agent is registered against
|
|
53
|
+
* - `BOTCORD_AGENT_ID` — default `--agent` for `botcord ...` invocations
|
|
54
|
+
* - `PATH` — prepended with the bundled CLI's `.bin` dir so
|
|
55
|
+
* `botcord` resolves to the version daemon shipped
|
|
56
|
+
* with (avoiding protocol-core drift). Falls
|
|
57
|
+
* through to whatever the user already has on PATH
|
|
58
|
+
* when the bundled CLI can't be resolved.
|
|
59
|
+
*/
|
|
60
|
+
export function buildCliEnv(opts) {
|
|
61
|
+
const env = {};
|
|
62
|
+
if (opts.hubUrl)
|
|
63
|
+
env.BOTCORD_HUB = opts.hubUrl;
|
|
64
|
+
if (opts.accountId)
|
|
65
|
+
env.BOTCORD_AGENT_ID = opts.accountId;
|
|
66
|
+
const cli = resolveBundledCliBin();
|
|
67
|
+
if (cli) {
|
|
68
|
+
const existing = opts.basePath ?? "";
|
|
69
|
+
env.PATH = existing
|
|
70
|
+
? `${cli.binDir}${path.delimiter}${existing}`
|
|
71
|
+
: cli.binDir;
|
|
72
|
+
}
|
|
73
|
+
return env;
|
|
74
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { GatewayLogger } from "./log.js";
|
|
2
2
|
import { type SessionStore } from "./session-store.js";
|
|
3
|
-
import
|
|
3
|
+
import { type TranscriptWriter } from "./transcript.js";
|
|
4
|
+
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
4
5
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
5
6
|
export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
|
|
6
7
|
/** Constructor options for `Dispatcher`. */
|
|
@@ -42,6 +43,30 @@ export interface DispatcherOptions {
|
|
|
42
43
|
* and suppressed so observer failures never break the turn.
|
|
43
44
|
*/
|
|
44
45
|
onOutbound?: OutboundObserver;
|
|
46
|
+
/**
|
|
47
|
+
* Optional attention gate (PR3, design §4.2). Resolved AFTER `onInbound`
|
|
48
|
+
* runs and BEFORE the runtime turn enqueues, so working memory / activity
|
|
49
|
+
* tracking still observe the message even when the gate skips the wake.
|
|
50
|
+
*
|
|
51
|
+
* Return `true` to wake the runtime, `false` to skip the turn. Errors are
|
|
52
|
+
* logged and treated as `true` (fail-open) so a buggy gate cannot silence
|
|
53
|
+
* the agent.
|
|
54
|
+
*/
|
|
55
|
+
attentionGate?: (message: GatewayInboundMessage) => Promise<boolean> | boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the hub URL the inbound message's agent is registered against.
|
|
58
|
+
* Threaded into `RuntimeRunOptions.hubUrl` so spawned CLI subprocesses
|
|
59
|
+
* target the correct hub. If unset, runtimes leave `BOTCORD_HUB`
|
|
60
|
+
* unspecified and fall back to whatever the bundled CLI defaults to.
|
|
61
|
+
*/
|
|
62
|
+
resolveHubUrl?: (accountId: string) => string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Optional NDJSON transcript writer. When provided, dispatcher emits one
|
|
65
|
+
* inbound record + one path record + (for dispatched turns) one terminal
|
|
66
|
+
* record per `handle()` call. A noop writer is used by default so existing
|
|
67
|
+
* call sites keep working unchanged. See `docs/transcript-logging.md`.
|
|
68
|
+
*/
|
|
69
|
+
transcript?: TranscriptWriter;
|
|
45
70
|
}
|
|
46
71
|
/**
|
|
47
72
|
* Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
|
|
@@ -65,6 +90,9 @@ export declare class Dispatcher {
|
|
|
65
90
|
private readonly onOutbound?;
|
|
66
91
|
private readonly composeUserTurn?;
|
|
67
92
|
private readonly managedRoutes?;
|
|
93
|
+
private readonly attentionGate?;
|
|
94
|
+
private readonly resolveHubUrl?;
|
|
95
|
+
private readonly transcript;
|
|
68
96
|
private readonly queues;
|
|
69
97
|
constructor(opts: DispatcherOptions);
|
|
70
98
|
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
@@ -74,7 +102,44 @@ export declare class Dispatcher {
|
|
|
74
102
|
private safeAck;
|
|
75
103
|
private getQueue;
|
|
76
104
|
private runCancelPrevious;
|
|
105
|
+
/**
|
|
106
|
+
* Serial mode with coalesce-on-drain semantics:
|
|
107
|
+
*
|
|
108
|
+
* 1. First arrival on an idle queue boots the worker, which dispatches a
|
|
109
|
+
* single-message turn immediately (no batching delay).
|
|
110
|
+
* 2. Arrivals during an in-flight turn append to `serialBuffer`; when the
|
|
111
|
+
* worker finishes the current turn it drains the entire buffer and
|
|
112
|
+
* merges all pending entries into ONE next turn (folded into a single
|
|
113
|
+
* `raw.batch` so the composer renders them as multi-block input).
|
|
114
|
+
* 3. Buffer caps: at most `MAX_BATCH_BUFFER_ENTRIES` entries are retained
|
|
115
|
+
* (drop oldest) and merged turns are further trimmed to fit
|
|
116
|
+
* `MAX_BATCH_BUFFER_CHARS` of total raw text.
|
|
117
|
+
*
|
|
118
|
+
* Note: the pre-composed `text` from `handle()` is intentionally discarded
|
|
119
|
+
* here — at drain time the worker re-invokes `composeUserTurn` on the
|
|
120
|
+
* merged message so the runtime sees a single coherent prompt covering all
|
|
121
|
+
* coalesced messages.
|
|
122
|
+
*/
|
|
77
123
|
private runSerial;
|
|
124
|
+
/**
|
|
125
|
+
* Merge buffered serial entries into a single dispatchable unit. With one
|
|
126
|
+
* entry the call is a near no-op (just recompose). With ≥2 entries this
|
|
127
|
+
* flattens any per-entry `raw.batch` (the BotCord channel already groups
|
|
128
|
+
* one inbox-poll's worth of same-room/topic messages into a `raw.batch`),
|
|
129
|
+
* applies the `MAX_BATCH_BUFFER_CHARS` cap by dropping oldest individual
|
|
130
|
+
* messages, and then synthesizes a merged inbound message anchored on the
|
|
131
|
+
* latest entry's metadata (mentioned = OR across all entries).
|
|
132
|
+
*/
|
|
133
|
+
private mergeSerialBuffer;
|
|
134
|
+
/**
|
|
135
|
+
* Re-run the user-turn composer at drain time. Mirrors the logic in
|
|
136
|
+
* `handle()` but operates on the (possibly merged) message. Falls back to
|
|
137
|
+
* raw trimmed text on composer failure so a buggy composer never drops a
|
|
138
|
+
* turn.
|
|
139
|
+
*/
|
|
140
|
+
private recomposeUserTurn;
|
|
78
141
|
private runTurn;
|
|
79
142
|
private sendReply;
|
|
143
|
+
private emitInbound;
|
|
144
|
+
private emitOutbound;
|
|
80
145
|
}
|