@botcord/daemon 0.2.47 → 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 +4 -3
- package/dist/gateway/dispatcher.js +1 -1
- package/dist/gateway/runtimes/openclaw-acp.js +18 -4
- 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__/openclaw-acp.test.ts +90 -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/dispatcher.ts +1 -1
- package/src/gateway/runtimes/openclaw-acp.ts +20 -3
- 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
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
|
-
*
|
|
23
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
|
@@ -612,12 +612,14 @@ function stripLeadingBoundaryResidue(text) {
|
|
|
612
612
|
// Keep real HTML/XML-ish tags and common comparison operators. A lone
|
|
613
613
|
// leading "<" before normal prose can be left behind when ACP streams a
|
|
614
614
|
// structural marker boundary separately from the final assistant text.
|
|
615
|
-
if (
|
|
616
|
-
return text;
|
|
617
|
-
if (/^<(?:\s|=|<)/.test(text))
|
|
615
|
+
if (startsWithRealAngleSyntax(text))
|
|
618
616
|
return text;
|
|
619
617
|
return text.slice(1).trimStart();
|
|
620
618
|
}
|
|
619
|
+
function startsWithRealAngleSyntax(text) {
|
|
620
|
+
return (/^<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s|>|\/>)/.test(text) ||
|
|
621
|
+
/^<(?:\s|=|<)/.test(text));
|
|
622
|
+
}
|
|
621
623
|
function createAssistantTextFilter() {
|
|
622
624
|
let pending = "";
|
|
623
625
|
let inThink = false;
|
|
@@ -721,7 +723,19 @@ function createAssistantTextFilter() {
|
|
|
721
723
|
if (!flush && knownPrefixes.some((prefix) => prefix.startsWith(lower))) {
|
|
722
724
|
return out;
|
|
723
725
|
}
|
|
724
|
-
|
|
726
|
+
if (!flush && pending === "<") {
|
|
727
|
+
return out;
|
|
728
|
+
}
|
|
729
|
+
if (!startsWithRealAngleSyntax(pending)) {
|
|
730
|
+
pending = pending.slice(1).trimStart();
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (seenFinal) {
|
|
734
|
+
out += "<";
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
fallback += "<";
|
|
738
|
+
}
|
|
725
739
|
pending = pending.slice(1);
|
|
726
740
|
}
|
|
727
741
|
if (flush && !seenFinal && fallback) {
|
package/dist/mention-scan.js
CHANGED
|
@@ -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 (
|
|
22
|
-
candidates.push(
|
|
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
|
+
}
|
package/dist/system-context.d.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
|
|
@@ -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.
|
package/dist/system-context.js
CHANGED
|
@@ -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
|
@@ -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
|
+
});
|
|
@@ -334,6 +334,7 @@ describe("OpenclawAcpAdapter.run", () => {
|
|
|
334
334
|
}
|
|
335
335
|
});
|
|
336
336
|
|
|
337
|
+
const blocks: any[] = [];
|
|
337
338
|
const res = await adapter.run({
|
|
338
339
|
text: "hi",
|
|
339
340
|
sessionId: null,
|
|
@@ -342,9 +343,98 @@ describe("OpenclawAcpAdapter.run", () => {
|
|
|
342
343
|
signal: new AbortController().signal,
|
|
343
344
|
trustLevel: "owner",
|
|
344
345
|
gateway,
|
|
346
|
+
onBlock: (b) => blocks.push(b),
|
|
345
347
|
});
|
|
346
348
|
|
|
347
349
|
expect(res.text).toBe("好!终于可以正常交流了。");
|
|
350
|
+
const assistantChunks = blocks
|
|
351
|
+
.filter((b) => b.kind === "assistant_text")
|
|
352
|
+
.map((b) => b.raw.params.update.content[0].text);
|
|
353
|
+
expect(assistantChunks).toEqual(["好!终于可以正常交流了。"]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("preserves real leading angle syntax in streamed fallback text", async () => {
|
|
357
|
+
const child = new FakeChild();
|
|
358
|
+
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
|
359
|
+
const gateway: ResolvedOpenclawGateway = {
|
|
360
|
+
name: "local",
|
|
361
|
+
url: "ws://127.0.0.1:1",
|
|
362
|
+
openclawAgent: "main",
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
child.stdin.on("data", (chunk: Buffer) => {
|
|
366
|
+
for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
|
|
367
|
+
const frame = JSON.parse(line);
|
|
368
|
+
if (frame.method === "initialize") {
|
|
369
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
|
|
370
|
+
} else if (frame.method === "session/new") {
|
|
371
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-angle" } }) + "\n");
|
|
372
|
+
} else if (frame.method === "session/prompt") {
|
|
373
|
+
for (const text of ["<", "b>bold</b> and ", "<", " 5"]) {
|
|
374
|
+
child.stdout.write(
|
|
375
|
+
JSON.stringify({
|
|
376
|
+
jsonrpc: "2.0",
|
|
377
|
+
method: "session/update",
|
|
378
|
+
params: {
|
|
379
|
+
sessionId: "sid-angle",
|
|
380
|
+
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
|
|
381
|
+
},
|
|
382
|
+
}) + "\n",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { stopReason: "end_turn" } }) + "\n");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const res = await adapter.run({
|
|
391
|
+
text: "hi",
|
|
392
|
+
sessionId: null,
|
|
393
|
+
cwd: "/tmp",
|
|
394
|
+
accountId: "ag_alice",
|
|
395
|
+
signal: new AbortController().signal,
|
|
396
|
+
trustLevel: "owner",
|
|
397
|
+
gateway,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(res.text).toBe("<b>bold</b> and < 5");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("forwards slash-command user text to session/prompt unchanged", async () => {
|
|
404
|
+
const child = new FakeChild();
|
|
405
|
+
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
|
406
|
+
const gateway: ResolvedOpenclawGateway = {
|
|
407
|
+
name: "local",
|
|
408
|
+
url: "ws://127.0.0.1:1",
|
|
409
|
+
openclawAgent: "main",
|
|
410
|
+
};
|
|
411
|
+
let promptPayload: any = null;
|
|
412
|
+
|
|
413
|
+
child.stdin.on("data", (chunk: Buffer) => {
|
|
414
|
+
for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
|
|
415
|
+
const frame = JSON.parse(line);
|
|
416
|
+
if (frame.method === "initialize") {
|
|
417
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
|
|
418
|
+
} else if (frame.method === "session/new") {
|
|
419
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-slash" } }) + "\n");
|
|
420
|
+
} else if (frame.method === "session/prompt") {
|
|
421
|
+
promptPayload = frame.params.prompt;
|
|
422
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "ok" } }) + "\n");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
await adapter.run({
|
|
428
|
+
text: "/start",
|
|
429
|
+
sessionId: null,
|
|
430
|
+
cwd: "/tmp",
|
|
431
|
+
accountId: "ag_alice",
|
|
432
|
+
signal: new AbortController().signal,
|
|
433
|
+
trustLevel: "owner",
|
|
434
|
+
gateway,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
expect(promptPayload).toEqual([{ type: "text", text: "/start" }]);
|
|
348
438
|
});
|
|
349
439
|
|
|
350
440
|
it("respawns the pooled child when gateway.url or gateway.token changes under the same name", async () => {
|
|
@@ -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
|
-
*
|
|
52
|
-
*
|
|
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 =
|
|
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
|
|
@@ -720,11 +720,17 @@ function stripLeadingBoundaryResidue(text: string): string {
|
|
|
720
720
|
// Keep real HTML/XML-ish tags and common comparison operators. A lone
|
|
721
721
|
// leading "<" before normal prose can be left behind when ACP streams a
|
|
722
722
|
// structural marker boundary separately from the final assistant text.
|
|
723
|
-
if (
|
|
724
|
-
if (/^<(?:\s|=|<)/.test(text)) return text;
|
|
723
|
+
if (startsWithRealAngleSyntax(text)) return text;
|
|
725
724
|
return text.slice(1).trimStart();
|
|
726
725
|
}
|
|
727
726
|
|
|
727
|
+
function startsWithRealAngleSyntax(text: string): boolean {
|
|
728
|
+
return (
|
|
729
|
+
/^<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s|>|\/>)/.test(text) ||
|
|
730
|
+
/^<(?:\s|=|<)/.test(text)
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
728
734
|
function createAssistantTextFilter(): {
|
|
729
735
|
push(text: string): string;
|
|
730
736
|
flush(): string;
|
|
@@ -830,7 +836,18 @@ function createAssistantTextFilter(): {
|
|
|
830
836
|
return out;
|
|
831
837
|
}
|
|
832
838
|
|
|
833
|
-
|
|
839
|
+
if (!flush && pending === "<") {
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
if (!startsWithRealAngleSyntax(pending)) {
|
|
843
|
+
pending = pending.slice(1).trimStart();
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
if (seenFinal) {
|
|
847
|
+
out += "<";
|
|
848
|
+
} else {
|
|
849
|
+
fallback += "<";
|
|
850
|
+
}
|
|
834
851
|
pending = pending.slice(1);
|
|
835
852
|
}
|
|
836
853
|
if (flush && !seenFinal && fallback) {
|
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,
|