osborn 0.9.211 → 0.9.213

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.
@@ -11,8 +11,10 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
11
11
  import { EventEmitter } from 'events';
12
12
  import { saveSessionMetadata, getSessionWorkspace } from './config.js';
13
13
  import { statusManager } from './status-manager.js';
14
- import { getResearchSystemPrompt, getDirectModeResearchPrompt } from './prompts.js';
14
+ import { getResearchSystemPrompt, getDirectModeResearchPrompt, getGroundingBlock, getRecalledContextBlock } from './prompts.js';
15
15
  import { getIndexPath } from './summary-index.js';
16
+ import { openStore, recall, storeExists, updateSessionStore, getStorePath } from './session-store.js';
17
+ import { getEmbedder } from './embedder.js';
16
18
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
17
19
  import { join, dirname, resolve, basename } from 'node:path';
18
20
  import { fileURLToPath } from 'node:url';
@@ -20,6 +22,70 @@ import { homedir } from 'node:os';
20
22
  // Directory of this module — used to locate co-located prompt files (e.g., turn-shape reminder).
21
23
  const __claudeLlmDir = dirname(fileURLToPath(import.meta.url));
22
24
  const TURN_SHAPE_REMINDER_PATH = join(__claudeLlmDir, 'prompts', 'turn-shape-reminder.md');
25
+ // ── Recall auto-injection (reliable "remembers to check" per Agent-SDK research) ──
26
+ // The RELIABLE primitive the Agent SDK exposes is a UserPromptSubmit hook returning
27
+ // additionalContext: deterministic per-turn injection the model cannot skip (vs. an
28
+ // MCP/skill recall tool the model must elect to call). We piggyback on the existing
29
+ // turn-shape-reminder hook and append the top-K prior messages relevant to THIS prompt.
30
+ //
31
+ // Scope: this hook lives on the MAIN conductor's query options only. Named adversarial
32
+ // sub-agents (reviewer/tester) are spawned as SEPARATE query() calls with their own
33
+ // options and NO UserPromptSubmit hook — they stay deliberately un-grounded, so this
34
+ // injection never leaks into them. Task-delegated agents (researcher/writer/…) don't
35
+ // fire UserPromptSubmit (that event is for real user turns), so they're unaffected too.
36
+ //
37
+ // COMPACT by design: paid on every turn, so top-5 with a per-hit char cap (~800) →
38
+ // ~4KB. FULL untruncated text stays available on demand via `osborn-recall`.
39
+ const RECALL_TOP_K = 5;
40
+ const RECALL_PER_HIT_CHARS = 800;
41
+ const RECALL_ENABLED = () => process.env.OSBORN_RECALL_INJECT !== '0';
42
+ // Warm the embedder once (fire-and-forget) so hybrid recall is ready without blocking
43
+ // the first turn; until it's warm, recall() falls back to keyword-only (~16ms).
44
+ let __embedderWarmed = false;
45
+ function warmEmbedder() {
46
+ if (__embedderWarmed || process.env.OSBORN_EMBED === '0')
47
+ return;
48
+ __embedderWarmed = true;
49
+ getEmbedder().catch(() => { });
50
+ }
51
+ /** Build the recalled-context block to inject alongside the turn-shape reminder. '' on any miss. */
52
+ async function buildRecallInjection(sessionId, workingDir, prompt) {
53
+ try {
54
+ if (!RECALL_ENABLED() || !sessionId || !workingDir)
55
+ return '';
56
+ const q = String(prompt || '').trim();
57
+ if (q.length < 3)
58
+ return '';
59
+ if (!storeExists(sessionId, workingDir))
60
+ return '';
61
+ warmEmbedder();
62
+ // Only use the embedder if it's already loaded — never block the turn on a cold load.
63
+ const embed = (__embedderWarmed && process.env.OSBORN_EMBED !== '0') ? (await getEmbedder()) ?? undefined : undefined;
64
+ const { getStorePath } = await import('./session-store.js');
65
+ const db = openStore(getStorePath(sessionId, workingDir));
66
+ let hits;
67
+ try {
68
+ hits = await recall(db, q, { mode: embed ? 'hybrid' : 'keyword', topK: RECALL_TOP_K, embed });
69
+ }
70
+ finally {
71
+ db.close();
72
+ }
73
+ if (!hits.length)
74
+ return '';
75
+ const lines = hits.map((h, i) => {
76
+ let body = h.text.replace(/\n{3,}/g, '\n\n').trim();
77
+ if (body.length > RECALL_PER_HIT_CHARS)
78
+ body = body.slice(0, RECALL_PER_HIT_CHARS) + ' …';
79
+ const src = `${h.source} L${h.lineNum} · ${h.msgType}${h.toolName ? `:${h.toolName}` : ''}`;
80
+ return `[${i + 1}] (${src})\n${body}`;
81
+ });
82
+ // Static wrapper is centralized + parameterized in ./prompts/recalled-context.md.
83
+ return getRecalledContextBlock(lines.join('\n\n'));
84
+ }
85
+ catch {
86
+ return ''; // recall is best-effort — never break the turn
87
+ }
88
+ }
23
89
  // ≤3 direct tool call budget per turn. Reset on every UserPromptSubmit (new user message).
24
90
  // Enforced mechanically in PreToolUse — the model CANNOT exceed this regardless of JSONL history.
25
91
  // Task/Agent delegations are exempt (delegation is what we WANT). Sub-agent tool calls
@@ -133,6 +199,58 @@ function loadAllSkills(_workingDir) {
133
199
  console.log(`📚 Loaded ${skillMap.size} skill(s) from ${homeSkillsDir}`);
134
200
  return `<available-skills>\n${[...skillMap.values()].join('\n\n---\n\n')}\n</available-skills>`;
135
201
  }
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.
209
+ */
210
+ function enumerateSkillsForCompaction() {
211
+ const dir = join(homedir(), '.claude', 'skills');
212
+ if (!existsSync(dir))
213
+ return '';
214
+ const lines = [];
215
+ try {
216
+ for (const name of readdirSync(dir).sort()) {
217
+ const file = join(dir, name, 'SKILL.md');
218
+ if (!existsSync(file))
219
+ continue;
220
+ let desc = '';
221
+ 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();
228
+ }
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();
234
+ }
235
+ if (!desc) {
236
+ 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)';
239
+ }
240
+ }
241
+ catch {
242
+ desc = '(unreadable)';
243
+ }
244
+ if (desc.length > 180)
245
+ desc = desc.slice(0, 177) + '…';
246
+ lines.push(`- ${name}: ${desc}`);
247
+ }
248
+ }
249
+ catch (err) {
250
+ console.warn('⚠️ enumerateSkillsForCompaction failed:', err instanceof Error ? err.message : err);
251
+ }
252
+ return lines.join('\n');
253
+ }
136
254
  // Compaction threshold: Fable 5 runs a 1M context window, so let sessions use
137
255
  // all of it before auto-compacting. autoCompactWindow max is 1_000_000; the SDK
138
256
  // reads it from settings.json (settingSources includes 'user'), so merge it into
@@ -201,6 +319,7 @@ export const NAMED_AGENTS = {
201
319
  'GROUNDED: reads the session search-index for prior findings before researching.',
202
320
  ].join(' '),
203
321
  tools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'Task'],
322
+ grounded: true, // applyGrounding() injects the osborn-recall command + ensures Bash
204
323
  model: 'sonnet',
