osborn 0.9.6 → 0.9.9

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.
@@ -547,6 +547,8 @@ export class ClaudeLLM extends llm.LLM {
547
547
  * @param callbacks - Event callbacks for the background consumer
548
548
  */
549
549
  pushMessage(userText, sdkOptions, callbacks) {
550
+ // Lower compaction threshold to 65% so PreCompact fires earlier and context is preserved
551
+ process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60';
550
552
  const userMessage = {
551
553
  type: 'user',
552
554
  message: { role: 'user', content: [{ type: 'text', text: userText }] },
@@ -912,30 +914,136 @@ class ClaudeLLMStream extends llm.LLMStream {
912
914
  }
913
915
  }]
914
916
  }],
915
- // ── PreCompact: inject "include behavioral learnings" instruction into the compact ──
916
- // When the SDK is about to compress the conversation, this tells Claude to
917
- // include a BEHAVIORAL_LEARNINGS section in the compact summary. The instruction
918
- // is read from disk (hot-editable like the other prompts). Also includes
919
- // existing learned skills so Claude can merge/update rather than start fresh.
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.
920
921
  PreCompact: [{
921
922
  matcher: '.*',
922
923
  hooks: [async (input) => {
923
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
924
928
  const instructionPath = join(__claudeLlmDir, 'prompts', 'compact-learnings-instruction.md');
925
- const instruction = readFileSync(instructionPath, 'utf-8');
926
- // Load existing learned skills so Claude can update them
929
+ 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
+ }
927
941
  const skillDir = this.#opts.sessionBaseDir || this.#opts.workingDirectory || process.cwd();
928
- const skillPath = join(skillDir, '.claude', 'skills', 'learned-behaviors', 'SKILL.md');
929
- let existingSkills = '';
930
- try {
931
- existingSkills = readFileSync(skillPath, 'utf-8');
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
+ }
932
1043
  }
933
- catch { }
934
- const fullInstruction = existingSkills
935
- ? `${instruction}\n\nEXISTING LEARNED SKILLS (update/merge — remove outdated, add new, strengthen confirmed):\n${existingSkills}`
936
- : instruction;
937
- console.log(`🧠 PreCompact: injected learnings instruction (${fullInstruction.length} chars, trigger=${input?.trigger || 'unknown'})`);
938
- return { systemMessage: fullInstruction };
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) };
939
1047
  }
940
1048
  catch (err) {
941
1049
  console.error('⚠️ PreCompact hook error:', err instanceof Error ? err.message : err);
@@ -943,34 +1051,108 @@ class ClaudeLLMStream extends llm.LLMStream {
943
1051
  }
944
1052
  }]
945
1053
  }],
946
- // ── PostCompact: extract BEHAVIORAL_LEARNINGS from summary and write to skill file ──
1054
+ // ── PostCompact: extract all four sections from summary and persist to disk ──
947
1055
  PostCompact: [{
948
1056
  matcher: '.*',
949
1057
  hooks: [async (input) => {
950
1058
  try {
951
1059
  const summary = input?.compact_summary || '';
952
- const marker = '=== BEHAVIORAL_LEARNINGS ===';
953
- const idx = summary.indexOf(marker);
954
- if (idx === -1) {
955
- console.log('🧠 PostCompact: no BEHAVIORAL_LEARNINGS section found in summary — skipping');
956
- return {};
957
- }
958
- const learnings = summary.substring(idx + marker.length).trim();
959
- if (learnings.length < 30) {
960
- console.log('🧠 PostCompact: BEHAVIORAL_LEARNINGS section too short — skipping');
961
- return {};
962
- }
963
- // Write the skill file
964
- const skillDir = this.#opts.sessionBaseDir || this.#opts.workingDirectory || process.cwd();
965
- const skillFolder = join(skillDir, '.claude', 'skills', 'learned-behaviors');
966
- const skillPath = join(skillFolder, 'SKILL.md');
967
1060
  const { mkdirSync, writeFileSync: writeSyncFs } = await import('fs');
968
- mkdirSync(skillFolder, { recursive: true });
1061
+ const skillDir = this.#opts.sessionBaseDir || this.#opts.workingDirectory || process.cwd();
969
1062
  const today = new Date().toISOString().split('T')[0];
970
1063
  const sessionId = this.#sessionId || 'unknown';
971
- const header = `# Learned Behaviors\n\nAuto-extracted from voice sessions via PreCompact.\nLast updated: ${today} | Session: ${sessionId.substring(0, 8)}...\n\n`;
972
- writeSyncFs(skillPath, header + learnings + '\n', 'utf-8');
973
- console.log(`🧠 PostCompact: wrote learned behaviors to ${skillPath} (${learnings.length} chars)`);
1064
+ // Helper: extract text between a marker and the next === marker (or end of string)
1065
+ const extractSection = (marker) => {
1066
+ const idx = summary.indexOf(marker);
1067
+ if (idx === -1)
1068
+ return '';
1069
+ const start = idx + marker.length;
1070
+ const nextMarker = summary.indexOf('=== ', start);
1071
+ return (nextMarker === -1 ? summary.substring(start) : summary.substring(start, nextMarker)).trim();
1072
+ };
1073
+ // ── Section 1: HANDOFF_STATE — log only, not written to disk ──
1074
+ const handoff = extractSection('=== HANDOFF_STATE ===');
1075
+ if (handoff) {
1076
+ console.log(`🧠 PostCompact: HANDOFF_STATE present (${handoff.length} chars) — not written to disk`);
1077
+ }
1078
+ // ── Section 2: DECISIONS — append project-scoped decisions ──
1079
+ try {
1080
+ const decisions = extractSection('=== DECISIONS ===');
1081
+ if (decisions) {
1082
+ const projectLines = decisions
1083
+ .split('\n')
1084
+ .filter(l => /DECISION:.*SCOPE:\s*project/i.test(l));
1085
+ if (projectLines.length) {
1086
+ const decFolder = join(skillDir, '.claude', 'skills', 'project-decisions');
1087
+ const decPath = join(decFolder, 'SKILL.md');
1088
+ mkdirSync(decFolder, { recursive: true });
1089
+ const existing = (() => { try {
1090
+ return require('fs').readFileSync(decPath, 'utf-8');
1091
+ }
1092
+ catch {
1093
+ return '';
1094
+ } })();
1095
+ const header = existing ? '' : `# Project Decisions\n\nAuto-extracted from compact summaries.\n\n`;
1096
+ const entry = `\n## ${today} (session ${sessionId.substring(0, 8)})\n${projectLines.join('\n')}\n`;
1097
+ writeSyncFs(decPath, header + existing + entry, 'utf-8');
1098
+ console.log(`🧠 PostCompact: appended ${projectLines.length} decision(s) to ${decPath}`);
1099
+ }
1100
+ }
1101
+ }
1102
+ catch (decErr) {
1103
+ console.error('⚠️ PostCompact: DECISIONS write failed:', decErr instanceof Error ? decErr.message : decErr);
1104
+ }
1105
+ // ── Section 3: SKILL_CANDIDATES — parse and write each skill ──
1106
+ try {
1107
+ const skillsSection = extractSection('=== SKILL_CANDIDATES ===');
1108
+ if (skillsSection) {
1109
+ const skillBlockRe = /---\s*SKILL:\s*([^\n-]+?)\s*---\n([\s\S]*?)---\s*END SKILL\s*---/g;
1110
+ const nameRe = /^[a-z][a-z0-9-]{1,39}$/;
1111
+ let match;
1112
+ while ((match = skillBlockRe.exec(skillsSection)) !== null) {
1113
+ const name = match[1].trim();
1114
+ const body = match[2].trim();
1115
+ if (!nameRe.test(name)) {
1116
+ console.warn(`⚠️ PostCompact: skipping skill with invalid name "${name}" (must match /^[a-z][a-z0-9-]{1,39}$/)`);
1117
+ continue;
1118
+ }
1119
+ const skillFolder = join(skillDir, '.claude', 'skills', name);
1120
+ const skillPath = join(skillFolder, 'SKILL.md');
1121
+ mkdirSync(skillFolder, { recursive: true });
1122
+ const header = `# ${name}\nAuto-extracted: ${today} | Session: ${sessionId.substring(0, 8)}\n\n`;
1123
+ writeSyncFs(skillPath, header + body + '\n', 'utf-8');
1124
+ console.log(`🧠 PostCompact: wrote skill '${name}' to ${skillPath}`);
1125
+ }
1126
+ }
1127
+ }
1128
+ catch (skillErr) {
1129
+ console.error('⚠️ PostCompact: SKILL_CANDIDATES write failed:', skillErr instanceof Error ? skillErr.message : skillErr);
1130
+ }
1131
+ // ── Section 4: BEHAVIORAL_LEARNINGS — write to learned-behaviors skill file ──
1132
+ 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
+ }
1148
+ }
1149
+ else {
1150
+ console.log('🧠 PostCompact: no BEHAVIORAL_LEARNINGS section found in summary — skipping');
1151
+ }
1152
+ }
1153
+ catch (blErr) {
1154
+ console.error('⚠️ PostCompact: BEHAVIORAL_LEARNINGS write failed:', blErr instanceof Error ? blErr.message : blErr);
1155
+ }
974
1156
  }
975
1157
  catch (err) {
976
1158
  console.error('⚠️ PostCompact hook error:', err instanceof Error ? err.message : err);
package/dist/index.js CHANGED
@@ -622,6 +622,34 @@ function startApiServer(workingDir, port) {
622
622
  console.log(`🌐 API server listening on http://${host}:${port}`);
623
623
  console.log(` Sessions: http://${host}:${port}/sessions`);
624
624
  });
625
+ // Stale upload-chunk cleanup: remove osborn-upload-* dirs older than 30 minutes
626
+ const cleanStaleUploadDirs = () => {
627
+ const tmp = tmpdir();
628
+ const cutoff = Date.now() - 30 * 60 * 1000;
629
+ try {
630
+ const entries = readdirSync(tmp);
631
+ for (const entry of entries) {
632
+ if (!entry.startsWith('osborn-upload-'))
633
+ continue;
634
+ const full = `${tmp}/${entry}`;
635
+ try {
636
+ const st = statSync(full);
637
+ if (st.isDirectory() && st.mtimeMs < cutoff) {
638
+ rmSync(full, { recursive: true, force: true });
639
+ console.log(`🧹 Removed stale upload dir: ${full}`);
640
+ }
641
+ }
642
+ catch {
643
+ // ignore per-entry errors
644
+ }
645
+ }
646
+ }
647
+ catch {
648
+ // ignore if /tmp is unreadable
649
+ }
650
+ };
651
+ cleanStaleUploadDirs();
652
+ setInterval(cleanStaleUploadDirs, 10 * 60 * 1000);
625
653
  server.on('error', (err) => {
626
654
  if (err.code === 'EADDRINUSE') {
627
655
  console.warn(`⚠️ API port ${port} in use, trying ${port + 1}...`);
@@ -1,14 +1,57 @@
1
- IMPORTANT BEFORE COMPACTING: Include a section at the END of your compact summary titled exactly:
1
+ # Pre-Compaction Instructions
2
2
 
3
- === BEHAVIORAL_LEARNINGS ===
3
+ Before Claude Code compacts this conversation, you MUST preserve critical context by including the following four sections at the very END of your compact summary. Be selective and specific — vague summaries are useless.
4
4
 
5
- In this section, extract and list:
6
- 1. USER CORRECTIONS — things the user explicitly told you to stop/start doing (e.g., "stop patching scripts", "use step-by-step not autonomous", "don't name sub-agents")
7
- 2. USER PREFERENCES — recurring patterns in how the user works (e.g., "prefers yahoo email for new accounts", "wants visible browser not headless", "expects score-based salary calculation")
8
- 3. DOMAIN KNOWLEDGE — specific technical facts verified during this session (e.g., "Workday sign-in is inline switch not modal", "CareSource hibernation is 30s confirmed")
9
- 4. EFFECTIVE PATTERNS — approaches that worked and the user confirmed (e.g., "co-direction questions during steerable work", "grounding with 2 reads before speaking")
10
- 5. ANTI-PATTERNS — approaches that failed or frustrated the user (e.g., "reporting exit code 0 as success without verification", "multiple browser windows from competing automation approaches")
5
+ ---
11
6
 
12
- Be SELECTIVE only include items that are generalizable, actionable, and non-obvious. Skip task-specific details. Each item should be one line.
7
+ ## Section 1: HANDOFF_STATE
8
+ Format: === HANDOFF_STATE ===
9
+ Include:
10
+ - **Current goal**: What are we building/fixing and WHY (the big-picture reason, not just the immediate task)
11
+ - **Progress**: What is done, what is in-progress, what is the very next step
12
+ - **Active facts**: Environment details mentioned in this session (API keys referenced, service URLs, version numbers confirmed, file paths that matter)
13
+ - **Test results**: What was tried, what it showed, what it ruled out
13
14
 
14
- This section will be extracted after compaction and saved as a persistent skill for future sessions.
15
+ Keep this under 800 characters. This stays in the compact summary it is NOT written to disk.
16
+
17
+ ---
18
+
19
+ ## Section 2: DECISIONS
20
+ Format: === DECISIONS ===
21
+ List each architectural or project decision made in this session, one per line:
22
+ - DECISION: <choice made> | RATIONALE: <why> | SCOPE: project
23
+
24
+ Only include decisions that would matter in a future session on the same project. Skip trivial choices.
25
+
26
+ ---
27
+
28
+ ## Section 3: SKILL_CANDIDATES
29
+ Format: === SKILL_CANDIDATES ===
30
+ For each reusable how-to procedure confirmed working in this session, emit:
31
+
32
+ --- SKILL: <kebab-case-name> ---
33
+ WHEN: <one line: when this skill applies>
34
+ STEPS:
35
+ 1. ...
36
+ 2. ...
37
+ VERIFIED: <exact command or observation that confirmed it works>
38
+ --- END SKILL ---
39
+
40
+ A skill is worth extracting only if: (1) it was confirmed working in this session AND (2) it would apply to future sessions on different tasks. Do NOT re-emit skills already shown in the EXISTING SKILLS section unless they need substantive updates.
41
+
42
+ ---
43
+
44
+ ## Section 4: BEHAVIORAL_LEARNINGS
45
+ Format: === BEHAVIORAL_LEARNINGS ===
46
+ Capture user corrections, preferences, and anti-patterns in these subsections:
47
+ USER CORRECTIONS:
48
+ USER PREFERENCES:
49
+ DOMAIN KNOWLEDGE:
50
+ EFFECTIVE PATTERNS:
51
+ ANTI-PATTERNS:
52
+
53
+ If the EXISTING LEARNED SKILLS section is shown below, merge — update outdated items, add new ones, keep confirmed ones.
54
+
55
+ ---
56
+
57
+ IMPORTANT: All four sections MUST appear at the end of the compact summary even if some are empty. Empty sections should have a single line: (none this session)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.6",
3
+ "version": "0.9.9",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {