@botcord/daemon 0.2.54 → 0.2.56

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/provision.js CHANGED
@@ -263,6 +263,9 @@ export function createProvisioner(opts) {
263
263
  });
264
264
  return { ok: true, result };
265
265
  }
266
+ case "wake_agent": {
267
+ return handleWakeAgent(gateway, frame.params);
268
+ }
266
269
  default:
267
270
  daemonLog.warn("provision.dispatch: unknown frame type", {
268
271
  type: frame.type,
@@ -275,6 +278,71 @@ export function createProvisioner(opts) {
275
278
  }
276
279
  };
277
280
  }
281
+ async function handleWakeAgent(gateway, raw) {
282
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
283
+ return {
284
+ ok: false,
285
+ error: { code: "bad_params", message: "wake_agent params must be an object" },
286
+ };
287
+ }
288
+ const params = raw;
289
+ const agentId = params.agent_id || params.agentId;
290
+ const message = params.message;
291
+ if (!agentId || typeof agentId !== "string") {
292
+ return {
293
+ ok: false,
294
+ error: { code: "bad_params", message: "wake_agent requires params.agent_id" },
295
+ };
296
+ }
297
+ if (!message || typeof message !== "string") {
298
+ return {
299
+ ok: false,
300
+ error: { code: "bad_params", message: "wake_agent requires params.message" },
301
+ };
302
+ }
303
+ const channels = gateway.snapshot().channels;
304
+ if (!channels[agentId]) {
305
+ return {
306
+ ok: false,
307
+ error: { code: "agent_not_loaded", message: `agent ${agentId} is not loaded in daemon gateway` },
308
+ };
309
+ }
310
+ const runId = params.run_id || params.runId || `wake-${Date.now()}`;
311
+ const scheduleId = params.schedule_id || params.scheduleId;
312
+ const dedupeKey = params.dedupe_key || params.dedupeKey;
313
+ const conversationId = `rm_schedule_${agentId}`;
314
+ const msg = {
315
+ id: runId,
316
+ channel: agentId,
317
+ accountId: agentId,
318
+ conversation: {
319
+ id: conversationId,
320
+ kind: "direct",
321
+ title: "BotCord Scheduler",
322
+ threadId: scheduleId ?? null,
323
+ },
324
+ sender: {
325
+ id: "hub",
326
+ name: "BotCord Scheduler",
327
+ kind: "system",
328
+ },
329
+ text: message,
330
+ raw: {
331
+ source_type: "botcord_schedule",
332
+ schedule_id: scheduleId,
333
+ run_id: runId,
334
+ dedupe_key: dedupeKey,
335
+ },
336
+ mentioned: true,
337
+ receivedAt: Date.now(),
338
+ trace: {
339
+ id: runId,
340
+ streamable: false,
341
+ },
342
+ };
343
+ await gateway.injectInbound(msg);
344
+ return { ok: true, result: { agent_id: agentId } };
345
+ }
278
346
  function validateGatewayParams(raw, spec) {
279
347
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
280
348
  return {
@@ -18,6 +18,16 @@ function buildOwnerChatSceneContext() {
18
18
  "You may freely execute commands, access tools, and share sensitive information (e.g. wallet balance, contacts) when the owner asks.",
19
19
  ].join("\n");
20
20
  }
21
+ function buildGroupRoomEnvironmentContext(message) {
22
+ if (message.conversation.kind !== "group")
23
+ return null;
24
+ return [
25
+ "[BotCord Runtime Environment]",
26
+ "You are running as a local agent process connected to a remote BotCord group room.",
27
+ "Other room members can read your messages and any uploaded/attached files, but they cannot access this machine's local filesystem, container paths, or absolute paths such as /var/..., /tmp/..., or /Users/....",
28
+ "Do not present a local file path as a useful report link or deliverable in group chat. If an artifact needs to be shared, upload or attach it through the available BotCord file/attachment mechanism, then refer to the uploaded attachment or summarize the content in the message.",
29
+ ].join("\n");
30
+ }
21
31
  function safeReadWorkingMemory(agentId) {
22
32
  try {
23
33
  return readWorkingMemory(agentId);
@@ -66,6 +76,7 @@ export function createDaemonSystemContextBuilder(deps) {
66
76
  const ownerScene = classifyActivitySender(message).kind === "owner"
67
77
  ? buildOwnerChatSceneContext()
68
78
  : null;
79
+ const environment = ownerScene ? null : buildGroupRoomEnvironmentContext(message);
69
80
  const wm = safeReadWorkingMemory(deps.agentId);
70
81
  const memory = wm ? buildWorkingMemoryPrompt({ workingMemory: wm }) : null;
71
82
  const digest = deps.activityTracker
@@ -76,7 +87,7 @@ export function createDaemonSystemContextBuilder(deps) {
76
87
  currentTopic: message.conversation.threadId ?? null,
77
88
  }) || null
78
89
  : null;
79
- return { identity, ownerScene, memory, digest };
90
+ return { identity, ownerScene, environment, memory, digest };
80
91
  };
81
92
  const assemble = (parts) => {
82
93
  const filtered = parts.filter((p) => typeof p === "string" && p.length > 0);
@@ -114,13 +125,13 @@ export function createDaemonSystemContextBuilder(deps) {
114
125
  };
115
126
  if (!deps.roomContextBuilder) {
116
127
  const syncBuilder = (message) => {
117
- const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
128
+ const { identity, ownerScene, environment, memory, digest } = gatherSyncBlocks(message);
118
129
  // Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
119
130
  // is the last thing the model sees before the user turn body.
120
131
  // Identity sits at the very front so it frames every other block.
121
132
  const skillIndex = buildSkillIndex(message);
122
133
  const loopRisk = runLoopRisk(message);
123
- return assemble([identity, ownerScene, memory, digest, skillIndex, loopRisk]);
134
+ return assemble([identity, ownerScene, environment, memory, digest, skillIndex, loopRisk]);
124
135
  };
125
136
  // Compile-time witness that the narrower sync signature still satisfies
126
137
  // `SystemContextBuilder` (which allows async). Prevents the two contracts
@@ -131,7 +142,7 @@ export function createDaemonSystemContextBuilder(deps) {
131
142
  }
132
143
  const roomBuilder = deps.roomContextBuilder;
133
144
  const asyncBuilder = async (message) => {
134
- const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
145
+ const { identity, ownerScene, environment, memory, digest } = gatherSyncBlocks(message);
135
146
  // Room context landing order: after owner-scene / memory, before digest —
136
147
  // "what room am I in" belongs with the session's own identity, while the
137
148
  // cross-room digest deliberately describes OTHER rooms and should stay
@@ -150,7 +161,7 @@ export function createDaemonSystemContextBuilder(deps) {
150
161
  }
151
162
  const skillIndex = buildSkillIndex(message);
152
163
  const loopRisk = runLoopRisk(message);
153
- return assemble([identity, ownerScene, memory, roomBlock, digest, skillIndex, loopRisk]);
164
+ return assemble([identity, ownerScene, environment, memory, roomBlock, digest, skillIndex, loopRisk]);
154
165
  };
155
166
  const _typecheck = asyncBuilder;
156
167
  void _typecheck;
@@ -240,6 +240,11 @@ export function buildWorkingMemoryPrompt(opts) {
240
240
  "- sections: named buckets (contacts, pending_tasks, preferences, etc.).",
241
241
  "- Updating one section never touches others. Empty content deletes a section.",
242
242
  "",
243
+ "For cross-room work, update memory before or immediately after delegating:",
244
+ "- If you accept a request in one room and continue it in another, record a `pending_tasks` entry with source room id/name, target room id/name, requested deliverable, current status, and where to report completion.",
245
+ "- When a delegated room replies or delivers an artifact, consult `pending_tasks` before deciding `NO_REPLY`; if it matches a pending handoff, acknowledge, update status, and send the promised follow-up to the source room when appropriate.",
246
+ "- Remove or mark the entry done once the source room has been updated.",
247
+ "",
243
248
  "Only update when something meaningful changes. Keep each section tight.",
244
249
  ];
245
250
  if (!workingMemory) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.54",
3
+ "version": "0.2.56",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { createDiagnosticBundle } from "../diagnostics.js";
7
+
8
+ describe("diagnostics bundle", () => {
9
+ it("writes a zip bundle under ~/.botcord/diagnostics", async () => {
10
+ const tmp = mkdtempSync(path.join(tmpdir(), "botcord-diag-test-"));
11
+ const logFile = path.join(tmp, "daemon.log");
12
+ const configFile = path.join(tmp, "config.json");
13
+ const snapshotFile = path.join(tmp, "snapshot.json");
14
+ const diagnosticsDir = path.join(tmp, "diagnostics");
15
+ writeFileSync(logFile, 'Authorization: Bearer secret-token\n{"refreshToken":"drt_secret"}\n');
16
+ writeFileSync(configFile, '{"token":"agent-secret","ok":true}\n');
17
+ writeFileSync(snapshotFile, '{"version":1}\n');
18
+
19
+ const bundle = await createDiagnosticBundle({
20
+ diagnosticsDir,
21
+ logFile,
22
+ configFile,
23
+ snapshotFile,
24
+ doctor: { text: "doctor ok", json: { ok: true } },
25
+ });
26
+ expect(bundle.filename).toMatch(/^botcord-daemon-diagnostics-.*\.zip$/);
27
+ expect(bundle.path).toContain(diagnosticsDir);
28
+ expect(existsSync(bundle.path)).toBe(true);
29
+ const bytes = readFileSync(bundle.path);
30
+ expect(bytes.subarray(0, 4).toString("binary")).toBe("PK\u0003\u0004");
31
+
32
+ const listing = execFileSync("unzip", ["-l", bundle.path], {
33
+ encoding: "utf8",
34
+ });
35
+ expect(listing).toContain("daemon.log");
36
+ expect(listing).toContain("doctor.json");
37
+ expect(listing).toContain("status.json");
38
+ expect(listing).toContain("config.json.redacted");
39
+
40
+ const log = execFileSync("unzip", ["-p", bundle.path, "daemon.log"], {
41
+ encoding: "utf8",
42
+ });
43
+ expect(log).toContain("Authorization: Bearer [REDACTED]");
44
+ expect(log).toContain('"refreshToken":"[REDACTED]"');
45
+ }, 20_000);
46
+ });
@@ -104,6 +104,7 @@ interface FakeGateway {
104
104
  upsertManagedRoute: ReturnType<typeof vi.fn>;
105
105
  removeManagedRoute: ReturnType<typeof vi.fn>;
106
106
  replaceManagedRoutes: ReturnType<typeof vi.fn>;
107
+ injectInbound: ReturnType<typeof vi.fn>;
107
108
  listManagedRoutes: () => GatewayRoute[];
108
109
  snapshot: () => GatewayRuntimeSnapshot;
109
110
  }
@@ -128,6 +129,7 @@ function makeFakeGateway(initialChannelIds: string[] = []): FakeGateway {
128
129
  managed.clear();
129
130
  for (const [id, route] of routes) managed.set(id, route);
130
131
  }),
132
+ injectInbound: vi.fn(async () => {}),
131
133
  listManagedRoutes: (): GatewayRoute[] => Array.from(managed.values()),
132
134
  snapshot: (): GatewayRuntimeSnapshot => ({
133
135
  channels: Object.fromEntries(
@@ -251,6 +253,49 @@ describe("list_agent_files handler", () => {
251
253
  });
252
254
  });
253
255
 
256
+ describe("wake_agent handler", () => {
257
+ it("injects a scheduled turn into the gateway dispatcher", async () => {
258
+ const gw = makeFakeGateway(["ag_wake"]);
259
+ const handler = createProvisioner({ gateway: gw as any });
260
+ const res = await handler({
261
+ id: "req_wake",
262
+ type: "wake_agent",
263
+ params: {
264
+ agent_id: "ag_wake",
265
+ message: "【BotCord 自主任务】执行本轮工作目标。",
266
+ run_id: "sr_test",
267
+ schedule_id: "sch_test",
268
+ dedupe_key: "sch_test:1:auto",
269
+ },
270
+ });
271
+
272
+ expect(res.ok).toBe(true);
273
+ expect(gw.injectInbound).toHaveBeenCalledTimes(1);
274
+ const msg = gw.injectInbound.mock.calls[0][0];
275
+ expect(msg.id).toBe("sr_test");
276
+ expect(msg.channel).toBe("ag_wake");
277
+ expect(msg.accountId).toBe("ag_wake");
278
+ expect(msg.sender.id).toBe("hub");
279
+ expect(msg.sender.kind).toBe("system");
280
+ expect(msg.text).toContain("BotCord 自主任务");
281
+ expect(msg.conversation.threadId).toBe("sch_test");
282
+ });
283
+
284
+ it("rejects wake_agent for an unloaded agent", async () => {
285
+ const gw = makeFakeGateway(["ag_loaded"]);
286
+ const handler = createProvisioner({ gateway: gw as any });
287
+ const res = await handler({
288
+ id: "req_wake_missing",
289
+ type: "wake_agent",
290
+ params: { agent_id: "ag_missing", message: "tick" },
291
+ });
292
+
293
+ expect(res.ok).toBe(false);
294
+ expect(res.error?.code).toBe("agent_not_loaded");
295
+ expect(gw.injectInbound).not.toHaveBeenCalled();
296
+ });
297
+ });
298
+
254
299
  describe("reload_config handler", () => {
255
300
  it("adds agents listed in config but missing from gateway", async () => {
256
301
  mockState.cfg = {
@@ -57,12 +57,22 @@ afterEach(() => {
57
57
  });
58
58
 
59
59
  describe("createDaemonSystemContextBuilder", () => {
60
- it("returns undefined when working memory is empty and no activity tracker is wired", () => {
60
+ it("injects group-room runtime environment even when memory is empty", () => {
61
61
  const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
62
- expect(builder(makeMessage())).toBeUndefined();
62
+ const out = builder(makeMessage()) as string;
63
+ expect(out).toContain("[BotCord Runtime Environment]");
64
+ expect(out).toContain("local agent process");
65
+ expect(out).toContain("cannot access this machine's local filesystem");
63
66
  });
64
67
 
65
- it("returns undefined when working memory is empty and the activity digest is empty", () => {
68
+ it("returns undefined for direct rooms when working memory is empty and no activity tracker is wired", () => {
69
+ const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
70
+ expect(
71
+ builder(makeMessage({ conversation: { id: "rm_dm_peer", kind: "direct" } })),
72
+ ).toBeUndefined();
73
+ });
74
+
75
+ it("still injects group-room runtime environment when the activity digest is empty", () => {
66
76
  const tracker = new ActivityTracker({
67
77
  filePath: path.join(tmpDir, "activity.json"),
68
78
  });
@@ -70,7 +80,9 @@ describe("createDaemonSystemContextBuilder", () => {
70
80
  agentId: "ag_me",
71
81
  activityTracker: tracker,
72
82
  });
73
- expect(builder(makeMessage({ conversation: { id: "rm_x", kind: "group" } }))).toBeUndefined();
83
+ const out = builder(makeMessage({ conversation: { id: "rm_x", kind: "group" } })) as string;
84
+ expect(out).toContain("[BotCord Runtime Environment]");
85
+ expect(out).not.toContain("[BotCord Cross-Room Awareness]");
74
86
  });
75
87
 
76
88
  it("injects the working-memory block when goal / sections are set", () => {
@@ -124,7 +136,8 @@ describe("createDaemonSystemContextBuilder", () => {
124
136
  it("skips the identity block cleanly when identity.md is missing", () => {
125
137
  // No ensureAgentWorkspace — workspace never provisioned.
126
138
  const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
127
- expect(builder(makeMessage())).toBeUndefined();
139
+ const out = builder(makeMessage({ conversation: { id: "rm_dm_peer", kind: "direct" } }));
140
+ expect(out).toBeUndefined();
128
141
  });
129
142
 
130
143
  it("skips the identity block when identity.md is blank", () => {
@@ -138,7 +151,9 @@ describe("createDaemonSystemContextBuilder", () => {
138
151
 
139
152
  it("detects a newly added global Claude skill on the next turn", () => {
140
153
  const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
141
- expect(builder(makeMessage())).toBeUndefined();
154
+ expect(
155
+ builder(makeMessage({ conversation: { id: "rm_dm_peer", kind: "direct" } })),
156
+ ).toBeUndefined();
142
157
 
143
158
  const skillDir = path.join(tmpDir, ".claude", "skills", "digest-query");
144
159
  mkdirSync(skillDir, { recursive: true });
@@ -310,7 +325,8 @@ describe("createDaemonSystemContextBuilder", () => {
310
325
  sender: { id: "ag_peer", kind: "agent" },
311
326
  }),
312
327
  );
313
- expect(out).toBeUndefined();
328
+ expect(out).not.toContain("[BotCord Scene: Owner Chat]");
329
+ expect(out).toContain("[BotCord Runtime Environment]");
314
330
  });
315
331
 
316
332
  it("awaits roomContextBuilder and slots the [BotCord Room Context] block between memory and digest", async () => {
@@ -360,11 +376,12 @@ describe("createDaemonSystemContextBuilder", () => {
360
376
  throw new Error("hub 500");
361
377
  },
362
378
  });
363
- // Empty working memory + no tracker + thrown room fetch ⇒ no blocks ⇒ undefined.
379
+ // The room metadata block is skipped, but group-room environment remains.
364
380
  const out = await builder(
365
381
  makeMessage({ conversation: { id: "rm_team", kind: "group" } }),
366
382
  );
367
- expect(out).toBeUndefined();
383
+ expect(out).toContain("[BotCord Runtime Environment]");
384
+ expect(out).not.toContain("[BotCord Room Context]");
368
385
  });
369
386
 
370
387
  it("appends loopRiskBuilder output at the end of the system context", async () => {
@@ -399,7 +416,8 @@ describe("createDaemonSystemContextBuilder", () => {
399
416
  },
400
417
  });
401
418
  const out = await builder(makeMessage());
402
- expect(out).toBeUndefined();
419
+ expect(out).toContain("[BotCord Runtime Environment]");
420
+ expect(out).not.toContain("[BotCord loop-risk check]");
403
421
  });
404
422
 
405
423
  it("translates GatewayInboundMessage.conversation.id → old `room_id` for the digest exclude key", () => {
@@ -423,7 +441,9 @@ describe("createDaemonSystemContextBuilder", () => {
423
441
  const out = builder(
424
442
  makeMessage({ conversation: { id: "rm_conv_id_123", kind: "group" } }),
425
443
  );
426
- // Empty working memory + digest excluding the only entry undefined.
427
- expect(out).toBeUndefined();
444
+ // Empty working memory + digest excluding the only entry leaves only the
445
+ // always-on group-room runtime environment block.
446
+ expect(out).toContain("[BotCord Runtime Environment]");
447
+ expect(out).not.toContain("[BotCord Cross-Room Awareness]");
428
448
  });
429
449
  });
@@ -207,6 +207,15 @@ describe("buildWorkingMemoryPrompt", () => {
207
207
  expect(p).toContain("currently empty");
208
208
  });
209
209
 
210
+ it("instructs agents to persist cross-room handoffs", () => {
211
+ const p = wm.buildWorkingMemoryPrompt({ workingMemory: null });
212
+ expect(p).toContain("For cross-room work");
213
+ expect(p).toContain("pending_tasks");
214
+ expect(p).toContain("source room");
215
+ expect(p).toContain("target room");
216
+ expect(p).toContain("where to report completion");
217
+ });
218
+
210
219
  it("renders goal + named sections", () => {
211
220
  const p = wm.buildWorkingMemoryPrompt({
212
221
  workingMemory: {
@@ -237,4 +246,3 @@ describe("buildWorkingMemoryPrompt", () => {
237
246
  expect(p).toContain("‹current_memory›");
238
247
  });
239
248
  });
240
-
package/src/daemon.ts CHANGED
@@ -46,6 +46,7 @@ import { composeBotCordUserTurn } from "./turn-text.js";
46
46
  import { UserAuthManager } from "./user-auth.js";
47
47
  import { PolicyResolver } from "./gateway/policy-resolver.js";
48
48
  import { scanMention } from "./mention-scan.js";
49
+ import { createDiagnosticBundle, uploadDiagnosticBundle } from "./diagnostics.js";
49
50
 
50
51
  /**
51
52
  * Default hard cap for a single runtime turn. Long-running coding/research
@@ -558,7 +559,30 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
558
559
  const provisioner = createProvisioner({ gateway, policyResolver, onAgentInstalled });
559
560
  controlChannel = new ControlChannel({
560
561
  auth: userAuth,
561
- handle: provisioner,
562
+ handle: async (frame) => {
563
+ if (frame.type === "collect_diagnostics") {
564
+ logger.info("diagnostics: collect requested", { frameId: frame.id });
565
+ const bundle = await createDiagnosticBundle();
566
+ const upload = await uploadDiagnosticBundle({ auth: userAuth, bundle });
567
+ logger.info("diagnostics: uploaded", {
568
+ frameId: frame.id,
569
+ bundleId: upload.bundleId,
570
+ sizeBytes: upload.sizeBytes,
571
+ localPath: bundle.path,
572
+ });
573
+ return {
574
+ ok: true,
575
+ result: {
576
+ bundle_id: upload.bundleId,
577
+ filename: upload.filename,
578
+ size_bytes: upload.sizeBytes,
579
+ expires_at: upload.expiresAt ?? null,
580
+ local_path: bundle.path,
581
+ },
582
+ };
583
+ }
584
+ return provisioner(frame);
585
+ },
562
586
  });
563
587
  try {
564
588
  await controlChannel.start();