205
324
  prompt: [
206
325
  'You are Osborn\'s research agent. Your job is information gathering — thorough, structured, factual.',
@@ -209,8 +328,7 @@ export const NAMED_AGENTS = {
209
328
  'Gather information the main agent needs to answer the user\'s question or make a decision.',
210
329
  'You are a scout — go find things, read them carefully, and report back.',
211
330
  '',
212
- '## Grounding — check the session index first',
213
- 'Before researching, locate the session index (search-index.txt — a compact line-per-message log of this mission, under .claude/projects/<slug>/osb/<session>/; if several exist pick the most recently modified) and Grep it for the topic you are about to investigate. Read ONLY the matching slice, never the whole file.',
331
+ 'Before researching, GROUND yourself using the recall command in the Grounding section appended to this prompt — check prior findings, decisions, and gotchas so you do not redo settled work.',
214
332
  'Purpose: find what has ALREADY been decided, answered, or ruled out so you do not re-research a settled question. If the index already establishes the answer, report that (with the index reference) instead of redoing the work.',
215
333
  'If you cannot find the index, proceed normally — this is an optimization, not a hard dependency.',
216
334
  '',
@@ -303,6 +421,7 @@ export const NAMED_AGENTS = {
303
421
  'GROUNDED: reads the session search-index before editing to avoid contradicting prior decisions.',
304
422
  ].join(' '),
305
423
  tools: ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Glob', 'Grep', 'NotebookRead', 'NotebookEdit'],
424
+ grounded: true, // applyGrounding() injects the osborn-recall command
306
425
  model: 'opus',
307
426
  prompt: [
308
427
  'You are Osborn\'s writer agent. You execute file changes with a verify-first approach.',
@@ -311,8 +430,7 @@ export const NAMED_AGENTS = {
311
430
  'Handle ALL file operations — code, config, documentation, scripts, data files.',
312
431
  'You are the only agent that writes. The main agent and reasoner produce plans; you execute them.',
313
432
  '',
314
- '## Grounding — consult the session index (light touch)',
315
- 'Before editing, locate the session index (search-index.txt under .claude/projects/<slug>/osb/<session>/; newest if several) and Grep it ONLY for: (a) the files/symbols you are about to change, and (b) any recorded DECISIONS or known GOTCHAS relevant to this change. Read only the matching lines — do NOT read the whole index (thousands of lines). This is a lighter dose than the reviewer: a targeted lookup.',
433
+ 'Before editing, GROUND yourself using the recall command in the Grounding section appended to this prompt — check for prior DECISIONS and known GOTCHAS on the files/symbols you are about to change.',
316
434
  'If a decision or gotcha contradicts your task, STOP and report to the main agent before editing. If you find nothing or no index exists, proceed normally.',
317
435
  '',
318
436
  '## VERIFY-FIRST workflow (mandatory)',
@@ -367,7 +485,7 @@ export const NAMED_AGENTS = {
367
485
  'observed behavior without a matching requirement is a regression — treat behavioral surprise as a defect.',
368
486
  '',
369
487
  '## Grounding — consult shared context before writing or running tests',
370
- 'Before deciding what to test, locate the session index (search-index.txt under .claude/projects/<slug>/osb/<session>/; newest if several) and Grep it for the changes/work under test. Also check project docs and known-issues files. Key doc locations to consult: `/workspace/osborn/CLAUDE.md`, `/workspace/osborn/docs/critical-patterns.md`, the `docs/` directory, `README.md`, and `CHANGELOG.md`. Check these for: (a) KNOWN ISSUES and gotchas already recorded, and (b) what behavior is ALREADY covered by existing tests.',
488
+ 'Before deciding what to test, check project docs and known-issues files (these live under the working dir and ARE readable). Key doc locations to consult: `/workspace/osborn/CLAUDE.md`, `/workspace/osborn/docs/critical-patterns.md`, the `docs/` directory, `README.md`, and `CHANGELOG.md`. Check these for: (a) KNOWN ISSUES and gotchas already recorded, and (b) what behavior is ALREADY covered by existing tests. (You are adversarial and deliberately NOT given session recall — validate with fresh eyes.)',
371
489
  'Purpose: target regression coverage at real GAPS and known-risk areas rather than testing blind or duplicating coverage — and stay IN SYNC with the reviewer, which reads the same sources.',
372
490
  'Read only the relevant slice of the index, never the whole file. If you find nothing or no index/docs exist, proceed normally — this is an optimization, not a hard dependency.',
373
491
  'If the change adds or renames a feature, flag any doc now out of date (see DOC STALENESS in "What to return").',
@@ -450,6 +568,7 @@ export const NAMED_AGENTS = {
450
568
  'GROUNDED: reads the session search-index before planning to respect prior decisions.',
451
569
  ].join(' '),
452
570
  tools: ['Read', 'Glob', 'Grep', 'WebSearch'],
571
+ grounded: true, // applyGrounding() injects the osborn-recall command + adds Bash
453
572
  model: 'sonnet',
454
573
  prompt: [
455
574
  'You are Osborn\'s planning agent. Your job is to decompose complex tasks into clear, atomic steps.',
@@ -459,7 +578,7 @@ export const NAMED_AGENTS = {
459
578
  'step by step without guessing. You are the bridge between "what" and "how".',
460
579
  '',
461
580
  '## Grounding — plan against what already exists',
462
- 'Before drafting a plan, locate the session index (search-index.txt under .claude/projects/<slug>/osb/<session>/; newest if several) and Grep it for prior DECISIONS, constraints, and known GOTCHAS relevant to the task. Read only the matching slice, never the whole file.',
581
+ 'Before drafting a plan, GROUND yourself using the recall command in the Grounding section appended to this prompt — check prior DECISIONS, constraints, and known GOTCHAS relevant to the task.',
463
582
  'Purpose: make the plan fit what has already been decided or tried — do not propose an approach the mission already ruled out. If a prior decision conflicts with the obvious plan, surface it in the plan rather than silently contradicting it.',
464
583
  'If you cannot find the index, proceed normally.',
465
584
  '',
@@ -630,6 +749,47 @@ export function applyTurbo(agents, turbo) {
630
749
  }
631
750
  return out;
632
751
  }
752
+ /**
753
+ * Inject session-recall grounding into any agent flagged `grounded: true`.
754
+ *
755
+ * WHY: a sub-agent's file tools (Read/Grep/Glob) are sandboxed to its cwd +
756
+ * additionalDirectories, so it CANNOT read the session index / session.db that live
757
+ * under $HOME/.claude/projects/… — the old "grep search-index.txt" grounding silently
758
+ * failed. Bash, however, is NOT cwd-restricted, so `osborn-recall` reaches the store.
759
+ *
760
+ * So for every grounded agent we (1) hand it the EXACT, absolute-path osborn-recall
761
+ * command (resolved once here — the single dynamic resolver, so we never hardcode a
762
+ * per-agent path; works for named AND user-created custom grounded agents), and
763
+ * (2) guarantee Bash is in its tool set so it can run that command. The `grounded`
764
+ * flag is stripped before the roster reaches the SDK. NEVER mutates the input.
765
+ *
766
+ * Adversarial agents (reviewer/tester) leave `grounded` unset → untouched, stay blind.
767
+ */
768
+ export function applyGrounding(agents, sessionId, workingDir) {
769
+ const out = {};
770
+ // Resolve the store command ONCE — absolute --db path, cwd-independent.
771
+ const dbPath = (sessionId && sessionId !== 'pending' && workingDir)
772
+ ? getStorePath(sessionId, workingDir) : null;
773
+ for (const [name, agent] of Object.entries(agents)) {
774
+ if (!agent?.grounded) {
775
+ out[name] = agent;
776
+ continue;
777
+ }
778
+ const { grounded, ...rest } = agent;
779
+ // Ensure Bash is available so the agent can actually run osborn-recall.
780
+ const tools = Array.isArray(rest.tools) ? [...rest.tools] : [];
781
+ if (!tools.includes('Bash'))
782
+ tools.push('Bash');
783
+ // Push the grounding block into the system prompt (arrives at spawn — sandbox-proof).
784
+ const cmd = dbPath
785
+ ? `osborn-recall "<terms from your task>" --db ${dbPath} --top-k 8`
786
+ : `osborn-recall "<terms from your task>" --top-k 8`;
787
+ // Body is centralized + parameterized in ./prompts/grounding-recall.md.
788
+ const groundingBlock = getGroundingBlock(cmd);
789
+ out[name] = { ...rest, tools, prompt: `${rest.prompt || ''}\n${groundingBlock}` };
790
+ }
791
+ return out;
792
+ }
633
793
  const RESEARCH_TOOLS = [
634
794
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
635
795
  'Bash', 'WebSearch', 'WebFetch',
@@ -714,6 +874,11 @@ export class ClaudeLLM extends llm.LLM {
714
874
  // Dedup guard — prevents double-firing reviewer/gate if SubagentStop fires
715
875
  // more than once for the same agent_id (e.g. retry edge cases).
716
876
  #dispatchedFor = new Set();
877
+ // Embedded session.db write-through guard. The store write is triggered from the
878
+ // UserPromptSubmit hook (once per real user submission, main-thread only) and sweeps
879
+ // the FULL source set — main JSONL + every sub-agent JSONL — exactly like the flat
880
+ // index. Incremental (byte-offset resume) + fire-and-forget, so it never blocks a turn.
881
+ #storeUpdating = false;
717
882
  // Turbo mode — when true, every spawned agent (main + sub-agents) runs on
718
883
  // FAST_MODEL regardless of individual model config. Default off = no-op.
719
884
  #turbo = false;
@@ -806,6 +971,34 @@ export class ClaudeLLM extends llm.LLM {
806
971
  // ============================================================
807
972
  // MCP SERVER MANAGEMENT - Runtime enable/disable MCP servers
808
973
  // ============================================================
974
+ /**
975
+ * Guarded, fire-and-forget write-through to the embedded session.db. Called from the
976
+ * main agent's UserPromptSubmit hook (once per real user submission). Sweeps the FULL
977
+ * source set — main JSONL + every sub-agent JSONL — incrementally (byte-offset resume).
978
+ * The guard lives here (on the long-lived ClaudeLLM, not the per-turn stream) so a slow
979
+ * write can't overlap the next turn's write. Never throws; never blocks the caller.
980
+ */
981
+ triggerStoreUpdate(sessionId, workingDir) {
982
+ if (this.#storeUpdating || !sessionId || sessionId === 'pending' || !workingDir)
983
+ return;
984
+ if (process.env.OSBORN_STORE === '0')
985
+ return;
986
+ this.#storeUpdating = true;
987
+ (async () => {
988
+ try {
989
+ const embed = process.env.OSBORN_EMBED === '0' ? undefined : (await getEmbedder()) ?? undefined;
990
+ const stats = await updateSessionStore(sessionId, workingDir, { embed });
991
+ if (stats.newRows > 0)
992
+ console.log(`🗄️ [store] +${stats.newRows} rows (${stats.totalRows} total, embedded=${stats.embeddedRows}) across main+subagents`);
993
+ }
994
+ catch (err) {
995
+ console.error('🗄️ [store] update failed:', err?.message);
996
+ }
997
+ finally {
998
+ this.#storeUpdating = false;
999
+ }
1000
+ })();
1001
+ }
809
1002
  /**
810
1003
  * Get all currently enabled MCP servers
811
1004
  */
@@ -1920,11 +2113,31 @@ class ClaudeLLMStream extends llm.LLMStream {
1920
2113
  turnToolCallCount = 0;
1921
2114
  const reminder = readFileSync(TURN_SHAPE_REMINDER_PATH, 'utf-8');
1922
2115
  const promptPreview = String(input?.prompt || '').substring(0, 60).replace(/\n/g, ' ');
1923
- console.log(`📌 UserPromptSubmit: injected turn-shape reminder (${reminder.length} chars) for prompt="${promptPreview}..." [tool budget reset to 0/${TOOL_CALL_BUDGET}]`);
2116
+ // Reliable recall: retrieve prior messages relevant to THIS prompt and inject
2117
+ // them deterministically. MAIN CONDUCTOR ONLY — gate explicitly on agent_id.
2118
+ // Per the SDK: agent_id is present ONLY when a hook fires from within a subagent,
2119
+ // absent on the main thread. So `agent_id` set ⇒ skip (grounded sub-agents pull
2120
+ // via osborn-recall from their own prompt; adversarial ones stay un-grounded).
2121
+ const fromSubagent = Boolean(input?.agent_id);
2122
+ const sid = input?.session_id || this.#sessionId;
2123
+ // Consolidated WRITE trigger: on every real user submission (main thread only),
2124
+ // sweep main + ALL sub-agent JSONLs into session.db. Naturally debounced to the
2125
+ // user's speech cadence (one submission = one sweep); guarded + fire-and-forget
2126
+ // on the long-lived ClaudeLLM so injection below never waits on it. This is the
2127
+ // single canonical write path — mirrors the flat index's sub-agent sweep, but
2128
+ // stores FULL untruncated text + FTS5 + sqlite-vec instead of truncated summaries.
2129
+ if (!fromSubagent && sid && this.#opts.workingDirectory) {
2130
+ this.#llmRef.triggerStoreUpdate(sid, this.#opts.workingDirectory);
2131
+ }
2132
+ const recalled = fromSubagent
2133
+ ? ''
2134
+ : await buildRecallInjection(sid, this.#opts.workingDirectory, String(input?.prompt || ''));
2135
+ const additionalContext = recalled ? `${reminder}\n\n${recalled}` : reminder;
2136
+ console.log(`📌 UserPromptSubmit: injected turn-shape reminder (${reminder.length} chars)${recalled ? ` + recall (${recalled.length} chars)` : ''} for prompt="${promptPreview}..." [tool budget reset to 0/${TOOL_CALL_BUDGET}]`);
1924
2137
  return {
1925
2138
  hookSpecificOutput: {
1926
2139
  hookEventName: 'UserPromptSubmit',
1927
- additionalContext: reminder,
2140
+ additionalContext,
1928
2141
  },
1929
2142
  };
1930
2143
  }
@@ -1944,8 +2157,28 @@ class ClaudeLLMStream extends llm.LLMStream {
1944
2157
  try {
1945
2158
  this.#opts.onCompactionEvent?.({ type: 'compaction_started', trigger: input?.trigger });
1946
2159
  const instructionPath = join(__claudeLlmDir, 'prompts', 'compact-learnings-instruction.md');
1947
- const instruction = existsSync(instructionPath) ? readFileSync(instructionPath, 'utf-8') : '';
1948
- console.log(`🧠 PreCompact: injecting instruction (${instruction.length} chars, trigger=${input?.trigger || 'unknown'})`);
2160
+ const instructionRaw = existsSync(instructionPath) ? readFileSync(instructionPath, 'utf-8') : '';
2161
+ // Populate the EXISTING SKILLS section the instruction .md already refers to
2162
+ // (lines 42 & 55) but that nothing used to fill. Without this, the model is told
2163
+ // to "not re-emit skills already shown" against a list it never received — so it
2164
+ // proliferated duplicates. We hand it the current skill set + an adversarial,
2165
+ // self-critical directive to merge/refine rather than create anew.
2166
+ const existingSkills = enumerateSkillsForCompaction();
2167
+ const skillCount = existingSkills ? existingSkills.split('\n').length : 0;
2168
+ 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 — `
2173
+ + `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, `
2175
+ + `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 — `
2177
+ + `reject your own low-signal or one-off candidates.\n\n`
2178
+ + `${existingSkills}\n`
2179
+ : '';
2180
+ const instruction = instructionRaw + criticBlock;
2181
+ console.log(`🧠 PreCompact: injecting instruction (${instruction.length} chars, ${skillCount} existing skills, trigger=${input?.trigger || 'unknown'})`);
1949
2182
  return { systemMessage: instruction };
1950
2183
  }
1951
2184
  catch (err) {
@@ -2005,7 +2238,7 @@ class ClaudeLLMStream extends llm.LLMStream {
2005
2238
  const decPath = join(decFolder, 'SKILL.md');
2006
2239
  mkdirSync(decFolder, { recursive: true });
2007
2240
  const existing = existsSyncFs(decPath) ? readSyncFs(decPath, 'utf-8') : '';
2008
- const header = existing ? '' : `# Project Decisions\n\nAuto-extracted from compact summaries.\n\n`;
2241
+ const header = existing ? '' : `---\nname: decisions\ndescription: "Project-scoped architectural and implementation decisions auto-extracted from compaction summaries; consult before revisiting settled choices."\nmetadata:\n type: learned\n source: postcompact\n---\n\n# Project Decisions\n\nAuto-extracted from compact summaries.\n\n`;
2009
2242
  const entry = `\n## ${today} (session ${sessionId.substring(0, 8)})\n${projectLines.join('\n')}\n`;
2010
2243
  writeSyncFs(decPath, header + existing + entry, 'utf-8');
2011
2244
  console.log(`🧠 PostCompact: appended ${projectLines.length} decision(s) to ${decPath}`);
@@ -2035,8 +2268,14 @@ class ClaudeLLMStream extends llm.LLMStream {
2035
2268
  const skillFolder = join(skillDir, '.claude', 'skills', name);
2036
2269
  const skillPath = join(skillFolder, 'SKILL.md');
2037
2270
  mkdirSync(skillFolder, { recursive: true });
2038
- const header = `# ${name}\nAuto-extracted: ${today} | Session: ${sessionId.substring(0, 8)}\n\n`;
2039
- writeSyncFs(skillPath, header + body + '\n', 'utf-8');
2271
+ // Emit STANDARD skill format: YAML frontmatter (name + description) so
2272
+ // learned skills conform to the same shape the loader/other agents expect.
2273
+ // Description is derived from the candidate's WHEN: line; JSON.stringify
2274
+ // yields a safely-quoted YAML scalar even when it contains colons/quotes.
2275
+ const whenMatch = body.match(/^\s*WHEN:\s*(.+)$/mi);
2276
+ const desc = (whenMatch ? whenMatch[1].trim() : `Learned procedure: ${name}`).replace(/\s+/g, ' ').slice(0, 200);
2277
+ const frontmatter = `---\nname: ${name}\ndescription: ${JSON.stringify(desc)}\nmetadata:\n type: learned\n source: postcompact\n session: ${sessionId.substring(0, 8)}\n updated: ${today}\n---\n\n`;
2278
+ writeSyncFs(skillPath, frontmatter + `# ${name}\n\n` + body + '\n', 'utf-8');
2040
2279
  console.log(`🧠 PostCompact: wrote skill '${name}' to ${skillPath}`);
2041
2280
  skillsWritten++;
2042
2281
  skillNames.push(name);
@@ -2055,7 +2294,7 @@ class ClaudeLLMStream extends llm.LLMStream {
2055
2294
  const skillFolder = join(skillDir, '.claude', 'skills', 'learned-behaviors');
2056
2295
  const skillPath = join(skillFolder, 'SKILL.md');
2057
2296
  mkdirSync(skillFolder, { recursive: true });
2058
- const header = `# Learned Behaviors\n\nAuto-extracted from voice sessions via PostCompact.\nLast updated: ${today} | Session: ${sessionId.substring(0, 8)}...\n\n`;
2297
+ const header = `---\nname: learned-behaviors\ndescription: "User corrections, preferences, domain knowledge, effective patterns, and anti-patterns learned across sessions; apply to align with how this user works."\nmetadata:\n type: learned\n source: postcompact\n updated: ${today}\n---\n\n# Learned Behaviors\n\nAuto-extracted from voice sessions via PostCompact.\nLast updated: ${today} | Session: ${sessionId.substring(0, 8)}...\n\n`;
2059
2298
  writeSyncFs(skillPath, header + learnings + '\n', 'utf-8');
2060
2299
  console.log(`🧠 PostCompact: wrote learned behaviors to ${skillPath} (${learnings.length} chars)`);
2061
2300
  skillsWritten++;
@@ -2159,9 +2398,9 @@ class ClaudeLLMStream extends llm.LLMStream {
2159
2398
  // opts.agents is undefined — the ?? NAMED_AGENTS fallback would skip
2160
2399
  // the override entirely. Explicitly apply applyTurbo(NAMED_AGENTS)
2161
2400
  // so built-in agents always get FAST_MODEL when turbo is on.
2162
- agents: this.#llmRef.turbo
2401
+ agents: applyGrounding(this.#llmRef.turbo
2163
2402
  ? applyTurbo(this.#opts.agents ?? NAMED_AGENTS, true)
2164
- : (this.#opts.agents ?? NAMED_AGENTS),
2403
+ : (this.#opts.agents ?? NAMED_AGENTS), this.#sessionId, this.#opts.workingDirectory),
2165
2404
  };
2166
2405
  // Run Claude Agent SDK query() and stream results
2167
2406
  let hasOutput = false;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * embedder.ts — Local text→vector embedder for the semantic (vec) layer of session-store.
3
+ *
4
+ * Uses all-MiniLM-L6-v2 (384-dim) via @xenova/transformers, running fully local (no API,
5
+ * no per-write network cost). Output is L2-normalized float32, quantized to int8[384]
6
+ * (×127) to match the sqlite-vec `int8[384]` column — 4× smaller than float32 with
7
+ * ~98–99% recall.
8
+ *
9
+ * DESIGN FOR RELIABILITY:
10
+ * • Lazy — the model is loaded on first use, never at import (startup stays fast).
11
+ * • Best-effort — if the package or model can't load, getEmbedder() returns null and
12
+ * the store runs keyword-only. Embeddings never block the keyword write path.
13
+ * • Gated — set OSBORN_EMBED=0 to force keyword-only (e.g. on machines without the
14
+ * model cached, or to avoid the first-run model download).
15
+ *
16
+ * Model cache honors TRANSFORMERS_CACHE / HF_HOME so it can be baked into the image.
17
+ */
18
+ import { type Embedder } from './session-store.js';
19
+ /**
20
+ * Returns an Embedder, or null if embeddings are unavailable/disabled.
21
+ * The returned function itself also degrades to null on runtime failure.
22
+ */
23
+ export declare function getEmbedder(): Promise<Embedder | null>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * embedder.ts — Local text→vector embedder for the semantic (vec) layer of session-store.
3
+ *
4
+ * Uses all-MiniLM-L6-v2 (384-dim) via @xenova/transformers, running fully local (no API,
5
+ * no per-write network cost). Output is L2-normalized float32, quantized to int8[384]
6
+ * (×127) to match the sqlite-vec `int8[384]` column — 4× smaller than float32 with
7
+ * ~98–99% recall.
8
+ *
9
+ * DESIGN FOR RELIABILITY:
10
+ * • Lazy — the model is loaded on first use, never at import (startup stays fast).
11
+ * • Best-effort — if the package or model can't load, getEmbedder() returns null and
12
+ * the store runs keyword-only. Embeddings never block the keyword write path.
13
+ * • Gated — set OSBORN_EMBED=0 to force keyword-only (e.g. on machines without the
14
+ * model cached, or to avoid the first-run model download).
15
+ *
16
+ * Model cache honors TRANSFORMERS_CACHE / HF_HOME so it can be baked into the image.
17
+ */
18
+ import { EMBED_DIM } from './session-store.js';
19
+ const MODEL_ID = process.env.OSBORN_EMBED_MODEL || 'Xenova/all-MiniLM-L6-v2';
20
+ let pipelinePromise = null;
21
+ let disabled = false;
22
+ /** Quantize a normalized float32 embedding to int8[dim] (×127, clamped). */
23
+ function quantizeInt8(floats) {
24
+ const out = new Int8Array(EMBED_DIM);
25
+ const n = Math.min(EMBED_DIM, floats.length);
26
+ for (let i = 0; i < n; i++) {
27
+ let v = Math.round(floats[i] * 127);
28
+ if (v > 127)
29
+ v = 127;
30
+ else if (v < -128)
31
+ v = -128;
32
+ out[i] = v;
33
+ }
34
+ return out;
35
+ }
36
+ async function loadPipeline() {
37
+ if (disabled)
38
+ return null;
39
+ if (process.env.OSBORN_EMBED === '0') {
40
+ disabled = true;
41
+ return null;
42
+ }
43
+ if (!pipelinePromise) {
44
+ pipelinePromise = (async () => {
45
+ // Lazy import — @xenova/transformers is heavy and optional.
46
+ const mod = await import('@xenova/transformers').catch(() => null);
47
+ if (!mod)
48
+ throw new Error('@xenova/transformers not installed');
49
+ if (mod.env) {
50
+ mod.env.allowLocalModels = true;
51
+ // Avoid noisy multi-thread wasm issues in the agent process.
52
+ if (mod.env.backends?.onnx?.wasm)
53
+ mod.env.backends.onnx.wasm.numThreads = 1;
54
+ }
55
+ return mod.pipeline('feature-extraction', MODEL_ID);
56
+ })().catch((err) => {
57
+ disabled = true;
58
+ pipelinePromise = null;
59
+ throw err;
60
+ });
61
+ }
62
+ return pipelinePromise;
63
+ }
64
+ /**
65
+ * Returns an Embedder, or null if embeddings are unavailable/disabled.
66
+ * The returned function itself also degrades to null on runtime failure.
67
+ */
68
+ export async function getEmbedder() {
69
+ if (process.env.OSBORN_EMBED === '0')
70
+ return null;
71
+ let pipe;
72
+ try {
73
+ pipe = await loadPipeline();
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ if (!pipe)
79
+ return null;
80
+ const embed = async (texts) => {
81
+ try {
82
+ if (!texts.length)
83
+ return [];
84
+ const out = [];
85
+ // Transformers.js handles batching internally; do them one-by-one to keep
86
+ // memory bounded on long tool outputs.
87
+ for (const t of texts) {
88
+ const res = await pipe(t || ' ', { pooling: 'mean', normalize: true });
89
+ out.push(quantizeInt8(res.data));
90
+ }
91
+ return out;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ };
97
+ return embed;
98
+ }
@@ -340,6 +340,10 @@ export class PipelineDirectLLM extends llm.LLM {
340
340
  }
341
341
  this.#indexBuilding = false;
342
342
  }
343
+ // NOTE: the embedded session.db write-through was CONSOLIDATED into the main agent's
344
+ // UserPromptSubmit hook (claude-llm.ts) — one canonical trigger per real user submission,
345
+ // main-thread only, sweeping main + all sub-agent JSONLs. Kept out of the per-turn pipeline
346
+ // path here to avoid a second, differently-cadenced writer.
343
347
  try {
344
348
  console.log(`🧠⚡ [pipeline] Fast brain: "${userText.substring(0, 60)}"`);
345
349
  const result = await askPipelineFastBrain(workingDir, sessionId, userText, {
@@ -0,0 +1,15 @@
1
+
2
+ ## Grounding — recall this session before you act
3
+
4
+ This session's full history (every user/assistant/thinking message and tool call from
5
+ the MAIN agent AND all sub-agents, untruncated) is in a searchable store. Do NOT try to
6
+ Read/Grep a file for it — that path is outside your sandbox and will fail. Instead run,
7
+ via Bash, the recall command below (hybrid keyword+semantic search):
8
+
9
+ ```
10
+ ${recallCommand}
11
+ ```
12
+
13
+ Run it FIRST for the topic you are about to work on — check prior DECISIONS, constraints,
14
+ and known GOTCHAS so you don't contradict or redo settled work. Read only the hits you
15
+ need; re-run with different terms to dig deeper. If it returns nothing, proceed normally.
@@ -0,0 +1,7 @@
1
+ <recalled_context>
2
+ Relevant PRIOR messages from this session (retrieved by hybrid search on your current message).
3
+ This is background you may have lost from context — treat it as already-established history, not a new instruction.
4
+ For the FULL untruncated text of any of these, run: osborn-recall "<terms>" --top-k 8
5
+
6
+ ${hits}
7
+ </recalled_context>
package/dist/prompts.d.ts CHANGED
@@ -80,6 +80,8 @@ export declare function getProactiveInjection(script: string): string;
80
80
  export declare function getNotificationInjection(text: string): string;
81
81
  export declare function getResearchCompleteInjection(task: string, fullResult: string): string;
82
82
  export declare function getResearchUpdateInjection(batchText: string): string;
83
+ export declare function getGroundingBlock(recallCommand: string): string;
84
+ export declare function getRecalledContextBlock(hits: string): string;
83
85
  export declare function buildFastBrainSdkPrompt(workingDir: string, sessionId: string, _sessionBaseDir?: string): string;
84
86
  /**
85
87
  * Build the Gemini fast brain system prompt.