osborn 0.9.213 → 0.9.215

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.
@@ -15,7 +15,7 @@ import { getResearchSystemPrompt, getDirectModeResearchPrompt, getGroundingBlock
15
15
  import { getIndexPath } from './summary-index.js';
16
16
  import { openStore, recall, storeExists, updateSessionStore, getStorePath } from './session-store.js';
17
17
  import { getEmbedder } from './embedder.js';
18
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
18
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
19
19
  import { join, dirname, resolve, basename } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { homedir } from 'node:os';
@@ -200,56 +200,105 @@ function loadAllSkills(_workingDir) {
200
200
  return `<available-skills>\n${[...skillMap.values()].join('\n\n---\n\n')}\n</available-skills>`;
201
201
  }
202
202
  /**
203
- * Enumerate the agent's CURRENT skills (name + one-line description) from
204
- * ~/.claude/skills, for injection into the PreCompact instruction. This is the
205
- * list the compaction model dedupes/merges against so it stops re-emitting
206
- * duplicate skills every session. Description is taken from YAML frontmatter
207
- * (`description:`) when present, else the WHEN: line, else the first non-heading
208
- * line. Returns one `- name: description` per line, or '' if none.
203
+ * Enumerate the agent's CURRENT skills from ~/.claude/skills for injection into
204
+ * the PreCompact instruction — the material the compaction model reads so it
205
+ * refines/merges existing skills instead of re-emitting duplicates.
206
+ *
207
+ * Containment principle (why this is cheap AND complete):
208
+ * - SKILL.md is the canonical knowledge file and is small (~1–15KB each). We
209
+ * inject it IN FULL so the model sees every minute implementation detail and
210
+ * its UPDATE_SECTION edits are never blind. Bodies are emitted up to a total
211
+ * char budget; any overflow skill degrades to headings-only (still enough to
212
+ * dedup / target a section) so a pathological skill count can't blow the turn.
213
+ * - Satellite files in the folder (scripts/tools/reference docs) can be huge and
214
+ * unbounded, so we NEVER inject their contents — only a manifest line
215
+ * (name + size). The model can flag one for update; editing it is deferred to
216
+ * the user or a future tool-equipped merger that reads just that one file.
209
217
  */
218
+ // ~50K tokens of full SKILL.md text — ~5% of the 1M-context summarizer window, so
219
+ // completeness (the user's priority) wins over frugality. 16 defaults already use
220
+ // ~59KB, so a real user's defaults + learned + UI skills need real headroom here.
221
+ // NOTE: skills are walked alphabetically, so once the budget is hit the *late-alphabet*
222
+ // skills degrade to headings-only. A future refinement is to order by session-relevance
223
+ // (skills touched this session first) rather than name, so the overflow is the least
224
+ // relevant skills rather than an alphabetical accident.
225
+ const SKILL_BODY_BUDGET = 200_000;
226
+ function extractSkillDescription(raw) {
227
+ let desc = '';
228
+ const fm = raw.match(/^---\n([\s\S]*?)\n---/);
229
+ if (fm) {
230
+ const m = fm[1].match(/^description:\s*(.+)$/m);
231
+ if (m)
232
+ desc = m[1].trim();
233
+ }
234
+ const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, '');
235
+ if (!desc) {
236
+ const when = body.match(/^\s*WHEN:\s*(.+)$/mi);
237
+ if (when)
238
+ desc = when[1].trim();
239
+ }
240
+ if (!desc) {
241
+ const first = body.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('#'));
242
+ desc = first || '(no description)';
243
+ }
244
+ return desc.length > 200 ? desc.slice(0, 197) + '…' : desc;
245
+ }
210
246
  function enumerateSkillsForCompaction() {
211
247
  const dir = join(homedir(), '.claude', 'skills');
212
248
  if (!existsSync(dir))
213
249
  return '';
214
- const lines = [];
250
+ const blocks = [];
251
+ let budgetLeft = SKILL_BODY_BUDGET;
215
252
  try {
216
253
  for (const name of readdirSync(dir).sort()) {
217
- const file = join(dir, name, 'SKILL.md');
254
+ const folder = join(dir, name);
255
+ const file = join(folder, 'SKILL.md');
218
256
  if (!existsSync(file))
219
257
  continue;
220
- let desc = '';
221
258
  try {
222
- const raw = readFileSync(file, 'utf-8');
223
- const fm = raw.match(/^---\n([\s\S]*?)\n---/);
224
- if (fm) {
225
- const m = fm[1].match(/^description:\s*(.+)$/m);
226
- if (m)
227
- desc = m[1].trim();
259
+ const raw = readFileSync(file, 'utf-8').trim();
260
+ const desc = extractSkillDescription(raw);
261
+ // Manifest of satellite files (name + size), contents NEVER injected.
262
+ let manifest = '';
263
+ try {
264
+ const others = readdirSync(folder)
265
+ .filter(f => f !== 'SKILL.md')
266
+ .map(f => {
267
+ try {
268
+ const st = statSync(join(folder, f));
269
+ const kb = st.isDirectory() ? '(dir)' : `${Math.max(1, Math.round(st.size / 1024))}KB`;
270
+ return `${f} ${kb}`;
271
+ }
272
+ catch {
273
+ return f;
274
+ }
275
+ });
276
+ if (others.length)
277
+ manifest = `files (contents NOT shown — flag for update if needed): ${others.join(', ')}\n`;
228
278
  }
229
- if (!desc) {
230
- const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, '');
231
- const when = body.match(/^\s*WHEN:\s*(.+)$/mi);
232
- if (when)
233
- desc = when[1].trim();
279
+ catch { /* folder read race — skip manifest */ }
280
+ const header = `########## SKILL: ${name} ##########\ndescription: ${desc}\n${manifest}`;
281
+ if (raw.length <= budgetLeft) {
282
+ budgetLeft -= raw.length;
283
+ blocks.push(`${header}${raw}`);
234
284
  }
235
- if (!desc) {
285
+ else {
286
+ // Over budget → headings-only fallback (still enough to dedup + target a section).
236
287
  const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, '');
237
- const first = body.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('#'));
238
- desc = first || '(no description)';
288
+ const headings = (body.match(/^#{2,6}\s+.+$/gm) || [])
289
+ .map(h => h.replace(/^#+\s+/, '').trim());
290
+ blocks.push(`${header}(full body omitted — over injection budget)\nsections: ${headings.join(' | ') || '(none)'}`);
239
291
  }
240
292
  }
241
293
  catch {
242
- desc = '(unreadable)';
294
+ blocks.push(`########## SKILL: ${name} ##########\n(unreadable)`);
243
295
  }
244
- if (desc.length > 180)
245
- desc = desc.slice(0, 177) + '…';
246
- lines.push(`- ${name}: ${desc}`);
247
296
  }
248
297
  }
249
298
  catch (err) {
250
299
  console.warn('⚠️ enumerateSkillsForCompaction failed:', err instanceof Error ? err.message : err);
251
300
  }
252
- return lines.join('\n');
301
+ return blocks.join('\n\n');
253
302
  }
254
303
  // Compaction threshold: Fable 5 runs a 1M context window, so let sessions use
255
304
  // all of it before auto-compacting. autoCompactWindow max is 1_000_000; the SDK
@@ -2164,16 +2213,27 @@ class ClaudeLLMStream extends llm.LLMStream {
2164
2213
  // proliferated duplicates. We hand it the current skill set + an adversarial,
2165
2214
  // self-critical directive to merge/refine rather than create anew.
2166
2215
  const existingSkills = enumerateSkillsForCompaction();
2167
- const skillCount = existingSkills ? existingSkills.split('\n').length : 0;
2216
+ // Each skill is delimited by a `########## SKILL: <name> ##########` header.
2217
+ const skillCount = existingSkills
2218
+ ? (existingSkills.match(/^########## SKILL: /gm) || []).length
2219
+ : 0;
2168
2220
  const criticBlock = existingSkills
2169
- ? `\n\n---\n\n=== EXISTING SKILLS (${skillCount}) ===\n`
2170
- + `These skills ALREADY EXIST for this user (name: description). Before you emit `
2171
- + `SKILL_CANDIDATES or BEHAVIORAL_LEARNINGS, act as an ADVERSARIAL reviewer of your own output:\n`
2172
- + `1. If a candidate duplicates or substantially overlaps one below, DO NOT create a new skill — `
2221
+ ? `\n\n---\n\n=== EXISTING SKILLS (${skillCount}) — FULL TEXT ===\n`
2222
+ + `Below is the COMPLETE current text of each skill the user already has (delimited by `
2223
+ + `\`########## SKILL: <name> ##########\`). A \`files:\` line lists any satellite files in the `
2224
+ + `skill folder (scripts/tools/docs) whose CONTENTS are not shown — flag one only if it clearly needs updating.\n`
2225
+ + `Read these before emitting SKILL_CANDIDATES or BEHAVIORAL_LEARNINGS, and act as an ADVERSARIAL reviewer of your own output:\n`
2226
+ + `1. If what you learned refines ONE part of an existing skill, do NOT re-emit the whole skill. Emit a `
2227
+ + `surgical section update inside SKILL_CANDIDATES using this exact block form (the heading must match a `
2228
+ + `\`## \`/\`### \` heading that appears verbatim in that skill's text above):\n`
2229
+ + ` --- UPDATE_SECTION: <skill-name> > <exact section heading> ---\n`
2230
+ + ` <the new markdown that should REPLACE that section's body — preserve detail already present that is still correct>\n`
2231
+ + ` --- END UPDATE_SECTION ---\n`
2232
+ + `2. If a candidate duplicates or substantially overlaps one above but isn't a single-section tweak, `
2173
2233
  + `re-emit it under the EXACT SAME kebab-case name, and only if it needs a substantive update; otherwise omit it.\n`
2174
- + `2. Propose a brand-new skill ONLY if nothing below covers it, it was CONFIRMED working this session, `
2234
+ + `3. Propose a brand-new skill ONLY if nothing above covers it, it was CONFIRMED working this session, `
2175
2235
  + `and it generalizes to future sessions on different tasks.\n`
2176
- + `3. Prefer merging/refining over proliferating. A small set of sharp, non-overlapping skills is the goal — `
2236
+ + `4. Prefer refining/section-updating over proliferating. A small set of sharp, non-overlapping skills is the goal — `
2177
2237
  + `reject your own low-signal or one-off candidates.\n\n`
2178
2238
  + `${existingSkills}\n`
2179
2239
  : '';
@@ -2286,6 +2346,64 @@ class ClaudeLLMStream extends llm.LLMStream {
2286
2346
  catch (skillErr) {
2287
2347
  console.error('⚠️ PostCompact: SKILL_CANDIDATES write failed:', skillErr instanceof Error ? skillErr.message : skillErr);
2288
2348
  }
2349
+ // ── Section 3b: UPDATE_SECTION — surgical, section-level edits to EXISTING skills ──
2350
+ // The model (via the PreCompact preamble) emits blocks of the form:
2351
+ // --- UPDATE_SECTION: <skill-name> > <exact heading> ---
2352
+ // <replacement markdown for that section body>
2353
+ // --- END UPDATE_SECTION ---
2354
+ // We splice the new body under the matching `## `/`### ` heading, replacing
2355
+ // everything down to the next heading of the same-or-higher level. We NEVER
2356
+ // create a file here — an unknown skill/heading is logged and skipped, so the
2357
+ // model can't silently mint a new skill through this path.
2358
+ try {
2359
+ const updateRe = /---\s*UPDATE_SECTION:\s*([a-z][a-z0-9-]{1,39})\s*>\s*([^\n]+?)\s*---\n([\s\S]*?)---\s*END UPDATE_SECTION\s*---/g;
2360
+ let um;
2361
+ while ((um = updateRe.exec(summary)) !== null) {
2362
+ const targetSkill = um[1].trim();
2363
+ const targetHeading = um[2].trim();
2364
+ const newBody = um[3].trim();
2365
+ const skillPath = join(skillDir, '.claude', 'skills', targetSkill, 'SKILL.md');
2366
+ if (!existsSyncFs(skillPath)) {
2367
+ console.warn(`⚠️ PostCompact: UPDATE_SECTION skipped — no such skill '${targetSkill}'`);
2368
+ continue;
2369
+ }
2370
+ const content = readSyncFs(skillPath, 'utf-8');
2371
+ const lines2 = content.split('\n');
2372
+ // Find the heading line (## or ###) whose text matches targetHeading (case-insensitive).
2373
+ const norm = (s) => s.replace(/^#{2,3}\s+/, '').trim().toLowerCase();
2374
+ const hIdx = lines2.findIndex(l => /^#{2,3}\s+/.test(l) && norm(l) === targetHeading.toLowerCase());
2375
+ if (hIdx === -1) {
2376
+ console.warn(`⚠️ PostCompact: UPDATE_SECTION skipped — heading '${targetHeading}' not found in '${targetSkill}'`);
2377
+ continue;
2378
+ }
2379
+ const level = (lines2[hIdx].match(/^#+/) || ['##'])[0].length;
2380
+ // Section ends at the next heading of the same-or-higher level (fewer/equal #s).
2381
+ let end = lines2.length;
2382
+ for (let i = hIdx + 1; i < lines2.length; i++) {
2383
+ const m2 = lines2[i].match(/^(#{1,6})\s+/);
2384
+ if (m2 && m2[1].length <= level) {
2385
+ end = i;
2386
+ break;
2387
+ }
2388
+ }
2389
+ const rebuilt = [
2390
+ ...lines2.slice(0, hIdx + 1),
2391
+ '',
2392
+ newBody,
2393
+ '',
2394
+ ...lines2.slice(end),
2395
+ ].join('\n');
2396
+ writeSyncFs(skillPath, rebuilt, 'utf-8');
2397
+ console.log(`🧠 PostCompact: UPDATE_SECTION applied — '${targetSkill}' › '${targetHeading}' (${newBody.length} chars)`);
2398
+ skillsWritten++;
2399
+ if (!skillNames.includes(targetSkill))
2400
+ skillNames.push(targetSkill);
2401
+ progress('Updated section', `${targetSkill} › ${targetHeading}`);
2402
+ }
2403
+ }
2404
+ catch (updErr) {
2405
+ console.error('⚠️ PostCompact: UPDATE_SECTION apply failed:', updErr instanceof Error ? updErr.message : updErr);
2406
+ }
2289
2407
  // ── Section 4: BEHAVIORAL_LEARNINGS — write to learned-behaviors/SKILL.md ──
2290
2408
  try {
2291
2409
  const learnings = extractSection('=== BEHAVIORAL_LEARNINGS ===');
package/dist/index.js CHANGED
@@ -559,10 +559,12 @@ function startApiServer(workingDir, port) {
559
559
  if (req.method === 'GET' && url.pathname === '/skills') {
560
560
  // Installed skills — same list the chat's get_skills data-channel message
561
561
  // returns, exposed over HTTP so the DASHBOARD (no LiveKit connection) can
562
- // render the skills manager too. process.cwd() === sessionBaseDir (the
563
- // osborn install dir where .claude/skills lives — see main()).
562
+ // render the skills manager too. Reads ~/.claude/skills (homedir) — the SINGLE
563
+ // location the agent ingests from (loadAllSkills) and PostCompact writes to.
564
+ // Previously used process.cwd()/.claude/skills, which the agent never read —
565
+ // so UI-created skills were invisible to the agent (the "divergence").
564
566
  res.writeHead(200, { 'Content-Type': 'application/json' });
565
- res.end(JSON.stringify({ skills: loadSkillsList(process.cwd()) }));
567
+ res.end(JSON.stringify({ skills: loadSkillsList(homedir()) }));
566
568
  return;
567
569
  }
568
570
  if (req.method === 'GET' && url.pathname === '/agents') {
@@ -4630,7 +4632,7 @@ async function main() {
4630
4632
  mcpServers: getMcpServerStatusList(config),
4631
4633
  enabledMcpServers: enabledMcpNames,
4632
4634
  workingDirectory: workingDir,
4633
- skills: loadSkillsList(sessionBaseDir),
4635
+ skills: loadSkillsList(homedir()),
4634
4636
  namedAgents: Object.entries(NAMED_AGENTS).map(([name, a]) => ({
4635
4637
  name, description: a.description, model: a.model, tools: a.tools,
4636
4638
  })),
@@ -5330,7 +5332,7 @@ async function main() {
5330
5332
  else if (data.type === 'get_skills') {
5331
5333
  await sendToFrontend({
5332
5334
  type: 'skills_status',
5333
- skills: loadSkillsList(sessionBaseDir),
5335
+ skills: loadSkillsList(homedir()),
5334
5336
  });
5335
5337
  }
5336
5338
  else if (data.type === 'get_agents') {
@@ -5404,11 +5406,11 @@ async function main() {
5404
5406
  }
5405
5407
  else {
5406
5408
  try {
5407
- const skillDir = join(sessionBaseDir, '.claude', 'skills', skillName);
5409
+ const skillDir = join(homedir(), '.claude', 'skills', skillName);
5408
5410
  mkdirSync(skillDir, { recursive: true });
5409
5411
  writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8');
5410
5412
  console.log(`📚 Skill added: ${skillName}`);
5411
- const skills = loadSkillsList(sessionBaseDir);
5413
+ const skills = loadSkillsList(homedir());
5412
5414
  await sendToFrontend({ type: 'skill_add_result', success: true, skills });
5413
5415
  }
5414
5416
  catch (err) {
@@ -5419,7 +5421,7 @@ async function main() {
5419
5421
  }
5420
5422
  else if (data.type === 'skill_get') {
5421
5423
  const folder = (data.name || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
5422
- const p = join(sessionBaseDir, '.claude', 'skills', folder, 'SKILL.md');
5424
+ const p = join(homedir(), '.claude', 'skills', folder, 'SKILL.md');
5423
5425
  if (folder && existsSync(p)) {
5424
5426
  await sendToFrontend({ type: 'skill_content', name: folder, content: readFileSync(p, 'utf-8') });
5425
5427
  }
@@ -5429,7 +5431,7 @@ async function main() {
5429
5431
  }
5430
5432
  else if (data.type === 'skill_remove') {
5431
5433
  const folder = (data.name || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
5432
- const dir = join(sessionBaseDir, '.claude', 'skills', folder);
5434
+ const dir = join(homedir(), '.claude', 'skills', folder);
5433
5435
  if (!folder || !existsSync(dir)) {
5434
5436
  await sendToFrontend({ type: 'skill_remove_result', success: false, error: 'skill not found' });
5435
5437
  }
@@ -5437,7 +5439,7 @@ async function main() {
5437
5439
  try {
5438
5440
  rmSync(dir, { recursive: true, force: true });
5439
5441
  console.log(`🗑️ Skill removed: ${folder}`);
5440
- await sendToFrontend({ type: 'skill_remove_result', success: true, skills: loadSkillsList(sessionBaseDir) });
5442
+ await sendToFrontend({ type: 'skill_remove_result', success: true, skills: loadSkillsList(homedir()) });
5441
5443
  }
5442
5444
  catch (err) {
5443
5445
  await sendToFrontend({ type: 'skill_remove_result', success: false, error: String(err) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.213",
3
+ "version": "0.9.215",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {