osborn 0.9.211 → 0.9.213
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/browser-apply/SKILL.md +5 -0
- package/.claude/skills/browser-screen-recorder/SKILL.md +5 -0
- package/.claude/skills/deploy-workflow/SKILL.md +5 -0
- package/.claude/skills/ground-assumptions/SKILL.md +5 -0
- package/.claude/skills/markdown-to-pdf/SKILL.md +5 -0
- package/.claude/skills/meetings/SKILL.md +5 -0
- package/.claude/skills/pdf-to-markdown/SKILL.md +5 -0
- package/.claude/skills/playwright-browser/SKILL.md +5 -0
- package/.claude/skills/recall/SKILL.md +92 -0
- package/.claude/skills/send-media/SKILL.md +5 -0
- package/.claude/skills/shadcn/SKILL.md +5 -0
- package/.claude/skills/slack-readonly/SKILL.md +5 -0
- package/.claude/skills/voice-native-sync/SKILL.md +5 -0
- package/.claude/skills/youtube-transcript/SKILL.md +5 -0
- package/bin/recall.js +42 -0
- package/dist/claude-llm.d.ts +28 -0
- package/dist/claude-llm.js +256 -17
- package/dist/embedder.d.ts +23 -0
- package/dist/embedder.js +98 -0
- package/dist/pipeline-direct-llm.js +4 -0
- package/dist/prompts/grounding-recall.md +15 -0
- package/dist/prompts/recalled-context.md +7 -0
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +53 -0
- package/dist/recall-cli.d.ts +17 -0
- package/dist/recall-cli.js +140 -0
- package/dist/session-store.d.ts +81 -0
- package/dist/session-store.js +458 -0
- package/package.json +6 -2
- package/scripts/backfill-stores.ts +99 -0
- package/dist/prompts/skill-learner-prompt.md +0 -46
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-store.ts — Embedded per-session store that replaces the flat search-index.txt.
|
|
3
|
+
*
|
|
4
|
+
* WHY: search-index.txt kept only TRUNCATED one-line summaries (≈13% of the real
|
|
5
|
+
* message text) in a grep-only flat file. This stores the FULL untruncated text of
|
|
6
|
+
* every message — compressed — plus a hybrid (keyword + semantic) search index, in a
|
|
7
|
+
* single SQLite file per session.
|
|
8
|
+
*
|
|
9
|
+
* ARCHITECTURE (one file: {osbDir}/session.db):
|
|
10
|
+
* • content — full untruncated text + metadata (model, git_branch, cwd, tool_name,
|
|
11
|
+
* byte_offset for resume-UI targeted reads). Text is brotli-compressed
|
|
12
|
+
* (Node built-in zlib — no native compression extension needed).
|
|
13
|
+
* • fts — FTS5 contentless index (BM25 keyword search). rowid == content.id.
|
|
14
|
+
* Contentless is safe because sessions are APPEND-ONLY (no row ever
|
|
15
|
+
* changes), so FTS never needs the original text to delete/update.
|
|
16
|
+
* • vec — sqlite-vec int8[384] (semantic search). Populated only when an
|
|
17
|
+
* embedder is supplied; otherwise stays empty and queries degrade
|
|
18
|
+
* gracefully to keyword-only.
|
|
19
|
+
* • sources — per-source byte offsets for incremental write-through (resume).
|
|
20
|
+
* • meta — key/value: version, sessionId, embed model/dim, timestamps.
|
|
21
|
+
*
|
|
22
|
+
* Native deps: better-sqlite3 + sqlite-vec only (both ship prebuilt binaries).
|
|
23
|
+
* Compression is Node's built-in brotli — nothing to build.
|
|
24
|
+
*
|
|
25
|
+
* Incremental: mirrors summary-index.ts's proven byte-offset resume — each poll reads
|
|
26
|
+
* only the new bytes appended to the JSONL since the last stored offset.
|
|
27
|
+
*
|
|
28
|
+
* Store lives at: ~/.claude/projects/{slug}/osb/{sessionId}/session.db
|
|
29
|
+
*/
|
|
30
|
+
import Database from 'better-sqlite3';
|
|
31
|
+
import * as sqliteVec from 'sqlite-vec';
|
|
32
|
+
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'zlib';
|
|
33
|
+
import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from 'fs';
|
|
34
|
+
import { join, basename } from 'path';
|
|
35
|
+
import { homedir } from 'os';
|
|
36
|
+
import { getSessionPaths, getSessionSubAgents, projectPathToSlug } from './session-access.js';
|
|
37
|
+
// ============================================================
|
|
38
|
+
// CONFIG / TYPES
|
|
39
|
+
// ============================================================
|
|
40
|
+
export const STORE_VERSION = 1;
|
|
41
|
+
export const EMBED_DIM = 384;
|
|
42
|
+
// ============================================================
|
|
43
|
+
// PATHS
|
|
44
|
+
// ============================================================
|
|
45
|
+
/** osb index dir for a session: ~/.claude/projects/{slug}/osb/{sessionId}/ */
|
|
46
|
+
function getOsbDir(sessionId, workingDir) {
|
|
47
|
+
const claudeDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
48
|
+
const slug = projectPathToSlug(workingDir);
|
|
49
|
+
return join(claudeDir, 'projects', slug, 'osb', sessionId);
|
|
50
|
+
}
|
|
51
|
+
export function getStorePath(sessionId, workingDir) {
|
|
52
|
+
return join(getOsbDir(sessionId, workingDir), 'session.db');
|
|
53
|
+
}
|
|
54
|
+
/** Returns the store path if it exists and is non-empty, else null. */
|
|
55
|
+
export function storeExists(sessionId, workingDir) {
|
|
56
|
+
const p = getStorePath(sessionId, workingDir);
|
|
57
|
+
return existsSync(p) && statSync(p).size > 0 ? p : null;
|
|
58
|
+
}
|
|
59
|
+
// ============================================================
|
|
60
|
+
// DB OPEN / SCHEMA
|
|
61
|
+
// ============================================================
|
|
62
|
+
export function openStore(dbPath) {
|
|
63
|
+
const db = new Database(dbPath);
|
|
64
|
+
sqliteVec.load(db);
|
|
65
|
+
db.pragma('journal_mode = WAL');
|
|
66
|
+
db.pragma('synchronous = NORMAL');
|
|
67
|
+
db.exec(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS content (
|
|
69
|
+
id INTEGER PRIMARY KEY,
|
|
70
|
+
source TEXT NOT NULL,
|
|
71
|
+
line_num INTEGER NOT NULL,
|
|
72
|
+
byte_offset INTEGER NOT NULL,
|
|
73
|
+
ts TEXT,
|
|
74
|
+
msg_type TEXT NOT NULL,
|
|
75
|
+
model TEXT,
|
|
76
|
+
git_branch TEXT,
|
|
77
|
+
cwd TEXT,
|
|
78
|
+
tool_name TEXT,
|
|
79
|
+
blob BLOB NOT NULL
|
|
80
|
+
);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_content_source ON content(source);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_content_type ON content(msg_type);
|
|
83
|
+
|
|
84
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(text, content='');
|
|
85
|
+
|
|
86
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vec USING vec0(rowid INTEGER PRIMARY KEY, embedding int8[${EMBED_DIM}]);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
89
|
+
source TEXT PRIMARY KEY,
|
|
90
|
+
jsonl_path TEXT,
|
|
91
|
+
byte_offset INTEGER NOT NULL DEFAULT 0,
|
|
92
|
+
line_count INTEGER NOT NULL DEFAULT 0
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
96
|
+
`);
|
|
97
|
+
const setMeta = db.prepare('INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)');
|
|
98
|
+
setMeta.run('version', String(STORE_VERSION));
|
|
99
|
+
setMeta.run('embed_dim', String(EMBED_DIM));
|
|
100
|
+
return db;
|
|
101
|
+
}
|
|
102
|
+
// ============================================================
|
|
103
|
+
// COMPRESSION
|
|
104
|
+
// ============================================================
|
|
105
|
+
const BROTLI_OPTS = { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } };
|
|
106
|
+
function compress(text) {
|
|
107
|
+
return brotliCompressSync(Buffer.from(text, 'utf-8'), BROTLI_OPTS);
|
|
108
|
+
}
|
|
109
|
+
function decompress(blob) {
|
|
110
|
+
return brotliDecompressSync(blob).toString('utf-8');
|
|
111
|
+
}
|
|
112
|
+
// ============================================================
|
|
113
|
+
// EXTRACTION (FULL text — no truncation)
|
|
114
|
+
// ============================================================
|
|
115
|
+
/** Extract all indexable records from one parsed JSONL object, with FULL text. */
|
|
116
|
+
function extractRecords(raw) {
|
|
117
|
+
if (raw?.isMeta)
|
|
118
|
+
return [];
|
|
119
|
+
const type = raw?.type;
|
|
120
|
+
if (!type || type === 'queue-operation' || type === 'file-history-snapshot' ||
|
|
121
|
+
type === 'system' || type === 'progress')
|
|
122
|
+
return [];
|
|
123
|
+
// ── user (regular text OR tool_result wrapper) ──
|
|
124
|
+
if (type === 'user') {
|
|
125
|
+
const content = raw.message?.content;
|
|
126
|
+
if (!Array.isArray(content))
|
|
127
|
+
return [];
|
|
128
|
+
if (content[0]?.type === 'tool_result') {
|
|
129
|
+
const tr = content[0];
|
|
130
|
+
const resultText = typeof tr.content === 'string'
|
|
131
|
+
? tr.content
|
|
132
|
+
: Array.isArray(tr.content)
|
|
133
|
+
? tr.content.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')
|
|
134
|
+
: '';
|
|
135
|
+
if (!resultText.trim())
|
|
136
|
+
return [];
|
|
137
|
+
const toolName = raw.toolUseResult?.name || null;
|
|
138
|
+
return [{ msgType: 'tool_result', model: null, toolName, text: resultText }];
|
|
139
|
+
}
|
|
140
|
+
const texts = [];
|
|
141
|
+
for (const block of content) {
|
|
142
|
+
if (block?.type === 'text' && block.text)
|
|
143
|
+
texts.push(block.text);
|
|
144
|
+
}
|
|
145
|
+
if (!texts.length)
|
|
146
|
+
return [];
|
|
147
|
+
return [{ msgType: 'user', model: null, toolName: null, text: texts.join('\n') }];
|
|
148
|
+
}
|
|
149
|
+
// ── assistant (text + thinking + tool_use blocks) ──
|
|
150
|
+
if (type === 'assistant') {
|
|
151
|
+
const content = raw.message?.content;
|
|
152
|
+
if (!Array.isArray(content))
|
|
153
|
+
return [];
|
|
154
|
+
const model = raw.message?.model || null;
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const block of content) {
|
|
157
|
+
if (block?.type === 'text' && block.text?.trim()) {
|
|
158
|
+
out.push({ msgType: 'assistant', model, toolName: null, text: block.text });
|
|
159
|
+
}
|
|
160
|
+
if (block?.type === 'thinking' && block.thinking?.trim()) {
|
|
161
|
+
out.push({ msgType: 'thinking', model, toolName: null, text: block.thinking });
|
|
162
|
+
}
|
|
163
|
+
if (block?.type === 'tool_use') {
|
|
164
|
+
out.push({ msgType: 'tool_use', model, toolName: block.name || null, text: formatToolUse(block.name, block.input) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
/** Render a tool_use into searchable text (full params, not truncated). */
|
|
172
|
+
function formatToolUse(name, input) {
|
|
173
|
+
if (!input)
|
|
174
|
+
return name;
|
|
175
|
+
try {
|
|
176
|
+
switch (name) {
|
|
177
|
+
case 'Read': return `Read ${input.file_path || ''}${input.offset ? ` offset=${input.offset}` : ''}`;
|
|
178
|
+
case 'Write': return `Write ${input.file_path || ''}\n${input.content || ''}`;
|
|
179
|
+
case 'Edit': return `Edit ${input.file_path || ''}\nOLD:\n${input.old_string || ''}\nNEW:\n${input.new_string || ''}`;
|
|
180
|
+
case 'Grep': return `Grep pattern="${input.pattern || ''}" path="${input.path || ''}"`;
|
|
181
|
+
case 'Glob': return `Glob pattern="${input.pattern || ''}"${input.path ? ` path="${input.path}"` : ''}`;
|
|
182
|
+
case 'Bash': return `Bash ${input.command || ''}${input.description ? ` # ${input.description}` : ''}`;
|
|
183
|
+
case 'WebSearch': return `WebSearch ${input.query || ''}`;
|
|
184
|
+
case 'WebFetch': return `WebFetch ${input.url || ''} ${input.prompt || ''}`;
|
|
185
|
+
case 'Task': return `Task ${input.description || ''}\n${input.prompt || ''}`;
|
|
186
|
+
case 'TodoWrite': return `TodoWrite ${JSON.stringify(input.todos || [])}`;
|
|
187
|
+
default: return `${name} ${JSON.stringify(input)}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return name;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ============================================================
|
|
195
|
+
// INCREMENTAL WRITE-THROUGH
|
|
196
|
+
// ============================================================
|
|
197
|
+
/** Count '\n' bytes up to an offset — for line-number continuity on resume. */
|
|
198
|
+
function countLines(filePath, upToBytes) {
|
|
199
|
+
if (upToBytes <= 0)
|
|
200
|
+
return 0;
|
|
201
|
+
const cap = Math.min(upToBytes, 4 * 1024 * 1024);
|
|
202
|
+
const buf = Buffer.alloc(cap);
|
|
203
|
+
const fd = openSync(filePath, 'r');
|
|
204
|
+
const n = readSync(fd, buf, 0, cap, Math.max(0, upToBytes - cap));
|
|
205
|
+
closeSync(fd);
|
|
206
|
+
let c = 0;
|
|
207
|
+
for (let i = 0; i < n; i++)
|
|
208
|
+
if (buf[i] === 10)
|
|
209
|
+
c++;
|
|
210
|
+
return c;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Ingest new bytes from a single JSONL file into the store.
|
|
214
|
+
* Reads only from `fromOffset` forward. Returns rows inserted + new offset.
|
|
215
|
+
*/
|
|
216
|
+
function ingestFile(db, filePath, source, fromOffset) {
|
|
217
|
+
const inserted = [];
|
|
218
|
+
if (!existsSync(filePath))
|
|
219
|
+
return { inserted, newOffset: fromOffset, linesProcessed: 0 };
|
|
220
|
+
const fileSize = statSync(filePath).size;
|
|
221
|
+
if (fromOffset >= fileSize)
|
|
222
|
+
return { inserted, newOffset: fromOffset, linesProcessed: 0 };
|
|
223
|
+
const buf = Buffer.alloc(fileSize - fromOffset);
|
|
224
|
+
const fd = openSync(filePath, 'r');
|
|
225
|
+
readSync(fd, buf, 0, buf.length, fromOffset);
|
|
226
|
+
closeSync(fd);
|
|
227
|
+
const rawLines = buf.toString('utf-8').split('\n');
|
|
228
|
+
let lineNum = fromOffset === 0 ? 1 : countLines(filePath, fromOffset) + 1;
|
|
229
|
+
let cursor = fromOffset;
|
|
230
|
+
let linesProcessed = 0;
|
|
231
|
+
const insContent = db.prepare(`INSERT INTO content (source, line_num, byte_offset, ts, msg_type, model, git_branch, cwd, tool_name, blob)
|
|
232
|
+
VALUES (@source, @line_num, @byte_offset, @ts, @msg_type, @model, @git_branch, @cwd, @tool_name, @blob)`);
|
|
233
|
+
const insFts = db.prepare('INSERT INTO fts (rowid, text) VALUES (?, ?)');
|
|
234
|
+
for (const rawLine of rawLines) {
|
|
235
|
+
const lineByteOffset = cursor;
|
|
236
|
+
cursor += Buffer.byteLength(rawLine, 'utf-8') + 1;
|
|
237
|
+
if (!rawLine.trim()) {
|
|
238
|
+
lineNum++;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
linesProcessed++;
|
|
242
|
+
try {
|
|
243
|
+
const obj = JSON.parse(rawLine);
|
|
244
|
+
const ts = obj.timestamp ? new Date(obj.timestamp).toISOString() : null;
|
|
245
|
+
const gitBranch = obj.gitBranch || null;
|
|
246
|
+
const cwd = obj.cwd || null;
|
|
247
|
+
for (const rec of extractRecords(obj)) {
|
|
248
|
+
const info = insContent.run({
|
|
249
|
+
source,
|
|
250
|
+
line_num: lineNum,
|
|
251
|
+
byte_offset: lineByteOffset,
|
|
252
|
+
ts,
|
|
253
|
+
msg_type: rec.msgType,
|
|
254
|
+
model: rec.model,
|
|
255
|
+
git_branch: gitBranch,
|
|
256
|
+
cwd,
|
|
257
|
+
tool_name: rec.toolName,
|
|
258
|
+
blob: compress(rec.text),
|
|
259
|
+
});
|
|
260
|
+
const id = Number(info.lastInsertRowid);
|
|
261
|
+
insFts.run(id, rec.text);
|
|
262
|
+
inserted.push({ id, text: rec.text });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// skip unparseable line
|
|
267
|
+
}
|
|
268
|
+
lineNum++;
|
|
269
|
+
}
|
|
270
|
+
return { inserted, newOffset: fileSize, linesProcessed };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Build or update the session store incrementally.
|
|
274
|
+
* Ingests the main JSONL + all sub-agent JSONLs from their last stored offsets.
|
|
275
|
+
* If `embed` is supplied, newly-inserted rows are embedded into the vec table
|
|
276
|
+
* (best-effort — embedding failure never blocks the keyword write).
|
|
277
|
+
*/
|
|
278
|
+
export async function updateSessionStore(sessionId, workingDir, opts) {
|
|
279
|
+
const paths = getSessionPaths(sessionId, workingDir);
|
|
280
|
+
const osbDir = getOsbDir(sessionId, workingDir);
|
|
281
|
+
mkdirSync(osbDir, { recursive: true });
|
|
282
|
+
const dbPath = getStorePath(sessionId, workingDir);
|
|
283
|
+
const db = openStore(dbPath);
|
|
284
|
+
try {
|
|
285
|
+
db.prepare('INSERT OR REPLACE INTO meta(key,value) VALUES (?,?)').run('sessionId', sessionId);
|
|
286
|
+
// Resolve every source file: main + sub-agents (both discovery paths).
|
|
287
|
+
const sources = [];
|
|
288
|
+
if (existsSync(paths.conversation))
|
|
289
|
+
sources.push({ source: 'main', path: paths.conversation });
|
|
290
|
+
for (const f of paths.subagents) {
|
|
291
|
+
const key = basename(f, '.jsonl').replace('agent-', '').substring(0, 8);
|
|
292
|
+
sources.push({ source: `agent-${key}`, path: f });
|
|
293
|
+
}
|
|
294
|
+
for (const a of getSessionSubAgents(sessionId, workingDir)) {
|
|
295
|
+
if (!a.agentFileExists)
|
|
296
|
+
continue;
|
|
297
|
+
const src = `agent-${a.agentId.substring(0, 8)}`;
|
|
298
|
+
if (!sources.some(s => s.source === src))
|
|
299
|
+
sources.push({ source: src, path: a.agentFile });
|
|
300
|
+
}
|
|
301
|
+
const getSrc = db.prepare('SELECT byte_offset, line_count FROM sources WHERE source = ?');
|
|
302
|
+
const upSrc = db.prepare(`INSERT INTO sources(source, jsonl_path, byte_offset, line_count) VALUES (@source,@path,@off,@lines)
|
|
303
|
+
ON CONFLICT(source) DO UPDATE SET jsonl_path=@path, byte_offset=@off, line_count=@lines`);
|
|
304
|
+
const freshRows = [];
|
|
305
|
+
const txn = db.transaction(() => {
|
|
306
|
+
for (const s of sources) {
|
|
307
|
+
const prev = getSrc.get(s.source);
|
|
308
|
+
const fromOffset = prev?.byte_offset ?? 0;
|
|
309
|
+
const res = ingestFile(db, s.path, s.source, fromOffset);
|
|
310
|
+
if (res.inserted.length || res.newOffset !== fromOffset) {
|
|
311
|
+
upSrc.run({
|
|
312
|
+
source: s.source, path: s.path, off: res.newOffset,
|
|
313
|
+
lines: (prev?.line_count ?? 0) + res.linesProcessed,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
freshRows.push(...res.inserted);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
txn();
|
|
320
|
+
opts?.onProgress?.(`content: +${freshRows.length} rows across ${sources.length} sources`);
|
|
321
|
+
// ── Embeddings (best-effort, never blocks the keyword layer) ──
|
|
322
|
+
let embeddedRows = 0;
|
|
323
|
+
if (opts?.embed && freshRows.length) {
|
|
324
|
+
try {
|
|
325
|
+
embeddedRows = await embedRows(db, freshRows, opts.embed, opts.onProgress);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
opts?.onProgress?.(`embed skipped: ${err?.message || err}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
db.prepare('INSERT OR REPLACE INTO meta(key,value) VALUES (?,?)').run('updatedAt', new Date().toISOString());
|
|
332
|
+
const totalRows = db.prepare('SELECT COUNT(*) c FROM content').get().c;
|
|
333
|
+
return {
|
|
334
|
+
totalRows,
|
|
335
|
+
newRows: freshRows.length,
|
|
336
|
+
embeddedRows,
|
|
337
|
+
bytes: statSync(dbPath).size,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
db.close();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/** Embed fresh rows in batches and write int8 vectors into the vec table. */
|
|
345
|
+
async function embedRows(db, rows, embed, onProgress) {
|
|
346
|
+
// sqlite-vec needs int8 vectors wrapped in vec_int8(), and better-sqlite3 must bind
|
|
347
|
+
// the rowid as a BigInt (a plain JS number binds as REAL, which vec0 rejects as a PK).
|
|
348
|
+
const insVec = db.prepare('INSERT OR REPLACE INTO vec(rowid, embedding) VALUES (?, vec_int8(?))');
|
|
349
|
+
const BATCH = 64;
|
|
350
|
+
let done = 0;
|
|
351
|
+
for (let i = 0; i < rows.length; i += BATCH) {
|
|
352
|
+
const batch = rows.slice(i, i + BATCH);
|
|
353
|
+
// Cap embedding input length — long tool outputs blow up the model with no recall gain.
|
|
354
|
+
const vectors = await embed(batch.map(r => r.text.slice(0, 8000)));
|
|
355
|
+
if (!vectors)
|
|
356
|
+
break; // embedder unavailable — leave vec empty (keyword-only)
|
|
357
|
+
const writeBatch = db.transaction(() => {
|
|
358
|
+
for (let j = 0; j < batch.length; j++) {
|
|
359
|
+
const v = vectors[j];
|
|
360
|
+
if (v)
|
|
361
|
+
insVec.run(BigInt(batch[j].id), Buffer.from(v.buffer, v.byteOffset, v.byteLength));
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
writeBatch();
|
|
365
|
+
done += batch.length;
|
|
366
|
+
}
|
|
367
|
+
if (done) {
|
|
368
|
+
db.prepare('INSERT OR REPLACE INTO meta(key,value) VALUES (?,?)').run('embedded', 'true');
|
|
369
|
+
onProgress?.(`vec: +${done} embeddings`);
|
|
370
|
+
}
|
|
371
|
+
return done;
|
|
372
|
+
}
|
|
373
|
+
// ============================================================
|
|
374
|
+
// RECALL (hybrid keyword + vector, RRF fusion)
|
|
375
|
+
// ============================================================
|
|
376
|
+
/** Escape a free-text query into a safe FTS5 MATCH expression (OR of quoted terms). */
|
|
377
|
+
function toFtsMatch(query) {
|
|
378
|
+
const terms = query.match(/[\p{L}\p{N}_]+/gu) || [];
|
|
379
|
+
if (!terms.length)
|
|
380
|
+
return '""';
|
|
381
|
+
return terms.map(t => `"${t}"`).join(' OR ');
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Recall the most relevant messages for a query.
|
|
385
|
+
* hybrid → FTS (BM25) + vec (cosine), fused with Reciprocal Rank Fusion.
|
|
386
|
+
* keyword → FTS only. vector → vec only (needs an embedder).
|
|
387
|
+
* Falls back to keyword automatically when no embeddings/embedder are available.
|
|
388
|
+
*/
|
|
389
|
+
export async function recall(db, query, opts) {
|
|
390
|
+
const mode = opts?.mode ?? 'hybrid';
|
|
391
|
+
const topK = opts?.topK ?? 8;
|
|
392
|
+
const pool = Math.max(topK * 4, 24);
|
|
393
|
+
const hasVec = db.prepare('SELECT COUNT(*) c FROM vec').get().c > 0;
|
|
394
|
+
const wantVec = (mode === 'hybrid' || mode === 'vector') && hasVec && !!opts?.embed;
|
|
395
|
+
// ── keyword ranks ──
|
|
396
|
+
const kw = new Map(); // id → rank position (0-based)
|
|
397
|
+
if (mode !== 'vector') {
|
|
398
|
+
try {
|
|
399
|
+
const match = toFtsMatch(query);
|
|
400
|
+
const rows = db.prepare('SELECT rowid AS id FROM fts WHERE fts MATCH ? ORDER BY rank LIMIT ?').all(match, pool);
|
|
401
|
+
rows.forEach((r, i) => kw.set(r.id, i));
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// malformed match — ignore keyword leg
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// ── vector ranks ──
|
|
408
|
+
const vec = new Map();
|
|
409
|
+
if (wantVec) {
|
|
410
|
+
const qvecs = await opts.embed([query]);
|
|
411
|
+
const qv = qvecs?.[0];
|
|
412
|
+
if (qv) {
|
|
413
|
+
const rows = db.prepare('SELECT rowid AS id FROM vec WHERE embedding MATCH vec_int8(?) ORDER BY distance LIMIT ?').all(Buffer.from(qv.buffer, qv.byteOffset, qv.byteLength), pool);
|
|
414
|
+
rows.forEach((r, i) => vec.set(r.id, i));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// ── Reciprocal Rank Fusion ──
|
|
418
|
+
const K = 60;
|
|
419
|
+
const fused = new Map();
|
|
420
|
+
for (const [id, rankPos] of kw) {
|
|
421
|
+
const cur = fused.get(id) || { score: 0, kw: false, vec: false };
|
|
422
|
+
cur.score += 1 / (K + rankPos);
|
|
423
|
+
cur.kw = true;
|
|
424
|
+
fused.set(id, cur);
|
|
425
|
+
}
|
|
426
|
+
for (const [id, rankPos] of vec) {
|
|
427
|
+
const cur = fused.get(id) || { score: 0, kw: false, vec: false };
|
|
428
|
+
cur.score += 1 / (K + rankPos);
|
|
429
|
+
cur.vec = true;
|
|
430
|
+
fused.set(id, cur);
|
|
431
|
+
}
|
|
432
|
+
const ranked = [...fused.entries()].sort((a, b) => b[1].score - a[1].score).slice(0, topK);
|
|
433
|
+
if (!ranked.length)
|
|
434
|
+
return [];
|
|
435
|
+
const getRow = db.prepare('SELECT id, source, line_num, byte_offset, ts, msg_type, model, git_branch, cwd, tool_name, blob FROM content WHERE id = ?');
|
|
436
|
+
const hits = [];
|
|
437
|
+
for (const [id, meta] of ranked) {
|
|
438
|
+
const row = getRow.get(id);
|
|
439
|
+
if (!row)
|
|
440
|
+
continue;
|
|
441
|
+
hits.push({
|
|
442
|
+
id: row.id,
|
|
443
|
+
source: row.source,
|
|
444
|
+
lineNum: row.line_num,
|
|
445
|
+
byteOffset: row.byte_offset,
|
|
446
|
+
ts: row.ts,
|
|
447
|
+
msgType: row.msg_type,
|
|
448
|
+
model: row.model,
|
|
449
|
+
gitBranch: row.git_branch,
|
|
450
|
+
cwd: row.cwd,
|
|
451
|
+
toolName: row.tool_name,
|
|
452
|
+
text: decompress(row.blob),
|
|
453
|
+
score: meta.score,
|
|
454
|
+
matchedBy: meta.kw && meta.vec ? 'both' : meta.vec ? 'vector' : 'keyword',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return hits;
|
|
458
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "osborn",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.213",
|
|
4
4
|
"description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"osborn": "bin/cli.js"
|
|
7
|
+
"osborn": "bin/cli.js",
|
|
8
|
+
"osborn-recall": "bin/recall.js"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"dev": "tsx src/index.ts",
|
|
@@ -45,12 +46,15 @@
|
|
|
45
46
|
"@smithery/api": "^0.48.0",
|
|
46
47
|
"@types/diff": "^8.0.0",
|
|
47
48
|
"@vscode/ripgrep": "^1.17.1",
|
|
49
|
+
"@xenova/transformers": "^2.17.2",
|
|
50
|
+
"better-sqlite3": "^13.0.3",
|
|
48
51
|
"diff": "^8.0.4",
|
|
49
52
|
"dotenv": "^16.4.0",
|
|
50
53
|
"http-proxy": "^1.18.1",
|
|
51
54
|
"livekit-server-sdk": "^2.15.0",
|
|
52
55
|
"minisearch": "^7.2.0",
|
|
53
56
|
"node-pty": "^1.1.0",
|
|
57
|
+
"sqlite-vec": "^0.1.9",
|
|
54
58
|
"tsx": "^4.0.0",
|
|
55
59
|
"ws": "^8.19.0",
|
|
56
60
|
"yaml": "^2.3.0",
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backfill-stores.ts — Build a session.db for every session that has a search-index.txt.
|
|
3
|
+
*
|
|
4
|
+
* Walks ~/.claude/projects/{slug}/osb/{sessionId}/ and, for each session that already has
|
|
5
|
+
* a search-index.txt (the legacy flat index), builds/updates the new embedded session.db
|
|
6
|
+
* (full text + FTS5 + sqlite-vec int8). Idempotent — resumes from stored byte offsets, so
|
|
7
|
+
* re-running only ingests new JSONL bytes.
|
|
8
|
+
*
|
|
9
|
+
* Runs locally or on a Fly machine (where sessions actually live).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* npx tsx scripts/backfill-stores.ts [--no-embed] [--slug <slug>] [--limit N] [--all-sessions]
|
|
13
|
+
*
|
|
14
|
+
* --no-embed keyword-only (skip MiniLM). Fast; vec layer stays empty.
|
|
15
|
+
* --slug <slug> only this project slug (e.g. -workspace).
|
|
16
|
+
* --limit N cap number of sessions processed.
|
|
17
|
+
* --all-sessions target every {sessionId}.jsonl, not just those with a search-index.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, statSync } from 'fs'
|
|
21
|
+
import { join } from 'path'
|
|
22
|
+
import { homedir } from 'os'
|
|
23
|
+
import { updateSessionStore } from '../src/session-store.js'
|
|
24
|
+
import { getEmbedder } from '../src/embedder.js'
|
|
25
|
+
|
|
26
|
+
function claudeDir(): string {
|
|
27
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Target { slug: string; sessionId: string; jsonl: string }
|
|
31
|
+
|
|
32
|
+
/** Discover (slug, sessionId) pairs to backfill. slug doubles as workingDir (round-trips). */
|
|
33
|
+
function discover(opts: { slug?: string; allSessions?: boolean }): Target[] {
|
|
34
|
+
const projects = join(claudeDir(), 'projects')
|
|
35
|
+
if (!existsSync(projects)) return []
|
|
36
|
+
const slugs = (opts.slug ? [opts.slug] : readdirSync(projects))
|
|
37
|
+
.filter(s => existsSync(join(projects, s)) && statSync(join(projects, s)).isDirectory())
|
|
38
|
+
|
|
39
|
+
const targets: Target[] = []
|
|
40
|
+
for (const slug of slugs) {
|
|
41
|
+
const base = join(projects, slug)
|
|
42
|
+
if (opts.allSessions) {
|
|
43
|
+
// every {sessionId}.jsonl at the project root
|
|
44
|
+
for (const f of readdirSync(base)) {
|
|
45
|
+
if (f.endsWith('.jsonl')) {
|
|
46
|
+
const sid = f.slice(0, -'.jsonl'.length)
|
|
47
|
+
targets.push({ slug, sessionId: sid, jsonl: join(base, f) })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
// only sessions that already have osb/{sid}/search-index.txt
|
|
52
|
+
const osb = join(base, 'osb')
|
|
53
|
+
if (!existsSync(osb)) continue
|
|
54
|
+
for (const sid of readdirSync(osb)) {
|
|
55
|
+
if (existsSync(join(osb, sid, 'search-index.txt'))) {
|
|
56
|
+
targets.push({ slug, sessionId: sid, jsonl: join(base, `${sid}.jsonl`) })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return targets
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
const argv = process.argv.slice(2)
|
|
66
|
+
const has = (f: string) => argv.includes(f)
|
|
67
|
+
const val = (f: string) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined }
|
|
68
|
+
|
|
69
|
+
const noEmbed = has('--no-embed')
|
|
70
|
+
const allSessions = has('--all-sessions')
|
|
71
|
+
const slug = val('--slug')
|
|
72
|
+
const limit = val('--limit') ? parseInt(val('--limit')!, 10) : Infinity
|
|
73
|
+
|
|
74
|
+
let targets = discover({ slug, allSessions })
|
|
75
|
+
if (targets.length > limit) targets = targets.slice(0, limit)
|
|
76
|
+
|
|
77
|
+
console.log(`backfill: ${targets.length} session(s) [${allSessions ? 'all-sessions' : 'has-search-index'}]${noEmbed ? ' keyword-only' : ' hybrid'}`)
|
|
78
|
+
if (!targets.length) { console.log('nothing to do'); return }
|
|
79
|
+
|
|
80
|
+
const embed = noEmbed ? null : await getEmbedder()
|
|
81
|
+
if (!noEmbed && !embed) console.log(' (embedder unavailable — proceeding keyword-only)')
|
|
82
|
+
|
|
83
|
+
let ok = 0, failed = 0, totalNew = 0, totalEmbedded = 0
|
|
84
|
+
const t0 = Date.now()
|
|
85
|
+
for (const [i, t] of targets.entries()) {
|
|
86
|
+
if (!existsSync(t.jsonl)) { console.log(` [${i + 1}/${targets.length}] SKIP ${t.sessionId} (no jsonl)`); continue }
|
|
87
|
+
try {
|
|
88
|
+
const stats = await updateSessionStore(t.sessionId, t.slug, { embed: embed ?? undefined })
|
|
89
|
+
ok++; totalNew += stats.newRows; totalEmbedded += stats.embeddedRows
|
|
90
|
+
console.log(` [${i + 1}/${targets.length}] ${t.sessionId} +${stats.newRows} rows, ${stats.totalRows} total, ${(stats.bytes / 1024 / 1024).toFixed(2)}MB, embedded=${stats.embeddedRows}`)
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
failed++
|
|
93
|
+
console.log(` [${i + 1}/${targets.length}] FAIL ${t.sessionId}: ${err?.message || err}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
console.log(`\ndone: ${ok} ok, ${failed} failed, +${totalNew} rows, ${totalEmbedded} embedded, ${((Date.now() - t0) / 1000).toFixed(1)}s`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
main().catch(err => { console.error('backfill error:', err); process.exit(1) })
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
You are analyzing a voice AI assistant conversation to extract behavioral learnings that should persist across sessions.
|
|
2
|
-
|
|
3
|
-
The conversation is between a user and "Osborn" — a voice AI thinking partner. Your job is to identify:
|
|
4
|
-
|
|
5
|
-
1. USER CORRECTIONS — things the user explicitly told the agent to stop doing or start doing differently
|
|
6
|
-
2. USER PREFERENCES — recurring patterns in how the user wants to work (tools, approaches, communication style)
|
|
7
|
-
3. DOMAIN KNOWLEDGE — specific technical facts learned during the session (API behaviors, selectors, platform quirks, vendor-specific details)
|
|
8
|
-
4. EFFECTIVE PATTERNS — approaches that worked well and the user confirmed or accepted without pushback
|
|
9
|
-
5. ANTI-PATTERNS — approaches that failed, got the user frustrated, or had to be abandoned
|
|
10
|
-
|
|
11
|
-
For each item, include:
|
|
12
|
-
- The specific learning (concrete, actionable)
|
|
13
|
-
- Brief context for WHY (so future sessions can judge if it still applies)
|
|
14
|
-
- Confidence level: HIGH (user explicitly stated it), MEDIUM (inferred from user behavior), LOW (observed but not confirmed)
|
|
15
|
-
|
|
16
|
-
Output as markdown in this exact format:
|
|
17
|
-
|
|
18
|
-
```markdown
|
|
19
|
-
# Session Learnings — {date}
|
|
20
|
-
|
|
21
|
-
## User Corrections (HIGH confidence)
|
|
22
|
-
- {correction}: {context}
|
|
23
|
-
|
|
24
|
-
## User Preferences (MEDIUM-HIGH confidence)
|
|
25
|
-
- {preference}: {context}
|
|
26
|
-
|
|
27
|
-
## Domain Knowledge Learned (varies)
|
|
28
|
-
- [{confidence}] {fact}: {how it was verified}
|
|
29
|
-
|
|
30
|
-
## Effective Patterns (MEDIUM confidence)
|
|
31
|
-
- {pattern}: {when it worked}
|
|
32
|
-
|
|
33
|
-
## Anti-Patterns to Avoid (HIGH confidence)
|
|
34
|
-
- {anti-pattern}: {what went wrong}
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Be SELECTIVE. Only include items that are:
|
|
38
|
-
- Generalizable to future sessions (not one-off task details)
|
|
39
|
-
- Actionable (the agent can actually change behavior based on this)
|
|
40
|
-
- Non-obvious (things the agent wouldn't know from its system prompt alone)
|
|
41
|
-
|
|
42
|
-
Do NOT include:
|
|
43
|
-
- Task-specific details (file paths, variable names, specific code changes)
|
|
44
|
-
- Things already in the system prompt
|
|
45
|
-
- Trivial confirmations or greetings
|
|
46
|
-
- Speculative patterns not grounded in the conversation
|