@botcord/daemon 0.2.48 → 0.2.50

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.
@@ -0,0 +1,204 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { buildCliEnv } from "../cli-resolver.js";
3
+ import { NdjsonStreamAdapter } from "./ndjson-stream.js";
4
+ import { readCommandVersion, resolveCommandOnPath, } from "./probe.js";
5
+ function isValidKimiSessionId(sessionId) {
6
+ if (sessionId.length === 0 || sessionId.length > 512)
7
+ return false;
8
+ if (sessionId.startsWith("-"))
9
+ return false;
10
+ for (const ch of sessionId) {
11
+ const code = ch.codePointAt(0);
12
+ if (code === undefined || code < 0x20 || code === 0x7f)
13
+ return false;
14
+ }
15
+ return true;
16
+ }
17
+ function invalidKimiSessionIdError() {
18
+ return "kimi-cli: invalid sessionId (expected non-control text not starting with '-')";
19
+ }
20
+ /** Resolve the Kimi CLI executable on PATH. */
21
+ export function resolveKimiCommand(deps = {}) {
22
+ return resolveCommandOnPath("kimi", deps);
23
+ }
24
+ /** Probe whether the Kimi CLI is installed and report its version. */
25
+ export function probeKimi(deps = {}) {
26
+ const command = resolveKimiCommand(deps);
27
+ if (!command)
28
+ return { available: false };
29
+ return {
30
+ available: true,
31
+ path: command,
32
+ version: readCommandVersion(command, [], deps) ?? undefined,
33
+ };
34
+ }
35
+ /**
36
+ * Kimi CLI adapter — spawns:
37
+ *
38
+ * kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
39
+ *
40
+ * `--session <sid>` resumes an existing session or creates a new session with
41
+ * that id, so the adapter generates a UUID on first turn and persists it for
42
+ * later turns. Kimi does not expose a Codex-style per-invocation AGENTS.md
43
+ * carrier, so dynamic `systemContext` is sent as a system-reminder prefix on
44
+ * the user prompt.
45
+ */
46
+ export class KimiAdapter extends NdjsonStreamAdapter {
47
+ id = "kimi-cli";
48
+ explicitBinary;
49
+ resolvedBinary = null;
50
+ constructor(opts) {
51
+ super();
52
+ this.explicitBinary = opts?.binary ?? process.env.BOTCORD_KIMI_CLI_BIN;
53
+ }
54
+ probe() {
55
+ return probeKimi();
56
+ }
57
+ async run(opts) {
58
+ if (opts.sessionId && !isValidKimiSessionId(opts.sessionId)) {
59
+ return { text: "", newSessionId: "", error: invalidKimiSessionIdError() };
60
+ }
61
+ const sessionId = opts.sessionId || randomUUID();
62
+ return super.run({ ...opts, sessionId });
63
+ }
64
+ resolveBinary() {
65
+ if (this.explicitBinary)
66
+ return this.explicitBinary;
67
+ if (this.resolvedBinary)
68
+ return this.resolvedBinary;
69
+ this.resolvedBinary = resolveKimiCommand() ?? "kimi";
70
+ return this.resolvedBinary;
71
+ }
72
+ buildArgs(opts) {
73
+ const sessionId = opts.sessionId || randomUUID();
74
+ if (!isValidKimiSessionId(sessionId))
75
+ throw new Error(invalidKimiSessionIdError());
76
+ const args = [
77
+ "--work-dir",
78
+ opts.cwd,
79
+ "--print",
80
+ "--output-format",
81
+ "stream-json",
82
+ "--session",
83
+ sessionId,
84
+ "--afk",
85
+ ];
86
+ if (opts.extraArgs?.length)
87
+ args.push(...opts.extraArgs);
88
+ args.push("--prompt", promptWithSystemContext(opts.text, opts.systemContext));
89
+ return args;
90
+ }
91
+ spawnEnv(opts) {
92
+ const cliEnv = buildCliEnv({
93
+ hubUrl: opts.hubUrl,
94
+ accountId: opts.accountId,
95
+ basePath: process.env.PATH,
96
+ });
97
+ return {
98
+ ...process.env,
99
+ ...cliEnv,
100
+ FORCE_COLOR: "0",
101
+ NO_COLOR: "1",
102
+ };
103
+ }
104
+ handleEvent(raw, ctx) {
105
+ const obj = raw;
106
+ const status = kimiStatusEvent(obj);
107
+ if (status)
108
+ ctx.emitStatus(status);
109
+ ctx.emitBlock(normalizeBlock(obj, ctx.seq));
110
+ const sessionId = kimiSessionId(obj);
111
+ if (sessionId)
112
+ ctx.state.newSessionId = sessionId;
113
+ if (obj.role === "assistant") {
114
+ const text = extractText(obj.content);
115
+ if (text) {
116
+ ctx.appendAssistantText(text);
117
+ ctx.state.finalText = text;
118
+ }
119
+ return;
120
+ }
121
+ const err = kimiErrorText(obj);
122
+ if (err)
123
+ ctx.state.errorText = err;
124
+ }
125
+ }
126
+ function promptWithSystemContext(text, systemContext) {
127
+ if (!systemContext)
128
+ return text;
129
+ return `<system-reminder>\n${systemContext}\n</system-reminder>\n\n${text}`;
130
+ }
131
+ function extractText(content) {
132
+ if (typeof content === "string")
133
+ return content;
134
+ if (!Array.isArray(content))
135
+ return "";
136
+ return content
137
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
138
+ .map((part) => part.text)
139
+ .join("");
140
+ }
141
+ function hasThinking(content) {
142
+ return Array.isArray(content)
143
+ ? content.some((part) => part?.type === "think" && typeof part.think === "string" && part.think)
144
+ : false;
145
+ }
146
+ function firstToolName(toolCalls) {
147
+ const name = toolCalls?.find((t) => typeof t.function?.name === "string")?.function?.name;
148
+ return name || "tool";
149
+ }
150
+ function kimiSessionId(obj) {
151
+ return typeof obj.session_id === "string" && obj.session_id ? obj.session_id : undefined;
152
+ }
153
+ function kimiErrorText(obj) {
154
+ if (typeof obj.error === "string" && obj.error)
155
+ return obj.error;
156
+ if (obj.error && typeof obj.error === "object") {
157
+ const message = obj.error.message;
158
+ if (typeof message === "string" && message)
159
+ return message;
160
+ }
161
+ if (obj.type === "error" && typeof obj.message === "string" && obj.message) {
162
+ return obj.message;
163
+ }
164
+ if (obj.severity === "error") {
165
+ return [obj.title, obj.body].filter(Boolean).join(": ") || "kimi-cli error";
166
+ }
167
+ return undefined;
168
+ }
169
+ function kimiStatusEvent(obj) {
170
+ if (obj.role === "assistant" && hasThinking(obj.content)) {
171
+ return { kind: "thinking", phase: "started", label: "Thinking" };
172
+ }
173
+ if (obj.role === "assistant" && obj.tool_calls?.length) {
174
+ return { kind: "thinking", phase: "updated", label: firstToolName(obj.tool_calls) };
175
+ }
176
+ if (obj.role === "assistant" && extractText(obj.content)) {
177
+ return { kind: "thinking", phase: "stopped" };
178
+ }
179
+ if (obj.role === "tool") {
180
+ return { kind: "thinking", phase: "updated", label: "Tool result" };
181
+ }
182
+ return undefined;
183
+ }
184
+ function normalizeBlock(obj, seq) {
185
+ let kind = "other";
186
+ if (obj.role === "assistant") {
187
+ if (obj.tool_calls?.length)
188
+ kind = "tool_use";
189
+ else if (extractText(obj.content))
190
+ kind = "assistant_text";
191
+ else if (hasThinking(obj.content))
192
+ kind = "other";
193
+ }
194
+ else if (obj.role === "tool") {
195
+ kind = "tool_result";
196
+ }
197
+ else if (obj.file_path && typeof obj.content === "string") {
198
+ kind = "other";
199
+ }
200
+ else if (obj.category || obj.severity) {
201
+ kind = "system";
202
+ }
203
+ return { raw: obj, kind, seq };
204
+ }
@@ -33,6 +33,10 @@ export interface RuntimeModule {
33
33
  export declare const claudeCodeModule: RuntimeModule;
34
34
  /** Built-in runtime module entry for Codex. */
35
35
  export declare const codexModule: RuntimeModule;
36
+ /** Built-in runtime module entry for DeepSeek TUI. */
37
+ export declare const deepseekTuiModule: RuntimeModule;
38
+ /** Built-in runtime module entry for Kimi CLI. */
39
+ export declare const kimiModule: RuntimeModule;
36
40
  /** Built-in runtime module entry for Hermes Agent (ACP stdio). */
37
41
  export declare const hermesAgentModule: RuntimeModule;
38
42
  /** Built-in runtime module entry for Gemini (probe-only stub). */
@@ -1,7 +1,9 @@
1
1
  import { ClaudeCodeAdapter, probeClaude } from "./claude-code.js";
2
2
  import { CodexAdapter, probeCodex } from "./codex.js";
3
+ import { DeepseekTuiAdapter, probeDeepseekTui } from "./deepseek-tui.js";
3
4
  import { GeminiAdapter, probeGemini } from "./gemini.js";
4
5
  import { HermesAgentAdapter, probeHermesAgent } from "./hermes-agent.js";
6
+ import { KimiAdapter, probeKimi } from "./kimi.js";
5
7
  import { OpenclawAcpAdapter, probeOpenclaw } from "./openclaw-acp.js";
6
8
  /** Built-in runtime module entry for Claude Code. */
7
9
  export const claudeCodeModule = {
@@ -20,6 +22,25 @@ export const codexModule = {
20
22
  probe: () => probeCodex(),
21
23
  create: () => new CodexAdapter(),
22
24
  };
25
+ /** Built-in runtime module entry for DeepSeek TUI. */
26
+ export const deepseekTuiModule = {
27
+ id: "deepseek-tui",
28
+ displayName: "DeepSeek TUI",
29
+ binary: "deepseek",
30
+ envVar: "BOTCORD_DEEPSEEK_TUI_BIN",
31
+ probe: () => probeDeepseekTui(),
32
+ create: () => new DeepseekTuiAdapter(),
33
+ installHint: "Install DeepSeek TUI and ensure the `deepseek` dispatcher is on PATH, or set BOTCORD_DEEPSEEK_TUI_BIN.",
34
+ };
35
+ /** Built-in runtime module entry for Kimi CLI. */
36
+ export const kimiModule = {
37
+ id: "kimi-cli",
38
+ displayName: "Kimi CLI",
39
+ binary: "kimi",
40
+ envVar: "BOTCORD_KIMI_CLI_BIN",
41
+ probe: () => probeKimi(),
42
+ create: () => new KimiAdapter(),
43
+ };
23
44
  /** Built-in runtime module entry for Hermes Agent (ACP stdio). */
24
45
  export const hermesAgentModule = {
25
46
  id: "hermes-agent",
@@ -57,6 +78,8 @@ export const openclawAcpModule = {
57
78
  export const RUNTIME_MODULES = [
58
79
  claudeCodeModule,
59
80
  codexModule,
81
+ deepseekTuiModule,
82
+ kimiModule,
60
83
  hermesAgentModule,
61
84
  geminiModule,
62
85
  openclawAcpModule,
@@ -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.50",
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", () => {