osborn 0.9.229 → 0.9.231

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.
@@ -358,7 +358,7 @@ export const NAMED_AGENTS = {
358
358
  'finding patterns, reading multiple files, searching for examples.',
359
359
  'Returns structured findings — does NOT make decisions or edit files.',
360
360
  'Use this for ANY task that needs more than 2 tool calls to gather information.',
361
- 'GROUNDED: reads the session search-index for prior findings before researching.',
361
+ 'GROUNDED: reads the session recall store (osborn-recall) for prior findings before researching.',
362
362
  ].join(' '),
363
363
  tools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'Task'],
364
364
  grounded: true, // applyGrounding() injects the osborn-recall command + ensures Bash
@@ -419,7 +419,7 @@ export const NAMED_AGENTS = {
419
419
  'Think hard about complex problems. Consider multiple approaches. Identify risks and edge cases.',
420
420
  '',
421
421
  '## Session context',
422
- 'The orchestrator provides, as an artifact in your brief, the PATH to the session index file (search-index.txt — the running index of this session/mission). You MUST actually READ it — do not rely on a summary or a preloaded window. Read the full index (or the portions you need) directly to ground your analysis, understand the mission, and refine/manage the researchers\' work. Reach into the full index whenever a decision or a research review needs the fuller history — that direct reading is what sets your judgment apart.',
422
+ 'You are deliberately NOT grounded (no raw session recall) — that keeps your analysis fresh and unbiased. Work from the STRUCTURED SUMMARIES the orchestrator hands you in your brief; do not attempt to pull raw session history yourself. If you need more context, ask the orchestrator for a structured summary rather than raw dumps.',
423
423
  '',
424
424
  '## How to work',
425
425
  '1. Read and understand the full context before forming an opinion.',
@@ -461,7 +461,7 @@ export const NAMED_AGENTS = {
461
461
  'Handles ALL file operations: code, config, docs, scripts, data files.',
462
462
  'VERIFY-FIRST workflow: checks assumptions before making changes, runs tests after.',
463
463
  'If anything is unclear, asks the main agent for clarification before touching files.',
464
- 'GROUNDED: reads the session search-index before editing to avoid contradicting prior decisions.',
464
+ 'GROUNDED: reads the session recall store (osborn-recall) before editing to avoid contradicting prior decisions.',
465
465
  ].join(' '),
466
466
  tools: ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Glob', 'Grep', 'NotebookRead', 'NotebookEdit'],
467
467
  grounded: true, // applyGrounding() injects the osborn-recall command
@@ -612,7 +612,7 @@ export const NAMED_AGENTS = {
612
612
  'ordered sequence of atomic writer-safe steps. Returns a self-contained brief the writer',
613
613
  'can execute without further clarification. Slow but thorough — only use for genuinely',
614
614
  'complex multi-file changes or when the approach is uncertain.',
615
- 'GROUNDED: reads the session search-index before planning to respect prior decisions.',
615
+ 'GROUNDED: reads the session recall store (osborn-recall) before planning to respect prior decisions.',
616
616
  ].join(' '),
617
617
  tools: ['Read', 'Glob', 'Grep', 'WebSearch'],
618
618
  grounded: true, // applyGrounding() injects the osborn-recall command + adds Bash
package/dist/index.js CHANGED
@@ -37,7 +37,6 @@ import { loadConfig, getMcpServers, getEnabledMcpServerNames, getVoiceMode, getD
37
37
  import { createSTT, createTTS } from './voice-io.js';
38
38
  import { createClaudeLLM, NAMED_AGENTS, applyTurbo } from './claude-llm.js';
39
39
  import { clearPipelineFastBrainSession, prewarmBM25Index } from './pipeline-fastbrain.js';
40
- import { getIndexPath, buildSummaryIndex } from './summary-index.js';
41
40
  import { ensureClaudeAuth, applyAuthFallback } from './claude-auth.js';
42
41
  import { createSmitheryProxy, destroySmitheryProxy, parseSmitheryUrl, isSmitheryUrl, SmitheryAuthorizationError } from './smithery-proxy.js';
43
42
  import { DIRECT_MODE_PROMPT } from './prompts.js';
@@ -1823,47 +1822,29 @@ function startApiServer(workingDir, port) {
1823
1822
  * OpenAI handles full history (30 exchanges, 2000 char content).
1824
1823
  */
1825
1824
  /**
1826
- * Pass the session index path to the voice agent's ChatContext.
1825
+ * Note to the voice agent's ChatContext on resume that prior conversation
1826
+ * history is searchable via the recall store.
1827
1827
  *
1828
- * For active/previously-run sessions the index already exists — startIndexWatcher
1829
- * keeps it current throughout the conversation. We just hand the agent the file
1830
- * path so it can reference it if the user asks about prior work. No file read at
1831
- * resume time.
1832
- *
1833
- * Only exception: first-ever resume of a session that was never indexed (e.g. a
1834
- * session started before the index watcher was added). In that case we build once.
1828
+ * Native --resume already replays the session into the SDK, so this is not a
1829
+ * context-injection path — it just reminds the agent that the FULL prior history
1830
+ * (this + previous sessions) is queryable through the `osborn-recall` command,
1831
+ * which reads the per-session recall store (session.db). We no longer inject the
1832
+ * old flat search-index.txt path or its `lineNum|…` line format (retired).
1835
1833
  */
1836
- function injectSessionIndexIntoChatCtx(agent, sessionId, workingDir) {
1834
+ function injectSessionIndexIntoChatCtx(agent, sessionId, _workingDir) {
1837
1835
  if (!agent || !sessionId)
1838
1836
  return;
1839
1837
  try {
1840
- let indexPath = getIndexPath(sessionId, workingDir);
1841
- if (!indexPath) {
1842
- // First-ever resume with no index — build once, then point at it
1843
- console.log(`🗂️ No index for ${sessionId.slice(0, 8)} — building (one-time)...`);
1844
- try {
1845
- const state = buildSummaryIndex(sessionId, workingDir);
1846
- indexPath = (state.indexPath && existsSync(state.indexPath) && statSync(state.indexPath).size > 0)
1847
- ? state.indexPath : null;
1848
- }
1849
- catch {
1850
- return;
1851
- }
1852
- }
1853
- if (!indexPath)
1854
- return;
1855
- // Pass the path — don't read the file. Native --resume already loaded the session;
1856
- // this is just a reference the agent can use to search history if asked.
1857
1838
  const chatCtx = agent.chatCtx.copy();
1858
1839
  chatCtx.addMessage({
1859
1840
  role: 'user',
1860
- content: `[Session resume] Previous conversation history is indexed at: ${indexPath}\nFormat per line: lineNum|timestamp|source|msgType|summary`,
1841
+ content: `[Session resume] Prior conversation history is searchable via the \`osborn-recall\` command (hybrid keyword + semantic recall over the session store). Use it if the user asks about earlier work.`,
1861
1842
  });
1862
1843
  agent.updateChatCtx(chatCtx);
1863
- console.log(`🗂️ Session index path injected: ${indexPath}`);
1844
+ console.log(`🗂️ Session recall note injected for ${sessionId.slice(0, 8)}`);
1864
1845
  }
1865
1846
  catch (err) {
1866
- console.log('⚠️ Failed to inject session index path:', err);
1847
+ console.log('⚠️ Failed to inject session recall note:', err);
1867
1848
  }
1868
1849
  }
1869
1850
  // Main function
@@ -2033,6 +2014,10 @@ async function main() {
2033
2014
  let currentLLM = null;
2034
2015
  const slots = new Map();
2035
2016
  let focusedSlotId = null; // reserved for future focused-slot wiring; not used yet
2017
+ // P1 host-agent anchor: the dashboard-started session that owns the room's env + keeps it alive.
2018
+ let hostActive = false;
2019
+ let hostSessionId = null;
2020
+ let hostWorkingDir = null;
2036
2021
  // Agent-side "alone in room" leave timer (see Room-presence lifecycle note up
2037
2022
  // top). Armed in ParticipantDisconnected once a user has left; if no one
2038
2023
  // rejoins within the grace window the agent leaves LiveKit on its own.
@@ -2047,7 +2032,31 @@ async function main() {
2047
2032
  // ParticipantDisconnected (covers a finished session). Cancelled the moment a
2048
2033
  // user joins. Net invariant: the agent never holds an empty room beyond
2049
2034
  // ALONE_GRACE_MS, in any scenario — the root cause of the 25h burn.
2035
+ // P1: room stays alive while the host anchor is active or any background slot
2036
+ // is running. Teardown timers below early-return on this so a dashboard-started
2037
+ // session + its background dispatches aren't killed by the empty-room grace.
2038
+ const agentsActive = () => hostActive || slots.size > 0;
2039
+ // Release the host anchor + hard-kill background slots at a genuine session-end
2040
+ // boundary so the room can idle-exit (agentsActive → false). Idempotent. MUST be
2041
+ // called before arming idle-exit on EVERY path where the session ends, or the
2042
+ // empty-room billing burn returns — the keep-alive guard is only as safe as its
2043
+ // release sites (endMeeting / intentional-leave / destroyRoomSession / disconnect).
2044
+ const releaseAgents = () => {
2045
+ hostActive = false;
2046
+ hostSessionId = null;
2047
+ hostWorkingDir = null;
2048
+ for (const [, s] of slots) {
2049
+ try {
2050
+ s.llm.abortQuery?.();
2051
+ s.llm.abortAgent?.();
2052
+ }
2053
+ catch { }
2054
+ }
2055
+ slots.clear();
2056
+ };
2050
2057
  const armAloneTimer = () => {
2058
+ if (agentsActive())
2059
+ return;
2051
2060
  if (aloneTimer)
2052
2061
  clearTimeout(aloneTimer);
2053
2062
  aloneTimer = null;
@@ -2073,6 +2082,8 @@ async function main() {
2073
2082
  let fastLeaveTimer = null;
2074
2083
  const POST_LEAVE_GRACE_MS = 20 * 1000;
2075
2084
  const armFastLeaveTimer = () => {
2085
+ if (agentsActive())
2086
+ return;
2076
2087
  if (fastLeaveTimer)
2077
2088
  clearTimeout(fastLeaveTimer);
2078
2089
  fastLeaveTimer = null;
@@ -2118,7 +2129,7 @@ async function main() {
2118
2129
  idleExitTimer = null;
2119
2130
  const stillIdle = livekitState.status === 'idle' || livekitState.status === 'failed';
2120
2131
  const empty = !activeRoom || activeRoom.remoteParticipants.size === 0;
2121
- if (stillIdle && empty) {
2132
+ if (stillIdle && empty && !agentsActive()) {
2122
2133
  console.log(`⏻ [IDLE-EXIT] still idle after grace — exiting 0 so Fly stops the machine (billing stops; next Resume boots it)`);
2123
2134
  // Give the log tee a beat to flush, then stop the machine.
2124
2135
  setTimeout(() => process.exit(0), 2000);
@@ -2172,21 +2183,10 @@ async function main() {
2172
2183
  return;
2173
2184
  try {
2174
2185
  const llm = currentLLM;
2175
- // Heap-OOM fix (2026-06-02): stop the PipelineDirectLLM summary-index
2176
- // watcher BEFORE we abort + drop the reference. The watcher is a 10s
2177
- // setInterval whose closure retains the entire PipelineDirectLLM →
2178
- // ClaudeLLM object graph. killCurrentLLM is the single chokepoint all
2179
- // three cleanup sites (Disconnected, previous-session-cleanup,
2180
- // ParticipantDisconnected) call, but it previously only aborted the
2181
- // SDK subprocess — leaving the interval (and the whole graph) alive and
2182
- // uncollectable on every disconnect/reconnect. A reconnect-heavy session
2183
- // (e.g. 15 reconnects from a frontend redeploy) leaked 15 timers + 15
2184
- // retained graphs, each re-reading JSONL every 10s, until the node heap
2185
- // OOM'd (~980MB) and the process crashed. Stopping the watcher here lets
2186
- // the abandoned graph be GC'd. Duck-typed: only PipelineDirectLLM has it.
2187
- if (typeof llm.stopIndexWatcher === 'function') {
2188
- llm.stopIndexWatcher();
2189
- }
2186
+ // NOTE (fast-brain migration): the old summary-index 10s setInterval watcher
2187
+ // was retired — fast-brain now reads the recall store (session.db) directly,
2188
+ // so there is no per-LLM timer holding the PipelineDirectLLM → ClaudeLLM graph
2189
+ // alive anymore. We only abort the SDK subprocess here.
2190
2190
  if (typeof llm.abortQuery === 'function') {
2191
2191
  llm.abortQuery();
2192
2192
  }
@@ -2259,6 +2259,25 @@ async function main() {
2259
2259
  console.error(`❌ Background slot ${slotId.substring(0, 8)}: resume failed — requested ${requestedSessionId?.substring(0, 8)} got ${actualSessionId?.substring(0, 8)}`);
2260
2260
  sendToFrontend({ type: 'background_session_error', slotId, error: 'Session resume failed' }).catch(() => { });
2261
2261
  });
2262
+ // P1: a real dispatch finished — notify the frontend. The [BACKGROUND_INIT]
2263
+ // ack also fires assistant_result, so gate on dispatchInFlight to skip it.
2264
+ bgEmitter.on('assistant_result', ({ text }) => {
2265
+ const slot = slots.get(slotId);
2266
+ if (!slot)
2267
+ return;
2268
+ // The [BACKGROUND_INIT] kickoff also fires assistant_result and, on the cold-spawn
2269
+ // dispatch path, can arrive AFTER dispatchInFlight was set. Consume that first result
2270
+ // as the kickoff ack so it's never mis-reported as (and never clobbers) the dispatch's
2271
+ // real answer.
2272
+ if (!slot.kickoffDone) {
2273
+ slot.kickoffDone = true;
2274
+ return;
2275
+ }
2276
+ if (!slot.dispatchInFlight)
2277
+ return; // only notify for a real dispatched instruction
2278
+ slot.dispatchInFlight = false;
2279
+ sendToFrontend({ type: 'background_update', sessionId: slotId, stage: 'done', summary: (text || '').trim().slice(0, 500) }).catch(() => { });
2280
+ });
2262
2281
  // Wire up the LLM's internal EventEmitter so hook-emitted events also flow.
2263
2282
  bgLLM.events.on('session_id', (data) => bgEmitter.emit('session_id', data));
2264
2283
  bgLLM.events.on('tool_blocked', (data) => bgEmitter.emit('tool_blocked', data));
@@ -2281,6 +2300,12 @@ async function main() {
2281
2300
  settingSources: ['project', 'user'],
2282
2301
  env: bgEnv,
2283
2302
  };
2303
+ // P1: persist the cold-start emitter + sdkOptions on the slot so a later
2304
+ // dispatch_background can reuse them. CRITICAL: pushMessage's fast path
2305
+ // ignores the callbacks/emitter arg once the subprocess exists, so a
2306
+ // dispatch MUST reuse THIS emitter — otherwise its output goes nowhere.
2307
+ slot.emitter = bgEmitter;
2308
+ slot.sdkOptions = bgSdkOptions;
2284
2309
  // Cold-start: deliver an init message so the subprocess actually spawns
2285
2310
  // and begins consuming the session JSONL.
2286
2311
  const KICKOFF_TEXT = '[BACKGROUND_INIT] You have been resumed in headless background mode. No voice session is active. Acknowledge receipt with a single brief line.';
@@ -2481,6 +2506,7 @@ async function main() {
2481
2506
  currentLLM = null;
2482
2507
  clearPipelineFastBrainSession();
2483
2508
  clearPipelineFastBrainSession();
2509
+ releaseAgents(); // session over → clear host + kill slots so idle-exit can fire
2484
2510
  armIdleExitTimer(`meeting ended (${reason}), no user`);
2485
2511
  }
2486
2512
  };
@@ -3284,6 +3310,15 @@ async function main() {
3284
3310
  // PipelineDirectLLM (llmOverride path) wraps its own ClaudeLLM and may not expose setTurbo.
3285
3311
  directLLM?.setTurbo?.(turboMode);
3286
3312
  currentLLM = directLLM;
3313
+ // P1 host-agent anchor: this dashboard-started session owns the room's env
3314
+ // and keeps it alive (agentsActive → true) so background dispatches survive.
3315
+ // Keep-alive keys off the hostActive boolean, NOT a session_id event;
3316
+ // hostSessionId is best-effort (the focused id is otherwise resolved as
3317
+ // currentLLM?.sessionId || currentResumeSessionId).
3318
+ hostActive = true;
3319
+ hostWorkingDir = workingDir;
3320
+ if (resumeSessionId)
3321
+ hostSessionId = resumeSessionId;
3287
3322
  // Reset the session always-allow list for each new direct session
3288
3323
  sessionAlwaysAllowPaths = new Set();
3289
3324
  // For resumed sessions, eagerly create workspace (we know the real ID)
@@ -3914,6 +3949,7 @@ async function main() {
3914
3949
  console.log('🕊️ Left LiveKit room intentionally — idle, awaiting /connect-room (no auto-rejoin)');
3915
3950
  // 0.9.73: idle machines must not bill forever — stop the Fly machine
3916
3951
  // after the grace window unless a /connect-room revives us first.
3952
+ releaseAgents(); // session over → clear host + kill slots so idle-exit can fire
3917
3953
  armIdleExitTimer('intentional leave → idle');
3918
3954
  return;
3919
3955
  }
@@ -4576,10 +4612,6 @@ async function main() {
4576
4612
  try {
4577
4613
  // Reuse existing session ID so Claude SDK resumes where it left off
4578
4614
  const recoverySessionId = currentLLM?.sessionId || resumeSessionId;
4579
- // Stop old index watcher if it exists
4580
- if (currentLLM && 'stopIndexWatcher' in currentLLM) {
4581
- currentLLM.stopIndexWatcher();
4582
- }
4583
4615
  let result;
4584
4616
  {
4585
4617
  // Pipeline mode: recreate PipelineDirectLLM wrapper with fast brain
@@ -4877,6 +4909,12 @@ async function main() {
4877
4909
  currentLLM = null;
4878
4910
  clearPipelineFastBrainSession();
4879
4911
  clearPipelineFastBrainSession();
4912
+ // P1 (CRITICAL FOR COST): the human left a non-meeting session. Clear the
4913
+ // host anchor AND hard-kill every background slot so their subprocesses
4914
+ // stop burning tokens. agentsActive() keeps the room alive on the host +
4915
+ // slots, so this is the ONLY safe place to release them — leaving a slot
4916
+ // alive here would defeat the empty-room teardown timers entirely.
4917
+ releaseAgents();
4880
4918
  // Auto-leave path for a NON-meeting session. 0.9.83: a real session just
4881
4919
  // ended → use the FAST leave (~20s), not the 3-min alone grace. Fires even
4882
4920
  // on an abrupt tab close; cancelled if a user rejoins within the grace.
@@ -5781,6 +5819,39 @@ async function main() {
5781
5819
  });
5782
5820
  }
5783
5821
  }
5822
+ else if (data.type === 'dispatch_background') {
5823
+ // P1: push an instruction into a background slot (spawning it cold-start
5824
+ // first if needed), then notify the frontend when the turn completes.
5825
+ const targetSessionId = data.targetSessionId;
5826
+ const instruction = data.instruction;
5827
+ if (!targetSessionId || !instruction) {
5828
+ await sendToFrontend({ type: 'background_session_error', slotId: targetSessionId ?? null, error: 'dispatch_background requires targetSessionId + instruction' });
5829
+ }
5830
+ else {
5831
+ const focusedId = currentLLM?.sessionId || currentResumeSessionId || null;
5832
+ if (targetSessionId === focusedId) {
5833
+ await sendToFrontend({ type: 'background_session_error', slotId: targetSessionId, error: 'Target is the focused session — use the normal path' });
5834
+ }
5835
+ else {
5836
+ const doDispatch = () => {
5837
+ const slot = slots.get(targetSessionId);
5838
+ if (!slot || !slot.emitter || !slot.sdkOptions) {
5839
+ void sendToFrontend({ type: 'background_session_error', slotId: targetSessionId, error: 'Slot not ready' });
5840
+ return;
5841
+ }
5842
+ slot.dispatchInFlight = true;
5843
+ // Reuse the cold-start emitter — pushMessage's fast path ignores a
5844
+ // fresh emitter once the subprocess exists (see spawnBackgroundSession).
5845
+ slot.llm.pushMessage(instruction, slot.sdkOptions, { onSessionId: () => { }, onCheckpoint: () => { }, eventEmitter: slot.emitter });
5846
+ void sendToFrontend({ type: 'background_dispatched', sessionId: targetSessionId, instruction });
5847
+ };
5848
+ if (slots.has(targetSessionId))
5849
+ doDispatch();
5850
+ else
5851
+ spawnBackgroundSession(targetSessionId).then(doDispatch).catch((err) => { void sendToFrontend({ type: 'background_session_error', slotId: targetSessionId, error: err instanceof Error ? err.message : String(err) }); });
5852
+ }
5853
+ }
5854
+ }
5784
5855
  else if (data.type === 'list_slots') {
5785
5856
  // Return all live slots: the focused session + every background slot.
5786
5857
  const focusedId = currentLLM?.sessionId || currentResumeSessionId || null;
@@ -5959,6 +6030,7 @@ async function main() {
5959
6030
  if (!activeRoom) {
5960
6031
  // Already idle — still ensure billing backstop is armed.
5961
6032
  livekitState.status = 'idle';
6033
+ releaseAgents(); // ensure no lingering host/slots keep the machine alive
5962
6034
  armIdleExitTimer(`destroyRoomSession(${reason}) — already idle`);
5963
6035
  return;
5964
6036
  }
@@ -5991,6 +6063,7 @@ async function main() {
5991
6063
  livekitState.status = 'idle';
5992
6064
  livekitState.error = null;
5993
6065
  livekitState.errorCode = null;
6066
+ releaseAgents(); // session torn down → clear host + kill slots so idle-exit can fire
5994
6067
  armIdleExitTimer(`destroyRoomSession(${reason})`);
5995
6068
  destroyingRoomSession = false;
5996
6069
  };
@@ -46,8 +46,6 @@ export declare class PipelineDirectLLM extends llm.LLM {
46
46
  #private;
47
47
  suppressMeetingTTS: boolean;
48
48
  constructor(opts: PipelineDirectOptions);
49
- /** Stop the index watcher (call on disconnect/session switch) */
50
- stopIndexWatcher(): void;
51
49
  get events(): EventEmitter;
52
50
  get sessionId(): string | null;
53
51
  get model(): string;
@@ -9,10 +9,9 @@
9
9
  * Phase 2 (future): Gemini speaks first, Claude suppressed when Gemini has HIGH confidence
10
10
  */
11
11
  import { llm, DEFAULT_API_CONNECT_OPTIONS } from '@livekit/agents';
12
- import { readFileSync } from 'fs';
13
12
  import { ClaudeLLM } from './claude-llm.js';
14
13
  import { askPipelineFastBrain } from './pipeline-fastbrain.js';
15
- import { buildSummaryIndex, startIndexWatcher, getIndexPath } from './summary-index.js';
14
+ import { storeExists, openStore, recentRows } from './session-store.js';
16
15
  // ============================================================
17
16
  // SESSION TAIL — compact recent-conversation block prepended to every user turn
18
17
  // so the orchestrator never loses the thread across context compaction.
@@ -27,31 +26,33 @@ function buildSessionTail(sessionId, workingDir) {
27
26
  return '';
28
27
  const maxLines = parseInt(process.env.OSBORN_SESSION_TAIL_COUNT || '3000', 10);
29
28
  try {
30
- const indexPath = getIndexPath(sessionId, workingDir);
31
- if (!indexPath)
32
- return '';
33
- const raw = readFileSync(indexPath, 'utf-8');
34
- const allLines = raw.split('\n').filter(Boolean);
35
- // Read a larger raw window (4× target) then keep the last maxLines user+assistant entries
36
- const rawWindow = allLines.slice(-maxLines * 4);
37
- const convLines = [];
38
- for (const line of rawWindow) {
39
- const parts = line.split('|');
40
- // format: lineNum|byteOffset|timestamp|source|msgType|summary
41
- if (parts.length < 6)
42
- continue;
43
- const msgType = parts[4];
44
- if (msgType !== 'user' && msgType !== 'assistant')
45
- continue;
46
- const timestamp = parts[2];
47
- const speaker = msgType === 'user' ? 'User' : 'Assistant';
48
- const summary = parts.slice(5).join('|').substring(0, 150);
49
- convLines.push(`${timestamp} ${speaker}: ${summary}`);
29
+ const dbPath = storeExists(sessionId, workingDir);
30
+ if (!dbPath)
31
+ return ''; // no store → prior empty-index behavior
32
+ const db = openStore(dbPath, { readonly: true });
33
+ try {
34
+ // Pull the most recent user+assistant rows (filtered in SQL, so we only
35
+ // decompress rows we keep), newest-first, then reverse to chronological.
36
+ const rows = recentRows(db, maxLines, ['user', 'assistant']);
37
+ const convLines = [];
38
+ for (const row of rows) {
39
+ if (row.msgType !== 'user' && row.msgType !== 'assistant')
40
+ continue;
41
+ const timestamp = (row.ts || '').substring(0, 19);
42
+ const speaker = row.msgType === 'user' ? 'User' : 'Assistant';
43
+ const summary = row.text.replace(/[\n\r\t]+/g, ' ').replace(/\s+/g, ' ').trim().substring(0, 150);
44
+ convLines.push(`${timestamp} ${speaker}: ${summary}`);
45
+ }
46
+ // recentRows is newest-first — reverse to chronological, then keep the tail.
47
+ convLines.reverse();
48
+ const kept = convLines.slice(-maxLines);
49
+ if (kept.length === 0)
50
+ return '';
51
+ return ['<session_tail>', ...kept, '</session_tail>'].join('\n');
52
+ }
53
+ finally {
54
+ db.close();
50
55
  }
51
- const kept = convLines.slice(-maxLines);
52
- if (kept.length === 0)
53
- return '';
54
- return ['<session_tail>', ...kept, '</session_tail>'].join('\n');
55
56
  }
56
57
  catch {
57
58
  return '';
@@ -61,8 +62,6 @@ export class PipelineDirectLLM extends llm.LLM {
61
62
  #claudeLLM;
62
63
  #opts;
63
64
  #turnAbort = null;
64
- #indexWatcher = null;
65
- #indexBuilding = false;
66
65
  // Set to true when PostCompact fires — cleared after injecting the tail once
67
66
  // on the next real user turn (seam-only injection, never per-turn).
68
67
  #postCompactionTailPending = false;
@@ -88,13 +87,6 @@ export class PipelineDirectLLM extends llm.LLM {
88
87
  this.#claudeLLM = new ClaudeLLM(chainedOpts);
89
88
  this.#opts = chainedOpts;
90
89
  }
91
- /** Stop the index watcher (call on disconnect/session switch) */
92
- stopIndexWatcher() {
93
- if (this.#indexWatcher) {
94
- this.#indexWatcher.stop();
95
- this.#indexWatcher = null;
96
- }
97
- }
98
90
  // Proxy all properties
99
91
  get events() { return this.#claudeLLM.events; }
100
92
  get sessionId() { return this.#claudeLLM.sessionId; }
@@ -105,8 +97,6 @@ export class PipelineDirectLLM extends llm.LLM {
105
97
  setResumeSessionId(id) { this.#claudeLLM.setResumeSessionId(id); }
106
98
  setContinueSession(e) { this.#claudeLLM.setContinueSession(e); }
107
99
  resetForSessionSwitch() {
108
- this.stopIndexWatcher();
109
- this.#indexBuilding = false;
110
100
  this.#claudeLLM.resetForSessionSwitch();
111
101
  }
112
102
  respondToPermission(allow, msg) { this.#claudeLLM.respondToPermission(allow, msg); }
@@ -326,20 +316,6 @@ export class PipelineDirectLLM extends llm.LLM {
326
316
  }
327
317
  const workingDir = this.#opts.workingDirectory || process.cwd();
328
318
  const sessionBaseDir = this.#opts.sessionBaseDir || workingDir;
329
- // Build summary index on first question (async, non-blocking for subsequent questions)
330
- if (!this.#indexWatcher && !this.#indexBuilding && sessionId !== 'pending') {
331
- this.#indexBuilding = true;
332
- try {
333
- const startBuild = Date.now();
334
- const state = buildSummaryIndex(sessionId, workingDir, undefined, (msg) => console.log(`🔍 [index] ${msg}`));
335
- this.#indexWatcher = startIndexWatcher(sessionId, workingDir, undefined, state);
336
- console.log(`🔍 [index] Built + watching in ${Date.now() - startBuild}ms`);
337
- }
338
- catch (err) {
339
- console.error('🔍 [index] Build failed:', err?.message);
340
- }
341
- this.#indexBuilding = false;
342
- }
343
319
  // NOTE: the embedded session.db write-through was CONSOLIDATED into the main agent's
344
320
  // UserPromptSubmit hook (claude-llm.ts) — one canonical trigger per real user submission,
345
321
  // main-thread only, sweeping main + all sub-agent JSONLs. Kept out of the per-turn pipeline
@@ -92,5 +92,7 @@ export declare function recall(db: Database.Database, query: string, opts?: Reca
92
92
  * Return the most recent N content rows (highest id = latest, append-only order),
93
93
  * newest first, as RecallHit-shaped objects. Powers fast-brain's "recent entries"
94
94
  * path. Encapsulates decompress so callers never touch the blob/compression.
95
+ * `msgTypes` optionally filters by msg_type IN SQL — so callers that only want
96
+ * user/assistant rows don't decompress rows they'd discard.
95
97
  */
96
- export declare function recentRows(db: Database.Database, count: number): RecallHit[];
98
+ export declare function recentRows(db: Database.Database, count: number, msgTypes?: string[]): RecallHit[];
@@ -575,10 +575,16 @@ export async function recall(db, query, opts) {
575
575
  * Return the most recent N content rows (highest id = latest, append-only order),
576
576
  * newest first, as RecallHit-shaped objects. Powers fast-brain's "recent entries"
577
577
  * path. Encapsulates decompress so callers never touch the blob/compression.
578
+ * `msgTypes` optionally filters by msg_type IN SQL — so callers that only want
579
+ * user/assistant rows don't decompress rows they'd discard.
578
580
  */
579
- export function recentRows(db, count) {
581
+ export function recentRows(db, count, msgTypes) {
582
+ const filter = msgTypes && msgTypes.length
583
+ ? `WHERE msg_type IN (${msgTypes.map(() => '?').join(', ')})`
584
+ : '';
585
+ const params = msgTypes && msgTypes.length ? [...msgTypes, Math.max(1, count)] : [Math.max(1, count)];
580
586
  const rows = db.prepare(`SELECT id, source, line_num, byte_offset, ts, msg_type, model, git_branch, cwd, tool_name, blob
581
- FROM content ORDER BY id DESC LIMIT ?`).all(Math.max(1, count));
587
+ FROM content ${filter} ORDER BY id DESC LIMIT ?`).all(...params);
582
588
  return rows.map((row) => ({
583
589
  id: row.id,
584
590
  source: row.source,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.229",
3
+ "version": "0.9.231",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {