osborn 0.9.228 → 0.9.229

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.
@@ -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,9 @@ 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
+ */
96
+ export declare function recentRows(db: Database.Database, count: number): RecallHit[];
@@ -571,3 +571,27 @@ 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
+ */
579
+ export function recentRows(db, count) {
580
+ 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));
582
+ return rows.map((row) => ({
583
+ id: row.id,
584
+ source: row.source,
585
+ lineNum: row.line_num,
586
+ byteOffset: row.byte_offset,
587
+ ts: row.ts,
588
+ msgType: row.msg_type,
589
+ model: row.model,
590
+ gitBranch: row.git_branch,
591
+ cwd: row.cwd,
592
+ toolName: row.tool_name,
593
+ text: decompress(row.blob),
594
+ score: 0,
595
+ matchedBy: 'keyword',
596
+ }));
597
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.228",
3
+ "version": "0.9.229",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {