@botcord/daemon 0.2.5 → 0.2.8
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 +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +64 -1
- package/dist/config.js +73 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +76 -6
- 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 +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- 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 +309 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +113 -1
- package/dist/provision.js +564 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +3 -2
- 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__/openclaw-discovery.test.ts +150 -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 +265 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +168 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +96 -6
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- 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 +394 -26
- 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 +321 -30
- package/src/mention-scan.ts +38 -0
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +682 -14
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
|
@@ -26,6 +26,7 @@ vi.mock("../config.js", async () => {
|
|
|
26
26
|
|
|
27
27
|
const {
|
|
28
28
|
addAgentToConfig,
|
|
29
|
+
adoptDiscoveredOpenclawAgents,
|
|
29
30
|
removeAgentFromConfig,
|
|
30
31
|
reloadConfig,
|
|
31
32
|
setRoute,
|
|
@@ -298,6 +299,58 @@ describe("set_route handler", () => {
|
|
|
298
299
|
expect(saved.routes[0].extraArgs).toEqual(["--debug"]);
|
|
299
300
|
});
|
|
300
301
|
|
|
302
|
+
it("inherits defaultRoute.extraArgs when caller omits them (mirrors adapter/cwd fallback)", () => {
|
|
303
|
+
mockState.cfg = {
|
|
304
|
+
defaultRoute: {
|
|
305
|
+
adapter: "claude-code",
|
|
306
|
+
cwd: process.env.HOME ?? "/tmp",
|
|
307
|
+
extraArgs: ["--permission-mode", "bypassPermissions"],
|
|
308
|
+
},
|
|
309
|
+
routes: [],
|
|
310
|
+
streamBlocks: true,
|
|
311
|
+
};
|
|
312
|
+
setRoute({ agentId: "ag_new", pattern: "rm_oc_" });
|
|
313
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
314
|
+
expect(saved.routes[0].extraArgs).toEqual([
|
|
315
|
+
"--permission-mode",
|
|
316
|
+
"bypassPermissions",
|
|
317
|
+
]);
|
|
318
|
+
// Mutating the saved route must not bleed back into defaultRoute.
|
|
319
|
+
saved.routes[0].extraArgs!.push("--mutated");
|
|
320
|
+
expect(mockState.cfg.defaultRoute).toEqual({
|
|
321
|
+
adapter: "claude-code",
|
|
322
|
+
cwd: process.env.HOME ?? "/tmp",
|
|
323
|
+
extraArgs: ["--permission-mode", "bypassPermissions"],
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("explicit extraArgs override defaultRoute.extraArgs", () => {
|
|
328
|
+
mockState.cfg = {
|
|
329
|
+
defaultRoute: {
|
|
330
|
+
adapter: "claude-code",
|
|
331
|
+
cwd: process.env.HOME ?? "/tmp",
|
|
332
|
+
extraArgs: ["--permission-mode", "bypassPermissions"],
|
|
333
|
+
},
|
|
334
|
+
routes: [],
|
|
335
|
+
streamBlocks: true,
|
|
336
|
+
};
|
|
337
|
+
setRoute({
|
|
338
|
+
agentId: "ag_new",
|
|
339
|
+
route: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp", extraArgs: ["--debug"] },
|
|
340
|
+
});
|
|
341
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
342
|
+
expect(saved.routes[0].extraArgs).toEqual(["--debug"]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("omits extraArgs when neither route nor defaultRoute provide them", () => {
|
|
346
|
+
setRoute({
|
|
347
|
+
agentId: "ag_new",
|
|
348
|
+
route: { adapter: "claude-code", cwd: process.env.HOME ?? "/tmp" },
|
|
349
|
+
});
|
|
350
|
+
const saved = mockState.saved[mockState.saved.length - 1] as unknown as DaemonConfig;
|
|
351
|
+
expect(saved.routes[0]).not.toHaveProperty("extraArgs");
|
|
352
|
+
});
|
|
353
|
+
|
|
301
354
|
it("forces match.accountId to the agentId even when callers omit it", () => {
|
|
302
355
|
setRoute({
|
|
303
356
|
agentId: "ag_x",
|
|
@@ -727,6 +780,110 @@ describe("provision_agent seeds workspace + hot-adds managed route", () => {
|
|
|
727
780
|
});
|
|
728
781
|
});
|
|
729
782
|
|
|
783
|
+
describe("adoptDiscoveredOpenclawAgents", () => {
|
|
784
|
+
it("registers unbound OpenClaw agents and writes the routing binding", async () => {
|
|
785
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
786
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
787
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
788
|
+
fs.writeFileSync(
|
|
789
|
+
nodePath.join(credDir, "ag_seed.json"),
|
|
790
|
+
JSON.stringify({
|
|
791
|
+
version: 1,
|
|
792
|
+
hubUrl: "https://hub.example",
|
|
793
|
+
agentId: "ag_seed",
|
|
794
|
+
keyId: "k_seed",
|
|
795
|
+
privateKey: Buffer.alloc(32, 5).toString("base64"),
|
|
796
|
+
savedAt: new Date().toISOString(),
|
|
797
|
+
}),
|
|
798
|
+
);
|
|
799
|
+
mockState.cfg = {
|
|
800
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
801
|
+
routes: [],
|
|
802
|
+
streamBlocks: true,
|
|
803
|
+
agents: ["ag_seed"],
|
|
804
|
+
openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const gw = makeFakeGateway(["ag_seed"]);
|
|
808
|
+
const register = vi.fn(async () => ({
|
|
809
|
+
agentId: "ag_adopted",
|
|
810
|
+
keyId: "k_adopted",
|
|
811
|
+
privateKey: Buffer.alloc(32, 31).toString("base64"),
|
|
812
|
+
publicKey: Buffer.alloc(32, 32).toString("base64"),
|
|
813
|
+
hubUrl: "https://hub.example",
|
|
814
|
+
token: "tok",
|
|
815
|
+
expiresAt: Date.now() + 60_000,
|
|
816
|
+
}));
|
|
817
|
+
|
|
818
|
+
const res = await adoptDiscoveredOpenclawAgents({
|
|
819
|
+
gateway: gw as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["gateway"],
|
|
820
|
+
register: register as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["register"],
|
|
821
|
+
cfg: mockState.cfg as unknown as DaemonConfig,
|
|
822
|
+
probe: async () => ({ ok: true, agents: [{ id: "main", name: "Main Agent" }] }),
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
expect(res.adopted).toEqual(["ag_adopted"]);
|
|
826
|
+
expect(register).toHaveBeenCalledWith("https://hub.example", "Main Agent", undefined);
|
|
827
|
+
const saved = JSON.parse(
|
|
828
|
+
fs.readFileSync(nodePath.join(credDir, "ag_adopted.json"), "utf8"),
|
|
829
|
+
) as Record<string, unknown>;
|
|
830
|
+
expect(saved.runtime).toBe("openclaw-acp");
|
|
831
|
+
expect(saved.openclawGateway).toBe("local");
|
|
832
|
+
expect(saved.openclawAgent).toBe("main");
|
|
833
|
+
expect((mockState.cfg.agents as string[])).toContain("ag_adopted");
|
|
834
|
+
expect(gw.addChannel).toHaveBeenCalledWith(
|
|
835
|
+
expect.objectContaining({ id: "ag_adopted", type: "botcord" }),
|
|
836
|
+
);
|
|
837
|
+
const route = gw.listManagedRoutes().find((r) => r.match?.accountId === "ag_adopted");
|
|
838
|
+
expect(route?.runtime).toBe("openclaw-acp");
|
|
839
|
+
expect(route?.gateway?.name).toBe("local");
|
|
840
|
+
expect(route?.gateway?.openclawAgent).toBe("main");
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("skips an OpenClaw agent that is already bound in credentials", async () => {
|
|
845
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
846
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
847
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
848
|
+
fs.writeFileSync(
|
|
849
|
+
nodePath.join(credDir, "ag_existing.json"),
|
|
850
|
+
JSON.stringify({
|
|
851
|
+
version: 1,
|
|
852
|
+
hubUrl: "https://hub.example",
|
|
853
|
+
agentId: "ag_existing",
|
|
854
|
+
keyId: "k_existing",
|
|
855
|
+
privateKey: Buffer.alloc(32, 6).toString("base64"),
|
|
856
|
+
savedAt: new Date().toISOString(),
|
|
857
|
+
runtime: "openclaw-acp",
|
|
858
|
+
openclawGateway: "local",
|
|
859
|
+
openclawAgent: "main",
|
|
860
|
+
}),
|
|
861
|
+
);
|
|
862
|
+
mockState.cfg = {
|
|
863
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
864
|
+
routes: [],
|
|
865
|
+
streamBlocks: true,
|
|
866
|
+
agents: ["ag_existing"],
|
|
867
|
+
openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
|
|
868
|
+
};
|
|
869
|
+
const register = vi.fn();
|
|
870
|
+
|
|
871
|
+
const res = await adoptDiscoveredOpenclawAgents({
|
|
872
|
+
gateway: makeFakeGateway(["ag_existing"]) as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["gateway"],
|
|
873
|
+
register: register as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["register"],
|
|
874
|
+
cfg: mockState.cfg as unknown as DaemonConfig,
|
|
875
|
+
probe: async () => ({ ok: true, agents: [{ id: "main" }] }),
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
expect(res.adopted).toEqual([]);
|
|
879
|
+
expect(res.skipped).toEqual([
|
|
880
|
+
{ gateway: "local", openclawAgent: "main", reason: "already_bound" },
|
|
881
|
+
]);
|
|
882
|
+
expect(register).not.toHaveBeenCalled();
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
730
887
|
// ---------------------------------------------------------------------------
|
|
731
888
|
// revoke_agent — new flag semantics (plan §11.3)
|
|
732
889
|
// ---------------------------------------------------------------------------
|
|
@@ -920,3 +1077,111 @@ describe("revoke_agent respects deleteState / deleteWorkspace flags", () => {
|
|
|
920
1077
|
});
|
|
921
1078
|
});
|
|
922
1079
|
});
|
|
1080
|
+
|
|
1081
|
+
// ---------------------------------------------------------------------------
|
|
1082
|
+
// hello + update_agent identity sync (lightweight reconcile path)
|
|
1083
|
+
// ---------------------------------------------------------------------------
|
|
1084
|
+
|
|
1085
|
+
describe("hello identity snapshot", () => {
|
|
1086
|
+
it("rewrites identity.md for every agent in the snapshot", async () => {
|
|
1087
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1088
|
+
const { ensureAgentWorkspace } = await import("../agent-workspace.js");
|
|
1089
|
+
ensureAgentWorkspace("ag_h1", { displayName: "Old1", bio: "Old bio 1" });
|
|
1090
|
+
ensureAgentWorkspace("ag_h2", { displayName: "Old2", bio: "Old bio 2" });
|
|
1091
|
+
void tmp;
|
|
1092
|
+
|
|
1093
|
+
const gw = makeFakeGateway();
|
|
1094
|
+
const provisioner = createProvisioner({
|
|
1095
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
const ack = await provisioner({
|
|
1099
|
+
id: "req_hello",
|
|
1100
|
+
type: CONTROL_FRAME_TYPES.HELLO,
|
|
1101
|
+
params: {
|
|
1102
|
+
server_time: Date.now(),
|
|
1103
|
+
agents: [
|
|
1104
|
+
{ agentId: "ag_h1", displayName: "Fresh1", bio: "Fresh bio 1" },
|
|
1105
|
+
{ agentId: "ag_h2", displayName: "Fresh2", bio: null },
|
|
1106
|
+
{ agentId: "ag_missing", displayName: "Nope", bio: "Nope" },
|
|
1107
|
+
],
|
|
1108
|
+
},
|
|
1109
|
+
});
|
|
1110
|
+
expect(ack.ok).toBe(true);
|
|
1111
|
+
const result = ack.result as { updated: number; skipped: number };
|
|
1112
|
+
expect(result.updated).toBe(2);
|
|
1113
|
+
expect(result.skipped).toBe(1);
|
|
1114
|
+
|
|
1115
|
+
const id1 = fs.readFileSync(
|
|
1116
|
+
nodePath.join(tmp, ".botcord", "agents", "ag_h1", "workspace", "identity.md"),
|
|
1117
|
+
"utf8",
|
|
1118
|
+
);
|
|
1119
|
+
expect(id1).toContain("Fresh1");
|
|
1120
|
+
expect(id1).toContain("Fresh bio 1");
|
|
1121
|
+
expect(id1).not.toContain("Old1");
|
|
1122
|
+
|
|
1123
|
+
const id2 = fs.readFileSync(
|
|
1124
|
+
nodePath.join(tmp, ".botcord", "agents", "ag_h2", "workspace", "identity.md"),
|
|
1125
|
+
"utf8",
|
|
1126
|
+
);
|
|
1127
|
+
expect(id2).toContain("Fresh2");
|
|
1128
|
+
// bio cleared → placeholder
|
|
1129
|
+
expect(id2).toContain("_(none provided at provision time");
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it("tolerates a hello frame with no agents array", async () => {
|
|
1134
|
+
const gw = makeFakeGateway();
|
|
1135
|
+
const provisioner = createProvisioner({
|
|
1136
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1137
|
+
});
|
|
1138
|
+
const ack = await provisioner({
|
|
1139
|
+
id: "req_hello_empty",
|
|
1140
|
+
type: CONTROL_FRAME_TYPES.HELLO,
|
|
1141
|
+
params: { server_time: Date.now() },
|
|
1142
|
+
});
|
|
1143
|
+
expect(ack.ok).toBe(true);
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
describe("update_agent handler", () => {
|
|
1148
|
+
it("rewrites identity.md for the targeted agent", async () => {
|
|
1149
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
1150
|
+
const { ensureAgentWorkspace } = await import("../agent-workspace.js");
|
|
1151
|
+
ensureAgentWorkspace("ag_u1", { displayName: "Before", bio: "Before bio" });
|
|
1152
|
+
|
|
1153
|
+
const gw = makeFakeGateway();
|
|
1154
|
+
const provisioner = createProvisioner({
|
|
1155
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1156
|
+
});
|
|
1157
|
+
const ack = await provisioner({
|
|
1158
|
+
id: "req_update",
|
|
1159
|
+
type: CONTROL_FRAME_TYPES.UPDATE_AGENT,
|
|
1160
|
+
params: { agentId: "ag_u1", displayName: "After", bio: "After bio" },
|
|
1161
|
+
});
|
|
1162
|
+
expect(ack.ok).toBe(true);
|
|
1163
|
+
expect((ack.result as { changed: boolean }).changed).toBe(true);
|
|
1164
|
+
|
|
1165
|
+
const md = fs.readFileSync(
|
|
1166
|
+
nodePath.join(tmp, ".botcord", "agents", "ag_u1", "workspace", "identity.md"),
|
|
1167
|
+
"utf8",
|
|
1168
|
+
);
|
|
1169
|
+
expect(md).toContain("After");
|
|
1170
|
+
expect(md).toContain("After bio");
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it("rejects update_agent without agentId", async () => {
|
|
1175
|
+
const gw = makeFakeGateway();
|
|
1176
|
+
const provisioner = createProvisioner({
|
|
1177
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
1178
|
+
});
|
|
1179
|
+
const ack = await provisioner({
|
|
1180
|
+
id: "req_update_bad",
|
|
1181
|
+
type: CONTROL_FRAME_TYPES.UPDATE_AGENT,
|
|
1182
|
+
params: {},
|
|
1183
|
+
});
|
|
1184
|
+
expect(ack.ok).toBe(false);
|
|
1185
|
+
expect(ack.error?.code).toBe("bad_params");
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
@@ -25,6 +25,10 @@ const { updateWorkingMemory, clearWorkingMemory } = await import(
|
|
|
25
25
|
);
|
|
26
26
|
const { ActivityTracker } = await import("../activity-tracker.js");
|
|
27
27
|
const { createDaemonSystemContextBuilder } = await import("../system-context.js");
|
|
28
|
+
const { ensureAgentWorkspace, agentWorkspaceDir } = await import(
|
|
29
|
+
"../agent-workspace.js"
|
|
30
|
+
);
|
|
31
|
+
const { writeFileSync } = await import("node:fs");
|
|
28
32
|
|
|
29
33
|
function makeMessage(
|
|
30
34
|
partial: Partial<GatewayInboundMessage> = {},
|
|
@@ -82,6 +86,54 @@ describe("createDaemonSystemContextBuilder", () => {
|
|
|
82
86
|
expect(out).toContain("remember X");
|
|
83
87
|
});
|
|
84
88
|
|
|
89
|
+
it("injects the identity block from workspace/identity.md, placed before other blocks", () => {
|
|
90
|
+
ensureAgentWorkspace("ag_me", {
|
|
91
|
+
displayName: "Susan's Helper",
|
|
92
|
+
bio: "A friendly IM agent who tracks contacts.",
|
|
93
|
+
runtime: "claude-code",
|
|
94
|
+
});
|
|
95
|
+
updateWorkingMemory("ag_me", { goal: "ship feature" });
|
|
96
|
+
|
|
97
|
+
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
98
|
+
const out = builder(makeMessage()) as string;
|
|
99
|
+
expect(out).toContain("[BotCord Identity]");
|
|
100
|
+
expect(out).toContain("Susan's Helper");
|
|
101
|
+
expect(out).toContain("A friendly IM agent who tracks contacts.");
|
|
102
|
+
// Identity must precede working memory so it frames every other block.
|
|
103
|
+
expect(out.indexOf("[BotCord Identity]")).toBeLessThan(
|
|
104
|
+
out.indexOf("[BotCord Working Memory]"),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("re-reads identity.md every turn so dashboard / agent edits take effect immediately", () => {
|
|
109
|
+
ensureAgentWorkspace("ag_me", { displayName: "Old Name" });
|
|
110
|
+
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
111
|
+
const first = builder(makeMessage()) as string;
|
|
112
|
+
expect(first).toContain("Old Name");
|
|
113
|
+
|
|
114
|
+
// Simulate an out-of-band edit (dashboard reconcile, user, control frame…).
|
|
115
|
+
writeFileSync(
|
|
116
|
+
path.join(agentWorkspaceDir("ag_me"), "identity.md"),
|
|
117
|
+
"# Identity\n\n- **Display name**: New Name\n",
|
|
118
|
+
);
|
|
119
|
+
const second = builder(makeMessage()) as string;
|
|
120
|
+
expect(second).toContain("New Name");
|
|
121
|
+
expect(second).not.toContain("Old Name");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("skips the identity block cleanly when identity.md is missing", () => {
|
|
125
|
+
// No ensureAgentWorkspace — workspace never provisioned.
|
|
126
|
+
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
127
|
+
expect(builder(makeMessage())).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("skips the identity block when identity.md is blank", () => {
|
|
131
|
+
ensureAgentWorkspace("ag_me", { displayName: "X" });
|
|
132
|
+
writeFileSync(path.join(agentWorkspaceDir("ag_me"), "identity.md"), "");
|
|
133
|
+
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
134
|
+
expect(builder(makeMessage())).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
85
137
|
it("emits the 'memory is currently empty' notice when the memory file exists but is blank", () => {
|
|
86
138
|
clearWorkingMemory("ag_me");
|
|
87
139
|
const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { appendNextParam } from "../url-utils.js";
|
|
3
|
+
|
|
4
|
+
describe("appendNextParam", () => {
|
|
5
|
+
it("appends next to a URL with no existing query string", () => {
|
|
6
|
+
const out = appendNextParam(
|
|
7
|
+
"https://app.botcord.chat/activate",
|
|
8
|
+
"/settings/daemons",
|
|
9
|
+
);
|
|
10
|
+
expect(out).toBe("https://app.botcord.chat/activate?next=%2Fsettings%2Fdaemons");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("preserves existing query params (e.g. ?code=...)", () => {
|
|
14
|
+
const out = appendNextParam(
|
|
15
|
+
"https://app.botcord.chat/activate?code=ABCD-EFGH",
|
|
16
|
+
"/settings/daemons",
|
|
17
|
+
);
|
|
18
|
+
const u = new URL(out);
|
|
19
|
+
expect(u.searchParams.get("code")).toBe("ABCD-EFGH");
|
|
20
|
+
expect(u.searchParams.get("next")).toBe("/settings/daemons");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("overwrites an existing next param rather than duplicating it", () => {
|
|
24
|
+
const out = appendNextParam(
|
|
25
|
+
"https://app.botcord.chat/activate?next=/old",
|
|
26
|
+
"/settings/daemons",
|
|
27
|
+
);
|
|
28
|
+
const u = new URL(out);
|
|
29
|
+
// searchParams.getAll guards against ?next=/old&next=/new style duplicates
|
|
30
|
+
expect(u.searchParams.getAll("next")).toEqual(["/settings/daemons"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns the original string when the URL cannot be parsed", () => {
|
|
34
|
+
const out = appendNextParam("not a url", "/settings/daemons");
|
|
35
|
+
expect(out).toBe("not a url");
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/agent-discovery.ts
CHANGED
|
@@ -38,6 +38,10 @@ export interface DiscoveredAgentCredential {
|
|
|
38
38
|
runtime?: string;
|
|
39
39
|
/** Working directory cached alongside `runtime`. */
|
|
40
40
|
cwd?: string;
|
|
41
|
+
/** OpenClaw gateway profile name from credentials (only meaningful for openclaw-acp). */
|
|
42
|
+
openclawGateway?: string;
|
|
43
|
+
/** OpenClaw agent profile override from credentials. */
|
|
44
|
+
openclawAgent?: string;
|
|
41
45
|
/** Key id from the credentials file — surfaced so boot-time workspace
|
|
42
46
|
* seeding (see daemon-agent-workspace-plan.md §9) can render identity.md
|
|
43
47
|
* without re-reading the file. */
|
|
@@ -164,6 +168,8 @@ export function discoverAgentCredentials(
|
|
|
164
168
|
if (creds.displayName) entry.displayName = creds.displayName;
|
|
165
169
|
if (creds.runtime) entry.runtime = creds.runtime;
|
|
166
170
|
if (creds.cwd) entry.cwd = creds.cwd;
|
|
171
|
+
if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
|
|
172
|
+
if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
|
|
167
173
|
if (creds.keyId) entry.keyId = creds.keyId;
|
|
168
174
|
if (creds.savedAt) entry.savedAt = creds.savedAt;
|
|
169
175
|
agents.push(entry);
|
|
@@ -236,6 +242,8 @@ export function resolveBootAgents(
|
|
|
236
242
|
if (creds.displayName) entry.displayName = creds.displayName;
|
|
237
243
|
if (creds.runtime) entry.runtime = creds.runtime;
|
|
238
244
|
if (creds.cwd) entry.cwd = creds.cwd;
|
|
245
|
+
if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
|
|
246
|
+
if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
|
|
239
247
|
if (creds.keyId) entry.keyId = creds.keyId;
|
|
240
248
|
if (creds.savedAt) entry.savedAt = creds.savedAt;
|
|
241
249
|
} catch (err) {
|
package/src/agent-workspace.ts
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* codex-home/ — per-agent CODEX_HOME used by the codex adapter so codex
|
|
8
8
|
* reads a daemon-written AGENTS.md (systemContext carrier)
|
|
9
9
|
* and stores its sessions/ without touching ~/.codex.
|
|
10
|
+
* hermes-home/ — per-agent HERMES_HOME used by the hermes-acp
|
|
11
|
+
* adapter (carries .env, state.db, skills/) so
|
|
12
|
+
* hermes-acp's per-user state stays isolated.
|
|
13
|
+
* hermes-workspace/ — per-agent runtime cwd for hermes-acp; the adapter
|
|
14
|
+
* writes systemContext into AGENTS.md here every turn.
|
|
15
|
+
* Kept separate from `workspace/` so daemon-written
|
|
16
|
+
* systemContext does not clobber the user/agent-
|
|
17
|
+
* editable workspace AGENTS.md.
|
|
10
18
|
*/
|
|
11
19
|
import {
|
|
12
20
|
chmodSync,
|
|
@@ -14,6 +22,7 @@ import {
|
|
|
14
22
|
existsSync,
|
|
15
23
|
lstatSync,
|
|
16
24
|
mkdirSync,
|
|
25
|
+
readFileSync,
|
|
17
26
|
readlinkSync,
|
|
18
27
|
symlinkSync,
|
|
19
28
|
unlinkSync,
|
|
@@ -58,6 +67,26 @@ export function agentCodexHomeDir(agentId: string): string {
|
|
|
58
67
|
return path.join(agentHomeDir(agentId), "codex-home");
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Per-agent HERMES_HOME. Carries the hermes-acp `.env`, `state.db`, and
|
|
72
|
+
* `skills/` so each daemon-managed agent has an isolated hermes config
|
|
73
|
+
* tree and never reads/writes the user's `~/.hermes`.
|
|
74
|
+
*/
|
|
75
|
+
export function agentHermesHomeDir(agentId: string): string {
|
|
76
|
+
return path.join(agentHomeDir(agentId), "hermes-home");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Per-agent runtime cwd for hermes-acp. Distinct from `workspace/` so the
|
|
81
|
+
* adapter can rewrite `AGENTS.md` here every turn (carrying the dynamic
|
|
82
|
+
* systemContext) without clobbering the user/agent-editable workspace
|
|
83
|
+
* `AGENTS.md`. hermes discovers `AGENTS.md` from cwd upward, so the file
|
|
84
|
+
* must live alongside the spawn cwd.
|
|
85
|
+
*/
|
|
86
|
+
export function agentHermesWorkspaceDir(agentId: string): string {
|
|
87
|
+
return path.join(agentHomeDir(agentId), "hermes-workspace");
|
|
88
|
+
}
|
|
89
|
+
|
|
61
90
|
export interface WorkspaceSeed {
|
|
62
91
|
displayName?: string;
|
|
63
92
|
bio?: string;
|
|
@@ -87,10 +116,14 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
|
|
|
87
116
|
|
|
88
117
|
## How to use this
|
|
89
118
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
119
|
+
- \`identity.md\` is **auto-loaded** by the daemon and injected into every turn's
|
|
120
|
+
system context as the \`[BotCord Identity]\` block. Edits to this file (yours,
|
|
121
|
+
the dashboard's via \`applyAgentIdentity\`, or a hello-snapshot reapply) take
|
|
122
|
+
effect on the next turn — no restart needed.
|
|
123
|
+
- \`memory.md\` and \`task.md\` are **convention, not mechanism**. The daemon does
|
|
124
|
+
not auto-load them; you are instructed to skim them before responding and to
|
|
125
|
+
write back what changed after meaningful turns. Keep them tight enough to be
|
|
126
|
+
worth re-reading.
|
|
94
127
|
`;
|
|
95
128
|
|
|
96
129
|
const MEMORY_MD = `# Memory
|
|
@@ -98,9 +131,9 @@ const MEMORY_MD = `# Memory
|
|
|
98
131
|
<!--
|
|
99
132
|
Long-lived facts about the user, past decisions, and preferences that should
|
|
100
133
|
survive across conversations. Organize by topic. Keep entries short. Prune
|
|
101
|
-
regularly — AGENTS.md instructs
|
|
102
|
-
response, but nothing loads it automatically; keep it
|
|
103
|
-
worth re-reading.
|
|
134
|
+
regularly — AGENTS.md instructs you to consult this file before each
|
|
135
|
+
response, but nothing loads it automatically (unlike identity.md); keep it
|
|
136
|
+
short enough to be worth re-reading.
|
|
104
137
|
-->
|
|
105
138
|
`;
|
|
106
139
|
|
|
@@ -215,6 +248,28 @@ export function ensureAgentCodexHome(agentId: string): string {
|
|
|
215
248
|
return dir;
|
|
216
249
|
}
|
|
217
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Idempotently create the per-agent HERMES_HOME and HERMES workspace
|
|
253
|
+
* directories. Writes a stub `.env` inside HERMES_HOME so hermes-acp's
|
|
254
|
+
* `_load_env` does not log "No .env found" on every spawn; users can edit
|
|
255
|
+
* this file to add API keys / model overrides.
|
|
256
|
+
*/
|
|
257
|
+
export function ensureAgentHermesWorkspace(agentId: string): {
|
|
258
|
+
hermesHome: string;
|
|
259
|
+
hermesWorkspace: string;
|
|
260
|
+
} {
|
|
261
|
+
const hermesHome = agentHermesHomeDir(agentId);
|
|
262
|
+
const hermesWorkspace = agentHermesWorkspaceDir(agentId);
|
|
263
|
+
mkdirTolerant(hermesHome);
|
|
264
|
+
mkdirTolerant(hermesWorkspace);
|
|
265
|
+
writeIfMissing(
|
|
266
|
+
path.join(hermesHome, ".env"),
|
|
267
|
+
"# hermes-agent environment overrides for this BotCord agent.\n" +
|
|
268
|
+
"# Add e.g. HERMES_INFERENCE_PROVIDER=openrouter, OPENROUTER_API_KEY=...\n",
|
|
269
|
+
);
|
|
270
|
+
return { hermesHome, hermesWorkspace };
|
|
271
|
+
}
|
|
272
|
+
|
|
218
273
|
/**
|
|
219
274
|
* Idempotently create the agent's home / workspace / state directories and
|
|
220
275
|
* seed the workspace Markdown files. Existing files are never overwritten —
|
|
@@ -233,6 +288,7 @@ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void
|
|
|
233
288
|
mkdirTolerant(notes);
|
|
234
289
|
mkdirTolerant(state);
|
|
235
290
|
ensureAgentCodexHome(agentId);
|
|
291
|
+
ensureAgentHermesWorkspace(agentId);
|
|
236
292
|
|
|
237
293
|
const agentsMdPath = path.join(workspace, "AGENTS.md");
|
|
238
294
|
const claudeMdPath = path.join(workspace, "CLAUDE.md");
|
|
@@ -243,3 +299,113 @@ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void
|
|
|
243
299
|
writeIfMissing(path.join(workspace, "task.md"), TASK_MD);
|
|
244
300
|
writeIfMissing(path.join(notes, ".gitkeep"), "");
|
|
245
301
|
}
|
|
302
|
+
|
|
303
|
+
/** Patch fields accepted by {@link applyAgentIdentity}. `bio = null` clears it. */
|
|
304
|
+
export interface AgentIdentityPatch {
|
|
305
|
+
displayName?: string;
|
|
306
|
+
bio?: string | null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Result of applying an identity patch. `changed` is true only when the
|
|
311
|
+
* file was rewritten on disk; `skipped` reports why (no-op vs. unable).
|
|
312
|
+
*/
|
|
313
|
+
export interface AgentIdentityApplyResult {
|
|
314
|
+
changed: boolean;
|
|
315
|
+
skipped?: "missing-file" | "no-change" | "unparseable";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const DISPLAY_NAME_LINE = /^- \*\*Display name\*\*: .*$/m;
|
|
319
|
+
// Match the Bio section's body. Anchor on the next `##` heading when one
|
|
320
|
+
// exists, otherwise consume to end-of-file — keeps the rewrite working when
|
|
321
|
+
// the user has stripped Role/Boundaries sections.
|
|
322
|
+
const BIO_SECTION = /(## Bio\n\n)([\s\S]*?)(\n+##\s|$)/;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Surgically rewrite the `Display name` and `Bio` fields inside an existing
|
|
326
|
+
* `identity.md`, preserving anything the user has authored elsewhere
|
|
327
|
+
* (Role / Boundaries / arbitrary new sections). No-op when the file is
|
|
328
|
+
* missing — provisioning will create it with the correct values, and
|
|
329
|
+
* subsequent hello snapshots simply reapply the dashboard truth.
|
|
330
|
+
*
|
|
331
|
+
* The identity.md template carries `Role` / `Boundaries` headings after
|
|
332
|
+
* `## Bio`; we anchor the Bio rewrite on "next `##`" so user-added
|
|
333
|
+
* paragraphs inside Bio are replaced wholesale (the dashboard is the
|
|
334
|
+
* source of truth) without disturbing siblings.
|
|
335
|
+
*/
|
|
336
|
+
export function applyAgentIdentity(
|
|
337
|
+
agentId: string,
|
|
338
|
+
patch: AgentIdentityPatch,
|
|
339
|
+
): AgentIdentityApplyResult {
|
|
340
|
+
assertSafeAgentId(agentId);
|
|
341
|
+
const file = path.join(agentWorkspaceDir(agentId), "identity.md");
|
|
342
|
+
if (!existsSync(file)) {
|
|
343
|
+
return { changed: false, skipped: "missing-file" };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let text: string;
|
|
347
|
+
try {
|
|
348
|
+
text = readFileSync(file, "utf8");
|
|
349
|
+
} catch {
|
|
350
|
+
return { changed: false, skipped: "missing-file" };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const original = text;
|
|
354
|
+
let touched = false;
|
|
355
|
+
|
|
356
|
+
if (typeof patch.displayName === "string") {
|
|
357
|
+
const value = patch.displayName.length > 0 ? patch.displayName : FIELD_PLACEHOLDER;
|
|
358
|
+
if (DISPLAY_NAME_LINE.test(text)) {
|
|
359
|
+
// Use a function replacer so `$1`, `$&` etc. inside the value are
|
|
360
|
+
// treated literally rather than as backreferences.
|
|
361
|
+
text = text.replace(DISPLAY_NAME_LINE, () => `- **Display name**: ${value}`);
|
|
362
|
+
touched = true;
|
|
363
|
+
} else {
|
|
364
|
+
// Heavily-edited file without the canonical metadata block — bail
|
|
365
|
+
// out rather than guess where to splice.
|
|
366
|
+
return { changed: false, skipped: "unparseable" };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (patch.bio !== undefined) {
|
|
371
|
+
const bioText =
|
|
372
|
+
patch.bio !== null && patch.bio.trim().length > 0
|
|
373
|
+
? patch.bio.trim()
|
|
374
|
+
: BIO_PLACEHOLDER;
|
|
375
|
+
if (BIO_SECTION.test(text)) {
|
|
376
|
+
text = text.replace(BIO_SECTION, (_match, head, _body, tail) => `${head}${bioText}${tail}`);
|
|
377
|
+
touched = true;
|
|
378
|
+
} else {
|
|
379
|
+
return { changed: false, skipped: "unparseable" };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!touched || text === original) {
|
|
384
|
+
return { changed: false, skipped: "no-change" };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
writeFileSync(file, text, { mode: 0o600 });
|
|
388
|
+
return { changed: true };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Read the agent's `identity.md` verbatim, if it exists. Returns the raw
|
|
393
|
+
* contents (including the leading `# Identity` heading) so callers can
|
|
394
|
+
* splice it into the system context. Returns `null` when the workspace
|
|
395
|
+
* has not been provisioned yet, the file is empty, or the read fails.
|
|
396
|
+
*
|
|
397
|
+
* Each call hits disk — same contract as `readWorkingMemory`, so a
|
|
398
|
+
* dashboard-driven edit (`applyAgentIdentity` from a control frame, or
|
|
399
|
+
* a hello-snapshot reapply, or the agent's own self-edit) is visible
|
|
400
|
+
* on the very next turn without restarting the gateway.
|
|
401
|
+
*/
|
|
402
|
+
export function readIdentity(agentId: string): string | null {
|
|
403
|
+
assertSafeAgentId(agentId);
|
|
404
|
+
const file = path.join(agentWorkspaceDir(agentId), "identity.md");
|
|
405
|
+
try {
|
|
406
|
+
const raw = readFileSync(file, "utf8");
|
|
407
|
+
return raw.trim().length > 0 ? raw : null;
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|