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