@botcord/daemon 0.2.66 → 0.2.67

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.
@@ -10,10 +10,8 @@ export declare const MAX_GOAL_CHARS = 500;
10
10
  export declare const MAX_TOTAL_CHARS = 20000;
11
11
  export declare const DEFAULT_SECTION = "notes";
12
12
  /**
13
- * Canonical per-agent state directory. Returns the new location
14
- * (`~/.botcord/agents/{agentId}/state`). The legacy location under
15
- * `~/.botcord/daemon/memory/{agentId}` is migrated lazily on first read —
16
- * see §8 of the daemon-agent-workspace plan.
13
+ * Canonical per-agent memory directory. This intentionally matches
14
+ * @botcord/cli's `botcord memory` path so the CLI and daemon share one store.
17
15
  */
18
16
  export declare function resolveMemoryDir(agentId: string): string;
19
17
  export declare function readWorkingMemory(agentId: string): WorkingMemory | null;
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Working memory — persistent, account-scoped notes injected into every turn.
3
3
  *
4
- * Stored at `~/.botcord/agents/{agentId}/state/working-memory.json` (the
5
- * per-agent state dir owned by the daemon).
4
+ * Stored at `~/.botcord/memory/{agentId}/working-memory.json`, matching the
5
+ * @botcord/cli `botcord memory` command so writes made by an agent are visible
6
+ * to daemon context injection on the next turn.
6
7
  *
7
8
  * Ported from plugin/src/memory.ts (dropping workspace + OpenClaw runtime
8
9
  * branches) and plugin/src/memory-protocol.ts (prompt builder).
9
10
  */
10
11
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
12
+ import { homedir } from "node:os";
11
13
  import path from "node:path";
12
14
  import { agentStateDir } from "./agent-workspace.js";
13
15
  import { DAEMON_DIR_PATH } from "./config.js";
@@ -23,86 +25,94 @@ const MEMORY_SIZE_WARN_CHARS = 2_000;
23
25
  const RESERVED_TAGS_RE = /<\/?(?:current_memory|section_\w+)\b[^>]*>/gi;
24
26
  // ── Path resolution ────────────────────────────────────────────────
25
27
  /**
26
- * Canonical per-agent state directory. Returns the new location
27
- * (`~/.botcord/agents/{agentId}/state`). The legacy location under
28
- * `~/.botcord/daemon/memory/{agentId}` is migrated lazily on first read —
29
- * see §8 of the daemon-agent-workspace plan.
28
+ * Canonical per-agent memory directory. This intentionally matches
29
+ * @botcord/cli's `botcord memory` path so the CLI and daemon share one store.
30
30
  */
31
31
  export function resolveMemoryDir(agentId) {
32
32
  if (!agentId)
33
33
  throw new Error("resolveMemoryDir: agentId is required");
34
+ return path.join(homedir(), ".botcord", "memory", agentId);
35
+ }
36
+ /** Previous daemon-owned location retained for one-shot migration on read. */
37
+ function daemonStateMemoryDir(agentId) {
34
38
  return agentStateDir(agentId);
35
39
  }
36
- /** Legacy location retained for one-shot migration on read. */
37
- function legacyMemoryDir(agentId) {
40
+ /** Older daemon location retained for one-shot migration on read. */
41
+ function daemonLegacyMemoryDir(agentId) {
38
42
  return path.join(DAEMON_DIR_PATH, "memory", agentId);
39
43
  }
40
44
  function workingMemoryPath(agentId) {
41
45
  return path.join(resolveMemoryDir(agentId), "working-memory.json");
42
46
  }
43
- function legacyWorkingMemoryPath(agentId) {
44
- return path.join(legacyMemoryDir(agentId), "working-memory.json");
47
+ function daemonStateWorkingMemoryPath(agentId) {
48
+ return path.join(daemonStateMemoryDir(agentId), "working-memory.json");
49
+ }
50
+ function daemonLegacyWorkingMemoryPath(agentId) {
51
+ return path.join(daemonLegacyMemoryDir(agentId), "working-memory.json");
45
52
  }
46
53
  // Migration conflict warnings are emitted at most once per agent per
47
54
  // process. Reset only by daemon restart — good enough for a one-release
48
55
  // transitional branch that gets removed later.
49
56
  const warnedMigrationConflict = new Set();
50
57
  /**
51
- * Resolve the path to read from, migrating from the legacy location if
58
+ * Resolve the path to read from, migrating from daemon-only locations if
52
59
  * necessary. Returns the path the caller should read, or `null` when no
53
60
  * memory file exists anywhere.
54
- *
55
- * Migration branch (the `else if` on `legacyExists` below) is meant to be
56
- * deleted one release after this change ships; see plan §8 step 6.
57
61
  */
58
62
  function resolveReadPath(agentId) {
59
- const newPath = workingMemoryPath(agentId);
60
- const oldPath = legacyWorkingMemoryPath(agentId);
61
- const newExists = existsSync(newPath);
62
- const oldExists = existsSync(oldPath);
63
- if (newExists) {
64
- if (oldExists && !warnedMigrationConflict.has(agentId)) {
63
+ const cliPath = workingMemoryPath(agentId);
64
+ const daemonStatePath = daemonStateWorkingMemoryPath(agentId);
65
+ const daemonLegacyPath = daemonLegacyWorkingMemoryPath(agentId);
66
+ const cliExists = existsSync(cliPath);
67
+ const daemonStateExists = existsSync(daemonStatePath);
68
+ const daemonLegacyExists = existsSync(daemonLegacyPath);
69
+ if (cliExists) {
70
+ if ((daemonStateExists || daemonLegacyExists) && !warnedMigrationConflict.has(agentId)) {
65
71
  warnedMigrationConflict.add(agentId);
66
- daemonLog.warn("working-memory: both new and legacy paths exist; using new", {
72
+ daemonLog.warn("working-memory: both cli and daemon paths exist; using cli", {
67
73
  agentId,
68
- oldPath,
69
- newPath,
74
+ cliPath,
75
+ daemonStatePath,
76
+ daemonLegacyPath,
70
77
  });
71
78
  }
72
- return newPath;
79
+ return cliPath;
73
80
  }
74
- if (oldExists) {
81
+ const migrateFrom = daemonStateExists
82
+ ? daemonStatePath
83
+ : daemonLegacyExists
84
+ ? daemonLegacyPath
85
+ : null;
86
+ if (migrateFrom) {
75
87
  try {
76
- mkdirSync(path.dirname(newPath), { recursive: true, mode: 0o700 });
88
+ mkdirSync(path.dirname(cliPath), { recursive: true, mode: 0o700 });
77
89
  try {
78
- renameSync(oldPath, newPath);
90
+ renameSync(migrateFrom, cliPath);
79
91
  }
80
92
  catch (err) {
81
- // EXDEV = legacy and new paths live on different filesystems
93
+ // EXDEV = old and canonical paths live on different filesystems
82
94
  // (bind mounts, tmpfs overlays). `renameSync` cannot cross fs
83
- // boundaries, so fall back to copy + unlink. Without this, the
84
- // next write would go to newPath while legacy still has the old
85
- // payload — silent divergence the reviewer of §8 flagged.
95
+ // boundaries, so fall back to copy + unlink.
86
96
  if (err.code === "EXDEV") {
87
- copyFileSync(oldPath, newPath);
88
- unlinkSync(oldPath);
97
+ copyFileSync(migrateFrom, cliPath);
98
+ unlinkSync(migrateFrom);
89
99
  }
90
100
  else {
91
101
  throw err;
92
102
  }
93
103
  }
94
- return newPath;
104
+ return cliPath;
95
105
  }
96
106
  catch (err) {
97
107
  const e = err;
98
- daemonLog.warn("working-memory: migration rename failed; reading legacy path", {
108
+ daemonLog.warn("working-memory: migration rename failed; reading daemon path", {
99
109
  agentId,
100
- oldPath,
101
- newPath,
110
+ oldPath: migrateFrom,
111
+ newPath: cliPath,
102
112
  code: e.code,
103
113
  error: e.message ?? String(err),
104
114
  });
105
- return oldPath;
115
+ return migrateFrom;
106
116
  }
107
117
  }
108
118
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.66",
3
+ "version": "0.2.67",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,11 @@ vi.mock("../log.js", () => ({
30
30
  const wm = await import("../working-memory.js");
31
31
  const { agentStateDir } = await import("../agent-workspace.js");
32
32
 
33
- function newPathFor(agentId: string): string {
33
+ function cliPathFor(agentId: string): string {
34
+ return path.join(tmpHome, ".botcord", "memory", agentId, "working-memory.json");
35
+ }
36
+
37
+ function daemonStatePathFor(agentId: string): string {
34
38
  return path.join(agentStateDir(agentId), "working-memory.json");
35
39
  }
36
40
 
@@ -44,8 +48,14 @@ function writeLegacy(agentId: string, body: unknown): void {
44
48
  writeFileSync(p, JSON.stringify(body));
45
49
  }
46
50
 
47
- function writeNew(agentId: string, body: unknown): void {
48
- const p = newPathFor(agentId);
51
+ function writeDaemonState(agentId: string, body: unknown): void {
52
+ const p = daemonStatePathFor(agentId);
53
+ mkdirSync(path.dirname(p), { recursive: true });
54
+ writeFileSync(p, JSON.stringify(body));
55
+ }
56
+
57
+ function writeCli(agentId: string, body: unknown): void {
58
+ const p = cliPathFor(agentId);
49
59
  mkdirSync(path.dirname(p), { recursive: true });
50
60
  writeFileSync(p, JSON.stringify(body));
51
61
  }
@@ -79,9 +89,10 @@ describe("working-memory I/O", () => {
79
89
  expect(got?.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
80
90
  });
81
91
 
82
- it("writes land in the new state dir", () => {
92
+ it("writes land in the CLI-compatible memory dir", () => {
83
93
  wm.updateWorkingMemory("ag_new", { goal: "g" });
84
- expect(existsSync(newPathFor("ag_new"))).toBe(true);
94
+ expect(existsSync(cliPathFor("ag_new"))).toBe(true);
95
+ expect(existsSync(daemonStatePathFor("ag_new"))).toBe(false);
85
96
  expect(existsSync(legacyPathFor("ag_new"))).toBe(false);
86
97
  });
87
98
 
@@ -136,34 +147,51 @@ describe("working-memory I/O", () => {
136
147
  });
137
148
  });
138
149
 
139
- describe("working-memory migration (§8)", () => {
140
- it("reads from new path when present and ignores legacy", () => {
141
- writeNew("ag_mig", { version: 2, sections: { notes: "fresh" }, updatedAt: "2026-01-01" });
150
+ describe("working-memory migration", () => {
151
+ it("reads from CLI path when present and ignores daemon paths", () => {
152
+ writeCli("ag_mig", { version: 2, sections: { notes: "fresh" }, updatedAt: "2026-01-01" });
153
+ writeDaemonState("ag_mig", { version: 2, sections: { notes: "state" }, updatedAt: "2025-01-01" });
142
154
  writeLegacy("ag_mig", { version: 2, sections: { notes: "stale" }, updatedAt: "2024-01-01" });
143
155
 
144
156
  const got = wm.readWorkingMemory("ag_mig");
145
157
  expect(got?.sections.notes).toBe("fresh");
146
- // Legacy is left in place when new wins; warning is emitted once.
158
+ // Old daemon paths are left in place when CLI wins; warning is emitted once.
159
+ expect(existsSync(daemonStatePathFor("ag_mig"))).toBe(true);
147
160
  expect(existsSync(legacyPathFor("ag_mig"))).toBe(true);
148
161
  expect(warnSpy).toHaveBeenCalled();
149
162
  });
150
163
 
151
- it("renames legacynew on first read when only legacy exists", () => {
164
+ it("renames daemon state CLI path on first read when only daemon state exists", () => {
165
+ writeDaemonState("ag_onlystate", {
166
+ version: 2,
167
+ sections: { notes: "state notes" },
168
+ updatedAt: "2025-01-01",
169
+ });
170
+ expect(existsSync(cliPathFor("ag_onlystate"))).toBe(false);
171
+
172
+ const got = wm.readWorkingMemory("ag_onlystate");
173
+ expect(got?.sections.notes).toBe("state notes");
174
+
175
+ expect(existsSync(daemonStatePathFor("ag_onlystate"))).toBe(false);
176
+ expect(existsSync(cliPathFor("ag_onlystate"))).toBe(true);
177
+ });
178
+
179
+ it("renames legacy daemon memory → CLI path on first read when only legacy exists", () => {
152
180
  writeLegacy("ag_onlyold", {
153
181
  version: 2,
154
182
  sections: { notes: "old notes" },
155
183
  updatedAt: "2024-01-01",
156
184
  });
157
- expect(existsSync(newPathFor("ag_onlyold"))).toBe(false);
185
+ expect(existsSync(cliPathFor("ag_onlyold"))).toBe(false);
158
186
 
159
187
  const got = wm.readWorkingMemory("ag_onlyold");
160
188
  expect(got?.sections.notes).toBe("old notes");
161
189
 
162
- // Legacy moved away; new path now holds the data.
190
+ // Legacy moved away; CLI path now holds the data.
163
191
  expect(existsSync(legacyPathFor("ag_onlyold"))).toBe(false);
164
- expect(existsSync(newPathFor("ag_onlyold"))).toBe(true);
192
+ expect(existsSync(cliPathFor("ag_onlyold"))).toBe(true);
165
193
 
166
- // Subsequent reads come from new path — delete legacy dir tree to
194
+ // Subsequent reads come from CLI path — delete legacy dir tree to
167
195
  // prove no re-read falls through to it.
168
196
  const got2 = wm.readWorkingMemory("ag_onlyold");
169
197
  expect(got2?.sections.notes).toBe("old notes");
@@ -180,13 +208,13 @@ describe("working-memory migration (§8)", () => {
180
208
  updatedAt: "2024-01-01",
181
209
  });
182
210
 
183
- // Plant a regular file where the new state *directory* would live, so
211
+ // Plant a regular file where the CLI memory *directory* would live, so
184
212
  // mkdirSync+renameSync inside the migration branch fails with ENOTDIR
185
- // (the agent home's `state` path already exists as a file). The
213
+ // (`~/.botcord/memory/<agentId>` already exists as a file). The
186
214
  // migration path must log and fall back to reading the legacy file.
187
- const home = path.join(tmpHome, ".botcord", "agents", "ag_renamefail");
215
+ const home = path.join(tmpHome, ".botcord", "memory");
188
216
  mkdirSync(home, { recursive: true });
189
- writeFileSync(path.join(home, "state"), "not a dir");
217
+ writeFileSync(path.join(home, "ag_renamefail"), "not a dir");
190
218
 
191
219
  const got = wm.readWorkingMemory("ag_renamefail");
192
220
  expect(got?.sections.notes).toBe("still readable");
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Working memory — persistent, account-scoped notes injected into every turn.
3
3
  *
4
- * Stored at `~/.botcord/agents/{agentId}/state/working-memory.json` (the
5
- * per-agent state dir owned by the daemon).
4
+ * Stored at `~/.botcord/memory/{agentId}/working-memory.json`, matching the
5
+ * @botcord/cli `botcord memory` command so writes made by an agent are visible
6
+ * to daemon context injection on the next turn.
6
7
  *
7
8
  * Ported from plugin/src/memory.ts (dropping workspace + OpenClaw runtime
8
9
  * branches) and plugin/src/memory-protocol.ts (prompt builder).
@@ -16,6 +17,7 @@ import {
16
17
  unlinkSync,
17
18
  writeFileSync,
18
19
  } from "node:fs";
20
+ import { homedir } from "node:os";
19
21
  import path from "node:path";
20
22
  import { agentStateDir } from "./agent-workspace.js";
21
23
  import { DAEMON_DIR_PATH } from "./config.js";
@@ -50,18 +52,21 @@ const RESERVED_TAGS_RE = /<\/?(?:current_memory|section_\w+)\b[^>]*>/gi;
50
52
  // ── Path resolution ────────────────────────────────────────────────
51
53
 
52
54
  /**
53
- * Canonical per-agent state directory. Returns the new location
54
- * (`~/.botcord/agents/{agentId}/state`). The legacy location under
55
- * `~/.botcord/daemon/memory/{agentId}` is migrated lazily on first read —
56
- * see §8 of the daemon-agent-workspace plan.
55
+ * Canonical per-agent memory directory. This intentionally matches
56
+ * @botcord/cli's `botcord memory` path so the CLI and daemon share one store.
57
57
  */
58
58
  export function resolveMemoryDir(agentId: string): string {
59
59
  if (!agentId) throw new Error("resolveMemoryDir: agentId is required");
60
+ return path.join(homedir(), ".botcord", "memory", agentId);
61
+ }
62
+
63
+ /** Previous daemon-owned location retained for one-shot migration on read. */
64
+ function daemonStateMemoryDir(agentId: string): string {
60
65
  return agentStateDir(agentId);
61
66
  }
62
67
 
63
- /** Legacy location retained for one-shot migration on read. */
64
- function legacyMemoryDir(agentId: string): string {
68
+ /** Older daemon location retained for one-shot migration on read. */
69
+ function daemonLegacyMemoryDir(agentId: string): string {
65
70
  return path.join(DAEMON_DIR_PATH, "memory", agentId);
66
71
  }
67
72
 
@@ -69,8 +74,12 @@ function workingMemoryPath(agentId: string): string {
69
74
  return path.join(resolveMemoryDir(agentId), "working-memory.json");
70
75
  }
71
76
 
72
- function legacyWorkingMemoryPath(agentId: string): string {
73
- return path.join(legacyMemoryDir(agentId), "working-memory.json");
77
+ function daemonStateWorkingMemoryPath(agentId: string): string {
78
+ return path.join(daemonStateMemoryDir(agentId), "working-memory.json");
79
+ }
80
+
81
+ function daemonLegacyWorkingMemoryPath(agentId: string): string {
82
+ return path.join(daemonLegacyMemoryDir(agentId), "working-memory.json");
74
83
  }
75
84
 
76
85
  // Migration conflict warnings are emitted at most once per agent per
@@ -79,59 +88,63 @@ function legacyWorkingMemoryPath(agentId: string): string {
79
88
  const warnedMigrationConflict = new Set<string>();
80
89
 
81
90
  /**
82
- * Resolve the path to read from, migrating from the legacy location if
91
+ * Resolve the path to read from, migrating from daemon-only locations if
83
92
  * necessary. Returns the path the caller should read, or `null` when no
84
93
  * memory file exists anywhere.
85
- *
86
- * Migration branch (the `else if` on `legacyExists` below) is meant to be
87
- * deleted one release after this change ships; see plan §8 step 6.
88
94
  */
89
95
  function resolveReadPath(agentId: string): string | null {
90
- const newPath = workingMemoryPath(agentId);
91
- const oldPath = legacyWorkingMemoryPath(agentId);
92
- const newExists = existsSync(newPath);
93
- const oldExists = existsSync(oldPath);
94
-
95
- if (newExists) {
96
- if (oldExists && !warnedMigrationConflict.has(agentId)) {
96
+ const cliPath = workingMemoryPath(agentId);
97
+ const daemonStatePath = daemonStateWorkingMemoryPath(agentId);
98
+ const daemonLegacyPath = daemonLegacyWorkingMemoryPath(agentId);
99
+ const cliExists = existsSync(cliPath);
100
+ const daemonStateExists = existsSync(daemonStatePath);
101
+ const daemonLegacyExists = existsSync(daemonLegacyPath);
102
+
103
+ if (cliExists) {
104
+ if ((daemonStateExists || daemonLegacyExists) && !warnedMigrationConflict.has(agentId)) {
97
105
  warnedMigrationConflict.add(agentId);
98
- daemonLog.warn("working-memory: both new and legacy paths exist; using new", {
106
+ daemonLog.warn("working-memory: both cli and daemon paths exist; using cli", {
99
107
  agentId,
100
- oldPath,
101
- newPath,
108
+ cliPath,
109
+ daemonStatePath,
110
+ daemonLegacyPath,
102
111
  });
103
112
  }
104
- return newPath;
113
+ return cliPath;
105
114
  }
106
- if (oldExists) {
115
+
116
+ const migrateFrom = daemonStateExists
117
+ ? daemonStatePath
118
+ : daemonLegacyExists
119
+ ? daemonLegacyPath
120
+ : null;
121
+ if (migrateFrom) {
107
122
  try {
108
- mkdirSync(path.dirname(newPath), { recursive: true, mode: 0o700 });
123
+ mkdirSync(path.dirname(cliPath), { recursive: true, mode: 0o700 });
109
124
  try {
110
- renameSync(oldPath, newPath);
125
+ renameSync(migrateFrom, cliPath);
111
126
  } catch (err) {
112
- // EXDEV = legacy and new paths live on different filesystems
127
+ // EXDEV = old and canonical paths live on different filesystems
113
128
  // (bind mounts, tmpfs overlays). `renameSync` cannot cross fs
114
- // boundaries, so fall back to copy + unlink. Without this, the
115
- // next write would go to newPath while legacy still has the old
116
- // payload — silent divergence the reviewer of §8 flagged.
129
+ // boundaries, so fall back to copy + unlink.
117
130
  if ((err as NodeJS.ErrnoException).code === "EXDEV") {
118
- copyFileSync(oldPath, newPath);
119
- unlinkSync(oldPath);
131
+ copyFileSync(migrateFrom, cliPath);
132
+ unlinkSync(migrateFrom);
120
133
  } else {
121
134
  throw err;
122
135
  }
123
136
  }
124
- return newPath;
137
+ return cliPath;
125
138
  } catch (err) {
126
139
  const e = err as NodeJS.ErrnoException;
127
- daemonLog.warn("working-memory: migration rename failed; reading legacy path", {
140
+ daemonLog.warn("working-memory: migration rename failed; reading daemon path", {
128
141
  agentId,
129
- oldPath,
130
- newPath,
142
+ oldPath: migrateFrom,
143
+ newPath: cliPath,
131
144
  code: e.code,
132
145
  error: e.message ?? String(err),
133
146
  });
134
- return oldPath;
147
+ return migrateFrom;
135
148
  }
136
149
  }
137
150
  return null;