osborn 0.9.228 → 0.9.230

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
@@ -2172,21 +2153,10 @@ async function main() {
2172
2153
  return;
2173
2154
  try {
2174
2155
  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
- }
2156
+ // NOTE (fast-brain migration): the old summary-index 10s setInterval watcher
2157
+ // was retired — fast-brain now reads the recall store (session.db) directly,
2158
+ // so there is no per-LLM timer holding the PipelineDirectLLM → ClaudeLLM graph
2159
+ // alive anymore. We only abort the SDK subprocess here.
2190
2160
  if (typeof llm.abortQuery === 'function') {
2191
2161
  llm.abortQuery();
2192
2162
  }
@@ -4576,10 +4546,6 @@ async function main() {
4576
4546
  try {
4577
4547
  // Reuse existing session ID so Claude SDK resumes where it left off
4578
4548
  const recoverySessionId = currentLLM?.sessionId || resumeSessionId;
4579
- // Stop old index watcher if it exists
4580
- if (currentLLM && 'stopIndexWatcher' in currentLLM) {
4581
- currentLLM.stopIndexWatcher();
4582
- }
4583
4549
  let result;
4584
4550
  {
4585
4551
  // Pipeline mode: recreate PipelineDirectLLM wrapper with fast brain
@@ -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
@@ -147,43 +147,56 @@ async function executeTool(name, args, sessionId, workingDir, agentControl) {
147
147
  // SEARCH HELPERS
148
148
  // ============================================================
149
149
  async function executeSearch(phrases, sessionId, workingDir) {
150
- const { ripgrepSearch } = await import('./jsonl-search.js');
151
- const { getIndexPath, readFullContent } = await import('./summary-index.js');
152
- const indexPath = getIndexPath(sessionId, workingDir);
153
- if (indexPath) {
154
- const sections = [];
155
- const matchedRefs = [];
156
- const seenLines = new Set();
157
- for (const phrase of phrases.slice(0, 6)) {
158
- const results = ripgrepSearch(indexPath, phrase, { maxResults: 8, fromEnd: true, contextLines: 0 });
159
- const newResults = results.filter((r) => {
160
- const key = `${r.lineNumber}`;
161
- if (seenLines.has(key))
162
- return false;
163
- seenLines.add(key);
164
- return true;
165
- });
166
- if (newResults.length > 0) {
167
- sections.push(`["${phrase}" — ${newResults.length} matches]`);
168
- for (const r of newResults) {
169
- const parts = r.content.split('|');
170
- if (parts.length >= 6) {
171
- matchedRefs.push({ lineNum: parseInt(parts[0], 10), byteOffset: parseInt(parts[1], 10), source: parts[3] });
172
- sections.push(r.content);
173
- }
174
- }
150
+ // DB-FIRST: query the recall store (full untruncated text + hybrid keyword+vector).
151
+ // Vector/hybrid is PREFERRED; recall() degrades to keyword INSIDE when the vec table is
152
+ // empty or no embedder is available. This replaces the old ripgrep-over-search-index.txt
153
+ // two-step (grep summary → read JSONL by offset) with one call that already returns full text.
154
+ const { storeExists, openStore, recall } = await import('./session-store.js');
155
+ const dbPath = storeExists(sessionId, workingDir);
156
+ if (dbPath) {
157
+ let embed = undefined;
158
+ try {
159
+ if (process.env.OSBORN_EMBED !== '0') {
160
+ const { getEmbedder } = await import('./embedder.js');
161
+ // Tight timeout so a slow/cold embed never blows the fast-brain deadline → keyword.
162
+ embed = (await getEmbedder(2500)) ?? undefined;
175
163
  }
176
164
  }
177
- if (matchedRefs.length > 0) {
178
- try {
179
- const fullTexts = readFullContent(matchedRefs, sessionId, workingDir, undefined, 2000);
180
- if (fullTexts.length > 0)
181
- sections.push('', `[FULL CONTENT — ${fullTexts.length} entries]`, ...fullTexts);
165
+ catch { }
166
+ // Best-effort: a recall/decompress throw must degrade to the ripgrep fallback below,
167
+ // never collapse the whole fast-brain turn (mirrors buildRecallInjection's swallow).
168
+ let dbResult = null;
169
+ const db = openStore(dbPath, { readonly: true });
170
+ try {
171
+ const query = phrases.slice(0, 6).join(' ');
172
+ const hits = await recall(db, query, { mode: embed ? 'hybrid' : 'keyword', topK: 12, embed });
173
+ if (!hits.length) {
174
+ dbResult = `No matches for: ${phrases.join(', ')}`;
175
+ }
176
+ else {
177
+ const sections = [];
178
+ for (const h of hits) {
179
+ const src = `${h.source} L${h.lineNum} · ${h.msgType}${h.toolName ? `:${h.toolName}` : ''} · ${h.matchedBy}`;
180
+ let body = h.text.replace(/\n{3,}/g, '\n\n').trim();
181
+ if (body.length > 2000)
182
+ body = body.slice(0, 2000) + ' …';
183
+ sections.push(`[${src}]${h.ts ? ' ' + h.ts : ''}\n${body}`);
184
+ }
185
+ dbResult = sections.join('\n\n');
182
186
  }
183
- catch { }
184
187
  }
185
- return sections.length === 0 ? `No matches for: ${phrases.join(', ')}` : sections.join('\n');
188
+ catch (err) {
189
+ console.warn('[fast-brain] recall DB search failed, falling back to raw JSONL:', err?.message || err);
190
+ // dbResult stays null → fall through to the ripgrep fallback below
191
+ }
192
+ finally {
193
+ db.close();
194
+ }
195
+ if (dbResult !== null)
196
+ return dbResult;
186
197
  }
198
+ // FALLBACK: store not built yet (brand-new session before the first sweep) → raw-JSONL ripgrep.
199
+ const { ripgrepSearch } = await import('./jsonl-search.js');
187
200
  const { getSessionPaths } = await import('./session-access.js');
188
201
  const paths = getSessionPaths(sessionId, workingDir);
189
202
  if (!paths.exists)
@@ -199,31 +212,33 @@ async function executeSearch(phrases, sessionId, workingDir) {
199
212
  return sections.length > 0 ? sections.join('\n') : `No matches for: ${phrases.join(', ')}`;
200
213
  }
201
214
  async function getRecentEntries(sessionId, workingDir, _, count) {
202
- const { readFileSync } = await import('fs');
203
- const { getIndexPath, readFullContent } = await import('./summary-index.js');
204
- const indexPath = getIndexPath(sessionId, workingDir);
205
- if (!indexPath)
215
+ // Newest content rows straight from the recall DB (append-only, so highest id = latest).
216
+ const { storeExists, openStore, recentRows } = await import('./session-store.js');
217
+ const dbPath = storeExists(sessionId, workingDir);
218
+ if (!dbPath)
206
219
  return 'Index not built yet.';
207
- const content = readFileSync(indexPath, 'utf-8');
208
- const recentLines = content.split('\n').filter(Boolean).slice(-count);
209
- const refs = [];
210
- const summaries = [`[RECENT — last ${recentLines.length} entries]`];
211
- for (const line of recentLines) {
212
- summaries.push(line);
213
- const parts = line.split('|');
214
- if (parts.length >= 6) {
215
- refs.push({ lineNum: parseInt(parts[0], 10), byteOffset: parseInt(parts[1], 10), source: parts[3] });
220
+ const db = openStore(dbPath, { readonly: true });
221
+ try {
222
+ const rows = recentRows(db, count); // newest first
223
+ if (!rows.length)
224
+ return 'No entries yet.';
225
+ const out = [`[RECENT — last ${rows.length} entries, newest first]`];
226
+ for (const h of rows) {
227
+ const src = `${h.source} L${h.lineNum} · ${h.msgType}${h.toolName ? `:${h.toolName}` : ''}`;
228
+ let body = h.text.replace(/\n{3,}/g, '\n\n').trim();
229
+ if (body.length > 1500)
230
+ body = body.slice(0, 1500) + ' …';
231
+ out.push(`[${src}]${h.ts ? ' ' + h.ts : ''}\n${body}`);
216
232
  }
233
+ return out.join('\n\n');
217
234
  }
218
- if (refs.length > 0) {
219
- try {
220
- const fullTexts = readFullContent(refs, sessionId, workingDir, undefined, 1500);
221
- if (fullTexts.length > 0)
222
- summaries.push('', `[FULL CONTENT — ${fullTexts.length} entries]`, ...fullTexts);
223
- }
224
- catch { }
235
+ catch (err) {
236
+ console.warn('[fast-brain] recent-entries read failed:', err?.message || err);
237
+ return 'Could not read recent entries.';
238
+ }
239
+ finally {
240
+ db.close();
225
241
  }
226
- return summaries.join('\n');
227
242
  }
228
243
  // ============================================================
229
244
  // SYSTEM PROMPT
@@ -249,9 +264,11 @@ function buildSystemPrompt(chatHistory, researchContext) {
249
264
  `or use vague references ("that thing", "the error"). Interpret intent, not just words.`,
250
265
  ``,
251
266
  `== RESULTS FORMAT ==`,
252
- `Each line: lineNum|byteOffset|timestamp|source|msgType|summary`,
253
- ` source: "main" = conversation, "agent-XXXX" = sub-agent research`,
254
- `Full content sections have complete untruncated text.`,
267
+ `Each hit is a header line then its FULL untruncated text:`,
268
+ ` [<source> L<lineNum> · <msgType>[:<tool>] · <matchedBy>] <timestamp>`,
269
+ ` <full text of that message/tool result>`,
270
+ ` source: "main" = conversation, "agent-XXXX" = sub-agent research.`,
271
+ ` matchedBy: keyword | vector | both (how recall found it — vector/hybrid is preferred).`,
255
272
  ``,
256
273
  `== HOW TO SEARCH ==`,
257
274
  `Think about what words people ACTUALLY USED when this topic came up.`,
@@ -88,3 +88,11 @@ export interface RecallOpts {
88
88
  * Falls back to keyword automatically when no embeddings/embedder are available.
89
89
  */
90
90
  export declare function recall(db: Database.Database, query: string, opts?: RecallOpts): Promise<RecallHit[]>;
91
+ /**
92
+ * Return the most recent N content rows (highest id = latest, append-only order),
93
+ * newest first, as RecallHit-shaped objects. Powers fast-brain's "recent entries"
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.
97
+ */
98
+ export declare function recentRows(db: Database.Database, count: number, msgTypes?: string[]): RecallHit[];
@@ -571,3 +571,33 @@ export async function recall(db, query, opts) {
571
571
  }
572
572
  return hits;
573
573
  }
574
+ /**
575
+ * Return the most recent N content rows (highest id = latest, append-only order),
576
+ * newest first, as RecallHit-shaped objects. Powers fast-brain's "recent entries"
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.
580
+ */
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)];
586
+ const rows = db.prepare(`SELECT id, source, line_num, byte_offset, ts, msg_type, model, git_branch, cwd, tool_name, blob
587
+ FROM content ${filter} ORDER BY id DESC LIMIT ?`).all(...params);
588
+ return rows.map((row) => ({
589
+ id: row.id,
590
+ source: row.source,
591
+ lineNum: row.line_num,
592
+ byteOffset: row.byte_offset,
593
+ ts: row.ts,
594
+ msgType: row.msg_type,
595
+ model: row.model,
596
+ gitBranch: row.git_branch,
597
+ cwd: row.cwd,
598
+ toolName: row.tool_name,
599
+ text: decompress(row.blob),
600
+ score: 0,
601
+ matchedBy: 'keyword',
602
+ }));
603
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.228",
3
+ "version": "0.9.230",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {