osborn 0.9.213 → 0.9.214

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.
@@ -237,13 +237,23 @@ function enumerateSkillsForCompaction() {
237
237
  const first = body.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('#'));
238
238
  desc = first || '(no description)';
239
239
  }
240
+ // Option A: expose the skill's SECTION HEADINGS so the compaction model can
241
+ // target a specific section for a surgical update (via UPDATE_SECTION) rather
242
+ // than re-dumping a whole new skill. The summarizer has no tool access — this
243
+ // is the only way it can "see inside" the file.
244
+ const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, '');
245
+ const headings = (body.match(/^#{2,3}\s+.+$/gm) || [])
246
+ .map(h => h.replace(/^#{2,3}\s+/, '').trim())
247
+ .filter(Boolean);
248
+ if (desc.length > 180)
249
+ desc = desc.slice(0, 177) + '…';
250
+ lines.push(`- ${name}: ${desc}`);
251
+ if (headings.length)
252
+ lines.push(` sections: ${headings.join(' | ')}`);
240
253
  }
241
254
  catch {
242
- desc = '(unreadable)';
255
+ lines.push(`- ${name}: (unreadable)`);
243
256
  }
244
- if (desc.length > 180)
245
- desc = desc.slice(0, 177) + '…';
246
- lines.push(`- ${name}: ${desc}`);
247
257
  }
248
258
  }
249
259
  catch (err) {
@@ -2164,16 +2174,26 @@ class ClaudeLLMStream extends llm.LLMStream {
2164
2174
  // proliferated duplicates. We hand it the current skill set + an adversarial,
2165
2175
  // self-critical directive to merge/refine rather than create anew.
2166
2176
  const existingSkills = enumerateSkillsForCompaction();
2167
- const skillCount = existingSkills ? existingSkills.split('\n').length : 0;
2177
+ // Count only skill lines (`- name: …`), not the ` sections:` continuation lines.
2178
+ const skillCount = existingSkills
2179
+ ? existingSkills.split('\n').filter(l => l.startsWith('- ')).length
2180
+ : 0;
2168
2181
  const criticBlock = existingSkills
2169
2182
  ? `\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 — `
2183
+ + `These skills ALREADY EXIST for this user. Each is listed as \`- name: description\`, and where known, `
2184
+ + `an indented \`sections:\` line lists that skill's section headings (\`## \`/\`### \`). `
2185
+ + `Before you emit SKILL_CANDIDATES or BEHAVIORAL_LEARNINGS, act as an ADVERSARIAL reviewer of your own output:\n`
2186
+ + `1. If what you learned refines ONE section of an existing skill, do NOT re-emit the whole skill. Instead emit a `
2187
+ + `surgical section update inside SKILL_CANDIDATES using this exact block form (the heading must match a `
2188
+ + `\`sections:\` entry below verbatim):\n`
2189
+ + ` --- UPDATE_SECTION: <skill-name> > <exact section heading> ---\n`
2190
+ + ` <the new markdown that should REPLACE that section's body>\n`
2191
+ + ` --- END UPDATE_SECTION ---\n`
2192
+ + `2. If a candidate duplicates or substantially overlaps one below but isn't a single-section tweak, `
2173
2193
  + `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, `
2194
+ + `3. Propose a brand-new skill ONLY if nothing below covers it, it was CONFIRMED working this session, `
2175
2195
  + `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 — `
2196
+ + `4. Prefer refining/section-updating over proliferating. A small set of sharp, non-overlapping skills is the goal — `
2177
2197
  + `reject your own low-signal or one-off candidates.\n\n`
2178
2198
  + `${existingSkills}\n`
2179
2199
  : '';
@@ -2286,6 +2306,64 @@ class ClaudeLLMStream extends llm.LLMStream {
2286
2306
  catch (skillErr) {
2287
2307
  console.error('⚠️ PostCompact: SKILL_CANDIDATES write failed:', skillErr instanceof Error ? skillErr.message : skillErr);
2288
2308
  }
2309
+ // ── Section 3b: UPDATE_SECTION — surgical, section-level edits to EXISTING skills ──
2310
+ // The model (via the PreCompact preamble) emits blocks of the form:
2311
+ // --- UPDATE_SECTION: <skill-name> > <exact heading> ---
2312
+ // <replacement markdown for that section body>
2313
+ // --- END UPDATE_SECTION ---
2314
+ // We splice the new body under the matching `## `/`### ` heading, replacing
2315
+ // everything down to the next heading of the same-or-higher level. We NEVER
2316
+ // create a file here — an unknown skill/heading is logged and skipped, so the
2317
+ // model can't silently mint a new skill through this path.
2318
+ try {
2319
+ 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;
2320
+ let um;
2321
+ while ((um = updateRe.exec(summary)) !== null) {
2322
+ const targetSkill = um[1].trim();
2323
+ const targetHeading = um[2].trim();
2324
+ const newBody = um[3].trim();
2325
+ const skillPath = join(skillDir, '.claude', 'skills', targetSkill, 'SKILL.md');
2326
+ if (!existsSyncFs(skillPath)) {
2327
+ console.warn(`⚠️ PostCompact: UPDATE_SECTION skipped — no such skill '${targetSkill}'`);
2328
+ continue;
2329
+ }
2330
+ const content = readSyncFs(skillPath, 'utf-8');
2331
+ const lines2 = content.split('\n');
2332
+ // Find the heading line (## or ###) whose text matches targetHeading (case-insensitive).
2333
+ const norm = (s) => s.replace(/^#{2,3}\s+/, '').trim().toLowerCase();
2334
+ const hIdx = lines2.findIndex(l => /^#{2,3}\s+/.test(l) && norm(l) === targetHeading.toLowerCase());
2335
+ if (hIdx === -1) {
2336
+ console.warn(`⚠️ PostCompact: UPDATE_SECTION skipped — heading '${targetHeading}' not found in '${targetSkill}'`);
2337
+ continue;
2338
+ }
2339
+ const level = (lines2[hIdx].match(/^#+/) || ['##'])[0].length;
2340
+ // Section ends at the next heading of the same-or-higher level (fewer/equal #s).
2341
+ let end = lines2.length;
2342
+ for (let i = hIdx + 1; i < lines2.length; i++) {
2343
+ const m2 = lines2[i].match(/^(#{1,6})\s+/);
2344
+ if (m2 && m2[1].length <= level) {
2345
+ end = i;
2346
+ break;
2347
+ }
2348
+ }
2349
+ const rebuilt = [
2350
+ ...lines2.slice(0, hIdx + 1),
2351
+ '',
2352
+ newBody,
2353
+ '',
2354
+ ...lines2.slice(end),
2355
+ ].join('\n');
2356
+ writeSyncFs(skillPath, rebuilt, 'utf-8');
2357
+ console.log(`🧠 PostCompact: UPDATE_SECTION applied — '${targetSkill}' › '${targetHeading}' (${newBody.length} chars)`);
2358
+ skillsWritten++;
2359
+ if (!skillNames.includes(targetSkill))
2360
+ skillNames.push(targetSkill);
2361
+ progress('Updated section', `${targetSkill} › ${targetHeading}`);
2362
+ }
2363
+ }
2364
+ catch (updErr) {
2365
+ console.error('⚠️ PostCompact: UPDATE_SECTION apply failed:', updErr instanceof Error ? updErr.message : updErr);
2366
+ }
2289
2367
  // ── Section 4: BEHAVIORAL_LEARNINGS — write to learned-behaviors/SKILL.md ──
2290
2368
  try {
2291
2369
  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.214",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {