@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.
- package/dist/daemon.js +4 -3
- package/dist/gateway/dispatcher.js +32 -7
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/runtimes/deepseek-tui.d.ts +44 -0
- package/dist/gateway/runtimes/deepseek-tui.js +560 -0
- package/dist/gateway/runtimes/kimi.d.ts +32 -0
- package/dist/gateway/runtimes/kimi.js +204 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +23 -0
- package/dist/mention-scan.js +6 -2
- package/dist/skill-index.d.ts +17 -0
- package/dist/skill-index.js +177 -0
- package/dist/system-context.d.ts +6 -0
- package/dist/system-context.js +20 -2
- package/dist/turn-text.js +31 -0
- package/package.json +1 -1
- package/src/__tests__/mention-scan.test.ts +16 -0
- package/src/__tests__/system-context.test.ts +28 -1
- package/src/__tests__/turn-text.test.ts +27 -0
- package/src/daemon.ts +4 -3
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +212 -0
- package/src/gateway/__tests__/dispatcher.test.ts +53 -3
- package/src/gateway/__tests__/kimi-adapter.test.ts +174 -0
- package/src/gateway/dispatcher.ts +32 -7
- package/src/gateway/index.ts +6 -0
- package/src/gateway/runtimes/deepseek-tui.ts +640 -0
- package/src/gateway/runtimes/kimi.ts +245 -0
- package/src/gateway/runtimes/registry.ts +26 -0
- package/src/mention-scan.ts +5 -1
- package/src/skill-index.ts +232 -0
- package/src/system-context.ts +25 -2
- package/src/turn-text.ts +42 -0
|
@@ -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,
|
package/src/mention-scan.ts
CHANGED
|
@@ -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 (
|
|
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
|
+
}
|
package/src/system-context.ts
CHANGED
|
@@ -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,
|