@botcord/daemon 0.2.48 → 0.2.49

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/daemon.js CHANGED
@@ -19,10 +19,11 @@ import { UserAuthManager } from "./user-auth.js";
19
19
  import { PolicyResolver } from "./gateway/policy-resolver.js";
20
20
  import { scanMention } from "./mention-scan.js";
21
21
  /**
22
- * Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
23
- * long-running CLI turns behave the same way under the gateway core.
22
+ * Default hard cap for a single runtime turn. Long-running coding/research
23
+ * tasks routinely exceed 10 minutes, so daemon-hosted agents get a larger
24
+ * window before the dispatcher aborts the runtime.
24
25
  */
25
- const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
26
+ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
26
27
  /**
27
28
  * Default cadence for writing `gateway.snapshot()` to disk. Override via
28
29
  * `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`.
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { resolveRoute } from "./router.js";
3
3
  import { sessionKey } from "./session-store.js";
4
4
  import { truncateTextField, } from "./transcript.js";
5
- const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
5
+ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
6
6
  /**
7
7
  * Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
8
8
  * `result.text` forwarded to the channel; in every other room the runtime's
@@ -17,9 +17,13 @@ export function scanMention(text, targets) {
17
17
  if (!text)
18
18
  return false;
19
19
  const lower = text.toLowerCase();
20
+ const normalizedAgentId = targets.agentId?.trim().toLowerCase();
21
+ if (normalizedAgentId && lower.includes("(" + normalizedAgentId + ")")) {
22
+ return true;
23
+ }
20
24
  const candidates = [];
21
- if (targets.agentId)
22
- candidates.push(targets.agentId.toLowerCase());
25
+ if (normalizedAgentId)
26
+ candidates.push(normalizedAgentId);
23
27
  if (targets.displayName) {
24
28
  const trimmed = targets.displayName.trim();
25
29
  if (trimmed)
@@ -0,0 +1,17 @@
1
+ export interface SoftSkillEntry {
2
+ name: string;
3
+ path: string;
4
+ source: string;
5
+ description?: string;
6
+ mtimeMs: number;
7
+ }
8
+ export interface SkillIndexOptions {
9
+ extraDirs?: string[];
10
+ includeGlobal?: boolean;
11
+ }
12
+ export declare function defaultSkillDirs(agentId: string, opts?: SkillIndexOptions): Array<{
13
+ dir: string;
14
+ source: string;
15
+ }>;
16
+ export declare function scanSoftSkills(agentId: string, opts?: SkillIndexOptions): SoftSkillEntry[];
17
+ export declare function buildSoftSkillIndexPrompt(agentId: string, opts?: SkillIndexOptions): string | null;
@@ -0,0 +1,177 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { agentCodexHomeDir, agentWorkspaceDir, } from "./agent-workspace.js";
5
+ const MAX_SKILLS = 24;
6
+ const MAX_DESCRIPTION_CHARS = 260;
7
+ const MAX_SKILL_MD_READ_CHARS = 8192;
8
+ export function defaultSkillDirs(agentId, opts = {}) {
9
+ const includeGlobal = opts.includeGlobal !== false;
10
+ const dirs = [
11
+ {
12
+ dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
13
+ source: "agent-claude",
14
+ },
15
+ {
16
+ dir: path.join(agentCodexHomeDir(agentId), "skills"),
17
+ source: "agent-codex",
18
+ },
19
+ ];
20
+ if (includeGlobal) {
21
+ dirs.push({ dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" }, { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" });
22
+ }
23
+ const envDirs = parseSkillDirsEnv(process.env.BOTCORD_SKILL_DIRS);
24
+ for (const dir of [...envDirs, ...(opts.extraDirs ?? [])]) {
25
+ dirs.push({ dir, source: "external" });
26
+ }
27
+ return dedupeDirs(dirs);
28
+ }
29
+ export function scanSoftSkills(agentId, opts = {}) {
30
+ const byName = new Map();
31
+ const byPath = new Set();
32
+ for (const root of defaultSkillDirs(agentId, opts)) {
33
+ if (!existsSync(root.dir))
34
+ continue;
35
+ let children;
36
+ try {
37
+ children = readdirSync(root.dir);
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ for (const child of children) {
43
+ const skillDir = path.join(root.dir, child);
44
+ const skillMd = path.join(skillDir, "SKILL.md");
45
+ if (byPath.has(skillMd) || !existsSync(skillMd))
46
+ continue;
47
+ byPath.add(skillMd);
48
+ let st;
49
+ try {
50
+ st = statSync(skillMd);
51
+ if (!st.isFile())
52
+ continue;
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ const parsed = parseSkillFile(skillMd, child);
58
+ const existing = byName.get(parsed.name);
59
+ const entry = {
60
+ name: parsed.name,
61
+ path: skillMd,
62
+ source: root.source,
63
+ description: parsed.description,
64
+ mtimeMs: st.mtimeMs,
65
+ };
66
+ if (!existing || priority(root.source) < priority(existing.source)) {
67
+ byName.set(entry.name, entry);
68
+ }
69
+ }
70
+ }
71
+ return Array.from(byName.values())
72
+ .sort((a, b) => a.name.localeCompare(b.name))
73
+ .slice(0, MAX_SKILLS);
74
+ }
75
+ export function buildSoftSkillIndexPrompt(agentId, opts = {}) {
76
+ const skills = scanSoftSkills(agentId, opts);
77
+ if (skills.length === 0)
78
+ return null;
79
+ const lines = [
80
+ "[BotCord Daemon Skill Index]",
81
+ "The daemon scanned these SKILL.md files on disk this turn. This is a soft skill index for runtimes whose native skill registry may not hot-reload during resumed sessions.",
82
+ "If the user's request matches a listed skill and the native skill is not already active, read that SKILL.md file directly and follow its workflow manually. Do not assume this index creates new native tools; use only tools and CLIs that are actually available.",
83
+ "",
84
+ ];
85
+ for (const skill of skills) {
86
+ const desc = skill.description ? ` - ${skill.description}` : "";
87
+ lines.push(`- ${skill.name} (${skill.source}): ${skill.path}${desc}`);
88
+ }
89
+ return lines.join("\n");
90
+ }
91
+ function parseSkillFile(skillMd, fallbackName) {
92
+ let raw = "";
93
+ try {
94
+ raw = readFileSync(skillMd, "utf8").slice(0, MAX_SKILL_MD_READ_CHARS);
95
+ }
96
+ catch {
97
+ return { name: fallbackName };
98
+ }
99
+ const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
100
+ const frontmatter = fm?.[1] ?? "";
101
+ const name = readYamlScalar(frontmatter, "name") ?? fallbackName;
102
+ const description = readYamlScalar(frontmatter, "description") ??
103
+ readMarkdownDescription(raw) ??
104
+ undefined;
105
+ return {
106
+ name: sanitizeInline(name) || fallbackName,
107
+ description: description ? truncate(sanitizeInline(description), MAX_DESCRIPTION_CHARS) : undefined,
108
+ };
109
+ }
110
+ function readYamlScalar(frontmatter, key) {
111
+ const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m");
112
+ const match = frontmatter.match(re);
113
+ if (!match)
114
+ return null;
115
+ return unquote(match[1] ?? "");
116
+ }
117
+ function readMarkdownDescription(raw) {
118
+ const purpose = raw.match(/\*\*Purpose:\*\*\s*([^\n]+)/i);
119
+ if (purpose?.[1])
120
+ return purpose[1];
121
+ const firstParagraph = raw
122
+ .replace(/^---\r?\n[\s\S]*?\r?\n---/, "")
123
+ .split(/\n\s*\n/)
124
+ .map((s) => s.trim())
125
+ .find((s) => s && !s.startsWith("#"));
126
+ return firstParagraph ?? null;
127
+ }
128
+ function parseSkillDirsEnv(value) {
129
+ if (!value)
130
+ return [];
131
+ return value
132
+ .split(path.delimiter)
133
+ .map((s) => s.trim())
134
+ .filter(Boolean);
135
+ }
136
+ function dedupeDirs(dirs) {
137
+ const seen = new Set();
138
+ const out = [];
139
+ for (const entry of dirs) {
140
+ const resolved = path.resolve(entry.dir);
141
+ if (seen.has(resolved))
142
+ continue;
143
+ seen.add(resolved);
144
+ out.push({ dir: resolved, source: entry.source });
145
+ }
146
+ return out;
147
+ }
148
+ function priority(source) {
149
+ switch (source) {
150
+ case "agent-claude":
151
+ return 0;
152
+ case "agent-codex":
153
+ return 1;
154
+ case "global-claude":
155
+ return 2;
156
+ case "global-codex":
157
+ return 3;
158
+ default:
159
+ return 4;
160
+ }
161
+ }
162
+ function unquote(value) {
163
+ const trimmed = value.trim();
164
+ if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
165
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
166
+ return trimmed.slice(1, -1);
167
+ }
168
+ return trimmed;
169
+ }
170
+ function sanitizeInline(value) {
171
+ return value.replace(/\s+/g, " ").trim();
172
+ }
173
+ function truncate(value, max) {
174
+ if (value.length <= max)
175
+ return value;
176
+ return `${value.slice(0, Math.max(0, max - 3))}...`;
177
+ }
@@ -11,6 +11,7 @@
11
11
  * 3. `[BotCord Working Memory]`
12
12
  * 4. `[BotCord Room Context]` (group rooms, via optional async fetcher)
13
13
  * 5. `[BotCord Cross-Room Awareness]` (optional activity tracker)
14
+ * 6. `[BotCord Daemon Skill Index]` (soft skill hot-reload index)
14
15
  *
15
16
  * Behavior:
16
17
  * - Working memory is loaded fresh per turn, so a `memory set` from another
@@ -53,6 +54,11 @@ export interface SystemContextDeps {
53
54
  * + cheap — consulted every turn even when roomContextBuilder is absent.
54
55
  */
