osborn 0.9.226 → 0.9.228

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.
@@ -121,8 +121,8 @@ export declare function decideWrite(agentType: string | null, filePath: string):
121
121
  reason?: string;
122
122
  };
123
123
  export declare const VERIFIER_CHAIN: Record<string, {
124
- then: string[];
125
- mode?: 'parallel' | 'sequential';
124
+ parallelAtStart?: string[];
125
+ afterReminder?: string;
126
126
  }>;
127
127
  /**
128
128
  * Claude LLM - Wraps Claude Agent SDK for LiveKit
@@ -297,25 +297,6 @@ export declare class ClaudeLLM extends llm.LLM {
297
297
  onCheckpoint: (checkpointId: string) => void;
298
298
  eventEmitter: EventEmitter;
299
299
  }): void;
300
- /**
301
- * Dispatcher v1 — auto-spawn a reviewer after a writer sub-agent completes.
302
- * Runs a one-shot query() with the reviewer agent and emits dispatch_rejected
303
- * if the verdict is REJECT. A reviewer failure must never crash the consumer.
304
- * Public so ClaudeLLMStream can call it via this.#llmRef.spawnReviewer().
305
- */
306
- spawnReviewer(agentId: string, writerOutput: string, emitter: EventEmitter): Promise<void>;
307
- /**
308
- * Dispatcher v1 — auto-spawn a tester after a writer sub-agent completes,
309
- * in parallel with spawnReviewer. Runs tests against the diff and emits
310
- * dispatch_rejected with verdict 'TEST-FAIL' if they fail.
311
- */
312
- spawnTester(agentId: string, _writerOutput: string, emitter: EventEmitter): Promise<void>;
313
- /**
314
- * Dispatcher v1 — research gate: vet a researcher sub-agent's output before
315
- * it reaches the main agent. Emits dispatch_rejected with verdict 'NEEDS-MORE'
316
- * (distinct from reviewer's 'REJECT') so the frontend can tell them apart.
317
- */
318
- spawnResearchGate(agentId: string, researchOutput: string, emitter: EventEmitter): Promise<void>;
319
300
  chat({ chatCtx, toolCtx, connOptions, abortController, }: {
320
301
  chatCtx: llm.ChatContext;
321
302
  toolCtx?: llm.ToolContext;
@@ -12,7 +12,6 @@ import { EventEmitter } from 'events';
12
12
  import { saveSessionMetadata, getSessionWorkspace } from './config.js';
13
13
  import { statusManager } from './status-manager.js';
14
14
  import { getResearchSystemPrompt, getDirectModeResearchPrompt, getGroundingBlock, getRecalledContextBlock } from './prompts.js';
15
- import { getIndexPath } from './summary-index.js';
16
15
  import { openStore, recall, storeExists, updateSessionStore, getStorePath } from './session-store.js';
17
16
  import { getEmbedder } from './embedder.js';
18
17
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
@@ -39,15 +38,10 @@ const TURN_SHAPE_REMINDER_PATH = join(__claudeLlmDir, 'prompts', 'turn-shape-rem
39
38
  const RECALL_TOP_K = 5;
40
39
  const RECALL_PER_HIT_CHARS = 800;
41
40
  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
- }
41
+ // Query-side embed budget. Vector/hybrid is the PREFERRED recall path; keyword is only
42
+ // the fallback. This tight timeout means a slow network degrades to keyword FAST rather
43
+ // than stalling the turn — the read path never waits the full write-path (20s) budget.
44
+ const QUERY_EMBED_TIMEOUT_MS = Number(process.env.OSBORN_QUERY_EMBED_TIMEOUT_MS || 2500);
51
45
  /** Build the recalled-context block to inject alongside the turn-shape reminder. '' on any miss. */
52
46
  async function buildRecallInjection(sessionId, workingDir, prompt) {
53
47
  try {
@@ -58,9 +52,11 @@ async function buildRecallInjection(sessionId, workingDir, prompt) {
58
52
  return '';
59
53
  if (!storeExists(sessionId, workingDir))
60
54
  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;
55
+ // Vector/hybrid is the PREFERRED recall path; keyword is only the fallback when the
56
+ // embedder is unavailable or the query embed exceeds its short budget. Acquire the
57
+ // embedder every turn with a tight query timeout — no warm-up needed (it's a cloud
58
+ // fetch, not a local model load). If embed is null, recall() runs keyword-only.
59
+ const embed = process.env.OSBORN_EMBED !== '0' ? (await getEmbedder(QUERY_EMBED_TIMEOUT_MS)) ?? undefined : undefined;
64
60
  const { getStorePath } = await import('./session-store.js');
65
61
  const db = openStore(getStorePath(sessionId, workingDir));
66
62
  let hits;
@@ -443,7 +439,7 @@ export const NAMED_AGENTS = {
443
439
  '',
444
440
  '## Research-review gate',
445
441
  'When reviewing a researcher\'s findings, judge whether the research is COMPLETE and well-sourced against the original task. If it is, pass it. If it\'s thin, missing sources, or the answer likely lives somewhere the researcher didn\'t look, report that it needs more — so the orchestrator sends the researcher back.',
446
- 'End every research review with exactly one of:',
442
+ 'Before you finalize, re-check your gate call ONCE: is the research genuinely complete and well-sourced for the original question — not passing thin work, nor failing solid work? Then end every research review with exactly one of:',
447
443
  ' GATE: PASS',
448
444
  ' GATE: NEEDS-MORE — <what\'s missing / where to look>',
449
445
  '',
@@ -512,8 +508,8 @@ export const NAMED_AGENTS = {
512
508
  '## When to use / handoff',
513
509
  'Invoked AFTER the planner produces a written plan — the writer is the SOLE agent that edits files.',
514
510
  'Do not invoke writer until a plan exists for any multi-step change.',
515
- 'When the writer returns, the orchestrator invokes tester AND reviewer in parallel before surfacing results.',
516
- 'Because that verification runs automatically after you finish, make the change review-ready and leave the tree in a runnable state — do not skip cleanup expecting a second pass.',
511
+ 'A tester runs IN PARALLEL with you — it writes tests from the spec, blind to your implementation (TDD). After you return, the orchestrator RUNS those tests against your code and dispatches the reviewer; on test-fail or REJECT you may be sent back to fix (bounded rounds).',
512
+ 'Because that verification runs after you finish, make the change review-ready and leave the tree in a runnable state — write the code so the independently-written tests can pass; do not skip cleanup expecting a second pass.',
517
513
  ].join('\n'),
518
514
  },
519
515
  tester: {
@@ -572,6 +568,7 @@ export const NAMED_AGENTS = {
572
568
  '7. Cap yourself at 6-8 tool calls unless the investigation clearly requires more.',
573
569
  '',
574
570
  '## What to return',
571
+ 'Before you finalize, re-check your conclusion ONCE: did you actually RUN the commands (not assume the outcome), does your result match the real output, and did you distinguish a genuine regression from a flaky/environment failure? Then report:',
575
572
  '- RESULT: PASS or FAIL (one word, first line)',
576
573
  '- COMMAND: the exact command(s) you ran',
577
574
  '- OUTPUT: relevant excerpt (errors, failing test names, line numbers)',
@@ -748,7 +745,7 @@ export const NAMED_AGENTS = {
748
745
  ' NIT — style, naming, or polish; never a reason to REJECT on its own',
749
746
  '',
750
747
  '## What to return',
751
- 'Structure your response EXACTLY as follows:',
748
+ 'Before you finalize, re-check your verdict ONCE: is each BLOCKER/MAJOR real and reproducible (not a style nit or false positive), and does your ACCEPT/REJECT match the severity of what you actually found? Then structure your response EXACTLY as follows:',
752
749
  '',
753
750
  'VERDICT: ACCEPT | REJECT — <one-line rationale>',
754
751
  '',
@@ -878,14 +875,31 @@ export function decideWrite(agentType, filePath) {
878
875
  }
879
876
  return { decision: 'defer' };
880
877
  }
881
- // ── Verifier chaining ───────────────────────────────────────────────────────
882
- // A plain name→verifiers lookup read by the SubagentStop hook. Same wiring the
883
- // old hardcoded branches had (writer→reviewer+tester, researcher→reasoner gate),
884
- // gathered in one visible place. Names resolve to spawnReviewer / spawnTester /
885
- // spawnResearchGate. Any agent not listed chains to nothing.
878
+ // ── Verifier chaining (Pattern #1) ──────────────────────────────────────────
879
+ // Verifiers are real named Task sub-agents (the `agents` roster), NOT out-of-band
880
+ // query() spawns. Two enforcement points:
881
+ // • parallelAtStart — verifiers the ORCHESTRATOR must dispatch IN PARALLEL with
882
+ // the keyed agent (enforced by the orchestrator prompt; a post-hoc hook cannot
883
+ // make two Tasks run concurrently). Tester is TDD: it WRITES tests from the
884
+ // spec in parallel with the writer, blind to the implementation.
885
+ // • afterReminder — injected into the MAIN agent by the PostToolUse-on-Task hook
886
+ // when the keyed sub-agent's Task completes, so the main agent dispatches the
887
+ // next verifier(s) as real Tasks (verdicts return inline). Includes RUNNING the
888
+ // tester's tests against the finished code. Any agent not listed chains to nothing.
886
889
  export const VERIFIER_CHAIN = {
887
- writer: { then: ['reviewer', 'tester'], mode: 'parallel' },
888
- researcher: { then: ['reasoner'] },
890
+ writer: {
891
+ parallelAtStart: ['tester'],
892
+ afterReminder: 'The writer sub-agent just finished. Before you finalize, you MUST now use the Task tool to: ' +
893
+ "(1) RUN the tester's tests against the new implementation and report PASS/FAIL; " +
894
+ '(2) dispatch the `reviewer` on the diff for a VERDICT. ' +
895
+ 'If tests FAIL or the reviewer REJECTs, dispatch the `writer` to fix and then re-verify — ' +
896
+ 'at most 2 fix rounds, after which surface the outcome to the user rather than looping.',
897
+ },
898
+ researcher: {
899
+ afterReminder: 'The researcher sub-agent just finished. Before you finalize, use the Task tool to dispatch the ' +
900
+ '`reasoner` gate to judge whether the research is complete and well-sourced for the original ' +
901
+ 'question. If it returns NEEDS-MORE, send the researcher back — at most 2 rounds, then surface.',
902
+ },
889
903
  };
890
904
  const RESEARCH_TOOLS = [
891
905
  'Read', 'Write', 'Edit', 'Glob', 'Grep',
@@ -1623,327 +1637,6 @@ export class ClaudeLLM extends llm.LLM {
1623
1637
  console.log('🔒 Persistent session background consumer exited');
1624
1638
  }
1625
1639
  }
1626
- /**
1627
- * Dispatcher v1 — auto-spawn a reviewer after a writer sub-agent completes.
1628
- * Runs a one-shot query() with the reviewer agent and emits dispatch_rejected
1629
- * if the verdict is REJECT. A reviewer failure must never crash the consumer.
1630
- * Public so ClaudeLLMStream can call it via this.#llmRef.spawnReviewer().
1631
- */
1632
- // writerOutput intentionally NOT fed to the reviewer (neutrality); kept for signature stability
1633
- async spawnReviewer(agentId, writerOutput, emitter) {
1634
- // Dedup guard — SubagentStop may fire more than once for the same agent_id.
1635
- if (this.#dispatchedFor.has(agentId))
1636
- return;
1637
- this.#dispatchedFor.add(agentId);
1638
- try {
1639
- // Get the actual diff to give reviewer concrete evidence instead of just the narrative
1640
- let gitDiff = '';
1641
- try {
1642
- const { execSync } = await import('child_process');
1643
- const diffStat = execSync('git diff HEAD~1 HEAD --stat 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
1644
- const diff = execSync('git diff HEAD~1 HEAD 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
1645
- gitDiff = diffStat ? `\n\n<git_diff_stat>\n${diffStat}\n</git_diff_stat>\n\n<git_diff>\n${diff.slice(0, 6000)}\n</git_diff>` : '';
1646
- }
1647
- catch {
1648
- // non-fatal: if git fails, proceed without diff
1649
- }
1650
- const prompt = [
1651
- 'Review the change below for correctness, spec/requirement adherence, and obvious bugs.',
1652
- 'The git diff is the AUTHORITATIVE source of what changed. Judge it on its own merits —',
1653
- 'you are deliberately NOT given the writer\'s rationale or the session index; form an independent verdict from the code and the project\'s own documented standards.',
1654
- 'Read the actual modified files and their callers as needed (you are not limited to the diff).',
1655
- 'End your reply with exactly `VERDICT: ACCEPT` or `VERDICT: REJECT`.',
1656
- gitDiff,
1657
- ].join('\n');
1658
- // Do NOT pass agents here — the reviewer must be review-only and must not
1659
- // be able to spawn writer/researcher/reasoner sub-agents. Passing an empty
1660
- // agents roster prevents any SubagentStop(agent_type==='writer') from
1661
- // firing inside this one-shot query and re-arming the backstop loop.
1662
- const reviewerOptions = {
1663
- cwd: getSubagentsDir(this.#opts.workingDirectory),
1664
- permissionMode: 'default',
1665
- systemPrompt: NAMED_AGENTS.reviewer.prompt,
1666
- allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
1667
- hooks: {
1668
- PreToolUse: [
1669
- {
1670
- matcher: '.*',
1671
- hooks: [async (input) => {
1672
- const toolName = input?.tool_name || 'unknown';
1673
- const toolInput = input?.tool_input || {};
1674
- emitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: 'reviewer' });
1675
- return {};
1676
- }],
1677
- },
1678
- {
1679
- matcher: '.*',
1680
- hooks: [async (input) => {
1681
- const toolName = input?.tool_name || '';
1682
- const toolInput = input?.tool_input;
1683
- if ((toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit') &&
1684
- !/\.(md|markdown|mdx|txt|rst|adoc)$/i.test(String(toolInput?.file_path))) {
1685
- return {
1686
- hookSpecificOutput: {
1687
- hookEventName: 'PreToolUse',
1688
- permissionDecision: 'deny',
1689
- },
1690
- reason: 'reviewer may only write documentation files (.md/.markdown/.mdx/.txt/.rst/.adoc)',
1691
- };
1692
- }
1693
- return {};
1694
- }],
1695
- },
1696
- ],
1697
- PostToolUse: [{
1698
- matcher: '.*',
1699
- hooks: [async (input) => {
1700
- const toolName = input?.tool_name || 'unknown';
1701
- const toolInput = input?.tool_input || {};
1702
- const toolResponse = input?.tool_response;
1703
- emitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse, agentRole: 'reviewer' });
1704
- return {};
1705
- }],
1706
- }],
1707
- // Self-review gate — block ONCE (stop_hook_active-guarded) so the reviewer
1708
- // re-checks its own verdict before returning, instead of stopping on a
1709
- // first-pass judgment.
1710
- Stop: [{
1711
- matcher: '.*',
1712
- hooks: [async (input) => {
1713
- if (input?.stop_hook_active)
1714
- return {};
1715
- return { decision: 'block', reason: 'Before you finalize: re-read your findings against the diff once. Confirm each BLOCKER/MAJOR is real and reproducible (not a style nit or a false positive), and that your verdict matches the severity of what you actually found. Then end with exactly `VERDICT: ACCEPT` or `VERDICT: REJECT`.' };
1716
- }],
1717
- }],
1718
- },
1719
- };
1720
- console.log(`[DISPATCH] spawning reviewer for agentId=${agentId.slice(0, 8)}`);
1721
- const reviewerQuery = query({ prompt, options: reviewerOptions });
1722
- this.#activeQueries.add(reviewerQuery);
1723
- this.#activeQueriesById.set(agentId, reviewerQuery);
1724
- let reviewerText = '';
1725
- try {
1726
- for await (const msg of reviewerQuery) {
1727
- const m = msg;
1728
- if (m.type === 'result' && m.result) {
1729
- reviewerText = String(m.result);
1730
- }
1731
- }
1732
- }
1733
- finally {
1734
- this.#activeQueries.delete(reviewerQuery);
1735
- this.#activeQueriesById.delete(agentId);
1736
- }
1737
- const verdictMatch = reviewerText.match(/VERDICT:\s*(ACCEPT|REJECT)/i);
1738
- const verdict = verdictMatch ? verdictMatch[1].toUpperCase() : null;
1739
- if (verdict === 'REJECT') {
1740
- console.log(`[DISPATCH] review REJECT for agentId=${agentId.slice(0, 8)}`);
1741
- statusManager.upsertDispatch(agentId, { dispatchState: 'rejected', artifact: reviewerText });
1742
- emitter.emit('dispatch_rejected', { tuid: agentId, verdict: 'REJECT', review: reviewerText });
1743
- }
1744
- else {
1745
- console.log(`[DISPATCH] review ACCEPT for agentId=${agentId.slice(0, 8)}`);
1746
- }
1747
- }
1748
- catch (err) {
1749
- console.error('[DISPATCH] reviewer spawn failed (non-fatal):', err);
1750
- }
1751
- }
1752
- /**
1753
- * Dispatcher v1 — auto-spawn a tester after a writer sub-agent completes,
1754
- * in parallel with spawnReviewer. Runs tests against the diff and emits
1755
- * dispatch_rejected with verdict 'TEST-FAIL' if they fail.
1756
- */
1757
- async spawnTester(agentId, _writerOutput, emitter) {
1758
- // Use a scoped key so reviewer's dedup guard (keyed on raw agentId) doesn't block us.
1759
- const testerKey = `${agentId}:tester`;
1760
- if (this.#dispatchedFor.has(testerKey))
1761
- return;
1762
- this.#dispatchedFor.add(testerKey);
1763
- try {
1764
- let gitDiff = '';
1765
- try {
1766
- const { execSync } = await import('child_process');
1767
- const diffStat = execSync('git diff HEAD~1 HEAD --stat 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
1768
- const diff = execSync('git diff HEAD~1 HEAD 2>/dev/null', { cwd: this.#opts.workingDirectory, timeout: 5000 }).toString().trim();
1769
- gitDiff = diffStat ? `\n\n<git_diff_stat>\n${diffStat}\n</git_diff_stat>\n\n<git_diff>\n${diff.slice(0, 6000)}\n</git_diff>` : '';
1770
- }
1771
- catch {
1772
- // non-fatal
1773
- }
1774
- const prompt = [
1775
- 'Run tests and verify that the change below did not introduce regressions.',
1776
- 'Use the git diff as your scope — run the full existing test suite to establish a baseline,',
1777
- 'then target tests at the specific changed files/functions.',
1778
- 'If no test suite exists, run the build or execute the changed code directly to confirm it works.',
1779
- 'End your reply with exactly `RESULT: PASS` or `RESULT: FAIL` followed by a brief summary.',
1780
- gitDiff,
1781
- ].join('\n');
1782
- const testerOptions = {
1783
- cwd: getSubagentsDir(this.#opts.workingDirectory),
1784
- permissionMode: 'default',
1785
- systemPrompt: NAMED_AGENTS.tester.prompt,
1786
- allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
1787
- hooks: {
1788
- PreToolUse: [{
1789
- matcher: '.*',
1790
- hooks: [async (input) => {
1791
- const toolName = input?.tool_name || 'unknown';
1792
- const toolInput = input?.tool_input || {};
1793
- emitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: 'tester' });
1794
- return {};
1795
- }],
1796
- }],
1797
- PostToolUse: [{
1798
- matcher: '.*',
1799
- hooks: [async (input) => {
1800
- const toolName = input?.tool_name || 'unknown';
1801
- const toolInput = input?.tool_input || {};
1802
- const toolResponse = input?.tool_response;
1803
- emitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse, agentRole: 'tester' });
1804
- return {};
1805
- }],
1806
- }],
1807
- // Self-review gate — block ONCE (stop_hook_active-guarded) so the tester
1808
- // re-checks its own conclusion before returning.
1809
- Stop: [{
1810
- matcher: '.*',
1811
- hooks: [async (input) => {
1812
- if (input?.stop_hook_active)
1813
- return {};
1814
- return { decision: 'block', reason: 'Before you finalize: re-check your conclusion once. Did you actually RUN the commands (not assume the outcome)? Does your result match the real output, and did you distinguish a genuine regression from a flaky/environment failure? Then end with exactly `RESULT: PASS` or `RESULT: FAIL` followed by a brief summary.' };
1815
- }],
1816
- }],
1817
- },
1818
- };
1819
- console.log(`[DISPATCH] spawning tester for agentId=${agentId.slice(0, 8)}`);
1820
- const testerQuery = query({ prompt, options: testerOptions });
1821
- this.#activeQueries.add(testerQuery);
1822
- this.#activeQueriesById.set(testerKey, testerQuery);
1823
- let testerText = '';
1824
- try {
1825
- for await (const msg of testerQuery) {
1826
- const m = msg;
1827
- if (m.type === 'result' && m.result) {
1828
- testerText = String(m.result);
1829
- }
1830
- }
1831
- }
1832
- finally {
1833
- this.#activeQueries.delete(testerQuery);
1834
- this.#activeQueriesById.delete(testerKey);
1835
- }
1836
- const resultMatch = testerText.match(/RESULT:\s*(PASS|FAIL)/i);
1837
- const result = resultMatch ? resultMatch[1].toUpperCase() : null;
1838
- if (result === 'FAIL') {
1839
- console.log(`[DISPATCH] tester FAIL for agentId=${agentId.slice(0, 8)}`);
1840
- statusManager.upsertDispatch(agentId, { dispatchState: 'rejected', artifact: testerText });
1841
- emitter.emit('dispatch_rejected', { tuid: agentId, verdict: 'TEST-FAIL', review: testerText });
1842
- }
1843
- else {
1844
- console.log(`[DISPATCH] tester PASS for agentId=${agentId.slice(0, 8)}`);
1845
- }
1846
- }
1847
- catch (err) {
1848
- console.error('[DISPATCH] tester spawn failed (non-fatal):', err);
1849
- }
1850
- }
1851
- /**
1852
- * Dispatcher v1 — research gate: vet a researcher sub-agent's output before
1853
- * it reaches the main agent. Emits dispatch_rejected with verdict 'NEEDS-MORE'
1854
- * (distinct from reviewer's 'REJECT') so the frontend can tell them apart.
1855
- */
1856
- async spawnResearchGate(agentId, researchOutput, emitter) {
1857
- // Dedup guard — SubagentStop may fire more than once for the same agent_id.
1858
- if (this.#dispatchedFor.has(agentId))
1859
- return;
1860
- this.#dispatchedFor.add(agentId);
1861
- try {
1862
- const idxPathGate = (this.#sessionId && this.#opts.workingDirectory)
1863
- ? getIndexPath(this.#sessionId, this.#opts.workingDirectory)
1864
- : null;
1865
- const prompt = [
1866
- 'Use the reasoner sub-agent to vet this research output.',
1867
- 'Determine whether the research is sufficient to answer the original question.',
1868
- 'End your reply with exactly `GATE: PASS` or `GATE: NEEDS-MORE`.',
1869
- ...(idxPathGate ? [`The session index is at ${idxPathGate} — you MUST read it before reviewing.`] : []),
1870
- '',
1871
- '<research_output>',
1872
- researchOutput.slice(0, 8000),
1873
- '</research_output>',
1874
- ].join('\n');
1875
- // Do NOT pass agents here — the research-gate reasoner must be review-only
1876
- // and must not be able to spawn sub-agents. Same rationale as spawnReviewer:
1877
- // an agents roster would allow delegation back to the writer, which would
1878
- // fire SubagentStop(agent_type==='writer') and re-arm the backstop.
1879
- const gateOptions = {
1880
- cwd: getSubagentsDir(this.#opts.workingDirectory),
1881
- permissionMode: 'default',
1882
- systemPrompt: NAMED_AGENTS.reasoner.prompt,
1883
- hooks: {
1884
- PreToolUse: [{
1885
- matcher: '.*',
1886
- hooks: [async (input) => {
1887
- const toolName = input?.tool_name || 'unknown';
1888
- const toolInput = input?.tool_input || {};
1889
- emitter.emit('tool_use', { name: toolName, input: toolInput, agentRole: 'reasoner' });
1890
- return {};
1891
- }],
1892
- }],
1893
- PostToolUse: [{
1894
- matcher: '.*',
1895
- hooks: [async (input) => {
1896
- const toolName = input?.tool_name || 'unknown';
1897
- const toolInput = input?.tool_input || {};
1898
- const toolResponse = input?.tool_response;
1899
- emitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse, agentRole: 'reasoner' });
1900
- return {};
1901
- }],
1902
- }],
1903
- // Self-review gate — block ONCE (stop_hook_active-guarded) so the gate
1904
- // re-checks its own PASS/NEEDS-MORE call before returning.
1905
- Stop: [{
1906
- matcher: '.*',
1907
- hooks: [async (input) => {
1908
- if (input?.stop_hook_active)
1909
- return {};
1910
- return { decision: 'block', reason: 'Before you finalize: re-check your gate decision once. Is the research genuinely complete and well-sourced for the original question — not passing thin work, nor failing solid work? Then end with exactly `GATE: PASS` or `GATE: NEEDS-MORE`.' };
1911
- }],
1912
- }],
1913
- },
1914
- };
1915
- console.log(`[DISPATCH] spawning research-gate for agentId=${agentId.slice(0, 8)}`);
1916
- const gateQuery = query({ prompt, options: gateOptions });
1917
- this.#activeQueries.add(gateQuery);
1918
- this.#activeQueriesById.set(agentId, gateQuery);
1919
- let review = '';
1920
- try {
1921
- for await (const msg of gateQuery) {
1922
- const m = msg;
1923
- if (m.type === 'result' && m.result) {
1924
- review = String(m.result);
1925
- }
1926
- }
1927
- }
1928
- finally {
1929
- this.#activeQueries.delete(gateQuery);
1930
- this.#activeQueriesById.delete(agentId);
1931
- }
1932
- const gateMatch = review.match(/GATE:\s*(PASS|NEEDS-MORE)/i);
1933
- const gateVerdict = gateMatch ? gateMatch[1].toUpperCase() : null;
1934
- if (gateVerdict === 'NEEDS-MORE') {
1935
- console.log(`[DISPATCH] research-gate NEEDS-MORE for agentId=${agentId.slice(0, 8)}`);
1936
- statusManager.upsertDispatch(agentId, { dispatchState: 'rejected', artifact: review });
1937
- emitter.emit('dispatch_rejected', { tuid: agentId, verdict: 'NEEDS-MORE', review });
1938
- }
1939
- else {
1940
- console.log(`[DISPATCH] research-gate PASS for agentId=${agentId.slice(0, 8)}`);
1941
- }
1942
- }
1943
- catch (err) {
1944
- console.error('[DISPATCH] research-gate spawn failed (non-fatal):', err);
1945
- }
1946
- }
1947
1640
  chat({ chatCtx, toolCtx, connOptions = DEFAULT_API_CONNECT_OPTIONS, abortController, }) {
1948
1641
  return new ClaudeLLMStream(this, {
1949
1642
  chatCtx,
@@ -2195,6 +1888,23 @@ class ClaudeLLMStream extends llm.LLMStream {
2195
1888
  console.log('[LIFECYCLE-PROBE] PostToolUse on', toolName, 'agent_id=', agentId, 'ts=', Date.now());
2196
1889
  }
2197
1890
  this.#eventEmitter.emit('tool_result', { name: toolName, input: toolInput, response: toolResponse, agentRole: agentTypePost || 'main' });
1891
+ // Pattern #1 verifier chaining: when the MAIN agent's Task sub-agent of a
1892
+ // chained type finishes, inject a reminder so the main agent dispatches the
1893
+ // next verifier(s) as real Task sub-agents (verdicts return inline). Gated to
1894
+ // the main thread (agent_type absent) so verifiers never re-chain themselves.
1895
+ if (!agentTypePost && (toolName === 'Task' || toolName === 'Agent')) {
1896
+ const subType = String(toolInput?.subagent_type || toolInput?.agent_type || '');
1897
+ const link = subType ? VERIFIER_CHAIN[subType] : undefined;
1898
+ if (link?.afterReminder) {
1899
+ console.log(`[DISPATCH] Pattern#1 chain: ${subType} done → reminding main agent to verify`);
1900
+ return {
1901
+ hookSpecificOutput: {
1902
+ hookEventName: 'PostToolUse',
1903
+ additionalContext: link.afterReminder,
1904
+ },
1905
+ };
1906
+ }
1907
+ }
2198
1908
  return {};
2199
1909
  }]
2200
1910
  }],
@@ -2231,15 +1941,17 @@ class ClaudeLLMStream extends llm.LLMStream {
2231
1941
  if (!fromSubagent && sid && this.#opts.workingDirectory) {
2232
1942
  this.#llmRef.triggerStoreUpdate(sid, this.#opts.workingDirectory);
2233
1943
  }
2234
- // Recall injection is best-effort and must NEVER approach the SDK's 30s hook
2235
- // budget. Cap it hard at 1.5s — if the store is momentarily busy (e.g. a large
2236
- // ingest sweep still draining), we skip recall for this turn rather than block
2237
- // the prompt. The keyword/vector layers catch up on the next turn.
1944
+ // Recall is best-effort and must never approach the SDK's 30s hook budget.
1945
+ // buildRecallInjection is already self-bounding — the query embed has a tight
1946
+ // timeout (falls back to keyword INSIDE recall) and the SQL is local/ms. This
1947
+ // outer race is only a catastrophic-hang backstop (e.g. a momentarily locked
1948
+ // store), set well above the embed budget so a slow embed degrades to keyword
1949
+ // rather than being cut to nothing.
2238
1950
  const recalled = fromSubagent
2239
1951
  ? ''
2240
1952
  : await Promise.race([
2241
1953
  buildRecallInjection(sid, this.#opts.workingDirectory, String(input?.prompt || '')),
2242
- new Promise((res) => setTimeout(() => res(''), 1500)),
1954
+ new Promise((res) => setTimeout(() => res(''), 6000)),
2243
1955
  ]);
2244
1956
  const additionalContext = recalled ? `${reminder}\n\n${recalled}` : reminder;
2245
1957
  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}]`);
@@ -2524,27 +2236,11 @@ class ClaudeLLMStream extends llm.LLMStream {
2524
2236
  return {};
2525
2237
  }]
2526
2238
  }],
2527
- // Idle-agent guard / end-of-run self-review. When the main agent tries
2528
- // to end its turn while background work is still in flight, block ONCE
2529
- // (stop_hook_active guards against a loop) and force one more turn so it
2530
- // either waits + synthesizes, or explicitly tells the user what's still
2531
- // running — never goes silent leaving dispatched work dangling.
2532
- Stop: [{
2533
- matcher: '.*',
2534
- hooks: [async (input) => {
2535
- const inFlight = Array.isArray(input?.background_tasks) ? input.background_tasks : [];
2536
- if (input?.stop_hook_active || inFlight.length === 0)
2537
- return {};
2538
- const labels = inFlight
2539
- .map((t) => t?.agent_type || t?.name || t?.command || t?.type || 'task')
2540
- .slice(0, 6);
2541
- console.log(`🛑 Stop gate: ${inFlight.length} background task(s) in flight → blocking once for self-review`);
2542
- return {
2543
- decision: 'block',
2544
- reason: `Before you end your turn: ${inFlight.length} background task(s) are still running (${labels.join(', ')}). Do NOT go idle. Either wait for them and synthesize their results into your answer, or explicitly tell the user what is still running and what you will do when it finishes.`,
2545
- };
2546
- }]
2547
- }],
2239
+ // (Removed the end-of-turn idle-guard Stop gate: it polled `background_tasks`
2240
+ // — Bash background jobs, never the verifier queries — so it couldn't coordinate
2241
+ // them, and its block-loop was a hang surface. Pattern #1 chaining via the
2242
+ // PostToolUse-on-Task reminder makes verifiers return inline, so the main agent
2243
+ // sees their verdicts deterministically without an end-turn gate.)
2548
2244
  SubagentStart: [{
2549
2245
  matcher: '.*',
2550
2246
  hooks: [async (input) => {
@@ -2562,34 +2258,9 @@ class ClaudeLLMStream extends llm.LLMStream {
2562
2258
  const aid = input?.agent_id ?? ('sa-' + Date.now());
2563
2259
  statusManager.upsertDispatch(aid, { subagentType: at, dispatchState: 'completed', artifact: msg });
2564
2260
  this.#eventEmitter.emit('task_completed', { agent_type: at, agent_id: aid, last_assistant_message: String(msg).slice(0, 400) });
2565
- // Infinite-loop guard — verifiers never re-dispatch (they carry no
2566
- // VERIFIER_CHAIN entry anyway; defense-in-depth).
2567
- if (at === 'reviewer' || at === 'tester' || at === 'reasoner')
2568
- return {};
2569
- // Verifier chaining — driven by the plain VERIFIER_CHAIN lookup
2570
- // (replaces the hardcoded writer/researcher branches).
2571
- const chain = at ? VERIFIER_CHAIN[at] : undefined;
2572
- if (chain?.then?.length && msg) {
2573
- const spawn = (role) => {
2574
- if (role === 'reviewer')
2575
- return this.#llmRef.spawnReviewer(aid, msg, this.#eventEmitter);
2576
- if (role === 'tester')
2577
- return this.#llmRef.spawnTester(aid, msg, this.#eventEmitter);
2578
- if (role === 'reasoner' || role === 'gate')
2579
- return this.#llmRef.spawnResearchGate(aid, msg, this.#eventEmitter);
2580
- console.warn(`[DISPATCH] unknown chain target '${role}' for ${at} — skipped`);
2581
- return Promise.resolve();
2582
- };
2583
- if (chain.mode === 'sequential') {
2584
- // Await in order WITHOUT blocking the hook return (fire the chain async).
2585
- void (async () => { for (const r of chain.then)
2586
- await spawn(r); })();
2587
- }
2588
- else {
2589
- for (const r of chain.then)
2590
- void spawn(r);
2591
- }
2592
- }
2261
+ // Verifier chaining is Pattern #1 now: the PostToolUse-on-Task hook injects a
2262
+ // reminder into the MAIN agent, which dispatches the reviewer/tester as real
2263
+ // Task sub-agents (verdicts return inline). No out-of-band spawns from here.
2593
2264
  return {};
2594
2265
  }]
2595
2266
  }],
@@ -25,4 +25,4 @@ import { type Embedder } from './session-store.js';
25
25
  * Returns an Embedder, or null if embeddings are disabled or no key is configured.
26
26
  * The returned function degrades to null on any per-call failure (keyword-only recall).
27
27
  */
28
- export declare function getEmbedder(): Promise<Embedder | null>;
28
+ export declare function getEmbedder(timeoutMs?: number): Promise<Embedder | null>;
package/dist/embedder.js CHANGED
@@ -25,7 +25,16 @@ const OPENROUTER_URL = process.env.OPENROUTER_EMBED_URL || 'https://openrouter.a
25
25
  const MODEL_ID = EMBED_MODEL;
26
26
  const MAX_BATCH = 64; // inputs per request (OpenAI-compatible arrays)
27
27
  const REQUEST_TIMEOUT_MS = Number(process.env.OSBORN_EMBED_TIMEOUT_MS || 20_000);
28
- let disabled = false;
28
+ let lastEmbedWarn = 0;
29
+ /** Throttled failure log (max ~1/30s) so a bulk re-embed can't spam, but embedded=0 is
30
+ * never silent — every failure surfaces a reason (status/network) in the machine logs. */
31
+ function embedWarn(msg) {
32
+ const now = Date.now();
33
+ if (now - lastEmbedWarn > 30_000) {
34
+ lastEmbedWarn = now;
35
+ console.warn(`[embedder] ${msg}`);
36
+ }
37
+ }
29
38
  function apiKey() {
30
39
  return process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_WORKSPACE_API_KEY;
31
40
  }
@@ -52,9 +61,9 @@ function normalizeAndQuantize(floats) {
52
61
  return out;
53
62
  }
54
63
  /** Embed one batch (≤ MAX_BATCH inputs). Returns aligned int8 vectors, or null on any failure. */
55
- async function embedBatch(inputs, key) {
64
+ async function embedBatch(inputs, key, timeoutMs) {
56
65
  const controller = new AbortController();
57
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
66
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
58
67
  try {
59
68
  const res = await fetch(OPENROUTER_URL, {
60
69
  method: 'POST',
@@ -72,26 +81,35 @@ async function embedBatch(inputs, key) {
72
81
  }),
73
82
  signal: controller.signal,
74
83
  });
75
- if (!res.ok)
84
+ if (!res.ok) {
85
+ const body = await res.text().catch(() => '');
86
+ embedWarn(`OpenRouter ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
76
87
  return null;
88
+ }
77
89
  const json = await res.json();
78
90
  const data = json?.data;
79
- if (!Array.isArray(data) || !data.length)
91
+ if (!Array.isArray(data) || !data.length) {
92
+ embedWarn('empty/invalid embeddings response');
80
93
  return null;
94
+ }
81
95
  // OpenAI-compatible: each entry carries its input `index`; align by it.
96
+ // Fall back to array order if `index` is absent so a missing field never voids the batch.
82
97
  const byIndex = new Array(inputs.length).fill(null);
83
- for (const d of data) {
84
- const idx = typeof d?.index === 'number' ? d.index : -1;
98
+ data.forEach((d, pos) => {
99
+ const idx = typeof d?.index === 'number' ? d.index : pos;
85
100
  const emb = d?.embedding;
86
101
  if (idx >= 0 && idx < inputs.length && Array.isArray(emb)) {
87
102
  byIndex[idx] = normalizeAndQuantize(emb);
88
103
  }
104
+ });
105
+ if (byIndex.some((v) => v == null)) {
106
+ embedWarn('response missing/misaligned embeddings');
107
+ return null;
89
108
  }
90
- if (byIndex.some((v) => v == null))
91
- return null; // malformed / partial response
92
109
  return byIndex;
93
110
  }
94
- catch {
111
+ catch (err) {
112
+ embedWarn(`request failed: ${err?.name || ''} ${err?.message || err}`);
95
113
  return null; // network error, timeout/abort, bad JSON — degrade to keyword-only
96
114
  }
97
115
  finally {
@@ -102,12 +120,17 @@ async function embedBatch(inputs, key) {
102
120
  * Returns an Embedder, or null if embeddings are disabled or no key is configured.
103
121
  * The returned function degrades to null on any per-call failure (keyword-only recall).
104
122
  */
105
- export async function getEmbedder() {
106
- if (process.env.OSBORN_EMBED === '0' || disabled)
123
+ export async function getEmbedder(timeoutMs = REQUEST_TIMEOUT_MS) {
124
+ if (process.env.OSBORN_EMBED === '0')
107
125
  return null;
126
+ // Re-read the key EVERY call and never latch off on a missing key: the platform
127
+ // OpenRouter key is hydrated into the env by the secrets layer, which can finish
128
+ // AFTER the first (boot-time) warm call. A permanent latch there disabled embedding
129
+ // for the whole session (embedded=0) even though fast-brain — which reads the key
130
+ // fresh per request — kept working. Re-reading recovers as soon as the key lands.
108
131
  const key = apiKey();
109
132
  if (!key) {
110
- disabled = true; // no key on this machine — don't retry every turn
133
+ embedWarn('no OPENROUTER_API_KEY in env yet — keyword-only for this call');
111
134
  return null;
112
135
  }
113
136
  const embed = async (texts) => {
@@ -116,7 +139,7 @@ export async function getEmbedder() {
116
139
  const out = [];
117
140
  for (let i = 0; i < texts.length; i += MAX_BATCH) {
118
141
  const chunk = texts.slice(i, i + MAX_BATCH).map((t) => t || ' ');
119
- const vecs = await embedBatch(chunk, key);
142
+ const vecs = await embedBatch(chunk, key, timeoutMs);
120
143
  if (!vecs)
121
144
  return null; // any failure → keyword-only for this call
122
145
  out.push(...vecs);
@@ -107,13 +107,15 @@ THE BUDGET — HARD QUANTITATIVE RULE:
107
107
  THE SUB-AGENTS:
108
108
  · researcher (Sonnet) — info gathering, web research, multi-file reads. Read-only outside workspace.
109
109
  · reasoner (Opus) — architecture decisions, complex tradeoffs, implementation planning. Read-only.
110
- · writer (Sonnet) — ALL file changes outside the workspace. Verifies before, runs tests after. The ONLY agent with write access outside the workspace.
111
- · NEVER use the SDK's built-in 'general-purpose' agent — it is not configured for this project and will hit write blocks. Always pick researcher, reasoner, or writer explicitly.
110
+ · writer (Sonnet) — ALL file changes outside the workspace. The ONLY agent with write access outside the workspace.
111
+ · tester (Sonnet) — TDD verifier. Dispatched IN PARALLEL with the writer: writes tests from the SPEC, blind to the writer's implementation. After the writer finishes, its tests are RUN against the new code (PASS/FAIL).
112
+ · reviewer (Sonnet) — dispatched AFTER the writer completes: reads the diff and returns VERDICT: ACCEPT/REJECT.
113
+ · NEVER use the SDK's built-in 'general-purpose' agent — it is not configured for this project and will hit write blocks. Always pick researcher, reasoner, writer, tester, or reviewer explicitly.
112
114
 
113
115
  DIVISION OF LABOR — follow this chain by default for any substantive or code task:
114
- researcher (gather facts) → planner (write a step plan for multi-step work) → writer (execute the plan) → tester AND reviewer in parallel (verify the change) → you synthesize and speak.
116
+ researcher (gather facts) → planner (step plan for multi-step work) → THEN dispatch writer AND tester IN THE SAME STEP, in parallel: the writer executes the plan while the tester writes tests from the spec, blind to the implementation (TDD) → after the writer returns, RUN the tester's tests against the new code AND dispatch the reviewer on the diff → on test-FAIL or REJECT, send the writer back to fix and re-verify (at most 2 rounds) → you synthesize and speak.
115
117
  For a quick factual query: researcher only → you speak.
116
- NEVER skip tester and reviewer after a code change.
118
+ NEVER skip the tester+reviewer verification after a code change — and dispatch the tester IN PARALLEL with the writer (not after), so tests are written independently of the code.
117
119
  You may OVERRIDE this chain at any time — stop a running agent, inject, or reorder — because you can see each task's live state. The chain is the default, not a cage.
118
120
  Surfacing findings and communicating to the user is YOUR job, not a sub-agent's.
119
121
  </turn-shape>
@@ -63,7 +63,9 @@ export interface StoreStats {
63
63
  export declare function getStorePath(sessionId: string, workingDir: string): string;
64
64
  /** Returns the store path if it exists and is non-empty, else null. */
65
65
  export declare function storeExists(sessionId: string, workingDir: string): string | null;
66
- export declare function openStore(dbPath: string): Database.Database;
66
+ export declare function openStore(dbPath: string, opts?: {
67
+ readonly?: boolean;
68
+ }): Database.Database;
67
69
  /**
68
70
  * Build or update the session store incrementally.
69
71
  * Ingests the main JSONL + all sub-agent JSONLs from their last stored offsets.
@@ -66,11 +66,23 @@ export function storeExists(sessionId, workingDir) {
66
66
  // ============================================================
67
67
  // DB OPEN / SCHEMA
68
68
  // ============================================================
69
- export function openStore(dbPath) {
69
+ export function openStore(dbPath, opts) {
70
+ // Read-only open for the hot read path (buildRecallInjection, fast-brain). A readonly
71
+ // connection takes only SHARED locks — under WAL it never blocks (or is blocked by) the
72
+ // writer — and it skips the DDL/migration/meta writes below entirely (a reader must not
73
+ // migrate). busy_timeout lets a transient exclusive lock (checkpoint/DDL on the writer)
74
+ // resolve instead of throwing SQLITE_BUSY. Caller guarantees the db exists (storeExists).
75
+ if (opts?.readonly) {
76
+ const rdb = new Database(dbPath, { readonly: true });
77
+ sqliteVec.load(rdb); // vec0 module must be registered per-connection for vec_int8() in recall()
78
+ rdb.pragma('busy_timeout = 5000');
79
+ return rdb;
80
+ }
70
81
  const db = new Database(dbPath);
71
82
  sqliteVec.load(db);
72
83
  db.pragma('journal_mode = WAL');
73
84
  db.pragma('synchronous = NORMAL');
85
+ db.pragma('busy_timeout = 5000'); // wait out a transient lock instead of throwing SQLITE_BUSY
74
86
  // ── Migration gate ──────────────────────────────────────────────
75
87
  // The derived tables (content/fts/vec/sources) are a re-derivable cache of the
76
88
  // JSONL, which is the source of truth. If this DB was built by a different
@@ -256,12 +268,22 @@ function countLines(filePath, upToBytes) {
256
268
  return c;
257
269
  }
258
270
  /**
259
- * Max bytes ingested from a single JSONL per sweep. A very large session file
260
- * (100MB+) is drained gap-free across many turns instead of being read whole in
261
- * one synchronous pass — that whole-file read + insert transaction is what pegged
262
- * the single Node event loop and starved the SDK's time-boxed UserPromptSubmit hook.
271
+ * Max bytes ingested from a SINGLE JSONL per sweep. Kept small so each synchronous
272
+ * read+parse+gzip+FTS-insert block is short (~sub-300ms) and never stutters realtime
273
+ * voice (VAD) between event-loop yields.
274
+ */
275
+ const MAX_INGEST_BYTES = 1 * 1024 * 1024;
276
+ /**
277
+ * Max bytes ingested across ALL sources in one turn. This is the cap that actually
278
+ * matters: a session can have HUNDREDS of subagent JSONL files, and on a store
279
+ * migration the offsets reset so every source re-ingests from 0 at once. The old
280
+ * code looped all sources inside ONE synchronous db.transaction — hundreds of MB of
281
+ * synchronous work that pegged the single Node thread for minutes and starved VAD,
282
+ * the SDK's UserPromptSubmit hook (30s timeout), and fast-brain. Bounding the total
283
+ * per turn (and yielding between sources) drains large sessions over many turns
284
+ * without ever monopolizing the loop.
263
285
  */
264
- const MAX_INGEST_BYTES = 4 * 1024 * 1024;
286
+ const MAX_INGEST_BYTES_PER_TURN = 4 * 1024 * 1024;
265
287
  /**
266
288
  * Ingest new bytes from a single JSONL file into the store.
267
289
  * Reads only from `fromOffset` forward, at most `maxBytes` per call. Returns rows
@@ -370,21 +392,44 @@ export async function updateSessionStore(sessionId, workingDir, opts) {
370
392
  const upSrc = db.prepare(`INSERT INTO sources(source, jsonl_path, byte_offset, line_count) VALUES (@source,@path,@off,@lines)
371
393
  ON CONFLICT(source) DO UPDATE SET jsonl_path=@path, byte_offset=@off, line_count=@lines`);
372
394
  const freshRows = [];
373
- const txn = db.transaction(() => {
374
- for (const s of sources) {
375
- const prev = getSrc.get(s.source);
376
- const fromOffset = prev?.byte_offset ?? 0;
377
- const res = ingestFile(db, s.path, s.source, fromOffset);
378
- if (res.inserted.length || res.newOffset !== fromOffset) {
379
- upSrc.run({
380
- source: s.source, path: s.path, off: res.newOffset,
381
- lines: (prev?.line_count ?? 0) + res.linesProcessed,
382
- });
383
- }
384
- freshRows.push(...res.inserted);
395
+ let budget = MAX_INGEST_BYTES_PER_TURN;
396
+ let sinceYield = 0;
397
+ for (const s of sources) {
398
+ // Yield to the event loop periodically even across drained sources, so iterating
399
+ // a large source list (statSync + SELECT each) can't itself stall voice/hook.
400
+ if (++sinceYield >= 8) {
401
+ sinceYield = 0;
402
+ await new Promise((r) => setImmediate(r));
385
403
  }
386
- });
387
- txn();
404
+ if (budget <= 0)
405
+ break; // per-turn cap reached — remaining sources drain next turn
406
+ const prev = getSrc.get(s.source);
407
+ const fromOffset = prev?.byte_offset ?? 0;
408
+ const cap = Math.min(budget, MAX_INGEST_BYTES);
409
+ let res;
410
+ try {
411
+ // Per-source transaction (not one giant txn over all sources) so we can yield
412
+ // between them and never hold the thread for the whole fleet of subagent files.
413
+ res = db.transaction(() => ingestFile(db, s.path, s.source, fromOffset, cap))();
414
+ }
415
+ catch {
416
+ continue; // a bad source never blocks the rest
417
+ }
418
+ if (res.inserted.length || res.newOffset !== fromOffset) {
419
+ upSrc.run({
420
+ source: s.source, path: s.path, off: res.newOffset,
421
+ lines: (prev?.line_count ?? 0) + res.linesProcessed,
422
+ });
423
+ }
424
+ const consumed = res.newOffset - fromOffset;
425
+ budget -= consumed;
426
+ freshRows.push(...res.inserted);
427
+ // Yield after any source that did real synchronous work.
428
+ if (consumed > 0) {
429
+ sinceYield = 0;
430
+ await new Promise((r) => setImmediate(r));
431
+ }
432
+ }
388
433
  opts?.onProgress?.(`content: +${freshRows.length} rows across ${sources.length} sources`);
389
434
  // ── Embeddings (best-effort, never blocks the keyword layer) ──
390
435
  let embeddedRows = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.226",
3
+ "version": "0.9.228",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {