osborn 0.9.9 → 0.9.13

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.
@@ -21,6 +21,11 @@ export interface ClaudeLLMOptions {
21
21
  model?: string;
22
22
  voiceMode?: 'direct' | 'realtime';
23
23
  skipTTSQueue?: boolean;
24
+ onCompactionEvent?: (event: {
25
+ type: 'compaction_started' | 'compaction_complete';
26
+ trigger?: string;
27
+ skillsWritten?: number;
28
+ }) => void;
24
29
  }
25
30
  /**
26
31
  * Claude LLM - Wraps Claude Agent SDK for LiveKit
@@ -548,7 +548,7 @@ export class ClaudeLLM extends llm.LLM {
548
548
  */
549
549
  pushMessage(userText, sdkOptions, callbacks) {
550
550
  // Lower compaction threshold to 65% so PreCompact fires earlier and context is preserved
551
- process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60';
551
+ process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '75';
552
552
  const userMessage = {
553
553
  type: 'user',
554
554
  message: { role: 'user', content: [{ type: 'text', text: userText }] },
@@ -914,136 +914,19 @@ class ClaudeLLMStream extends llm.LLMStream {
914
914
  }
915
915
  }]
916
916
  }],
917
- // ── PreCompact: read full transcript, call Anthropic API directly to extract skills ──
918
- // Fires before the SDK compresses the conversation. Reads the FULL transcript JSONL,
919
- // calls claude-haiku-4-5-20251001 directly for structured extraction, and writes
920
- // skill files to disk immediately no relying on PostCompact for persistence.
917
+ // ── PreCompact: inject extraction instruction only ──
918
+ // Fires before the SDK compresses the conversation. Injects the compact-learnings
919
+ // instruction so the SDK compaction summary includes the four structured sections.
920
+ // Skill extraction happens in PostCompact, which reads input.compact_summary directly.
921
921
  PreCompact: [{
922
922
  matcher: '.*',
923
923
  hooks: [async (input) => {
924
924
  try {
925
- const { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } = await import('node:fs');
926
- const { join } = await import('node:path');
927
- // 1. Read the instruction file
925
+ this.#opts.onCompactionEvent?.({ type: 'compaction_started', trigger: input?.trigger });
928
926
  const instructionPath = join(__claudeLlmDir, 'prompts', 'compact-learnings-instruction.md');
929
927
  const instruction = existsSync(instructionPath) ? readFileSync(instructionPath, 'utf-8') : '';
930
- // 2. Read FULL transcript (no line limit)
931
- const transcriptPath = input?.transcript_path;
932
- let transcriptContent = '';
933
- if (transcriptPath && existsSync(transcriptPath)) {
934
- try {
935
- transcriptContent = readFileSync(transcriptPath, 'utf-8');
936
- }
937
- catch (readErr) {
938
- console.warn('⚠️ PreCompact: could not read transcript:', readErr instanceof Error ? readErr.message : readErr);
939
- }
940
- }
941
- const skillDir = this.#opts.sessionBaseDir || this.#opts.workingDirectory || process.cwd();
942
- const skillsRoot = join(skillDir, '.claude', 'skills');
943
- // 3. Load existing skills for context (cap each at 500 chars)
944
- let existingSkillsBlock = '';
945
- if (existsSync(skillsRoot)) {
946
- try {
947
- const names = readdirSync(skillsRoot).slice(0, 15);
948
- const fragments = [];
949
- for (const name of names) {
950
- const p = join(skillsRoot, name, 'SKILL.md');
951
- if (existsSync(p)) {
952
- const body = readFileSync(p, 'utf-8');
953
- const cleaned = body.replace(/^===.*===\s*$/gm, '').substring(0, 500);
954
- fragments.push(`### ${name}\n${cleaned}`);
955
- }
956
- }
957
- if (fragments.length) {
958
- existingSkillsBlock = `EXISTING SKILLS (do NOT re-emit unchanged):\n${fragments.join('\n\n')}`;
959
- }
960
- }
961
- catch (skillErr) {
962
- console.warn('⚠️ PreCompact: skills load failed:', skillErr instanceof Error ? skillErr.message : skillErr);
963
- }
964
- }
965
- // 4. If transcript available, use direct Anthropic API call to extract everything
966
- if (transcriptContent && process.env.ANTHROPIC_API_KEY) {
967
- try {
968
- const Anthropic = (await import('@anthropic-ai/sdk')).default;
969
- const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
970
- const extractionPrompt = [
971
- instruction,
972
- existingSkillsBlock,
973
- `FULL SESSION TRANSCRIPT (JSONL format — extract the four sections from this):`,
974
- transcriptContent,
975
- ].filter(Boolean).join('\n\n---\n\n');
976
- console.log(`🧠 PreCompact: calling extraction API (${extractionPrompt.length} chars, trigger=${input?.trigger || 'unknown'})`);
977
- const response = await client.messages.create({
978
- model: 'claude-haiku-4-5-20251001',
979
- max_tokens: 4096,
980
- messages: [{ role: 'user', content: extractionPrompt }],
981
- });
982
- const extractedText = response.content[0]?.type === 'text' ? response.content[0].text : '';
983
- if (extractedText) {
984
- // Write SKILL_CANDIDATES to disk directly
985
- const skillRegex = /--- SKILL: ([a-z][a-z0-9-]{1,39}) ---\n([\s\S]*?)\n--- END SKILL ---/g;
986
- let match;
987
- while ((match = skillRegex.exec(extractedText)) !== null) {
988
- const [, name, body] = match;
989
- if (/^[a-z][a-z0-9-]{1,39}$/.test(name)) {
990
- const today = new Date().toISOString().split('T')[0];
991
- const sessionId = this.#sessionId || 'unknown';
992
- const skillFolder = join(skillsRoot, name);
993
- mkdirSync(skillFolder, { recursive: true });
994
- writeFileSync(join(skillFolder, 'SKILL.md'), `# ${name}\nAuto-extracted: ${today} | Session: ${sessionId.substring(0, 8)}\n\n${body}`);
995
- console.log(`🧠 PreCompact: wrote skill '${name}' to ${skillFolder}`);
996
- }
997
- }
998
- // Write BEHAVIORAL_LEARNINGS to learned-behaviors skill
999
- const blMarker = '=== BEHAVIORAL_LEARNINGS ===';
1000
- const blIdx = extractedText.indexOf(blMarker);
1001
- if (blIdx !== -1) {
1002
- const blContent = extractedText.substring(blIdx);
1003
- if (blContent.length > 30) {
1004
- const today = new Date().toISOString().split('T')[0];
1005
- const sessionId = this.#sessionId || 'unknown';
1006
- const learnedDir = join(skillsRoot, 'learned-behaviors');
1007
- mkdirSync(learnedDir, { recursive: true });
1008
- const header = `# Learned Behaviors\n\nAuto-extracted from voice sessions via PreCompact.\nLast updated: ${today} | Session: ${sessionId.substring(0, 8)}...\n\n`;
1009
- writeFileSync(join(learnedDir, 'SKILL.md'), header + blContent);
1010
- console.log(`🧠 PreCompact: wrote learned-behaviors (${blContent.length} chars)`);
1011
- }
1012
- }
1013
- // Write project-scoped DECISIONS
1014
- const decMarker = '=== DECISIONS ===';
1015
- const decIdx = extractedText.indexOf(decMarker);
1016
- if (decIdx !== -1) {
1017
- const decEnd = extractedText.indexOf('===', decIdx + decMarker.length);
1018
- const decContent = extractedText.substring(decIdx + decMarker.length, decEnd !== -1 ? decEnd : undefined);
1019
- const projectDecisions = decContent.split('\n').filter((l) => l.includes('SCOPE: project')).join('\n');
1020
- if (projectDecisions.trim()) {
1021
- const decDir = join(skillsRoot, 'project-decisions');
1022
- mkdirSync(decDir, { recursive: true });
1023
- const decFile = join(decDir, 'SKILL.md');
1024
- const existing = existsSync(decFile) ? readFileSync(decFile, 'utf-8') : '# Project Decisions\n\n';
1025
- writeFileSync(decFile, existing + '\n' + projectDecisions);
1026
- console.log(`🧠 PreCompact: appended project decisions`);
1027
- }
1028
- }
1029
- // Return extracted content as systemMessage for compact summary
1030
- const systemMessage = [
1031
- '## Pre-Compaction Extraction Complete',
1032
- 'Skill files have been written to disk. Include the following in your compact summary verbatim:',
1033
- extractedText.substring(0, 8000),
1034
- ].join('\n\n').substring(0, 9500);
1035
- console.log(`🧠 PreCompact: extraction complete, returning systemMessage (${systemMessage.length} chars)`);
1036
- return { systemMessage };
1037
- }
1038
- }
1039
- catch (apiErr) {
1040
- console.error('⚠️ PreCompact: API extraction failed, falling back to basic mode:', apiErr instanceof Error ? apiErr.message : apiErr);
1041
- // Fall through to basic fallback
1042
- }
1043
- }
1044
- // 5. Fallback: basic mode — just inject the instruction
1045
- console.log(`🧠 PreCompact: basic mode (no transcript or API key unavailable)`);
1046
- return { systemMessage: instruction.substring(0, 9500) };
928
+ console.log(`🧠 PreCompact: injecting instruction (${instruction.length} chars, trigger=${input?.trigger || 'unknown'})`);
929
+ return { systemMessage: instruction };
1047
930
  }
1048
931
  catch (err) {
1049
932
  console.error('⚠️ PreCompact hook error:', err instanceof Error ? err.message : err);
@@ -1051,16 +934,19 @@ class ClaudeLLMStream extends llm.LLMStream {
1051
934
  }
1052
935
  }]
1053
936
  }],
1054
- // ── PostCompact: extract all four sections from summary and persist to disk ──
937
+ // ── PostCompact: parse compact_summary from stdin payload and persist skill sections ──
938
+ // input.compact_summary is the full summary text the SDK just wrote.
939
+ // We read the four sections directly from it — no JSONL file access needed.
1055
940
  PostCompact: [{
1056
941
  matcher: '.*',
1057
942
  hooks: [async (input) => {
1058
943
  try {
1059
944
  const summary = input?.compact_summary || '';
1060
- const { mkdirSync, writeFileSync: writeSyncFs } = await import('fs');
945
+ const { mkdirSync, writeFileSync: writeSyncFs, readFileSync: readSyncFs, existsSync: existsSyncFs } = await import('node:fs');
1061
946
  const skillDir = this.#opts.sessionBaseDir || this.#opts.workingDirectory || process.cwd();
1062
947
  const today = new Date().toISOString().split('T')[0];
1063
948
  const sessionId = this.#sessionId || 'unknown';
949
+ let skillsWritten = 0;
1064
950
  // Helper: extract text between a marker and the next === marker (or end of string)
1065
951
  const extractSection = (marker) => {
1066
952
  const idx = summary.indexOf(marker);
@@ -1070,7 +956,7 @@ class ClaudeLLMStream extends llm.LLMStream {
1070
956
  const nextMarker = summary.indexOf('=== ', start);
1071
957
  return (nextMarker === -1 ? summary.substring(start) : summary.substring(start, nextMarker)).trim();
1072
958
  };
1073
- // ── Section 1: HANDOFF_STATE — log only, not written to disk ──
959
+ // ── Section 1: HANDOFF_STATE — stays in compact summary only, not written to disk ──
1074
960
  const handoff = extractSection('=== HANDOFF_STATE ===');
1075
961
  if (handoff) {
1076
962
  console.log(`🧠 PostCompact: HANDOFF_STATE present (${handoff.length} chars) — not written to disk`);
@@ -1083,26 +969,22 @@ class ClaudeLLMStream extends llm.LLMStream {
1083
969
  .split('\n')
1084
970
  .filter(l => /DECISION:.*SCOPE:\s*project/i.test(l));
1085
971
  if (projectLines.length) {
1086
- const decFolder = join(skillDir, '.claude', 'skills', 'project-decisions');
972
+ const decFolder = join(skillDir, '.claude', 'skills', 'decisions');
1087
973
  const decPath = join(decFolder, 'SKILL.md');
1088
974
  mkdirSync(decFolder, { recursive: true });
1089
- const existing = (() => { try {
1090
- return require('fs').readFileSync(decPath, 'utf-8');
1091
- }
1092
- catch {
1093
- return '';
1094
- } })();
975
+ const existing = existsSyncFs(decPath) ? readSyncFs(decPath, 'utf-8') : '';
1095
976
  const header = existing ? '' : `# Project Decisions\n\nAuto-extracted from compact summaries.\n\n`;
1096
977
  const entry = `\n## ${today} (session ${sessionId.substring(0, 8)})\n${projectLines.join('\n')}\n`;
1097
978
  writeSyncFs(decPath, header + existing + entry, 'utf-8');
1098
979
  console.log(`🧠 PostCompact: appended ${projectLines.length} decision(s) to ${decPath}`);
980
+ skillsWritten++;
1099
981
  }
1100
982
  }
1101
983
  }
1102
984
  catch (decErr) {
1103
985
  console.error('⚠️ PostCompact: DECISIONS write failed:', decErr instanceof Error ? decErr.message : decErr);
1104
986
  }
1105
- // ── Section 3: SKILL_CANDIDATES — parse and write each skill ──
987
+ // ── Section 3: SKILL_CANDIDATES — parse individual blocks and write each ──
1106
988
  try {
1107
989
  const skillsSection = extractSection('=== SKILL_CANDIDATES ===');
1108
990
  if (skillsSection) {
@@ -1122,37 +1004,34 @@ class ClaudeLLMStream extends llm.LLMStream {
1122
1004
  const header = `# ${name}\nAuto-extracted: ${today} | Session: ${sessionId.substring(0, 8)}\n\n`;
1123
1005
  writeSyncFs(skillPath, header + body + '\n', 'utf-8');
1124
1006
  console.log(`🧠 PostCompact: wrote skill '${name}' to ${skillPath}`);
1007
+ skillsWritten++;
1125
1008
  }
1126
1009
  }
1127
1010
  }
1128
1011
  catch (skillErr) {
1129
1012
  console.error('⚠️ PostCompact: SKILL_CANDIDATES write failed:', skillErr instanceof Error ? skillErr.message : skillErr);
1130
1013
  }
1131
- // ── Section 4: BEHAVIORAL_LEARNINGS — write to learned-behaviors skill file ──
1014
+ // ── Section 4: BEHAVIORAL_LEARNINGS — write to learned-behaviors/SKILL.md ──
1132
1015
  try {
1133
- const marker = '=== BEHAVIORAL_LEARNINGS ===';
1134
- const idx = summary.indexOf(marker);
1135
- if (idx !== -1) {
1136
- const learnings = summary.substring(idx + marker.length).trim();
1137
- if (learnings.length >= 30) {
1138
- const skillFolder = join(skillDir, '.claude', 'skills', 'learned-behaviors');
1139
- const skillPath = join(skillFolder, 'SKILL.md');
1140
- mkdirSync(skillFolder, { recursive: true });
1141
- const header = `# Learned Behaviors\n\nAuto-extracted from voice sessions via PostCompact.\nLast updated: ${today} | Session: ${sessionId.substring(0, 8)}...\n\n`;
1142
- writeSyncFs(skillPath, header + learnings + '\n', 'utf-8');
1143
- console.log(`🧠 PostCompact: wrote learned behaviors to ${skillPath} (${learnings.length} chars)`);
1144
- }
1145
- else {
1146
- console.log('🧠 PostCompact: BEHAVIORAL_LEARNINGS section too short — skipping');
1147
- }
1016
+ const learnings = extractSection('=== BEHAVIORAL_LEARNINGS ===');
1017
+ if (learnings.length >= 30) {
1018
+ const skillFolder = join(skillDir, '.claude', 'skills', 'learned-behaviors');
1019
+ const skillPath = join(skillFolder, 'SKILL.md');
1020
+ mkdirSync(skillFolder, { recursive: true });
1021
+ const header = `# Learned Behaviors\n\nAuto-extracted from voice sessions via PostCompact.\nLast updated: ${today} | Session: ${sessionId.substring(0, 8)}...\n\n`;
1022
+ writeSyncFs(skillPath, header + learnings + '\n', 'utf-8');
1023
+ console.log(`🧠 PostCompact: wrote learned behaviors to ${skillPath} (${learnings.length} chars)`);
1024
+ skillsWritten++;
1148
1025
  }
1149
1026
  else {
1150
- console.log('🧠 PostCompact: no BEHAVIORAL_LEARNINGS section found in summary — skipping');
1027
+ console.log('🧠 PostCompact: no BEHAVIORAL_LEARNINGS section found or too short — skipping');
1151
1028
  }
1152
1029
  }
1153
1030
  catch (blErr) {
1154
1031
  console.error('⚠️ PostCompact: BEHAVIORAL_LEARNINGS write failed:', blErr instanceof Error ? blErr.message : blErr);
1155
1032
  }
1033
+ this.#opts.onCompactionEvent?.({ type: 'compaction_complete', skillsWritten });
1034
+ console.log(`🧠 PostCompact: complete — ${skillsWritten} skill file(s) written`);
1156
1035
  }
1157
1036
  catch (err) {
1158
1037
  console.error('⚠️ PostCompact hook error:', err instanceof Error ? err.message : err);
package/dist/index.js CHANGED
@@ -1288,6 +1288,12 @@ async function main() {
1288
1288
  resumeSessionId,
1289
1289
  voiceMode: 'direct',
1290
1290
  skipTTSQueue: true,
1291
+ onCompactionEvent: (event) => {
1292
+ try {
1293
+ sendToFrontend({ type: event.type, trigger: event.trigger, skillsWritten: event.skillsWritten });
1294
+ }
1295
+ catch { /* non-fatal */ }
1296
+ },
1291
1297
  });
1292
1298
  currentLLM = directLLM;
1293
1299
  // Reset the session always-allow list for each new direct session
@@ -1615,6 +1621,12 @@ async function main() {
1615
1621
  sessionBaseDir,
1616
1622
  mcpServers,
1617
1623
  resumeSessionId,
1624
+ onCompactionEvent: (event) => {
1625
+ try {
1626
+ sendToFrontend({ type: event.type, trigger: event.trigger, skillsWritten: event.skillsWritten });
1627
+ }
1628
+ catch { /* non-fatal */ }
1629
+ },
1618
1630
  });
1619
1631
  currentLLM = realtimeClaudeHandler;
1620
1632
  // For resumed sessions, eagerly create workspace (we know the real ID)
@@ -13,6 +13,8 @@ Include:
13
13
  - **Test results**: What was tried, what it showed, what it ruled out
14
14
 
15
15
  Keep this under 800 characters. This stays in the compact summary — it is NOT written to disk.
16
+ - **ANNOUNCE**: End HANDOFF_STATE with one line formatted as:
17
+ `ANNOUNCE: <natural-language sentence for Osborn to speak aloud when resuming — mention that session memory was crystallized and how many skills/decisions were updated. Keep it to one sentence, conversational tone, like a colleague mentioning it in passing.>`
16
18
 
17
19
  ---
18
20
 
@@ -10,6 +10,8 @@ Session workspace: ${workspacePath}
10
10
  · You CAN write other files to the workspace (detailed findings, diffs, notes, code samples) that the user sees in a files panel
11
11
 
12
12
  Working principle: SPEAK the thinking, WRITE the details.
13
+
14
+ COMPACT RESUME BEHAVIOR: When this conversation has been compacted, the compact summary may contain a line beginning with "ANNOUNCE:". If it does, speak that line aloud as your first response before picking up any pending task. This is NOT recapping the summary — it is delivering a brief memory-crystallization update that the user is expecting. After speaking it, continue normally.
13
15
  </context>
14
16
 
15
17
  <objective>
package/dist/prompts.js CHANGED
@@ -554,6 +554,8 @@ Quick lookups (1-2 calls) you can do directly. Everything else goes to an agent.
554
554
  You do NOT produce findings from training data alone. You use tools to confirm every specific fact — file names, version numbers, function signatures, configuration values, URLs. If a tool is not available to verify a claim, you say so.
555
555
 
556
556
  IF INTERRUPTED OR RESTARTED: Check ~/.claude/projects/ subagents folder for recent sub-agent JSONL files. Read the last entries to understand what was completed. Resume from that point.
557
+
558
+ Exception: if the compact summary contains a line beginning with "ANNOUNCE:", speak that line to the user as your first response before picking up the task. This is NOT recapping the summary — it is delivering a brief memory-crystallization update that the user is expecting.
557
559
  </role>
558
560
 
559
561
  <write-rules>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.9",
3
+ "version": "0.9.13",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {