@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.
Files changed (37) hide show
  1. package/dist/agent.js +22 -1
  2. package/dist/cli.js +163 -0
  3. package/dist/skills-loader.d.ts +37 -5
  4. package/dist/skills-loader.js +342 -50
  5. package/dist/teacher-logger.d.ts +71 -0
  6. package/dist/teacher-logger.js +162 -0
  7. package/dist/tools/idempotency-check.d.ts +2 -0
  8. package/dist/tools/idempotency-check.js +31 -0
  9. package/dist/tools/schedule-persistence.d.ts +2 -0
  10. package/dist/tools/schedule-persistence.js +19 -0
  11. package/dist/train-agent-trace.d.ts +29 -0
  12. package/dist/train-agent-trace.js +141 -0
  13. package/dist/train-curate.d.ts +25 -0
  14. package/dist/train-curate.js +354 -0
  15. package/dist/train-cycle.d.ts +22 -0
  16. package/dist/train-cycle.js +230 -0
  17. package/dist/train-grpo.d.ts +68 -0
  18. package/dist/train-grpo.js +206 -0
  19. package/dist/train-merge.d.ts +26 -0
  20. package/dist/train-merge.js +148 -0
  21. package/dist/train-self.d.ts +38 -0
  22. package/dist/train-self.js +232 -0
  23. package/package.json +2 -1
  24. package/skills/deployment/daemon-deployment/SKILL.md +70 -0
  25. package/skills/deployment/ship-pipeline/SKILL.md +81 -0
  26. package/skills/emergent/forge-reflex/SKILL.md +53 -0
  27. package/skills/emergent/mimic-hybrid/SKILL.md +56 -0
  28. package/skills/memory/dream-to-commit/SKILL.md +52 -0
  29. package/skills/memory/memory-cascade/SKILL.md +59 -0
  30. package/skills/music-production/ableton-session-build/SKILL.md +61 -0
  31. package/skills/orchestration/cross-agent-blackboard/SKILL.md +58 -0
  32. package/skills/orchestration/specialist-routing/SKILL.md +57 -0
  33. package/skills/self-improvement/autopoiesis-loop/SKILL.md +47 -0
  34. package/skills/self-improvement/skill-self-authorship/SKILL.md +70 -0
  35. package/skills/self-improvement/teacher-trace-curation/SKILL.md +54 -0
  36. package/skills/software-development/systematic-debugging/SKILL.md +86 -0
  37. package/skills/software-development/test-driven-development/SKILL.md +74 -0
@@ -1,92 +1,384 @@
1
- // Skills Loader — Auto-discover and load .md skill files
1
+ // Skills Loader — Auto-discover and load skill documents
2
2
  //
3
- // Skill files are Markdown documents that inject domain knowledge, tool combinations,
4
- // and project-specific patterns into the agent's context. They're the kbot equivalent
5
- // of Copilot's "Agent Skills" or Cursor's "Rules for AI."
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/*.md — project-specific skills
9
- // 2. ~/.kbot/skills/*.md — user global 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 (~8000 characters) to leave room for
12
- // repo map, learning context, and memory.
13
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
14
- import { join, basename } from 'node:path';
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 from project and global directories.
20
- * Returns formatted string ready to inject into system prompt.
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 skills = discoverSkillFiles(projectRoot);
24
- if (skills.length === 0)
40
+ export function loadSkills(projectRoot, message, ctx) {
41
+ const all = discoverSkillFiles(projectRoot);
42
+ if (all.length === 0)
25
43
  return '';
26
- return formatSkillsForPrompt(skills);
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
- * Discover .md files from both project-local and global skill directories.
30
- * Project skills take precedence (loaded first, consume token budget first).
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'), // Project-specific
35
- join(homedir(), '.kbot', 'skills'), // User global
81
+ join(projectRoot, '.kbot', 'skills'),
82
+ getBundledSkillsDir(),
83
+ join(homedir(), '.kbot', 'skills'),
36
84
  ];
37
85
  const skills = [];
38
- const seen = new Set(); // Deduplicate by filename
39
- for (const dir of locations) {
40
- if (!existsSync(dir))
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
- const files = readdirSync(dir)
44
- .filter(f => f.endsWith('.md'))
45
- .sort(); // Alphabetical for deterministic ordering
46
- for (const file of files) {
47
- if (seen.has(file))
48
- continue; // Project overrides global
49
- seen.add(file);
50
- try {
51
- const path = join(dir, file);
52
- const content = readFileSync(path, 'utf-8').trim();
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
- return skills;
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
- * Format skill files for prompt injection, respecting token budget.
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 formatSkillsForPrompt(skills, maxTokens = MAX_SKILL_TOKENS) {
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 (currentTokens + skill.tokens > maxTokens) {
77
- // Try to fit a truncated version
78
- const remaining = maxTokens - currentTokens;
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
@@ -0,0 +1,2 @@
1
+ export declare function registerIdempotencyTools(): void;
2
+ //# sourceMappingURL=idempotency-check.d.ts.map