@botcord/daemon 0.2.72 → 0.2.74

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.
@@ -65,7 +65,7 @@ export declare function ensureAttachedHermesProfileSkills(profileHome: string):
65
65
  /**
66
66
  * Idempotently create the agent's home / workspace / state directories and
67
67
  * seed the workspace Markdown files. Existing files are never overwritten —
68
- * users' edits to AGENTS.md, memory.md, etc. are preserved across calls.
68
+ * users' edits to AGENTS.md, task.md, etc. are preserved across calls.
69
69
  * State files are not touched here; working-memory.ts owns `state/`.
70
70
  */
71
71
  export declare function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void;
@@ -3,7 +3,7 @@
3
3
  * directory tree under `~/.botcord/agents/{agentId}/`:
4
4
  *
5
5
  * workspace/ — runtime cwd; seed Markdown files live here (LLM-owned)
6
- * state/ — daemon-owned JSON (e.g. working-memory.json)
6
+ * state/ — daemon-owned JSON
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.
@@ -78,8 +78,6 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
78
78
  ## Files you own
79
79
 
80
80
  - \`identity.md\` — who you are, your role, your boundaries. Read before responding.
81
- - \`memory.md\` — long-lived facts, user preferences, past decisions. Update when
82
- you learn something durable. Prune when it grows stale.
83
81
  - \`task.md\` — current task and plan. Update as you make progress. Clear when done.
84
82
  - \`notes/\` — free-form scratch space.
85
83
 
@@ -88,6 +86,10 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
88
86
  - Do not modify files outside this workspace unless the user explicitly asks.
89
87
  - \`../state/\` (sibling directory, outside this workspace) is managed by the
90
88
  daemon — do not read or edit it directly.
89
+ - Working memory is stored outside this workspace at
90
+ \`~/.botcord/memory/{agentId}/working-memory.json\`. Read and update it with
91
+ \`botcord memory\` or the \`botcord_memory\` skill instead of creating local
92
+ memory notes.
91
93
 
92
94
  ## How to use this
93
95
 
@@ -95,20 +97,10 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
95
97
  system context as the \`[BotCord Identity]\` block. Edits to this file (yours,
96
98
  the dashboard's via \`applyAgentIdentity\`, or a hello-snapshot reapply) take
97
99
  effect on the next turn — no restart needed.
98
- - \`memory.md\` and \`task.md\` are **convention, not mechanism**. The daemon does
99
- not auto-load them; you are instructed to skim them before responding and to
100
- write back what changed after meaningful turns. Keep them tight enough to be
101
- worth re-reading.
102
- `;
103
- const MEMORY_MD = `# Memory
104
-
105
- <!--
106
- Long-lived facts about the user, past decisions, and preferences that should
107
- survive across conversations. Organize by topic. Keep entries short. Prune
108
- regularly — AGENTS.md instructs you to consult this file before each
109
- response, but nothing loads it automatically (unlike identity.md); keep it
110
- short enough to be worth re-reading.
111
- -->
100
+ - Working memory is **mechanism**: the daemon loads \`working-memory.json\` fresh
101
+ and injects it into every turn as the \`[BotCord Working Memory]\` block.
102
+ - \`task.md\` is **convention, not mechanism**. The daemon does not auto-load it;
103
+ keep it only for short-lived task notes that are not durable memory.
112
104
  `;