55
56
  loopRiskBuilder?: (message: GatewayInboundMessage) => string | null;
57
+ /**
58
+ * Optional soft skill index builder. Defaults to scanning daemon-known skill
59
+ * dirs each turn. Return null to suppress the block.
60
+ */
61
+ skillIndexBuilder?: (message: GatewayInboundMessage) => string | null;
56
62
  }
57
63
  /**
58
64
  * Build a {@link SystemContextBuilder} for the gateway dispatcher.
@@ -3,6 +3,7 @@ import { buildWorkingMemoryPrompt, readWorkingMemory } from "./working-memory.js
3
3
  import { readIdentity } from "./agent-workspace.js";
4
4
  import { classifyActivitySender } from "./sender-classify.js";
5
5
  import { log } from "./log.js";
6
+ import { buildSoftSkillIndexPrompt } from "./skill-index.js";
6
7
  /**
7
8
  * Scene prompt injected when the inbound turn comes from the owner's
8
9
  * dashboard chat. Mirrors `plugin/src/room-context.ts#buildOwnerChatSceneContext`
@@ -96,14 +97,30 @@ export function createDaemonSystemContextBuilder(deps) {
96
97
  return null;
97
98
  }
98
99
  };
100
+ const buildSkillIndex = (message) => {
101
+ try {
102
+ if (deps.skillIndexBuilder)
103
+ return deps.skillIndexBuilder(message);
104
+ return buildSoftSkillIndexPrompt(deps.agentId);
105
+ }
106
+ catch (err) {
107
+ log.warn("system-context: skill index build failed — skipping skill block", {
108
+ agentId: deps.agentId,
109
+ roomId: message.conversation.id,
110
+ err: err instanceof Error ? err.message : String(err),
111
+ });
112
+ return null;
113
+ }
114
+ };
99
115
  if (!deps.roomContextBuilder) {
100
116
  const syncBuilder = (message) => {
101
117
  const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
102
118
  // Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
103
119
  // is the last thing the model sees before the user turn body.
104
120
  // Identity sits at the very front so it frames every other block.
121
+ const skillIndex = buildSkillIndex(message);
105
122
  const loopRisk = runLoopRisk(message);
106
- return assemble([identity, ownerScene, memory, digest, loopRisk]);
123
+ return assemble([identity, ownerScene, memory, digest, skillIndex, loopRisk]);
107
124
  };
108
125
  // Compile-time witness that the narrower sync signature still satisfies
109
126
  // `SystemContextBuilder` (which allows async). Prevents the two contracts
@@ -131,8 +148,9 @@ export function createDaemonSystemContextBuilder(deps) {
131
148
  err: err instanceof Error ? err.message : String(err),
132
149
  });
133
150
  }
151
+ const skillIndex = buildSkillIndex(message);
134
152
  const loopRisk = runLoopRisk(message);
135
- return assemble([identity, ownerScene, memory, roomBlock, digest, loopRisk]);
153
+ return assemble([identity, ownerScene, memory, roomBlock, digest, skillIndex, loopRisk]);
136
154
  };
137
155
  const _typecheck = asyncBuilder;
138
156
  void _typecheck;
package/dist/turn-text.js CHANGED
@@ -73,6 +73,35 @@ function entryText(e) {
73
73
  return e.envelope.payload.text;
74
74
  return "";
75
75
  }
76
+ function formatRoomContext(raw, fallback) {
77
+ const r = raw && typeof raw === "object" ? raw : {};
78
+ const roomId = typeof r.room_id === "string" && r.room_id ? r.room_id : fallback.id;
79
+ const roomName = typeof r.room_name === "string" && r.room_name ? r.room_name : fallback.title;
80
+ const memberCount = typeof r.room_member_count === "number" && Number.isFinite(r.room_member_count)
81
+ ? r.room_member_count
82
+ : undefined;
83
+ const memberNames = Array.isArray(r.room_member_names)
84
+ ? r.room_member_names.filter((n) => typeof n === "string" && n.length > 0)
85
+ : [];
86
+ const role = typeof r.my_role === "string" && r.my_role ? r.my_role : undefined;
87
+ const canSend = typeof r.my_can_send === "boolean" ? r.my_can_send : undefined;
88
+ const parts = [`id: ${sanitizeSenderName(roomId)}`];
89
+ if (roomName)
90
+ parts.push(`name: ${sanitizeSenderName(roomName)}`);
91
+ if (memberCount !== undefined) {
92
+ const names = memberNames.slice(0, 10).map(sanitizeSenderName).join(", ");
93
+ parts.push(names ? `members: ${memberCount}: ${names}` : `members: ${memberCount}`);
94
+ }
95
+ if (role)
96
+ parts.push(`role: ${sanitizeSenderName(role)}`);
97
+ if (canSend !== undefined)
98
+ parts.push(`can_send: ${canSend ? "true" : "false"}`);
99
+ const lines = [`[BotCord Room] | ${parts.join(" | ")}`];
100
+ if (typeof r.room_rule === "string" && r.room_rule.trim()) {
101
+ lines.push(`[Room Rule] ${sanitizeUntrustedContent(r.room_rule.trim())}`);
102
+ }
103
+ return lines;
104
+ }
76
105
  /**
77
106
  * Compose the user-turn text for a BotCord inbound message.
78
107
  *
@@ -133,6 +162,7 @@ export function composeBotCordUserTurn(msg) {
133
162
  : null;
134
163
  const lines = [
135
164
  headerFields.join(" | "),
165
+ ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
136
166
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
137
167
  trimmed,
138
168
  `</${tag}>`,
@@ -186,6 +216,7 @@ function composeBatchedTurn(msg, batch) {
186
216
  const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
187
217
  const lines = [
188
218
  header.join(" | "),
219
+ ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
189
220
  blocks.join("\n"),
190
221
  "",
191
222
  hint,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.48",
3
+ "version": "0.2.49",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { scanMention } from "../mention-scan.js";
3
+
4
+ describe("scanMention", () => {
5
+ it("matches the exact agent id in structured @Name(agentId) mentions", () => {
6
+ expect(
7
+ scanMention("@Harry(ag_973dfb9193eb) 今天的AI日报发一下呢", {
8
+ agentId: "ag_973dfb9193eb",
9
+ }),
10
+ ).toBe(true);
11
+ });
12
+
13
+ it("still matches display-name mentions", () => {
14
+ expect(scanMention("@Harry 今天的AI日报发一下呢", { displayName: "Harry" })).toBe(true);
15
+ });
16
+ });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import type { GatewayInboundMessage } from "../gateway/index.js";
@@ -130,8 +130,35 @@ describe("createDaemonSystemContextBuilder", () => {
130
130
  it("skips the identity block when identity.md is blank", () => {
131
131
  ensureAgentWorkspace("ag_me", { displayName: "X" });
132
132
  writeFileSync(path.join(agentWorkspaceDir("ag_me"), "identity.md"), "");
133
+ const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
134
+ const out = builder(makeMessage()) as string;
135
+ expect(out).not.toContain("[BotCord Identity]");
136
+ expect(out).toContain("[BotCord Daemon Skill Index]");
137
+ });
138
+
139
+ it("detects a newly added global Claude skill on the next turn", () => {
133
140
  const builder = createDaemonSystemContextBuilder({ agentId: "ag_me" });
134
141
  expect(builder(makeMessage())).toBeUndefined();
142
+
143
+ const skillDir = path.join(tmpDir, ".claude", "skills", "digest-query");
144
+ mkdirSync(skillDir, { recursive: true });
145
+ writeFileSync(
146
+ path.join(skillDir, "SKILL.md"),
147
+ [
148
+ "---",
149
+ "name: digest-query",
150
+ "description: \"Search archived conversation digests with the local digest_query CLI.\"",
151
+ "---",
152
+ "",
153
+ "# Digest Query",
154
+ ].join("\n"),
155
+ );
156
+
157
+ const out = builder(makeMessage()) as string;
158
+ expect(out).toContain("[BotCord Daemon Skill Index]");
159
+ expect(out).toContain("digest-query (global-claude)");
160
+ expect(out).toContain(path.join(skillDir, "SKILL.md"));
161
+ expect(out).toContain("Search archived conversation digests");
135
162
  });
136
163
 
137
164
  it("emits the 'memory is currently empty' notice when the memory file exists but is blank", () => {
@@ -57,6 +57,33 @@ describe("composeBotCordUserTurn", () => {
57
57
  expect(out).toContain("mentioned: true");
58
58
  });
59
59
 
60
+ it("renders structured room context outside the human message body", () => {
61
+ const out = composeBotCordUserTurn(
62
+ makeMessage({
63
+ sender: { id: "hu_alice", name: "Alice", kind: "user" },
64
+ text: "@Harry(ag_973dfb9193eb) 今天的AI日报发一下呢",
65
+ conversation: { id: "rm_news", kind: "group", title: "AI News Daily Brief" },
66
+ raw: {
67
+ room_id: "rm_news",
68
+ room_name: "AI News Daily Brief",
69
+ room_member_count: 6,
70
+ room_member_names: ["Alice", "Harry"],
71
+ my_role: "member",
72
+ my_can_send: true,
73
+ room_rule: "Post concise daily summaries.",
74
+ },
75
+ }),
76
+ );
77
+ const roomIdx = out.indexOf("[BotCord Room]");
78
+ const tagIdx = out.indexOf('<human-message sender="Alice" sender_kind="human">');
79
+ const closeIdx = out.indexOf("</human-message>");
80
+ expect(roomIdx).toBeGreaterThan(-1);
81
+ expect(roomIdx).toBeLessThan(tagIdx);
82
+ expect(out).toContain("[Room Rule] Post concise daily summaries.");
83
+ expect(out.slice(tagIdx, closeIdx)).not.toContain("[BotCord Room]");
84
+ expect(out.slice(tagIdx, closeIdx)).toContain("@Harry(ag_973dfb9193eb)");
85
+ });
86
+
60
87
  it("emits the direct-chat hint (not the group hint) for DM conversations", () => {
61
88
  const out = composeBotCordUserTurn(
62
89
  makeMessage({
package/src/daemon.ts CHANGED
@@ -48,10 +48,11 @@ import { PolicyResolver } from "./gateway/policy-resolver.js";
48
48
  import { scanMention } from "./mention-scan.js";
49
49
 
50
50
  /**
51
- * Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
52
- * long-running CLI turns behave the same way under the gateway core.
51
+ * Default hard cap for a single runtime turn. Long-running coding/research
52
+ * tasks routinely exceed 10 minutes, so daemon-hosted agents get a larger
53
+ * window before the dispatcher aborts the runtime.
53
54
  */
54
- const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
55
+ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
55
56
 
56
57
  /**
57
58
  * Default cadence for writing `gateway.snapshot()` to disk. Override via
@@ -28,7 +28,7 @@ import type {
28
28
  UserTurnBuilder,
29
29
  } from "./types.js";
30
30
 
31
- const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
31
+ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
32
32
 
33
33
  /**
34
34
  * Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
@@ -24,8 +24,12 @@ export interface MentionTargets {
24
24
  export function scanMention(text: string | undefined, targets: MentionTargets): boolean {
25
25
  if (!text) return false;
26
26
  const lower = text.toLowerCase();
27
+ const normalizedAgentId = targets.agentId?.trim().toLowerCase();
28
+ if (normalizedAgentId && lower.includes("(" + normalizedAgentId + ")")) {
29
+ return true;
30
+ }
27
31
  const candidates: string[] = [];
28
- if (targets.agentId) candidates.push(targets.agentId.toLowerCase());
32
+ if (normalizedAgentId) candidates.push(normalizedAgentId);
29
33
  if (targets.displayName) {
30
34
  const trimmed = targets.displayName.trim();
31
35
  if (trimmed) candidates.push(trimmed.toLowerCase());
@@ -0,0 +1,232 @@
1
+ import {
2
+ existsSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ statSync,
6
+ } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import path from "node:path";
9
+ import {
10
+ agentCodexHomeDir,
11
+ agentWorkspaceDir,
12
+ } from "./agent-workspace.js";
13
+
14
+ const MAX_SKILLS = 24;
15
+ const MAX_DESCRIPTION_CHARS = 260;
16
+ const MAX_SKILL_MD_READ_CHARS = 8192;
17
+
18
+ export interface SoftSkillEntry {
19
+ name: string;
20
+ path: string;
21
+ source: string;
22
+ description?: string;
23
+ mtimeMs: number;
24
+ }
25
+
26
+ export interface SkillIndexOptions {
27
+ extraDirs?: string[];
28
+ includeGlobal?: boolean;
29
+ }
30
+
31
+ export function defaultSkillDirs(
32
+ agentId: string,
33
+ opts: SkillIndexOptions = {},
34
+ ): Array<{ dir: string; source: string }> {
35
+ const includeGlobal = opts.includeGlobal !== false;
36
+ const dirs: Array<{ dir: string; source: string }> = [
37
+ {
38
+ dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
39
+ source: "agent-claude",
40
+ },
41
+ {
42
+ dir: path.join(agentCodexHomeDir(agentId), "skills"),
43
+ source: "agent-codex",
44
+ },
45
+ ];
46
+
47
+ if (includeGlobal) {
48
+ dirs.push(
49
+ { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" },
50
+ { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" },
51
+ );
52
+ }
53
+
54
+ const envDirs = parseSkillDirsEnv(process.env.BOTCORD_SKILL_DIRS);
55
+ for (const dir of [...envDirs, ...(opts.extraDirs ?? [])]) {
56
+ dirs.push({ dir, source: "external" });
57
+ }
58
+
59
+ return dedupeDirs(dirs);
60
+ }
61
+
62
+ export function scanSoftSkills(
63
+ agentId: string,
64
+ opts: SkillIndexOptions = {},
65
+ ): SoftSkillEntry[] {
66
+ const byName = new Map<string, SoftSkillEntry>();
67
+ const byPath = new Set<string>();
68
+
69
+ for (const root of defaultSkillDirs(agentId, opts)) {
70
+ if (!existsSync(root.dir)) continue;
71
+ let children: string[];
72
+ try {
73
+ children = readdirSync(root.dir);
74
+ } catch {
75
+ continue;
76
+ }
77
+
78
+ for (const child of children) {
79
+ const skillDir = path.join(root.dir, child);
80
+ const skillMd = path.join(skillDir, "SKILL.md");
81
+ if (byPath.has(skillMd) || !existsSync(skillMd)) continue;
82
+ byPath.add(skillMd);
83
+
84
+ let st;
85
+ try {
86
+ st = statSync(skillMd);
87
+ if (!st.isFile()) continue;
88
+ } catch {
89
+ continue;
90
+ }
91
+
92
+ const parsed = parseSkillFile(skillMd, child);
93
+ const existing = byName.get(parsed.name);
94
+ const entry: SoftSkillEntry = {
95
+ name: parsed.name,
96
+ path: skillMd,
97
+ source: root.source,
98
+ description: parsed.description,
99
+ mtimeMs: st.mtimeMs,
100
+ };
101
+ if (!existing || priority(root.source) < priority(existing.source)) {
102
+ byName.set(entry.name, entry);
103
+ }
104
+ }
105
+ }
106
+
107
+ return Array.from(byName.values())
108
+ .sort((a, b) => a.name.localeCompare(b.name))
109
+ .slice(0, MAX_SKILLS);
110
+ }
111
+
112
+ export function buildSoftSkillIndexPrompt(
113
+ agentId: string,
114
+ opts: SkillIndexOptions = {},
115
+ ): string | null {
116
+ const skills = scanSoftSkills(agentId, opts);
117
+ if (skills.length === 0) return null;
118
+
119
+ const lines = [
120
+ "[BotCord Daemon Skill Index]",
121
+ "The daemon scanned these SKILL.md files on disk this turn. This is a soft skill index for runtimes whose native skill registry may not hot-reload during resumed sessions.",
122
+ "If the user's request matches a listed skill and the native skill is not already active, read that SKILL.md file directly and follow its workflow manually. Do not assume this index creates new native tools; use only tools and CLIs that are actually available.",
123
+ "",
124
+ ];
125
+
126
+ for (const skill of skills) {
127
+ const desc = skill.description ? ` - ${skill.description}` : "";
128
+ lines.push(`- ${skill.name} (${skill.source}): ${skill.path}${desc}`);
129
+ }
130
+
131
+ return lines.join("\n");
132
+ }
133
+
134
+ function parseSkillFile(
135
+ skillMd: string,
136
+ fallbackName: string,
137
+ ): { name: string; description?: string } {
138
+ let raw = "";
139
+ try {
140
+ raw = readFileSync(skillMd, "utf8").slice(0, MAX_SKILL_MD_READ_CHARS);
141
+ } catch {
142
+ return { name: fallbackName };
143
+ }
144
+
145
+ const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
146
+ const frontmatter = fm?.[1] ?? "";
147
+ const name = readYamlScalar(frontmatter, "name") ?? fallbackName;
148
+ const description =
149
+ readYamlScalar(frontmatter, "description") ??
150
+ readMarkdownDescription(raw) ??
151
+ undefined;
152
+
153
+ return {
154
+ name: sanitizeInline(name) || fallbackName,
155
+ description: description ? truncate(sanitizeInline(description), MAX_DESCRIPTION_CHARS) : undefined,
156
+ };
157
+ }
158
+
159
+ function readYamlScalar(frontmatter: string, key: string): string | null {
160
+ const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m");
161
+ const match = frontmatter.match(re);
162
+ if (!match) return null;
163
+ return unquote(match[1] ?? "");
164
+ }
165
+
166
+ function readMarkdownDescription(raw: string): string | null {
167
+ const purpose = raw.match(/\*\*Purpose:\*\*\s*([^\n]+)/i);
168
+ if (purpose?.[1]) return purpose[1];
169
+ const firstParagraph = raw
170
+ .replace(/^---\r?\n[\s\S]*?\r?\n---/, "")
171
+ .split(/\n\s*\n/)
172
+ .map((s) => s.trim())
173
+ .find((s) => s && !s.startsWith("#"));
174
+ return firstParagraph ?? null;
175
+ }
176
+
177
+ function parseSkillDirsEnv(value: string | undefined): string[] {
178
+ if (!value) return [];
179
+ return value
180
+ .split(path.delimiter)
181
+ .map((s) => s.trim())
182
+ .filter(Boolean);
183
+ }
184
+
185
+ function dedupeDirs(
186
+ dirs: Array<{ dir: string; source: string }>,
187
+ ): Array<{ dir: string; source: string }> {
188
+ const seen = new Set<string>();
189
+ const out: Array<{ dir: string; source: string }> = [];
190
+ for (const entry of dirs) {
191
+ const resolved = path.resolve(entry.dir);
192
+ if (seen.has(resolved)) continue;
193
+ seen.add(resolved);
194
+ out.push({ dir: resolved, source: entry.source });
195
+ }
196
+ return out;
197
+ }
198
+
199
+ function priority(source: string): number {
200
+ switch (source) {
201
+ case "agent-claude":
202
+ return 0;
203
+ case "agent-codex":
204
+ return 1;
205
+ case "global-claude":
206
+ return 2;
207
+ case "global-codex":
208
+ return 3;
209
+ default:
210
+ return 4;
211
+ }
212
+ }
213
+
214
+ function unquote(value: string): string {
215
+ const trimmed = value.trim();
216
+ if (
217
+ (trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
218
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
219
+ ) {
220
+ return trimmed.slice(1, -1);
221
+ }
222
+ return trimmed;
223
+ }
224
+
225
+ function sanitizeInline(value: string): string {
226
+ return value.replace(/\s+/g, " ").trim();
227
+ }
228
+
229
+ function truncate(value: string, max: number): string {
230
+ if (value.length <= max) return value;
231
+ return `${value.slice(0, Math.max(0, max - 3))}...`;
232
+ }
@@ -11,6 +11,7 @@
11
11
  * 3. `[BotCord Working Memory]`
12
12
  * 4. `[BotCord Room Context]` (group rooms, via optional async fetcher)
13
13
  * 5. `[BotCord Cross-Room Awareness]` (optional activity tracker)
14
+ * 6. `[BotCord Daemon Skill Index]` (soft skill hot-reload index)
14
15
  *
15
16
  * Behavior:
16
17
  * - Working memory is loaded fresh per turn, so a `memory set` from another
@@ -30,6 +31,7 @@ import { buildWorkingMemoryPrompt, readWorkingMemory } from "./working-memory.js
30
31
  import { readIdentity } from "./agent-workspace.js";
31
32
  import { classifyActivitySender } from "./sender-classify.js";
32
33
  import { log } from "./log.js";
34
+ import { buildSoftSkillIndexPrompt } from "./skill-index.js";
33
35
 
34
36
  /**
35
37
  * Async per-turn room-context builder (see `room-context.ts`). Returns the
@@ -77,6 +79,11 @@ export interface SystemContextDeps {
77
79
  * + cheap — consulted every turn even when roomContextBuilder is absent.
78
80
  */
