@kernel.chat/kbot 3.97.4 → 3.99.0
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/agent.js +22 -1
- package/dist/cli.js +163 -0
- package/dist/skills-loader.d.ts +37 -5
- package/dist/skills-loader.js +342 -50
- package/dist/teacher-logger.d.ts +71 -0
- package/dist/teacher-logger.js +162 -0
- package/dist/tools/idempotency-check.d.ts +2 -0
- package/dist/tools/idempotency-check.js +31 -0
- package/dist/tools/schedule-persistence.d.ts +2 -0
- package/dist/tools/schedule-persistence.js +19 -0
- package/dist/train-agent-trace.d.ts +29 -0
- package/dist/train-agent-trace.js +141 -0
- package/dist/train-curate.d.ts +25 -0
- package/dist/train-curate.js +354 -0
- package/dist/train-cycle.d.ts +22 -0
- package/dist/train-cycle.js +230 -0
- package/dist/train-grpo.d.ts +68 -0
- package/dist/train-grpo.js +206 -0
- package/dist/train-merge.d.ts +26 -0
- package/dist/train-merge.js +148 -0
- package/dist/train-self.d.ts +38 -0
- package/dist/train-self.js +232 -0
- package/package.json +2 -1
- package/skills/deployment/daemon-deployment/SKILL.md +70 -0
- package/skills/deployment/ship-pipeline/SKILL.md +81 -0
- package/skills/emergent/forge-reflex/SKILL.md +53 -0
- package/skills/emergent/mimic-hybrid/SKILL.md +56 -0
- package/skills/memory/dream-to-commit/SKILL.md +52 -0
- package/skills/memory/memory-cascade/SKILL.md +59 -0
- package/skills/music-production/ableton-session-build/SKILL.md +61 -0
- package/skills/orchestration/cross-agent-blackboard/SKILL.md +58 -0
- package/skills/orchestration/specialist-routing/SKILL.md +57 -0
- package/skills/self-improvement/autopoiesis-loop/SKILL.md +47 -0
- package/skills/self-improvement/skill-self-authorship/SKILL.md +70 -0
- package/skills/self-improvement/teacher-trace-curation/SKILL.md +54 -0
- package/skills/software-development/systematic-debugging/SKILL.md +86 -0
- package/skills/software-development/test-driven-development/SKILL.md +74 -0
package/dist/skills-loader.js
CHANGED
|
@@ -1,92 +1,384 @@
|
|
|
1
|
-
// Skills Loader — Auto-discover and load
|
|
1
|
+
// Skills Loader — Auto-discover and load skill documents
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Supports two formats:
|
|
4
|
+
// 1. kbot native: ~/.kbot/skills/<name>.md (flat)
|
|
5
|
+
// 2. agentskills.io: ~/.kbot/skills/<category>/<name>/SKILL.md (Claude/Hermes/Copilot standard)
|
|
6
|
+
//
|
|
7
|
+
// Frontmatter fields recognized (either format):
|
|
8
|
+
// name | title — skill identifier
|
|
9
|
+
// description — 1-line "when to use this"
|
|
10
|
+
// keywords | tags — list for relevance matching
|
|
11
|
+
// metadata.hermes.tags — agentskills.io tag list (Hermes/Claude)
|
|
12
|
+
// domain — kbot category
|
|
6
13
|
//
|
|
7
14
|
// Discovery locations (in priority order):
|
|
8
|
-
// 1. ./.kbot/skills
|
|
9
|
-
// 2. ~/.kbot/skills
|
|
15
|
+
// 1. ./.kbot/skills/ — project-specific
|
|
16
|
+
// 2. ~/.kbot/skills/ — user global (includes imported Hermes/Claude skills)
|
|
10
17
|
//
|
|
11
|
-
// Token budget: 2000 tokens max
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
import {
|
|
18
|
+
// Token budget: 2000 tokens max. When a message is provided, skills are
|
|
19
|
+
// scored for relevance and only the top matches are injected — so a user
|
|
20
|
+
// with 200 imported skills doesn't blow the budget on irrelevant docs.
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
22
|
+
import { join, basename, dirname } from 'node:path';
|
|
15
23
|
import { homedir } from 'node:os';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
/** Package-bundled skills directory — ships with the npm tarball. */
|
|
26
|
+
function getBundledSkillsDir() {
|
|
27
|
+
// dist/skills-loader.js → ../skills (package root)
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
return join(here, '..', 'skills');
|
|
30
|
+
}
|
|
16
31
|
const MAX_SKILL_TOKENS = 2000;
|
|
32
|
+
const MAX_SKILLS_WITHOUT_MESSAGE = 6; // unfiltered cap
|
|
33
|
+
const MAX_SKILLS_WITH_MESSAGE = 4; // relevance-filtered cap
|
|
17
34
|
const estimateTokens = (text) => Math.ceil(text.length / 4);
|
|
18
35
|
/**
|
|
19
|
-
* Discover and load skill files
|
|
20
|
-
*
|
|
36
|
+
* Discover and load skill files. Returns a prompt-ready string.
|
|
37
|
+
* When `message` is provided, skills are scored for relevance and only the
|
|
38
|
+
* most relevant are included (keeps token budget tight with a large library).
|
|
21
39
|
*/
|
|
22
|
-
export function loadSkills(projectRoot) {
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
40
|
+
export function loadSkills(projectRoot, message, ctx) {
|
|
41
|
+
const all = discoverSkillFiles(projectRoot);
|
|
42
|
+
if (all.length === 0)
|
|
25
43
|
return '';
|
|
26
|
-
|
|
44
|
+
const filtered = applyConditionalActivation(all, ctx);
|
|
45
|
+
if (filtered.length === 0)
|
|
46
|
+
return '';
|
|
47
|
+
const selected = message ? rankByRelevance(filtered, message) : filtered;
|
|
48
|
+
return formatSkillsForPrompt(selected, message ? MAX_SKILLS_WITH_MESSAGE : MAX_SKILLS_WITHOUT_MESSAGE);
|
|
27
49
|
}
|
|
28
50
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
51
|
+
* Filter skills by platform and toolset conditions.
|
|
52
|
+
* Matches Hermes's activation semantics:
|
|
53
|
+
* - `platforms: [darwin]` — only loads on macOS
|
|
54
|
+
* - `requires_toolsets: [browser]` — only loads when browser tools are available
|
|
55
|
+
* - `fallback_for_toolsets: [browser]` — only loads when browser tools are NOT available
|
|
56
|
+
*/
|
|
57
|
+
function applyConditionalActivation(skills, ctx) {
|
|
58
|
+
const platform = ctx?.platform ?? process.platform;
|
|
59
|
+
const toolsets = new Set((ctx?.availableToolsets ?? []).map(t => t.toLowerCase()));
|
|
60
|
+
return skills.filter(s => {
|
|
61
|
+
if (s.platforms.length > 0 && !s.platforms.some(p => platform.startsWith(p)))
|
|
62
|
+
return false;
|
|
63
|
+
if (s.requiresToolsets.length > 0 && !s.requiresToolsets.every(t => toolsets.has(t.toLowerCase())))
|
|
64
|
+
return false;
|
|
65
|
+
if (s.fallbackForToolsets.length > 0 && s.fallbackForToolsets.some(t => toolsets.has(t.toLowerCase())))
|
|
66
|
+
return false;
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Walk both skill roots and return every skill document found.
|
|
72
|
+
* Handles flat files (name.md) AND subdirectory layouts (cat/name/SKILL.md).
|
|
73
|
+
* Project skills take precedence over global skills with the same name.
|
|
31
74
|
*/
|
|
32
75
|
export function discoverSkillFiles(projectRoot) {
|
|
76
|
+
// Precedence (first wins on name collision):
|
|
77
|
+
// 1. project-local — most specific, author's own
|
|
78
|
+
// 2. bundled — kbot-curated skills shipping with the package
|
|
79
|
+
// 3. user-global — includes imported third-party skills (symlinks)
|
|
33
80
|
const locations = [
|
|
34
|
-
join(projectRoot, '.kbot', 'skills'),
|
|
35
|
-
|
|
81
|
+
join(projectRoot, '.kbot', 'skills'),
|
|
82
|
+
getBundledSkillsDir(),
|
|
83
|
+
join(homedir(), '.kbot', 'skills'),
|
|
36
84
|
];
|
|
37
85
|
const skills = [];
|
|
38
|
-
const seen = new Set();
|
|
39
|
-
for (const
|
|
40
|
-
if (!existsSync(
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
for (const root of locations) {
|
|
88
|
+
if (!existsSync(root))
|
|
89
|
+
continue;
|
|
90
|
+
walkSkillRoot(root, skills, seen);
|
|
91
|
+
}
|
|
92
|
+
return skills;
|
|
93
|
+
}
|
|
94
|
+
function walkSkillRoot(root, out, seen, depth = 0) {
|
|
95
|
+
if (depth > 3)
|
|
96
|
+
return; // guard against deep nesting
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = readdirSync(root);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (entry.startsWith('.') || entry === 'node_modules')
|
|
41
106
|
continue;
|
|
107
|
+
const full = join(root, entry);
|
|
108
|
+
let stat;
|
|
42
109
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (content.length === 0)
|
|
54
|
-
continue;
|
|
55
|
-
skills.push({
|
|
56
|
-
name: basename(file, '.md'),
|
|
57
|
-
path,
|
|
58
|
-
content,
|
|
59
|
-
tokens: estimateTokens(content),
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
catch { /* permission denied, etc. */ }
|
|
110
|
+
stat = statSync(full);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (stat.isDirectory()) {
|
|
116
|
+
// Subdirectory layout: look for SKILL.md first (agentskills.io standard)
|
|
117
|
+
const skillMd = join(full, 'SKILL.md');
|
|
118
|
+
if (existsSync(skillMd)) {
|
|
119
|
+
tryAddSkill(skillMd, entry, out, seen);
|
|
63
120
|
}
|
|
121
|
+
else {
|
|
122
|
+
// Category directory — recurse
|
|
123
|
+
walkSkillRoot(full, out, seen, depth + 1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (entry.endsWith('.md') && entry !== 'README.md') {
|
|
127
|
+
const name = basename(entry, '.md');
|
|
128
|
+
tryAddSkill(full, name, out, seen);
|
|
64
129
|
}
|
|
65
|
-
catch { /* directory read error */ }
|
|
66
130
|
}
|
|
67
|
-
|
|
131
|
+
}
|
|
132
|
+
function tryAddSkill(path, fallbackName, out, seen) {
|
|
133
|
+
let content;
|
|
134
|
+
try {
|
|
135
|
+
content = readFileSync(path, 'utf-8').trim();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!content)
|
|
141
|
+
return;
|
|
142
|
+
const parsed = parseFrontmatter(content);
|
|
143
|
+
const name = String(parsed.fm.name ?? parsed.fm.title ?? fallbackName).trim() || fallbackName;
|
|
144
|
+
if (seen.has(name))
|
|
145
|
+
return;
|
|
146
|
+
seen.add(name);
|
|
147
|
+
const description = String(parsed.fm.description ?? '').trim();
|
|
148
|
+
const tags = extractTags(parsed.fm);
|
|
149
|
+
const hermesMeta = parsed.fm.metadata?.hermes ?? {};
|
|
150
|
+
const kbotMeta = parsed.fm.metadata?.kbot ?? {};
|
|
151
|
+
const asList = (v) => {
|
|
152
|
+
if (Array.isArray(v))
|
|
153
|
+
return v.map(String).map(s => s.trim()).filter(Boolean);
|
|
154
|
+
if (typeof v === 'string')
|
|
155
|
+
return v.split(',').map(s => s.trim()).filter(Boolean);
|
|
156
|
+
return [];
|
|
157
|
+
};
|
|
158
|
+
const bundledDir = getBundledSkillsDir();
|
|
159
|
+
const native = path.startsWith(bundledDir) || Object.keys(kbotMeta).length > 0;
|
|
160
|
+
out.push({
|
|
161
|
+
name,
|
|
162
|
+
path,
|
|
163
|
+
content,
|
|
164
|
+
description,
|
|
165
|
+
tags,
|
|
166
|
+
tokens: estimateTokens(content),
|
|
167
|
+
requiresToolsets: [...asList(kbotMeta.requires_toolsets), ...asList(hermesMeta.requires_toolsets)],
|
|
168
|
+
fallbackForToolsets: [...asList(kbotMeta.fallback_for_toolsets), ...asList(hermesMeta.fallback_for_toolsets)],
|
|
169
|
+
platforms: asList(parsed.fm.platforms),
|
|
170
|
+
relatedSkills: [...asList(kbotMeta.related_skills), ...asList(hermesMeta.related_skills)],
|
|
171
|
+
native,
|
|
172
|
+
});
|
|
68
173
|
}
|
|
69
174
|
/**
|
|
70
|
-
*
|
|
175
|
+
* Lightweight YAML-frontmatter parser. Handles the subset skills use:
|
|
176
|
+
* key: value
|
|
177
|
+
* key: [a, b, c]
|
|
178
|
+
* metadata:
|
|
179
|
+
* hermes:
|
|
180
|
+
* tags: [x, y]
|
|
181
|
+
* Avoids pulling in a full YAML dep. Good enough for flat skill metadata.
|
|
71
182
|
*/
|
|
72
|
-
function
|
|
183
|
+
function parseFrontmatter(content) {
|
|
184
|
+
if (!content.startsWith('---'))
|
|
185
|
+
return { fm: {}, body: content };
|
|
186
|
+
const end = content.indexOf('\n---', 3);
|
|
187
|
+
if (end < 0)
|
|
188
|
+
return { fm: {}, body: content };
|
|
189
|
+
const yaml = content.slice(3, end).trim();
|
|
190
|
+
const body = content.slice(end + 4).trim();
|
|
191
|
+
const fm = {};
|
|
192
|
+
const stack = [{ indent: -1, obj: fm }];
|
|
193
|
+
for (const rawLine of yaml.split('\n')) {
|
|
194
|
+
const line = rawLine.replace(/\s+$/, '');
|
|
195
|
+
if (!line.trim() || line.trim().startsWith('#'))
|
|
196
|
+
continue;
|
|
197
|
+
const indent = line.match(/^ */)[0].length;
|
|
198
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent)
|
|
199
|
+
stack.pop();
|
|
200
|
+
const parent = stack[stack.length - 1].obj;
|
|
201
|
+
const m = line.trim().match(/^([A-Za-z0-9_\-]+)\s*:\s*(.*)$/);
|
|
202
|
+
if (!m)
|
|
203
|
+
continue;
|
|
204
|
+
const [, key, valueRaw] = m;
|
|
205
|
+
const value = valueRaw.trim();
|
|
206
|
+
if (!value) {
|
|
207
|
+
// Nested object
|
|
208
|
+
const child = {};
|
|
209
|
+
parent[key] = child;
|
|
210
|
+
stack.push({ indent, obj: child });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
parent[key] = parseScalar(value);
|
|
214
|
+
}
|
|
215
|
+
return { fm, body };
|
|
216
|
+
}
|
|
217
|
+
function parseScalar(value) {
|
|
218
|
+
// Inline list: [a, b, "c"]
|
|
219
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
220
|
+
return value.slice(1, -1)
|
|
221
|
+
.split(',')
|
|
222
|
+
.map(s => s.trim().replace(/^["']|["']$/g, ''))
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
}
|
|
225
|
+
// Quoted string
|
|
226
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
227
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
228
|
+
return value.slice(1, -1);
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
function extractTags(fm) {
|
|
233
|
+
const tags = [];
|
|
234
|
+
const push = (v) => {
|
|
235
|
+
if (Array.isArray(v))
|
|
236
|
+
tags.push(...v.map(String));
|
|
237
|
+
else if (typeof v === 'string')
|
|
238
|
+
tags.push(...v.split(',').map(s => s.trim()).filter(Boolean));
|
|
239
|
+
};
|
|
240
|
+
push(fm.keywords);
|
|
241
|
+
push(fm.tags);
|
|
242
|
+
const meta = fm.metadata;
|
|
243
|
+
if (meta && typeof meta === 'object') {
|
|
244
|
+
push(meta.hermes?.tags);
|
|
245
|
+
push(meta.claude?.tags);
|
|
246
|
+
}
|
|
247
|
+
return [...new Set(tags.map(t => t.toLowerCase()))];
|
|
248
|
+
}
|
|
249
|
+
// ── Relevance scoring ────────────────────────────────────────────────
|
|
250
|
+
function rankByRelevance(skills, message) {
|
|
251
|
+
const terms = tokenize(message);
|
|
252
|
+
if (terms.size === 0)
|
|
253
|
+
return skills;
|
|
254
|
+
const scored = skills.map(s => ({ skill: s, score: scoreSkill(s, terms) }));
|
|
255
|
+
scored.sort((a, b) => b.score - a.score);
|
|
256
|
+
// Keep skills with at least one hit; fall back to top-N if nothing matches
|
|
257
|
+
const hits = scored.filter(x => x.score > 0);
|
|
258
|
+
return (hits.length > 0 ? hits : scored).map(x => x.skill);
|
|
259
|
+
}
|
|
260
|
+
function scoreSkill(skill, terms) {
|
|
261
|
+
let score = 0;
|
|
262
|
+
const nameTokens = tokenize(skill.name);
|
|
263
|
+
const descTokens = tokenize(skill.description);
|
|
264
|
+
for (const t of terms) {
|
|
265
|
+
if (nameTokens.has(t))
|
|
266
|
+
score += 3;
|
|
267
|
+
if (skill.tags.some(tag => tag === t || tag.includes(t)))
|
|
268
|
+
score += 2;
|
|
269
|
+
if (descTokens.has(t))
|
|
270
|
+
score += 1;
|
|
271
|
+
}
|
|
272
|
+
// Bundled kbot-native skills get a curated-content boost over imported third-party skills.
|
|
273
|
+
// Only applied when the skill already matched on something (score > 0) — we don't surface
|
|
274
|
+
// unrelated native skills over perfectly-matched imported ones.
|
|
275
|
+
if (score > 0 && skill.native)
|
|
276
|
+
score += 2;
|
|
277
|
+
return score;
|
|
278
|
+
}
|
|
279
|
+
const STOPWORDS = new Set([
|
|
280
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'if', 'then', 'of', 'to', 'for', 'in', 'on', 'at',
|
|
281
|
+
'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'this', 'that', 'it', 'i', 'you',
|
|
282
|
+
'we', 'they', 'how', 'what', 'when', 'why', 'can', 'do', 'does', 'please',
|
|
283
|
+
]);
|
|
284
|
+
function tokenize(text) {
|
|
285
|
+
const out = new Set();
|
|
286
|
+
// Split on non-alphanumeric AND on kebab/snake separators so "daemon-deployment"
|
|
287
|
+
// and "skill_self_authorship" both yield useful word tokens.
|
|
288
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
289
|
+
if (raw.length < 3)
|
|
290
|
+
continue;
|
|
291
|
+
if (STOPWORDS.has(raw))
|
|
292
|
+
continue;
|
|
293
|
+
out.add(raw);
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
// ── Prompt formatting ────────────────────────────────────────────────
|
|
298
|
+
function formatSkillsForPrompt(skills, maxSkills) {
|
|
73
299
|
const parts = [];
|
|
74
300
|
let currentTokens = 0;
|
|
301
|
+
let included = 0;
|
|
75
302
|
for (const skill of skills) {
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
303
|
+
if (included >= maxSkills)
|
|
304
|
+
break;
|
|
305
|
+
if (currentTokens + skill.tokens > MAX_SKILL_TOKENS) {
|
|
306
|
+
const remaining = MAX_SKILL_TOKENS - currentTokens;
|
|
79
307
|
if (remaining > 100) {
|
|
80
308
|
const truncated = skill.content.slice(0, remaining * 4) + '\n...(truncated)';
|
|
81
309
|
parts.push(`## Skill: ${skill.name}\n${truncated}`);
|
|
310
|
+
included++;
|
|
82
311
|
}
|
|
83
312
|
break;
|
|
84
313
|
}
|
|
85
314
|
parts.push(`## Skill: ${skill.name}\n${skill.content}`);
|
|
86
315
|
currentTokens += skill.tokens;
|
|
316
|
+
included++;
|
|
87
317
|
}
|
|
88
318
|
if (parts.length === 0)
|
|
89
319
|
return '';
|
|
90
320
|
return `\n\n[Custom Skills]\n${parts.join('\n---\n')}`;
|
|
91
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Copy (as symlinks) every SKILL.md under a foreign skills directory
|
|
324
|
+
* into ~/.kbot/skills/imported/<category>/<name>/SKILL.md.
|
|
325
|
+
* Non-destructive: existing symlinks are replaced, real files are skipped.
|
|
326
|
+
*/
|
|
327
|
+
export async function importExternalSkills(sourceRoot) {
|
|
328
|
+
const { mkdirSync, symlinkSync, unlinkSync, lstatSync } = await import('node:fs');
|
|
329
|
+
const destRoot = join(homedir(), '.kbot', 'skills', 'imported');
|
|
330
|
+
mkdirSync(destRoot, { recursive: true });
|
|
331
|
+
let imported = 0;
|
|
332
|
+
let skipped = 0;
|
|
333
|
+
const walk = (dir, rel) => {
|
|
334
|
+
let entries;
|
|
335
|
+
try {
|
|
336
|
+
entries = readdirSync(dir);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
for (const e of entries) {
|
|
342
|
+
if (e.startsWith('.'))
|
|
343
|
+
continue;
|
|
344
|
+
const full = join(dir, e);
|
|
345
|
+
let st;
|
|
346
|
+
try {
|
|
347
|
+
st = statSync(full);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (st.isDirectory()) {
|
|
353
|
+
const skillMd = join(full, 'SKILL.md');
|
|
354
|
+
if (existsSync(skillMd)) {
|
|
355
|
+
const destDir = join(destRoot, rel, e);
|
|
356
|
+
mkdirSync(destDir, { recursive: true });
|
|
357
|
+
const destFile = join(destDir, 'SKILL.md');
|
|
358
|
+
try {
|
|
359
|
+
if (existsSync(destFile)) {
|
|
360
|
+
const ls = lstatSync(destFile);
|
|
361
|
+
if (ls.isSymbolicLink())
|
|
362
|
+
unlinkSync(destFile);
|
|
363
|
+
else {
|
|
364
|
+
skipped++;
|
|
365
|
+
continue;
|
|
366
|
+
} // don't clobber user-authored file
|
|
367
|
+
}
|
|
368
|
+
symlinkSync(skillMd, destFile);
|
|
369
|
+
imported++;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
skipped++;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
walk(full, join(rel, e));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
walk(sourceRoot, '');
|
|
382
|
+
return { imported, skipped, source: sourceRoot, destination: destRoot };
|
|
383
|
+
}
|
|
92
384
|
//# sourceMappingURL=skills-loader.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export interface TeacherTrace {
|
|
2
|
+
id: string;
|
|
3
|
+
ts: number;
|
|
4
|
+
session_id?: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
model: string;
|
|
7
|
+
system: string;
|
|
8
|
+
messages: Array<{
|
|
9
|
+
role: string;
|
|
10
|
+
content: string;
|
|
11
|
+
}>;
|
|
12
|
+
response: {
|
|
13
|
+
content: string;
|
|
14
|
+
thinking?: string;
|
|
15
|
+
tool_calls?: Array<{
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
arguments: Record<string, unknown>;
|
|
19
|
+
}>;
|
|
20
|
+
stop_reason?: string;
|
|
21
|
+
};
|
|
22
|
+
usage?: {
|
|
23
|
+
input_tokens: number;
|
|
24
|
+
output_tokens: number;
|
|
25
|
+
};
|
|
26
|
+
latency_ms?: number;
|
|
27
|
+
outcome?: {
|
|
28
|
+
verified: boolean;
|
|
29
|
+
signal: 'user_retry' | 'build_pass' | 'test_pass' | 'tool_error' | 'self_eval' | 'none';
|
|
30
|
+
score?: number;
|
|
31
|
+
};
|
|
32
|
+
tags?: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface TeacherLoggerOptions {
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
dir?: string;
|
|
37
|
+
maxBytes?: number;
|
|
38
|
+
scrub?: boolean;
|
|
39
|
+
}
|
|
40
|
+
declare class TeacherLogger {
|
|
41
|
+
private enabled;
|
|
42
|
+
private dir;
|
|
43
|
+
private traceFile;
|
|
44
|
+
private maxBytes;
|
|
45
|
+
private scrub;
|
|
46
|
+
private pending;
|
|
47
|
+
constructor(opts?: TeacherLoggerOptions);
|
|
48
|
+
isEnabled(): boolean;
|
|
49
|
+
setEnabled(v: boolean): void;
|
|
50
|
+
/** Begin a trace — returns an ID to finalize later */
|
|
51
|
+
begin(input: {
|
|
52
|
+
sessionId?: string;
|
|
53
|
+
provider: string;
|
|
54
|
+
model: string;
|
|
55
|
+
system: string;
|
|
56
|
+
messages: Array<{
|
|
57
|
+
role: string;
|
|
58
|
+
content: string;
|
|
59
|
+
}>;
|
|
60
|
+
}): string;
|
|
61
|
+
/** Finalize a trace with the model response. No-op if id is empty/unknown. */
|
|
62
|
+
end(id: string, response: TeacherTrace['response'], usage?: TeacherTrace['usage'], outcome?: TeacherTrace['outcome']): void;
|
|
63
|
+
/** Tag an already-persisted trace with outcome later (e.g. after verifier runs). */
|
|
64
|
+
tagOutcome(traceId: string, outcome: TeacherTrace['outcome']): void;
|
|
65
|
+
private persist;
|
|
66
|
+
path(): string;
|
|
67
|
+
}
|
|
68
|
+
export declare function getTeacherLogger(): TeacherLogger;
|
|
69
|
+
export declare function setTeacherLogger(logger: TeacherLogger): void;
|
|
70
|
+
export {};
|
|
71
|
+
//# sourceMappingURL=teacher-logger.d.ts.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Teacher Logger — captures provider calls as (prompt, response, tools, outcome) pairs
|
|
2
|
+
// for later distillation / fine-tuning. Writes to ~/.kbot/teacher/traces.jsonl.
|
|
3
|
+
//
|
|
4
|
+
// One logger per process. Append-only, JSONL, crash-safe (flush per record).
|
|
5
|
+
// PII scrubber runs before persist.
|
|
6
|
+
import { appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
// ── PII / secret scrubber ────────────────────────────────────────────
|
|
11
|
+
// Patterns for API keys, tokens, common secrets
|
|
12
|
+
const SCRUB_PATTERNS = [
|
|
13
|
+
[/sk-ant-[A-Za-z0-9\-_]{20,}/g, 'sk-ant-<REDACTED>'],
|
|
14
|
+
[/sk-[A-Za-z0-9]{20,}/g, 'sk-<REDACTED>'],
|
|
15
|
+
[/ghp_[A-Za-z0-9]{30,}/g, 'ghp_<REDACTED>'],
|
|
16
|
+
[/github_pat_[A-Za-z0-9_]{30,}/g, 'github_pat_<REDACTED>'],
|
|
17
|
+
[/AIza[A-Za-z0-9\-_]{30,}/g, 'AIza<REDACTED>'],
|
|
18
|
+
[/xoxb-[A-Za-z0-9\-]{20,}/g, 'xoxb-<REDACTED>'],
|
|
19
|
+
[/eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/g, '<JWT_REDACTED>'],
|
|
20
|
+
[/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g, '<EMAIL>'],
|
|
21
|
+
[/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '<IP>'],
|
|
22
|
+
[/\b[A-Fa-f0-9]{64}\b/g, '<HEX64>'],
|
|
23
|
+
];
|
|
24
|
+
function scrubString(s) {
|
|
25
|
+
let out = s;
|
|
26
|
+
for (const [pat, repl] of SCRUB_PATTERNS)
|
|
27
|
+
out = out.replace(pat, repl);
|
|
28
|
+
// Home path scrub — keep structure but redact username
|
|
29
|
+
const home = homedir();
|
|
30
|
+
if (home && out.includes(home)) {
|
|
31
|
+
out = out.split(home).join('~');
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function scrubTrace(t) {
|
|
36
|
+
return {
|
|
37
|
+
...t,
|
|
38
|
+
system: scrubString(t.system),
|
|
39
|
+
messages: t.messages.map(m => ({ role: m.role, content: scrubString(m.content) })),
|
|
40
|
+
response: {
|
|
41
|
+
...t.response,
|
|
42
|
+
content: scrubString(t.response.content),
|
|
43
|
+
thinking: t.response.thinking ? scrubString(t.response.thinking) : undefined,
|
|
44
|
+
tool_calls: t.response.tool_calls?.map(tc => ({
|
|
45
|
+
id: tc.id,
|
|
46
|
+
name: tc.name,
|
|
47
|
+
arguments: JSON.parse(scrubString(JSON.stringify(tc.arguments))),
|
|
48
|
+
})),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// ── Logger ───────────────────────────────────────────────────────────
|
|
53
|
+
class TeacherLogger {
|
|
54
|
+
enabled;
|
|
55
|
+
dir;
|
|
56
|
+
traceFile;
|
|
57
|
+
maxBytes;
|
|
58
|
+
scrub;
|
|
59
|
+
pending = new Map();
|
|
60
|
+
constructor(opts = {}) {
|
|
61
|
+
this.enabled = opts.enabled ?? envEnabled();
|
|
62
|
+
this.dir = opts.dir ?? join(homedir(), '.kbot', 'teacher');
|
|
63
|
+
this.traceFile = join(this.dir, 'traces.jsonl');
|
|
64
|
+
this.maxBytes = opts.maxBytes ?? 500 * 1024 * 1024; // 500MB cap; rotate beyond
|
|
65
|
+
this.scrub = opts.scrub ?? true;
|
|
66
|
+
if (this.enabled && !existsSync(this.dir)) {
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(this.dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
catch { /* ignore */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
isEnabled() { return this.enabled; }
|
|
74
|
+
setEnabled(v) { this.enabled = v; }
|
|
75
|
+
/** Begin a trace — returns an ID to finalize later */
|
|
76
|
+
begin(input) {
|
|
77
|
+
if (!this.enabled)
|
|
78
|
+
return '';
|
|
79
|
+
const id = randomUUID();
|
|
80
|
+
this.pending.set(id, {
|
|
81
|
+
id,
|
|
82
|
+
session_id: input.sessionId,
|
|
83
|
+
provider: input.provider,
|
|
84
|
+
model: input.model,
|
|
85
|
+
system: input.system,
|
|
86
|
+
messages: input.messages,
|
|
87
|
+
started_at: Date.now(),
|
|
88
|
+
});
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
/** Finalize a trace with the model response. No-op if id is empty/unknown. */
|
|
92
|
+
end(id, response, usage, outcome) {
|
|
93
|
+
if (!this.enabled || !id)
|
|
94
|
+
return;
|
|
95
|
+
const p = this.pending.get(id);
|
|
96
|
+
if (!p)
|
|
97
|
+
return;
|
|
98
|
+
this.pending.delete(id);
|
|
99
|
+
const trace = {
|
|
100
|
+
id: p.id,
|
|
101
|
+
ts: Date.now(),
|
|
102
|
+
session_id: p.session_id,
|
|
103
|
+
provider: p.provider,
|
|
104
|
+
model: p.model,
|
|
105
|
+
system: p.system,
|
|
106
|
+
messages: p.messages,
|
|
107
|
+
response,
|
|
108
|
+
usage,
|
|
109
|
+
latency_ms: Date.now() - p.started_at,
|
|
110
|
+
outcome,
|
|
111
|
+
};
|
|
112
|
+
this.persist(trace);
|
|
113
|
+
}
|
|
114
|
+
/** Tag an already-persisted trace with outcome later (e.g. after verifier runs). */
|
|
115
|
+
tagOutcome(traceId, outcome) {
|
|
116
|
+
if (!this.enabled || !traceId)
|
|
117
|
+
return;
|
|
118
|
+
const outcomeFile = join(this.dir, 'outcomes.jsonl');
|
|
119
|
+
try {
|
|
120
|
+
appendFileSync(outcomeFile, JSON.stringify({ id: traceId, outcome, ts: Date.now() }) + '\n');
|
|
121
|
+
}
|
|
122
|
+
catch { /* swallow */ }
|
|
123
|
+
}
|
|
124
|
+
persist(trace) {
|
|
125
|
+
try {
|
|
126
|
+
// Size-based rotation
|
|
127
|
+
if (existsSync(this.traceFile)) {
|
|
128
|
+
const sz = statSync(this.traceFile).size;
|
|
129
|
+
if (sz > this.maxBytes) {
|
|
130
|
+
const rotated = join(this.dir, `traces.${Date.now()}.jsonl`);
|
|
131
|
+
try {
|
|
132
|
+
require('node:fs').renameSync(this.traceFile, rotated);
|
|
133
|
+
}
|
|
134
|
+
catch { /* ignore */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const t = this.scrub ? scrubTrace(trace) : trace;
|
|
138
|
+
appendFileSync(this.traceFile, JSON.stringify(t) + '\n');
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Never throw from logger — swallow and continue
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
path() { return this.traceFile; }
|
|
145
|
+
}
|
|
146
|
+
function envEnabled() {
|
|
147
|
+
const v = process.env.KBOT_TEACHER_LOG;
|
|
148
|
+
if (v == null)
|
|
149
|
+
return true; // default on — cost-free data collection
|
|
150
|
+
return v !== '0' && v.toLowerCase() !== 'false' && v !== '';
|
|
151
|
+
}
|
|
152
|
+
// ── Singleton ────────────────────────────────────────────────────────
|
|
153
|
+
let singleton = null;
|
|
154
|
+
export function getTeacherLogger() {
|
|
155
|
+
if (!singleton)
|
|
156
|
+
singleton = new TeacherLogger();
|
|
157
|
+
return singleton;
|
|
158
|
+
}
|
|
159
|
+
export function setTeacherLogger(logger) {
|
|
160
|
+
singleton = logger;
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=teacher-logger.js.map
|