113
105
  const TASK_MD = `# Current Task
114
106
 
@@ -450,7 +442,7 @@ export function ensureAttachedHermesProfileSkills(profileHome) {
450
442
  /**
451
443
  * Idempotently create the agent's home / workspace / state directories and
452
444
  * seed the workspace Markdown files. Existing files are never overwritten —
453
- * users' edits to AGENTS.md, memory.md, etc. are preserved across calls.
445
+ * users' edits to AGENTS.md, task.md, etc. are preserved across calls.
454
446
  * State files are not touched here; working-memory.ts owns `state/`.
455
447
  */
456
448
  export function ensureAgentWorkspace(agentId, seed) {
@@ -471,7 +463,6 @@ export function ensureAgentWorkspace(agentId, seed) {
471
463
  writeIfMissing(agentsMdPath, AGENTS_MD);
472
464
  writeIfMissing(claudeMdPath, AGENTS_MD);
473
465
  writeIfMissing(path.join(workspace, "identity.md"), renderIdentity(agentId, seed));
474
- writeIfMissing(path.join(workspace, "memory.md"), MEMORY_MD);
475
466
  writeIfMissing(path.join(workspace, "task.md"), TASK_MD);
476
467
  writeIfMissing(path.join(notes, ".gitkeep"), "");
477
468
  seedClaudeCodeSkills(workspace);
@@ -836,7 +836,7 @@ function normalizeBlockForHub(block, seq) {
836
836
  }
837
837
  if (kind === "tool_use") {
838
838
  // Claude Code: assistant message w/ content[].type === "tool_use" → {id,name,input}
839
- // Codex: item.started / item.completed for command_execution, file_change, mcp_tool_call, web_search
839
+ // Codex: item.started for command_execution, file_change, mcp_tool_call, web_search
840
840
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
841
841
  const tu = contents.find((c) => c?.type === "tool_use");
842
842
  if (tu) {
@@ -848,12 +848,19 @@ function normalizeBlockForHub(block, seq) {
848
848
  }
849
849
  else if (raw?.item && typeof raw.item === "object") {
850
850
  payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
851
- payload.params = raw.item;
851
+ const params = codexToolParams(raw.item);
852
+ if (Object.keys(params).length > 0)
853
+ payload.params = params;
854
+ if (typeof raw.item.id === "string")
855
+ payload.id = raw.item.id;
856
+ if (typeof raw.item.status === "string")
857
+ payload.status = raw.item.status;
852
858
  }
853
859
  return { kind: "tool_call", seq, payload };
854
860
  }
855
861
  if (kind === "tool_result") {
856
862
  // Claude Code: {type:"user", message:{content:[{type:"tool_result",tool_use_id,content}]}}
863
+ // Codex: item.completed for command_execution, file_change, mcp_tool_call, web_search
857
864
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
858
865
  const tr = contents.find((c) => c?.type === "tool_result");
859
866
  if (tr) {
@@ -870,6 +877,14 @@ function normalizeBlockForHub(block, seq) {
870
877
  if (typeof tr.tool_use_id === "string")
871
878
  payload.tool_use_id = tr.tool_use_id;
872
879
  }
880
+ else if (raw?.item && typeof raw.item === "object") {
881
+ payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
882
+ if (typeof raw.item.id === "string")
883
+ payload.tool_use_id = raw.item.id;
884
+ const result = codexToolResult(raw.item);
885
+ if (result)
886
+ payload.result = result;
887
+ }
873
888
  return { kind: "tool_result", seq, payload };
874
889
  }
875
890
  if (kind === "system") {
@@ -928,6 +943,54 @@ function formatBlockDetails(raw) {
928
943
  return String(raw);
929
944
  }
930
945
  }
946
+ function codexToolParams(item) {
947
+ const params = {};
948
+ for (const key of [
949
+ "command",
950
+ "cmd",
951
+ "args",
952
+ "path",
953
+ "query",
954
+ "url",
955
+ "name",
956
+ "input",
957
+ "arguments",
958
+ "action",
959
+ "changes",
960
+ ]) {
961
+ const value = item[key];
962
+ if (value !== undefined && value !== null && value !== "")
963
+ params[key] = value;
964
+ }
965
+ const action = item.action;
966
+ if (action && typeof action === "object") {
967
+ for (const key of ["query", "url", "command", "path"]) {
968
+ const value = action[key];
969
+ if (value !== undefined && value !== null && value !== "")
970
+ params[key] = value;
971
+ }
972
+ }
973
+ return params;
974
+ }
975
+ function codexToolResult(item) {
976
+ const parts = [];
977
+ const status = typeof item.status === "string" ? item.status : "";
978
+ const exitCode = item.exit_code ?? item.exitCode;
979
+ if (status)
980
+ parts.push(`status: ${status}`);
981
+ if (typeof exitCode === "number" || typeof exitCode === "string")
982
+ parts.push(`exit_code: ${exitCode}`);
983
+ for (const key of ["output", "stdout", "stderr", "aggregated_output", "result", "summary"]) {
984
+ const value = item[key];
985
+ if (typeof value === "string" && value.trim())
986
+ parts.push(value.trim());
987
+ }
988
+ const results = item.results;
989
+ if (Array.isArray(results) && results.length > 0) {
990
+ parts.push(JSON.stringify(results, null, 2));
991
+ }
992
+ return parts.join("\n");
993
+ }
931
994
  function extractContentText(content) {
932
995
  if (!content)
933
996
  return "";
@@ -380,7 +380,7 @@ function normalizeBlock(obj, seq) {
380
380
  itemType === "file_change" ||
381
381
  itemType === "mcp_tool_call" ||
382
382
  itemType === "web_search") {
383
- kind = "tool_use";
383
+ kind = type === "item.completed" ? "tool_result" : "tool_use";
384
384
  }
385
385
  }
386
386
  return { raw: obj, kind, seq };
package/dist/provision.js CHANGED
@@ -17,6 +17,7 @@ import { createGatewayControl } from "./gateway-control.js";
17
17
  import { hermesProfileHomeDir, isValidHermesProfileName, listHermesProfiles, } from "./gateway/runtimes/hermes-agent.js";
18
18
  import { log as daemonLog } from "./log.js";
19
19
  import { discoverAgentCredentials } from "./agent-discovery.js";
20
+ import { resolveMemoryDir } from "./working-memory.js";
20
21
  /**
21
22
  * Build a dispatcher function that routes a `ControlFrame` to the right
22
23
  * handler. Returned function signature matches
@@ -432,6 +433,7 @@ function runtimeFileCandidates(credentials) {
432
433
  const runtime = credentials.runtime;
433
434
  const out = [];
434
435
  addWorkspaceFiles(out, agentId, runtime);
436
+ addWorkingMemoryFile(out, agentId, runtime);
435
437
  if (runtime === "hermes-agent") {
436
438
  addHermesFiles(out, credentials);
437
439
  }
@@ -442,7 +444,7 @@ function runtimeFileCandidates(credentials) {
442
444
  }
443
445
  function addWorkspaceFiles(out, agentId, runtime) {
444
446
  const root = agentWorkspaceDir(agentId);
445
- for (const file of ["AGENTS.md", "CLAUDE.md", "identity.md", "memory.md", "task.md"]) {
447
+ for (const file of ["AGENTS.md", "CLAUDE.md", "identity.md", "task.md"]) {
446
448
  out.push({
447
449
  id: `workspace:${file}`,
448
450
  name: `workspace/${file}`,
@@ -453,6 +455,17 @@ function addWorkspaceFiles(out, agentId, runtime) {
453
455
  });
454
456
  }
455
457
  }
458
+ function addWorkingMemoryFile(out, agentId, runtime) {
459
+ out.push({
460
+ id: "memory:working-memory.json",
461
+ name: "memory/working-memory.json",
462
+ scope: "memory",
463
+ root: resolveMemoryDir(agentId),
464
+ relativePath: "working-memory.json",
465
+ ...(runtime ? { runtime } : {}),
466
+ missingContent: JSON.stringify({ version: 2, sections: {}, updatedAt: null }, null, 2),
467
+ });
468
+ }
456
469
  function addHermesFiles(out, credentials) {
457
470
  if (credentials.hermesProfile) {
458
471
  const profile = credentials.hermesProfile;
@@ -541,8 +554,16 @@ function readRuntimeFileCandidate(candidate) {
541
554
  return base;
542
555
  }
543
556
  catch (err) {
544
- if (err.code === "ENOENT")
557
+ if (err.code === "ENOENT") {
558
+ if (candidate.missingContent !== undefined) {
559
+ return {
560
+ ...base,
561
+ size: Buffer.byteLength(candidate.missingContent, "utf8"),
562
+ content: candidate.missingContent,
563
+ };
564
+ }
545
565
  return null;
566
+ }
546
567
  return {
547
568
  ...base,
548
569
  error: err instanceof Error ? err.message : String(err),
@@ -78,7 +78,7 @@ export function createDaemonSystemContextBuilder(deps) {
78
78
  : null;
79
79
  const environment = ownerScene ? null : buildGroupRoomEnvironmentContext(message);
80
80
  const wm = safeReadWorkingMemory(deps.agentId);
81
- const memory = wm ? buildWorkingMemoryPrompt({ workingMemory: wm }) : null;
81
+ const memory = buildWorkingMemoryPrompt({ workingMemory: wm });
82
82
  const digest = deps.activityTracker
83
83
  ? buildCrossRoomDigest({
84
84
  tracker: deps.activityTracker,
@@ -267,6 +267,7 @@ export function buildWorkingMemoryPrompt(opts) {
267
267
  "[BotCord Working Memory]",
268
268
  "You have a persistent working memory that survives across turns and rooms.",
269
269
  "Use it to track your goal, important facts, pending commitments, and context worth remembering.",
270
+ "This is the only BotCord memory source. It is stored in `~/.botcord/memory/{agentId}/working-memory.json`; do not create or update workspace memory files.",
270
271
  "",
271
272
  "Update via the daemon's `memory` CLI (or whatever tool the operator wires):",
272
273
  "- goal: a short pinned statement of what you're working on.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.72",
3
+ "version": "0.2.74",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,9 +60,10 @@ describe("ensureAgentWorkspace", () => {
60
60
  expect(existsSync(state)).toBe(true);
61
61
  expect(existsSync(path.join(workspace, "notes"))).toBe(true);
62
62
 
63
- for (const name of ["AGENTS.md", "CLAUDE.md", "identity.md", "memory.md", "task.md"]) {
63
+ for (const name of ["AGENTS.md", "CLAUDE.md", "identity.md", "task.md"]) {
64
64
  expect(existsSync(path.join(workspace, name))).toBe(true);
65
65
  }
66
+ expect(existsSync(path.join(workspace, "memory.md"))).toBe(false);
66
67
 
67
68
  const agentsMd = readFileSync(path.join(workspace, "AGENTS.md"), "utf8");
68
69
  const claudeMd = readFileSync(path.join(workspace, "CLAUDE.md"), "utf8");
@@ -174,16 +175,6 @@ describe("ensureAgentWorkspace", () => {
174
175
  expect(existsSync(hermesHome)).toBe(false);
175
176
  });
176
177
 
177
- it("does not overwrite a user-modified memory.md on a second call", () => {
178
- ensureAgentWorkspace("ag_keep", {});
179
- const memoryPath = path.join(agentWorkspaceDir("ag_keep"), "memory.md");
180
- writeFileSync(memoryPath, "my custom notes\n");
181
-
182
- ensureAgentWorkspace("ag_keep", {});
183
-
184
- expect(readFileSync(memoryPath, "utf8")).toBe("my custom notes\n");
185
- });
186
-
187
178
  it("seeds Hermes config and provider env without copying unrelated secrets", () => {
188
179
  const globalHermes = path.join(tmpHome, ".hermes");
189
180
  mkdirSync(globalHermes, { recursive: true });
@@ -255,12 +255,11 @@ describe("backfillBootAgents", () => {
255
255
 
256
256
  it("is idempotent: a second call leaves user-edited files alone", () => {
257
257
  backfillBootAgents([bootAgent("ag_one")], { logger: silentLogger() });
258
- const memoryPath = path.join(agentWorkspaceDir("ag_one"), "memory.md");
259
- const edited = "# My notes\n\nremembered thing\n";
260
- // Simulate the LLM/user editing memory.md.
261
- writeFileSync(memoryPath, edited);
258
+ const taskPath = path.join(agentWorkspaceDir("ag_one"), "task.md");
259
+ const edited = "# Current Task\n\nin progress\n";
260
+ writeFileSync(taskPath, edited);
262
261
  backfillBootAgents([bootAgent("ag_one")], { logger: silentLogger() });
263
- expect(readFileSync(memoryPath, "utf8")).toBe(edited);
262
+ expect(readFileSync(taskPath, "utf8")).toBe(edited);
264
263
  });
265
264
 
266
265
  it("warns and continues when ensureAgentWorkspace throws for one agent", () => {
@@ -175,8 +175,10 @@ describe("list_agent_files handler", () => {
175
175
  );
176
176
 
177
177
  const workspace = nodePath.join(tmp, ".botcord", "agents", "ag_hermes", "workspace");
178
+ const memoryDir = nodePath.join(tmp, ".botcord", "memory", "ag_hermes");
178
179
  fs.mkdirSync(workspace, { recursive: true });
179
- fs.writeFileSync(nodePath.join(workspace, "memory.md"), "# Memory\nowned\n");
180
+ fs.mkdirSync(memoryDir, { recursive: true });
181
+ fs.writeFileSync(nodePath.join(memoryDir, "working-memory.json"), '{"sections":{}}\n');
180
182
  fs.writeFileSync(nodePath.join(workspace, "task.md"), "# Task\n");
181
183
 
182
184
  const hermesMem = nodePath.join(tmp, ".hermes", "memories");
@@ -196,7 +198,7 @@ describe("list_agent_files handler", () => {
196
198
  expect(result.agentId).toBe("ag_hermes");
197
199
  expect(result.runtime).toBe("hermes-agent");
198
200
  const byName = Object.fromEntries(result.files.map((f: any) => [f.name, f]));
199
- expect(byName["workspace/memory.md"].content).toBe("# Memory\nowned\n");
201
+ expect(byName["memory/working-memory.json"].content).toBe('{"sections":{}}\n');
200
202
  expect(byName["workspace/task.md"].content).toBe("# Task\n");
201
203
  expect(byName["hermes/default/SOUL.md"].content).toBe("# Soul\n");
202
204
  expect(byName["hermes/default/memories/MEMORY.md"].content).toBe("# Hermes Memory\n");
@@ -231,7 +233,6 @@ describe("list_agent_files handler", () => {
231
233
  );
232
234
  const workspace = nodePath.join(tmp, ".botcord", "agents", "ag_one", "workspace");
233
235
  fs.mkdirSync(workspace, { recursive: true });
234
- fs.writeFileSync(nodePath.join(workspace, "memory.md"), "# Memory\n");
235
236
  fs.writeFileSync(nodePath.join(workspace, "task.md"), "# Task\n");
236
237
 
237
238
  const handler = createProvisioner({ gateway: makeFakeGateway() as any });
@@ -251,6 +252,50 @@ describe("list_agent_files handler", () => {
251
252
  else process.env.HOME = prevHome;
252
253
  }
253
254
  });
255
+
256
+ it("returns a stable empty working-memory file when none exists yet", async () => {
257
+ const os = await import("node:os");
258
+ const fs = await import("node:fs");
259
+ const nodePath = await import("node:path");
260
+
261
+ const tmp = fs.mkdtempSync(nodePath.join(os.tmpdir(), "daemon-runtime-files-"));
262
+ const prevHome = process.env.HOME;
263
+ process.env.HOME = tmp;
264
+ try {
265
+ const credDir = nodePath.join(tmp, ".botcord", "credentials");
266
+ fs.mkdirSync(credDir, { recursive: true });
267
+ fs.writeFileSync(
268
+ nodePath.join(credDir, "ag_empty_mem.json"),
269
+ JSON.stringify({
270
+ version: 1,
271
+ hubUrl: "https://hub.example",
272
+ agentId: "ag_empty_mem",
273
+ keyId: "k_empty_mem",
274
+ privateKey: Buffer.alloc(32, 9).toString("base64"),
275
+ runtime: "claude-code",
276
+ }),
277
+ );
278
+
279
+ const handler = createProvisioner({ gateway: makeFakeGateway() as any });
280
+ const res = await handler({
281
+ id: "req_empty_memory_file",
282
+ type: "list_agent_files",
283
+ params: { agentId: "ag_empty_mem", fileId: "memory:working-memory.json" },
284
+ });
285
+
286
+ expect(res.ok).toBe(true);
287
+ const result = res.result as any;
288
+ expect(result.files).toHaveLength(1);
289
+ expect(result.files[0].id).toBe("memory:working-memory.json");
290
+ expect(result.files[0].name).toBe("memory/working-memory.json");
291
+ expect(result.files[0].scope).toBe("memory");
292
+ expect(result.files[0].content).toContain('"version": 2');
293
+ expect(result.files[0].content).toContain('"sections": {}');
294
+ } finally {
295
+ if (prevHome === undefined) delete process.env.HOME;
296
+ else process.env.HOME = prevHome;
297
+ }
298
+ });
254
299
  });
255
300
 
256
301
  describe("wake_agent handler", () => {
@@ -704,7 +749,7 @@ async function withSandboxHome<T>(run: (sbx: SandboxFixture) => Promise<T>): Pro
704
749
  }
705
750
  }
706
751
 
707
- const SEED_FILES = ["AGENTS.md", "CLAUDE.md", "identity.md", "memory.md", "task.md"];
752
+ const SEED_FILES = ["AGENTS.md", "CLAUDE.md", "identity.md", "task.md"];
708
753
 
709
754
  describe("provision_agent seeds workspace + hot-adds managed route", () => {
710
755
  it("defaults cwd to agentWorkspaceDir on the fast path (Hub-supplied credentials)", async () => {
@@ -1375,7 +1420,7 @@ function seedAgentOnDisk(
1375
1420
  savedAt: new Date().toISOString(),
1376
1421
  }),
1377
1422
  );
1378
- fs.writeFileSync(nodePath.join(workspaceDir, "memory.md"), "# Memory\nprecious\n");
1423
+ fs.writeFileSync(nodePath.join(workspaceDir, "task.md"), "# Task\nprecious\n");
1379
1424
  fs.writeFileSync(nodePath.join(stateDir, "working-memory.json"), "{}");
1380
1425
  return { credFile, workspaceDir, stateDir, homeDir };
1381
1426
  }
@@ -65,11 +65,12 @@ describe("createDaemonSystemContextBuilder", () => {
65
65
  expect(out).toContain("cannot access this machine's local filesystem");
66
66
  });
67
67
 
68
- it("returns undefined for direct rooms when working memory is empty and no activity tracker is wired", () => {
68
+ it("injects the empty working-memory block even when no memory file exists", () => {
69
69
  const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
70
- expect(
71
- builder(makeMessage({ conversation: { id: "rm_dm_peer", kind: "direct" } })),
72
- ).toBeUndefined();
70
+ const out = builder(makeMessage({ conversation: { id: "rm_dm_peer", kind: "direct" } }));
71
+ expect(out).toContain("[BotCord Working Memory]");
72
+ expect(out).toContain("This is the only BotCord memory source.");
73
+ expect(out).toContain("Your working memory is currently empty.");
73
74
  });
74
75
 
75
76
  it("still injects group-room runtime environment when the activity digest is empty", () => {
@@ -137,7 +138,8 @@ describe("createDaemonSystemContextBuilder", () => {
137
138
  // No ensureAgentWorkspace — workspace never provisioned.
138
139
  const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
139
140
  const out = builder(makeMessage({ conversation: { id: "rm_dm_peer", kind: "direct" } }));
140
- expect(out).toBeUndefined();
141
+ expect(out).not.toContain("[BotCord Identity]");
142
+ expect(out).toContain("[BotCord Working Memory]");
141
143
  });
142
144
 
143
145
  it("skips the identity block when identity.md is blank", () => {
@@ -151,9 +153,9 @@ describe("createDaemonSystemContextBuilder", () => {
151
153
 
152
154
  it("detects a newly added global Claude skill on the next turn", () => {
153
155
  const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
154
- expect(
155
- builder(makeMessage({ conversation: { id: "rm_dm_peer", kind: "direct" } })),
156
- ).toBeUndefined();
156
+ const before = builder(makeMessage({ conversation: { id: "rm_dm_peer", kind: "direct" } }));
157
+ expect(before).toContain("[BotCord Working Memory]");
158
+ expect(before).not.toContain("[BotCord Daemon Skill Index]");
157
159
 
158
160
  const skillDir = path.join(tmpDir, ".claude", "skills", "digest-query");
159
161
  mkdirSync(skillDir, { recursive: true });
@@ -3,7 +3,7 @@
3
3
  * directory tree under `~/.botcord/agents/{agentId}/`:
4
4
  *
5
5
  * workspace/ — runtime cwd; seed Markdown files live here (LLM-owned)
6
- * state/ — daemon-owned JSON (e.g. working-memory.json)
6
+ * state/ — daemon-owned JSON
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.
@@ -108,8 +108,6 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
108
108
  ## Files you own
109
109
 
110
110
  - \`identity.md\` — who you are, your role, your boundaries. Read before responding.
111
- - \`memory.md\` — long-lived facts, user preferences, past decisions. Update when
112
- you learn something durable. Prune when it grows stale.
113
111
  - \`task.md\` — current task and plan. Update as you make progress. Clear when done.
114
112
  - \`notes/\` — free-form scratch space.
115
113
 
@@ -118,6 +116,10 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
118
116
  - Do not modify files outside this workspace unless the user explicitly asks.
119
117
  - \`../state/\` (sibling directory, outside this workspace) is managed by the
120
118
  daemon — do not read or edit it directly.
119
+ - Working memory is stored outside this workspace at
120
+ \`~/.botcord/memory/{agentId}/working-memory.json\`. Read and update it with
121
+ \`botcord memory\` or the \`botcord_memory\` skill instead of creating local
122
+ memory notes.
121
123
 
122
124
  ## How to use this
123
125
 
@@ -125,21 +127,10 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
125
127
  system context as the \`[BotCord Identity]\` block. Edits to this file (yours,
126
128
  the dashboard's via \`applyAgentIdentity\`, or a hello-snapshot reapply) take
127
129
  effect on the next turn — no restart needed.
128
- - \`memory.md\` and \`task.md\` are **convention, not mechanism**. The daemon does
129
- not auto-load them; you are instructed to skim them before responding and to
130
- write back what changed after meaningful turns. Keep them tight enough to be
131
- worth re-reading.
132
- `;
133
-
134
- const MEMORY_MD = `# Memory
135
-
136
- <!--
137
- Long-lived facts about the user, past decisions, and preferences that should
138
- survive across conversations. Organize by topic. Keep entries short. Prune
139
- regularly — AGENTS.md instructs you to consult this file before each
140
- response, but nothing loads it automatically (unlike identity.md); keep it
141
- short enough to be worth re-reading.
142
- -->
130
+ - Working memory is **mechanism**: the daemon loads \`working-memory.json\` fresh
131
+ and injects it into every turn as the \`[BotCord Working Memory]\` block.
132
+ - \`task.md\` is **convention, not mechanism**. The daemon does not auto-load it;
133
+ keep it only for short-lived task notes that are not durable memory.
143
134
  `;
