@botcord/daemon 0.2.66 → 0.2.68

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/index.js CHANGED
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, rmSync } from "node:fs";
4
4
  import { homedir, hostname } from "node:os";
5
5
  import path from "node:path";
6
+ import { augmentProcessPath } from "./path-env.js";
6
7
  import { loadConfig, saveConfig, initDefaultConfig, resolveConfiguredAgentIds, PID_PATH, SNAPSHOT_PATH, CONFIG_FILE_PATH, CONFIG_MISSING, } from "./config.js";
7
8
  import { resolveBootAgents } from "./agent-discovery.js";
8
9
  import { defaultTranscriptRoot, resolveTranscriptEnabled, transcriptAgentRoot, transcriptFilePath, } from "./gateway/index.js";
@@ -18,6 +19,7 @@ import { clearWorkingMemory, readWorkingMemory, resolveMemoryDir, updateWorkingM
18
19
  import { createDiagnosticBundle } from "./diagnostics.js";
19
20
  import { resolveStartAuthAction } from "./start-auth.js";
20
21
  import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
22
+ augmentProcessPath();
21
23
  const ADAPTER_LIST = listAdapterIds().join("|");
22
24
  const DEFAULT_HUB = "https://api.botcord.chat";
23
25
  /**
package/dist/loop-risk.js CHANGED
@@ -62,12 +62,26 @@ export function stripBotCordPromptScaffolding(text) {
62
62
  return false;
63
63
  if (line.startsWith("[Room Rule]"))
64
64
  return false;
65
- if (line.startsWith("[In group chats, do NOT reply"))
65
+ if (line.startsWith("[In group chats,"))
66
+ return false;
67
+ if (line.startsWith("This group-reply restriction"))
68
+ return false;
69
+ if (line.startsWith("including analyzing the message"))
70
+ return false;
71
+ if (line.startsWith("forwarding a summary"))
72
+ return false;
73
+ if (line.startsWith("When a message matches an active monitoring rule"))
74
+ return false;
75
+ if (line.startsWith("keyword, sender rule"))
76
+ return false;
77
+ if (line.startsWith("you do not reply to the group"))
66
78
  return false;
67
79
  if (line.startsWith("[If the conversation has naturally concluded"))
68
80
  return false;
69
81
  if (line.startsWith("[You received a contact request"))
70
82
  return false;
83
+ if (line.includes("no background action is needed"))
84
+ return false;
71
85
  if (line.includes('reply with exactly "NO_REPLY"'))
72
86
  return false;
73
87
  if (line.startsWith("<agent-message"))
@@ -0,0 +1,8 @@
1
+ export declare function commonDaemonPathEntries(home?: string | undefined): string[];
2
+ export declare function mergePathEntries(basePath: string | undefined, extras: string[]): string;
3
+ /**
4
+ * GUI-launched macOS apps inherit a sparse launchd PATH and do not read the
5
+ * user's shell profile. Add common per-user CLI install locations so runtime
6
+ * adapters can find tools installed by uv/pipx, cargo, bun, npm, etc.
7
+ */
8
+ export declare function augmentProcessPath(): void;
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+ const COMMON_USER_BIN_RELATIVE_PATHS = [
3
+ ".botcord/bin",
4
+ ".local/bin",
5
+ ".cargo/bin",
6
+ ".bun/bin",
7
+ ".deno/bin",
8
+ ".npm-global/bin",
9
+ ".yarn/bin",
10
+ ".pnpm",
11
+ ".pyenv/shims",
12
+ ".rye/shims",
13
+ ".pixi/bin",
14
+ ];
15
+ const COMMON_SYSTEM_BIN_PATHS = process.platform === "darwin"
16
+ ? ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin"]
17
+ : ["/usr/local/bin", "/usr/local/sbin"];
18
+ export function commonDaemonPathEntries(home = process.env.HOME) {
19
+ const userEntries = home
20
+ ? COMMON_USER_BIN_RELATIVE_PATHS.map((entry) => path.join(home, entry))
21
+ : [];
22
+ return [...COMMON_SYSTEM_BIN_PATHS, ...userEntries];
23
+ }
24
+ export function mergePathEntries(basePath, extras) {
25
+ const seen = new Set();
26
+ const out = [];
27
+ for (const raw of [...(basePath ?? "").split(path.delimiter), ...extras]) {
28
+ const entry = raw.trim();
29
+ if (!entry || seen.has(entry))
30
+ continue;
31
+ seen.add(entry);
32
+ out.push(entry);
33
+ }
34
+ return out.join(path.delimiter);
35
+ }
36
+ /**
37
+ * GUI-launched macOS apps inherit a sparse launchd PATH and do not read the
38
+ * user's shell profile. Add common per-user CLI install locations so runtime
39
+ * adapters can find tools installed by uv/pipx, cargo, bun, npm, etc.
40
+ */
41
+ export function augmentProcessPath() {
42
+ process.env.PATH = mergePathEntries(process.env.PATH, commonDaemonPathEntries(process.env.HOME));
43
+ }
@@ -15,9 +15,11 @@
15
15
  * hello
16
16
  * </agent-message>
17
17
  *
18
- * [In group chats, do NOT reply unless you are explicitly mentioned or
19
- * addressed. If no response is needed, reply with exactly "NO_REPLY"
20
- * and nothing else.]
18
+ * [In group chats, do not send a message back to the current group room
19
+ * unless you are explicitly mentioned, addressed, or the room policy says
20
+ * you should participate. This group-reply restriction only controls
21
+ * whether you post back into the current group. It does not prevent
22
+ * owner-approved or policy-approved background actions...]
21
23
  *
22
24
  * Owner-chat messages bypass the wrapper entirely — they are trusted and
23
25
  * the owner-chat scene prompt in `system-context.ts` already gives the
package/dist/turn-text.js CHANGED
@@ -1,7 +1,15 @@
1
1
  import { sanitizeSenderName, sanitizeUntrustedContent } from "./gateway/index.js";
2
2
  import { classifyActivitySender } from "./sender-classify.js";
3
- const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. ' +
4
- 'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
3
+ const GROUP_HINT = "[In group chats, do not send a message back to the current group room " +
4
+ "unless you are explicitly mentioned, addressed, or the room policy says you should participate.\n\n" +
5
+ "This group-reply restriction only controls whether you post back into the current group. " +
6
+ "It does not prevent you from performing owner-approved or policy-approved background actions, " +
7
+ "including analyzing the message, updating memory, calling tools, starting a task, " +
8
+ "forwarding a summary, or notifying the owner.\n\n" +
9
+ "When a message matches an active monitoring rule, automation goal, working-memory task, " +
10
+ "keyword, sender rule, or owner-approved workflow, perform the required action even if " +
11
+ "you do not reply to the group.\n\n" +
12
+ 'If no group reply and no background action is needed, reply exactly "NO_REPLY".]';
5
13
  const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
6
14
  'reply with exactly "NO_REPLY" and nothing else.]';
7
15
  /**
@@ -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.68",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,9 @@
32
32
  "@larksuiteoapi/node-sdk": "^1.63.1",
33
33
  "ws": "^8.18.0"
34
34
  },
35
+ "overrides": {
36
+ "axios": "^1.15.2"
37
+ },
35
38
  "devDependencies": {
36
39
  "@types/node": "^20.0.0",
37
40
  "@types/ws": "^8.5.0",
@@ -22,7 +22,13 @@ describe("stripBotCordPromptScaffolding", () => {
22
22
  "hello world",
23
23
  "</agent-message>",
24
24
  "",
25
- '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. If no response is needed, reply with exactly "NO_REPLY" and nothing else.]',
25
+ "[In group chats, do not send a message back to the current group room unless you are explicitly mentioned, addressed, or the room policy says you should participate.",
26
+ "",
27
+ "This group-reply restriction only controls whether you post back into the current group. It does not prevent you from performing owner-approved or policy-approved background actions, including analyzing the message, updating memory, calling tools, starting a task, forwarding a summary, or notifying the owner.",
28
+ "",
29
+ "When a message matches an active monitoring rule, automation goal, working-memory task, keyword, sender rule, or owner-approved workflow, perform the required action even if you do not reply to the group.",
30
+ "",
31
+ 'If no group reply and no background action is needed, reply exactly "NO_REPLY".]',
26
32
  ].join("\n");
27
33
  expect(stripBotCordPromptScaffolding(wrapped)).toBe("hello world");
28
34
  });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import path from "node:path";
3
+ import { commonDaemonPathEntries, mergePathEntries } from "../path-env.js";
4
+
5
+ describe("path-env", () => {
6
+ it("adds common user CLI locations", () => {
7
+ expect(commonDaemonPathEntries("/Users/alice")).toEqual(
8
+ expect.arrayContaining([
9
+ "/Users/alice/.botcord/bin",
10
+ "/Users/alice/.local/bin",
11
+ "/Users/alice/.cargo/bin",
12
+ "/Users/alice/.bun/bin",
13
+ "/Users/alice/.pyenv/shims",
14
+ ]),
15
+ );
16
+ });
17
+
18
+ it("preserves existing PATH precedence and de-duplicates entries", () => {
19
+ const base = ["/usr/bin", "/bin", "/Users/alice/.local/bin"].join(path.delimiter);
20
+ const merged = mergePathEntries(base, [
21
+ "/Users/alice/.local/bin",
22
+ "/opt/homebrew/bin",
23
+ "/usr/bin",
24
+ ]);
25
+
26
+ expect(merged.split(path.delimiter)).toEqual([
27
+ "/usr/bin",
28
+ "/bin",
29
+ "/Users/alice/.local/bin",
30
+ "/opt/homebrew/bin",
31
+ ]);
32
+ });
33
+ });
@@ -34,7 +34,9 @@ describe("composeBotCordUserTurn", () => {
34
34
  expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
35
35
  expect(out).toContain("hey everyone");
36
36
  expect(out).toContain("</agent-message>");
37
- expect(out).toContain('do NOT reply unless you are explicitly mentioned');
37
+ expect(out).toContain("do not send a message back to the current group room");
38
+ expect(out).toContain("owner-approved or policy-approved background actions");
39
+ expect(out).toContain("active monitoring rule");
38
40
  expect(out).toContain('"NO_REPLY"');
39
41
  });
40
42
 
@@ -247,7 +249,8 @@ describe("composeBotCordUserTurn", () => {
247
249
  // Single-message header must NOT appear in batch mode.
248
250
  expect(out).not.toContain("[BotCord Message]");
249
251
  // Group hint still appears after the blocks.
250
- expect(out).toContain("do NOT reply unless");
252
+ expect(out).toContain("do not send a message back to the current group room");
253
+ expect(out).toContain("no background action is needed");
251
254
  });
252
255
 
253
256
  it("batched path tags dashboard_human_room senders as human-message", () => {
@@ -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");
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, rmSync } from "node:fs";
4
4
  import { homedir, hostname } from "node:os";
5
5
  import path from "node:path";
6
+ import { augmentProcessPath } from "./path-env.js";
6
7
  import {
7
8
  loadConfig,
8
9
  saveConfig,
@@ -65,6 +66,8 @@ import {
65
66
  openclawDiscoveryConfigEnabled,
66
67
  } from "./openclaw-discovery.js";
67
68
 
69
+ augmentProcessPath();
70
+
68
71
  const ADAPTER_LIST = listAdapterIds().join("|");
69
72
 
70
73
  const DEFAULT_HUB = "https://api.botcord.chat";
package/src/loop-risk.ts CHANGED
@@ -83,9 +83,16 @@ export function stripBotCordPromptScaffolding(text: string): string {
83
83
  if (line.startsWith("[BotCord Message]")) return false;
84
84
  if (line.startsWith("[BotCord Notification]")) return false;
85
85
  if (line.startsWith("[Room Rule]")) return false;
86
- if (line.startsWith("[In group chats, do NOT reply")) return false;
86
+ if (line.startsWith("[In group chats,")) return false;
87
+ if (line.startsWith("This group-reply restriction")) return false;
88
+ if (line.startsWith("including analyzing the message")) return false;
89
+ if (line.startsWith("forwarding a summary")) return false;
90
+ if (line.startsWith("When a message matches an active monitoring rule")) return false;
91
+ if (line.startsWith("keyword, sender rule")) return false;
92
+ if (line.startsWith("you do not reply to the group")) return false;
87
93
  if (line.startsWith("[If the conversation has naturally concluded")) return false;
88
94
  if (line.startsWith("[You received a contact request")) return false;
95
+ if (line.includes("no background action is needed")) return false;
89
96
  if (line.includes('reply with exactly "NO_REPLY"')) return false;
90
97
  if (line.startsWith("<agent-message")) return false;
91
98
  if (line === "</agent-message>") return false;
@@ -0,0 +1,53 @@
1
+ import path from "node:path";
2
+
3
+ const COMMON_USER_BIN_RELATIVE_PATHS = [
4
+ ".botcord/bin",
5
+ ".local/bin",
6
+ ".cargo/bin",
7
+ ".bun/bin",
8
+ ".deno/bin",
9
+ ".npm-global/bin",
10
+ ".yarn/bin",
11
+ ".pnpm",
12
+ ".pyenv/shims",
13
+ ".rye/shims",
14
+ ".pixi/bin",
15
+ ];
16
+
17
+ const COMMON_SYSTEM_BIN_PATHS =
18
+ process.platform === "darwin"
19
+ ? ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin"]
20
+ : ["/usr/local/bin", "/usr/local/sbin"];
21
+
22
+ export function commonDaemonPathEntries(home = process.env.HOME): string[] {
23
+ const userEntries = home
24
+ ? COMMON_USER_BIN_RELATIVE_PATHS.map((entry) => path.join(home, entry))
25
+ : [];
26
+ return [...COMMON_SYSTEM_BIN_PATHS, ...userEntries];
27
+ }
28
+
29
+ export function mergePathEntries(basePath: string | undefined, extras: string[]): string {
30
+ const seen = new Set<string>();
31
+ const out: string[] = [];
32
+
33
+ for (const raw of [...(basePath ?? "").split(path.delimiter), ...extras]) {
34
+ const entry = raw.trim();
35
+ if (!entry || seen.has(entry)) continue;
36
+ seen.add(entry);
37
+ out.push(entry);
38
+ }
39
+
40
+ return out.join(path.delimiter);
41
+ }
42
+
43
+ /**
44
+ * GUI-launched macOS apps inherit a sparse launchd PATH and do not read the
45
+ * user's shell profile. Add common per-user CLI install locations so runtime
46
+ * adapters can find tools installed by uv/pipx, cargo, bun, npm, etc.
47
+ */
48
+ export function augmentProcessPath(): void {
49
+ process.env.PATH = mergePathEntries(
50
+ process.env.PATH,
51
+ commonDaemonPathEntries(process.env.HOME),
52
+ );
53
+ }
package/src/turn-text.ts CHANGED
@@ -15,9 +15,11 @@
15
15
  * hello
16
16
  * </agent-message>
17
17
  *
18
- * [In group chats, do NOT reply unless you are explicitly mentioned or
19
- * addressed. If no response is needed, reply with exactly "NO_REPLY"
20
- * and nothing else.]
18
+ * [In group chats, do not send a message back to the current group room
19
+ * unless you are explicitly mentioned, addressed, or the room policy says
20
+ * you should participate. This group-reply restriction only controls
21
+ * whether you post back into the current group. It does not prevent
22
+ * owner-approved or policy-approved background actions...]
21
23
  *
22
24
  * Owner-chat messages bypass the wrapper entirely — they are trusted and
23
25
  * the owner-chat scene prompt in `system-context.ts` already gives the
@@ -28,8 +30,16 @@ import { sanitizeSenderName, sanitizeUntrustedContent } from "./gateway/index.js
28
30
  import { classifyActivitySender } from "./sender-classify.js";
29
31
 
30
32
  const GROUP_HINT =
31
- '[In group chats, do NOT reply unless you are explicitly mentioned or addressed. ' +
32
- 'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
33
+ "[In group chats, do not send a message back to the current group room " +
34
+ "unless you are explicitly mentioned, addressed, or the room policy says you should participate.\n\n" +
35
+ "This group-reply restriction only controls whether you post back into the current group. " +
36
+ "It does not prevent you from performing owner-approved or policy-approved background actions, " +
37
+ "including analyzing the message, updating memory, calling tools, starting a task, " +
38
+ "forwarding a summary, or notifying the owner.\n\n" +
39
+ "When a message matches an active monitoring rule, automation goal, working-memory task, " +
40
+ "keyword, sender rule, or owner-approved workflow, perform the required action even if " +
41
+ "you do not reply to the group.\n\n" +
42
+ 'If no group reply and no background action is needed, reply exactly "NO_REPLY".]';
33
43
  const DIRECT_HINT =
34
44
  '[If the conversation has naturally concluded or no response is needed, ' +
35
45
  'reply with exactly "NO_REPLY" and nothing else.]';
@@ -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;