79
81
  loopRiskBuilder?: (message: GatewayInboundMessage) => string | null;
82
+ /**
83
+ * Optional soft skill index builder. Defaults to scanning daemon-known skill
84
+ * dirs each turn. Return null to suppress the block.
85
+ */
86
+ skillIndexBuilder?: (message: GatewayInboundMessage) => string | null;
80
87
  }
81
88
 
82
89
  function safeReadWorkingMemory(agentId: string) {
@@ -172,14 +179,29 @@ export function createDaemonSystemContextBuilder(
172
179
  }
173
180
  };
174
181
 
182
+ const buildSkillIndex = (message: GatewayInboundMessage): string | null => {
183
+ try {
184
+ if (deps.skillIndexBuilder) return deps.skillIndexBuilder(message);
185
+ return buildSoftSkillIndexPrompt(deps.agentId);
186
+ } catch (err) {
187
+ log.warn("system-context: skill index build failed — skipping skill block", {
188
+ agentId: deps.agentId,
189
+ roomId: message.conversation.id,
190
+ err: err instanceof Error ? err.message : String(err),
191
+ });
192
+ return null;
193
+ }
194
+ };
195
+
175
196
  if (!deps.roomContextBuilder) {
176
197
  const syncBuilder = (message: GatewayInboundMessage): string | undefined => {
177
198
  const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
178
199
  // Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
179
200
  // is the last thing the model sees before the user turn body.
180
201
  // Identity sits at the very front so it frames every other block.
202
+ const skillIndex = buildSkillIndex(message);
181
203
  const loopRisk = runLoopRisk(message);
182
- return assemble([identity, ownerScene, memory, digest, loopRisk]);
204
+ return assemble([identity, ownerScene, memory, digest, skillIndex, loopRisk]);
183
205
  };
184
206
  // Compile-time witness that the narrower sync signature still satisfies
185
207
  // `SystemContextBuilder` (which allows async). Prevents the two contracts
@@ -209,8 +231,9 @@ export function createDaemonSystemContextBuilder(
209
231
  err: err instanceof Error ? err.message : String(err),
210
232
  });
211
233
  }
234
+ const skillIndex = buildSkillIndex(message);
212
235
  const loopRisk = runLoopRisk(message);
213
- return assemble([identity, ownerScene, memory, roomBlock, digest, loopRisk]);
236
+ return assemble([identity, ownerScene, memory, roomBlock, digest, skillIndex, loopRisk]);
214
237
  };
215
238
  const _typecheck: SystemContextBuilder = asyncBuilder;
216
239
  void _typecheck;
package/src/turn-text.ts CHANGED
@@ -88,6 +88,16 @@ interface BatchedEntry {
88
88
  mentioned?: unknown;
89
89
  }
90
90
 
91
+ interface RoomContextRaw {
92
+ room_id?: unknown;
93
+ room_name?: unknown;
94
+ room_rule?: unknown;
95
+ room_member_count?: unknown;
96
+ room_member_names?: unknown;
97
+ my_role?: unknown;
98
+ my_can_send?: unknown;
99
+ }
100
+
91
101
  /**
92
102
  * Read the `raw.batch` array emitted by the BotCord channel when inbox
93
103
  * drain groups multiple messages for the same `(room, topic)`. Returns the
@@ -125,6 +135,36 @@ function entryText(e: BatchedEntry): string {
125
135
  return "";
126
136
  }
127
137
 
138
+ function formatRoomContext(raw: unknown, fallback: { id: string; title?: string }): string[] {
139
+ const r = raw && typeof raw === "object" ? (raw as RoomContextRaw) : {};
140
+ const roomId = typeof r.room_id === "string" && r.room_id ? r.room_id : fallback.id;
141
+ const roomName = typeof r.room_name === "string" && r.room_name ? r.room_name : fallback.title;
142
+ const memberCount =
143
+ typeof r.room_member_count === "number" && Number.isFinite(r.room_member_count)
144
+ ? r.room_member_count
145
+ : undefined;
146
+ const memberNames = Array.isArray(r.room_member_names)
147
+ ? r.room_member_names.filter((n): n is string => typeof n === "string" && n.length > 0)
148
+ : [];
149
+ const role = typeof r.my_role === "string" && r.my_role ? r.my_role : undefined;
150
+ const canSend = typeof r.my_can_send === "boolean" ? r.my_can_send : undefined;
151
+
152
+ const parts = [`id: ${sanitizeSenderName(roomId)}`];
153
+ if (roomName) parts.push(`name: ${sanitizeSenderName(roomName)}`);
154
+ if (memberCount !== undefined) {
155
+ const names = memberNames.slice(0, 10).map(sanitizeSenderName).join(", ");
156
+ parts.push(names ? `members: ${memberCount}: ${names}` : `members: ${memberCount}`);
157
+ }
158
+ if (role) parts.push(`role: ${sanitizeSenderName(role)}`);
159
+ if (canSend !== undefined) parts.push(`can_send: ${canSend ? "true" : "false"}`);
160
+
161
+ const lines = [`[BotCord Room] | ${parts.join(" | ")}`];
162
+ if (typeof r.room_rule === "string" && r.room_rule.trim()) {
163
+ lines.push(`[Room Rule] ${sanitizeUntrustedContent(r.room_rule.trim())}`);
164
+ }
165
+ return lines;
166
+ }
167
+
128
168
  /**
129
169
  * Compose the user-turn text for a BotCord inbound message.
130
170
  *
@@ -193,6 +233,7 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
193
233
 
194
234
  const lines: string[] = [
195
235
  headerFields.join(" | "),
236
+ ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
196
237
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
197
238
  trimmed,
198
239
  `</${tag}>`,
@@ -256,6 +297,7 @@ function composeBatchedTurn(
256
297
  const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
257
298
  const lines: string[] = [
258
299
  header.join(" | "),
300
+ ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
259
301
  blocks.join("\n"),
260
302
  "",
261
303
  hint,