144
135
 
145
136
  const TASK_MD = `# Current Task
@@ -491,7 +482,7 @@ export function ensureAttachedHermesProfileSkills(profileHome: string): void {
491
482
  /**
492
483
  * Idempotently create the agent's home / workspace / state directories and
493
484
  * seed the workspace Markdown files. Existing files are never overwritten —
494
- * users' edits to AGENTS.md, memory.md, etc. are preserved across calls.
485
+ * users' edits to AGENTS.md, task.md, etc. are preserved across calls.
495
486
  * State files are not touched here; working-memory.ts owns `state/`.
496
487
  */
497
488
  export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void {
@@ -513,7 +504,6 @@ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void
513
504
  writeIfMissing(agentsMdPath, AGENTS_MD);
514
505
  writeIfMissing(claudeMdPath, AGENTS_MD);
515
506
  writeIfMissing(path.join(workspace, "identity.md"), renderIdentity(agentId, seed));
516
- writeIfMissing(path.join(workspace, "memory.md"), MEMORY_MD);
517
507
  writeIfMissing(path.join(workspace, "task.md"), TASK_MD);
518
508
  writeIfMissing(path.join(notes, ".gitkeep"), "");
519
509
  seedClaudeCodeSkills(workspace);
@@ -1,7 +1,11 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { WebSocketServer, type WebSocket as WsType } from "ws";
3
3
  import type { AddressInfo } from "node:net";
4
- import { createBotCordChannel, type BotCordChannelClient } from "../channels/botcord.js";
4
+ import {
5
+ createBotCordChannel,
6
+ __normalizeBlockForHubForTests,
7
+ type BotCordChannelClient,
8
+ } from "../channels/botcord.js";
5
9
  import type { ChannelStartContext, GatewayInboundEnvelope } from "../types.js";
6
10
  import type { GatewayLogger } from "../log.js";
7
11
  import type { InboxMessage } from "@botcord/protocol-core";
@@ -649,6 +653,89 @@ describe("createBotCordChannel — ack + dedup", () => {
649
653
  // ---------------------------------------------------------------------------
650
654
 
651
655
  describe("createBotCordChannel — streamBlock()", () => {
656
+ it("normalizes Codex tool items without using internal ids as params", () => {
657
+ expect(
658
+ __normalizeBlockForHubForTests(
659
+ {
660
+ kind: "tool_use",
661
+ seq: 1,
662
+ raw: {
663
+ type: "item.started",
664
+ item: { id: "item_26", type: "command_execution", command: "rg stream-block" },
665
+ },
666
+ },
667
+ 1,
668
+ ),
669
+ ).toEqual({
670
+ kind: "tool_call",
671
+ seq: 1,
672
+ payload: {
673
+ name: "command_execution",
674
+ id: "item_26",
675
+ params: { command: "rg stream-block" },
676
+ },
677
+ });
678
+
679
+ expect(
680
+ __normalizeBlockForHubForTests(
681
+ {
682
+ kind: "tool_use",
683
+ seq: 2,
684
+ raw: {
685
+ type: "item.started",
686
+ item: {
687
+ id: "ws_abc",
688
+ type: "web_search",
689
+ action: { type: "search", query: "codex stream response" },
690
+ },
691
+ },
692
+ },
693
+ 2,
694
+ ),
695
+ ).toEqual({
696
+ kind: "tool_call",
697
+ seq: 2,
698
+ payload: {
699
+ name: "web_search",
700
+ id: "ws_abc",
701
+ params: {
702
+ action: { type: "search", query: "codex stream response" },
703
+ query: "codex stream response",
704
+ },
705
+ },
706
+ });
707
+ });
708
+
709
+ it("normalizes Codex completed tool items as results", () => {
710
+ expect(
711
+ __normalizeBlockForHubForTests(
712
+ {
713
+ kind: "tool_result",
714
+ seq: 3,
715
+ raw: {
716
+ type: "item.completed",
717
+ item: {
718
+ id: "item_26",
719
+ type: "command_execution",
720
+ status: "completed",
721
+ exit_code: 0,
722
+ output: "found 3 matches",
723
+ },
724
+ },
725
+ },
726
+ 3,
727
+ ),
728
+ ).toEqual({
729
+ kind: "tool_result",
730
+ seq: 3,
731
+ payload: {
732
+ name: "command_execution",
733
+ tool_use_id: "item_26",
734
+ result: "status: completed\nexit_code: 0\nfound 3 matches",
735
+ },
736
+ });
737
+ });
738
+
652
739
  it("POSTs to /hub/stream-block with the right trace_id + block", async () => {
653
740
  const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
654
741
  const realFetch = globalThis.fetch;
@@ -77,13 +77,14 @@ process.exit(0);
77
77
  expect(res.error).toBeUndefined();
78
78
  });
79
79
 
80
- it("emits tool_use StreamBlock for command_execution items", async () => {
80
+ it("emits tool_use/tool_result StreamBlocks for command_execution items", async () => {
81
81
  const script = makeScript(
82
82
  "toolblock.js",
83
83
  `
84
84
  const lines = [
85
85
  {type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcde0"},
86
86
  {type:"item.started", item:{id:"i0", type:"command_execution", command:"ls"}},
87
+ {type:"item.completed", item:{id:"i0", type:"command_execution", status:"completed", output:"ok"}},
87
88
  {type:"item.completed", item:{id:"i1", type:"agent_message", text:"done"}},
88
89
  ];
89
90
  for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
@@ -103,6 +104,7 @@ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
103
104
  });
104
105
  expect(res.text).toBe("done");
105
106
  expect(seen).toContain("tool_use");
107
+ expect(seen).toContain("tool_result");
106
108
  expect(seen).toContain("assistant_text");
107
109
  expect(seen).toContain("system");
108
110
  });
@@ -979,7 +979,7 @@ function normalizeBlockForHub(
979
979
 
980
980
  if (kind === "tool_use") {
981
981
  // Claude Code: assistant message w/ content[].type === "tool_use" → {id,name,input}
982
- // Codex: item.started / item.completed for command_execution, file_change, mcp_tool_call, web_search
982
+ // Codex: item.started for command_execution, file_change, mcp_tool_call, web_search
983
983
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
984
984
  const tu = contents.find((c: any) => c?.type === "tool_use");
985
985
  if (tu) {
@@ -988,13 +988,17 @@ function normalizeBlockForHub(
988
988
  if (typeof tu.id === "string") payload.id = tu.id;
989
989
  } else if (raw?.item && typeof raw.item === "object") {
990
990
  payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
991
- payload.params = raw.item;
991
+ const params = codexToolParams(raw.item);
992
+ if (Object.keys(params).length > 0) payload.params = params;
993
+ if (typeof raw.item.id === "string") payload.id = raw.item.id;
994
+ if (typeof raw.item.status === "string") payload.status = raw.item.status;
992
995
  }
993
996
  return { kind: "tool_call", seq, payload };
994
997
  }
995
998
 
996
999
  if (kind === "tool_result") {
997
1000
  // Claude Code: {type:"user", message:{content:[{type:"tool_result",tool_use_id,content}]}}
1001
+ // Codex: item.completed for command_execution, file_change, mcp_tool_call, web_search
998
1002
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
999
1003
  const tr = contents.find((c: any) => c?.type === "tool_result");
1000
1004
  if (tr) {
@@ -1008,6 +1012,11 @@ function normalizeBlockForHub(
1008
1012
  }
1009
1013
  payload.result = resultStr;
1010
1014
  if (typeof tr.tool_use_id === "string") payload.tool_use_id = tr.tool_use_id;
1015
+ } else if (raw?.item && typeof raw.item === "object") {
1016
+ payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
1017
+ if (typeof raw.item.id === "string") payload.tool_use_id = raw.item.id;
1018
+ const result = codexToolResult(raw.item);
1019
+ if (result) payload.result = result;
1011
1020
  }
1012
1021
  return { kind: "tool_result", seq, payload };
1013
1022
  }
@@ -1062,6 +1071,56 @@ function formatBlockDetails(raw: unknown): string {
1062
1071
  }
1063
1072
  }
1064
1073
 
1074
+ function codexToolParams(item: Record<string, unknown>): Record<string, unknown> {
1075
+ const params: Record<string, unknown> = {};
1076
+ for (const key of [
1077
+ "command",
1078
+ "cmd",
1079
+ "args",
1080
+ "path",
1081
+ "query",
1082
+ "url",
1083
+ "name",
1084
+ "input",
1085
+ "arguments",
1086
+ "action",
1087
+ "changes",
1088
+ ]) {
1089
+ const value = item[key];
1090
+ if (value !== undefined && value !== null && value !== "") params[key] = value;
1091
+ }
1092
+
1093
+ const action = item.action as Record<string, unknown> | undefined;
1094
+ if (action && typeof action === "object") {
1095
+ for (const key of ["query", "url", "command", "path"]) {
1096
+ const value = action[key];
1097
+ if (value !== undefined && value !== null && value !== "") params[key] = value;
1098
+ }
1099
+ }
1100
+
1101
+ return params;
1102
+ }
1103
+
1104
+ function codexToolResult(item: Record<string, unknown>): string {
1105
+ const parts: string[] = [];
1106
+ const status = typeof item.status === "string" ? item.status : "";
1107
+ const exitCode = item.exit_code ?? item.exitCode;
1108
+ if (status) parts.push(`status: ${status}`);
1109
+ if (typeof exitCode === "number" || typeof exitCode === "string") parts.push(`exit_code: ${exitCode}`);
1110
+
1111
+ for (const key of ["output", "stdout", "stderr", "aggregated_output", "result", "summary"]) {
1112
+ const value = item[key];
1113
+ if (typeof value === "string" && value.trim()) parts.push(value.trim());
1114
+ }
1115
+
1116
+ const results = item.results;
1117
+ if (Array.isArray(results) && results.length > 0) {
1118
+ parts.push(JSON.stringify(results, null, 2));
1119
+ }
1120
+
1121
+ return parts.join("\n");
1122
+ }
1123
+
1065
1124
  function extractContentText(content: unknown): string {
1066
1125
  if (!content) return "";
1067
1126
  if (typeof content === "string") return content;
@@ -420,7 +420,7 @@ function normalizeBlock(obj: any, seq: number): StreamBlock {
420
420
  itemType === "mcp_tool_call" ||
421
421
  itemType === "web_search"
422
422
  ) {
423
- kind = "tool_use";
423
+ kind = type === "item.completed" ? "tool_result" : "tool_use";
424
424
  }
425
425
  }
426
426
  return { raw: obj, kind, seq };
package/src/provision.ts CHANGED
@@ -71,6 +71,7 @@ import {
71
71
  } from "./gateway/runtimes/hermes-agent.js";
72
72
  import { log as daemonLog } from "./log.js";
73
73
  import { discoverAgentCredentials } from "./agent-discovery.js";
74
+ import { resolveMemoryDir } from "./working-memory.js";
74
75
 
75
76
  /**
76
77
  * Information passed to {@link OnAgentInstalledHook} after a successful
@@ -618,7 +619,7 @@ interface ListAgentFilesParams {
618
619
  interface AgentRuntimeFile {
619
620
  id: string;
620
621
  name: string;
621
- scope: "workspace" | "hermes" | "openclaw";
622
+ scope: "workspace" | "memory" | "hermes" | "openclaw";
622
623
  runtime?: string;
623
624
  profile?: string;
624
625
  size?: number;
@@ -642,6 +643,7 @@ interface RuntimeFileCandidate {
642
643
  relativePath: string;
643
644
  runtime?: string;
644
645
  profile?: string;
646
+ missingContent?: string;
645
647
  }
646
648
 
647
649
  function listAgentRuntimeFiles(params: ListAgentFilesParams): ListAgentFilesResult {
@@ -664,6 +666,7 @@ function runtimeFileCandidates(credentials: StoredBotCordCredentials): RuntimeFi
664
666
  const out: RuntimeFileCandidate[] = [];
665
667
 
666
668
  addWorkspaceFiles(out, agentId, runtime);
669
+ addWorkingMemoryFile(out, agentId, runtime);
667
670
 
668
671
  if (runtime === "hermes-agent") {
669
672
  addHermesFiles(out, credentials);
@@ -681,7 +684,7 @@ function addWorkspaceFiles(
681
684
  runtime?: string,
682
685
  ): void {
683
686
  const root = agentWorkspaceDir(agentId);
684
- for (const file of ["AGENTS.md", "CLAUDE.md", "identity.md", "memory.md", "task.md"]) {
687
+ for (const file of ["AGENTS.md", "CLAUDE.md", "identity.md", "task.md"]) {
685
688
  out.push({
686
689
  id: `workspace:${file}`,
687
690
  name: `workspace/${file}`,
@@ -693,6 +696,26 @@ function addWorkspaceFiles(
693
696
  }
694
697
  }
695
698
 
699
+ function addWorkingMemoryFile(
700
+ out: RuntimeFileCandidate[],
701
+ agentId: string,
702
+ runtime?: string,
703
+ ): void {
704
+ out.push({
705
+ id: "memory:working-memory.json",
706
+ name: "memory/working-memory.json",
707
+ scope: "memory",
708
+ root: resolveMemoryDir(agentId),
709
+ relativePath: "working-memory.json",
710
+ ...(runtime ? { runtime } : {}),
711
+ missingContent: JSON.stringify(
712
+ { version: 2, sections: {}, updatedAt: null },
713
+ null,
714
+ 2,
715
+ ),
716
+ });
717
+ }
718
+
696
719
  function addHermesFiles(
697
720
  out: RuntimeFileCandidate[],
698
721
  credentials: StoredBotCordCredentials,
@@ -786,7 +809,16 @@ function readRuntimeFileCandidate(candidate: RuntimeFileCandidate): AgentRuntime
786
809
  base.content = readFileSync(resolved, "utf8");
787
810
  return base;
788
811
  } catch (err) {
789
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
812
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
813
+ if (candidate.missingContent !== undefined) {
814
+ return {
815
+ ...base,
816
+ size: Buffer.byteLength(candidate.missingContent, "utf8"),
817
+ content: candidate.missingContent,
818
+ };
819
+ }
820
+ return null;
821
+ }
790
822
  return {
791
823
  ...base,
792
824
  error: err instanceof Error ? err.message : String(err),
@@ -156,7 +156,7 @@ export function createDaemonSystemContextBuilder(
156
156
  const environment = ownerScene ? null : buildGroupRoomEnvironmentContext(message);
157
157
 
158
158
  const wm = safeReadWorkingMemory(deps.agentId);
159
- const memory = wm ? buildWorkingMemoryPrompt({ workingMemory: wm }) : null;
159
+ const memory = buildWorkingMemoryPrompt({ workingMemory: wm });
160
160
 
161
161
  const digest = deps.activityTracker
162
162
  ? buildCrossRoomDigest({
@@ -347,6 +347,7 @@ export function buildWorkingMemoryPrompt(opts: {
347
347
  "[BotCord Working Memory]",
348
348
  "You have a persistent working memory that survives across turns and rooms.",
349
349
  "Use it to track your goal, important facts, pending commitments, and context worth remembering.",
350
+ "This is the only BotCord memory source. It is stored in `~/.botcord/memory/{agentId}/working-memory.json`; do not create or update workspace memory files.",
350
351
  "",
351
352
  "Update via the daemon's `memory` CLI (or whatever tool the operator wires):",
352
353
  "- goal: a short pinned statement of what you're working on.",