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
package/dist/claude-llm.js
CHANGED
|
@@ -11,8 +11,10 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
12
12
|
import { saveSessionMetadata, getSessionWorkspace } from './config.js';
|
|
13
13
|
import { statusManager } from './status-manager.js';
|
|
14
|
-
import { getResearchSystemPrompt, getDirectModeResearchPrompt } from './prompts.js';
|
|
14
|
+
import { getResearchSystemPrompt, getDirectModeResearchPrompt, getGroundingBlock, getRecalledContextBlock } from './prompts.js';
|
|
15
15
|
import { getIndexPath } from './summary-index.js';
|
|
16
|
+
import { openStore, recall, storeExists, updateSessionStore, getStorePath } from './session-store.js';
|
|
17
|
+
import { getEmbedder } from './embedder.js';
|
|
16
18
|
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
17
19
|
import { join, dirname, resolve, basename } from 'node:path';
|
|
18
20
|
import { fileURLToPath } from 'node:url';
|
|
@@ -20,6 +22,70 @@ import { homedir } from 'node:os';
|
|
|
20
22
|
// Directory of this module — used to locate co-located prompt files (e.g., turn-shape reminder).
|
|
21
23
|
const __claudeLlmDir = dirname(fileURLToPath(import.meta.url));
|
|
22
24
|
const TURN_SHAPE_REMINDER_PATH = join(__claudeLlmDir, 'prompts', 'turn-shape-reminder.md');
|
|
25
|
+
// ── Recall auto-injection (reliable "remembers to check" per Agent-SDK research) ──
|
|
26
|
+
// The RELIABLE primitive the Agent SDK exposes is a UserPromptSubmit hook returning
|
|
27
|
+
// additionalContext: deterministic per-turn injection the model cannot skip (vs. an
|
|
28
|
+
// MCP/skill recall tool the model must elect to call). We piggyback on the existing
|
|
29
|
+
// turn-shape-reminder hook and append the top-K prior messages relevant to THIS prompt.
|
|
30
|
+
//
|
|
31
|
+
// Scope: this hook lives on the MAIN conductor's query options only. Named adversarial
|
|
32
|
+
// sub-agents (reviewer/tester) are spawned as SEPARATE query() calls with their own
|
|
33
|
+
// options and NO UserPromptSubmit hook — they stay deliberately un-grounded, so this
|
|
34
|
+
// injection never leaks into them. Task-delegated agents (researcher/writer/…) don't
|
|
35
|
+
// fire UserPromptSubmit (that event is for real user turns), so they're unaffected too.
|
|
36
|
+
//
|
|
37
|
+
// COMPACT by design: paid on every turn, so top-5 with a per-hit char cap (~800) →
|
|
38
|
+
// ~4KB. FULL untruncated text stays available on demand via `osborn-recall`.
|
|
39
|
+
const RECALL_TOP_K = 5;
|
|
40
|
+
const RECALL_PER_HIT_CHARS = 800;
|
|
41
|
+
const RECALL_ENABLED = () => process.env.OSBORN_RECALL_INJECT !== '0';
|
|
42
|
+
// Warm the embedder once (fire-and-forget) so hybrid recall is ready without blocking
|
|
43
|
+
// the first turn; until it's warm, recall() falls back to keyword-only (~16ms).
|
|
44
|
+
let __embedderWarmed = false;
|
|
45
|
+
function warmEmbedder() {
|
|
46
|
+
if (__embedderWarmed || process.env.OSBORN_EMBED === '0')
|
|
47
|
+
return;
|
|
48
|
+
__embedderWarmed = true;
|
|
49
|
+
getEmbedder().catch(() => { });
|
|
50
|
+
}
|
|
51
|
+
/** Build the recalled-context block to inject alongside the turn-shape reminder. '' on any miss. */
|
|
52
|
+
async function buildRecallInjection(sessionId, workingDir, prompt) {
|
|
53
|
+
try {
|
|
54
|
+
if (!RECALL_ENABLED() || !sessionId || !workingDir)
|
|
55
|
+
return '';
|
|
56
|
+
const q = String(prompt || '').trim();
|
|
57
|
+
if (q.length < 3)
|
|
58
|
+
return '';
|
|
59
|
+
if (!storeExists(sessionId, workingDir))
|
|
60
|
+
return '';
|
|
61
|
+
warmEmbedder();
|
|
62
|
+
// Only use the embedder if it's already loaded — never block the turn on a cold load.
|
|
63
|
+
const embed = (__embedderWarmed && process.env.OSBORN_EMBED !== '0') ? (await getEmbedder()) ?? undefined : undefined;
|
|
64
|
+
const { getStorePath } = await import('./session-store.js');
|
|
65
|
+
const db = openStore(getStorePath(sessionId, workingDir));
|
|
66
|
+
let hits;
|
|
67
|
+
try {
|
|
68
|
+
hits = await recall(db, q, { mode: embed ? 'hybrid' : 'keyword', topK: RECALL_TOP_K, embed });
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
db.close();
|
|
72
|
+
}
|
|
73
|
+
if (!hits.length)
|
|
74
|
+
return '';
|
|
75
|
+
const lines = hits.map((h, i) => {
|
|
76
|
+
let body = h.text.replace(/\n{3,}/g, '\n\n').trim();
|
|
77
|
+
if (body.length > RECALL_PER_HIT_CHARS)
|
|
78
|
+
body = body.slice(0, RECALL_PER_HIT_CHARS) + ' …';
|
|
79
|
+
const src = `${h.source} L${h.lineNum} · ${h.msgType}${h.toolName ? `:${h.toolName}` : ''}`;
|
|
80
|
+
return `[${i + 1}] (${src})\n${body}`;
|
|
81
|
+
});
|
|
82
|
+
// Static wrapper is centralized + parameterized in ./prompts/recalled-context.md.
|
|
83
|
+
return getRecalledContextBlock(lines.join('\n\n'));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return ''; // recall is best-effort — never break the turn
|
|
87
|
+
}
|
|
88
|
+
}
|
|
23
89
|
// ≤3 direct tool call budget per turn. Reset on every UserPromptSubmit (new user message).
|
|
24
90
|
// Enforced mechanically in PreToolUse — the model CANNOT exceed this regardless of JSONL history.
|
|
25
91
|
// Task/Agent delegations are exempt (delegation is what we WANT). Sub-agent tool calls
|
|
@@ -133,6 +199,58 @@ function loadAllSkills(_workingDir) {
|
|
|
133
199
|
console.log(`📚 Loaded ${skillMap.size} skill(s) from ${homeSkillsDir}`);
|
|
134
200
|
return `<available-skills>\n${[...skillMap.values()].join('\n\n---\n\n')}\n</available-skills>`;
|
|
135
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Enumerate the agent's CURRENT skills (name + one-line description) from
|
|
204
|
+
* ~/.claude/skills, for injection into the PreCompact instruction. This is the
|
|
205
|
+
* list the compaction model dedupes/merges against so it stops re-emitting
|
|
206
|
+
* duplicate skills every session. Description is taken from YAML frontmatter
|
|
207
|
+
* (`description:`) when present, else the WHEN: line, else the first non-heading
|
|
208
|
+
* line. Returns one `- name: description` per line, or '' if none.
|
|
209
|
+
*/
|
|
210
|
+
function enumerateSkillsForCompaction() {
|
|
211
|
+
const dir = join(homedir(), '.claude', 'skills');
|
|
212
|
+
if (!existsSync(dir))
|
|
213
|
+
return '';
|
|
214
|
+
const lines = [];
|
|
215
|
+
try {
|
|
216
|
+
for (const name of readdirSync(dir).sort()) {
|
|
217
|
+
const file = join(dir, name, 'SKILL.md');
|
|
218
|
+
if (!existsSync(file))
|
|
219
|
+
continue;
|
|
220
|
+
let desc = '';
|
|
221
|
+
try {
|
|
222
|
+
const raw = readFileSync(file, 'utf-8');
|
|
223
|
+
const fm = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
224
|
+
if (fm) {
|
|
225
|
+
const m = fm[1].match(/^description:\s*(.+)$/m);
|
|
226
|
+
if (m)
|
|
227
|
+
desc = m[1].trim();
|
|
228
|
+
}
|
|
229
|
+
if (!desc) {
|
|
230
|
+
const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
231
|
+
const when = body.match(/^\s*WHEN:\s*(.+)$/mi);
|
|
232
|
+
if (when)
|
|
233
|
+
desc = when[1].trim();
|
|
234
|
+
}
|
|
235
|
+
if (!desc) {
|
|
236
|
+
const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
237
|
+
const first = body.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('#'));
|
|
238
|
+
desc = first || '(no description)';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
desc = '(unreadable)';
|
|
243
|
+
}
|
|
244
|
+
if (desc.length > 180)
|
|
245
|
+
desc = desc.slice(0, 177) + '…';
|
|
246
|
+
lines.push(`- ${name}: ${desc}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
console.warn('⚠️ enumerateSkillsForCompaction failed:', err instanceof Error ? err.message : err);
|
|
251
|
+
}
|
|
252
|
+
return lines.join('\n');
|
|
253
|
+
}
|
|
136
254
|
// Compaction threshold: Fable 5 runs a 1M context window, so let sessions use
|
|
137
255
|
// all of it before auto-compacting. autoCompactWindow max is 1_000_000; the SDK
|
|
138
256
|
// reads it from settings.json (settingSources includes 'user'), so merge it into
|
|
@@ -201,6 +319,7 @@ export const NAMED_AGENTS = {
|
|
|
201
319
|
'GROUNDED: reads the session search-index for prior findings before researching.',
|
|
202
320
|
].join(' '),
|
|
203
321
|
tools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'Task'],
|
|
322
|
+
grounded: true, // applyGrounding() injects the osborn-recall command + ensures Bash
|
|
204
323
|
model: 'sonnet',
|
|
205
324
|
prompt: [
|
|
206
325
|
'You are Osborn\'s research agent. Your job is information gathering — thorough, structured, factual.',
|
|
@@ -209,8 +328,7 @@ export const NAMED_AGENTS = {
|
|
|
209
328
|
'Gather information the main agent needs to answer the user\'s question or make a decision.',
|
|
210
329
|
'You are a scout — go find things, read them carefully, and report back.',
|
|
211
330
|
'',
|
|
212
|
-
'
|
|
213
|
-
'Before researching, locate the session index (search-index.txt — a compact line-per-message log of this mission, under .claude/projects/<slug>/osb/<session>/; if several exist pick the most recently modified) and Grep it for the topic you are about to investigate. Read ONLY the matching slice, never the whole file.',
|
|
331
|
+
'Before researching, GROUND yourself using the recall command in the Grounding section appended to this prompt — check prior findings, decisions, and gotchas so you do not redo settled work.',
|
|
214
332
|
'Purpose: find what has ALREADY been decided, answered, or ruled out so you do not re-research a settled question. If the index already establishes the answer, report that (with the index reference) instead of redoing the work.',
|
|
215
333
|
'If you cannot find the index, proceed normally — this is an optimization, not a hard dependency.',
|
|
216
334
|
'',
|
|
@@ -303,6 +421,7 @@ export const NAMED_AGENTS = {
|
|
|
303
421
|
'GROUNDED: reads the session search-index before editing to avoid contradicting prior decisions.',
|
|
304
422
|
].join(' '),
|
|
305
423
|
tools: ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Glob', 'Grep', 'NotebookRead', 'NotebookEdit'],
|
|
424
|
+
grounded: true, // applyGrounding() injects the osborn-recall command
|
|
306
425
|
model: 'opus',
|
|
307
426
|
prompt: [
|
|
308
427
|
'You are Osborn\'s writer agent. You execute file changes with a verify-first approach.',
|
|
@@ -311,8 +430,7 @@ export const NAMED_AGENTS = {
|
|
|
311
430
|
'Handle ALL file operations — code, config, documentation, scripts, data files.',
|
|
312
431
|
'You are the only agent that writes. The main agent and reasoner produce plans; you execute them.',
|
|
313
432
|
'',
|
|
314
|
-
'
|
|
315
|
-
'Before editing, locate the session index (search-index.txt under .claude/projects/<slug>/osb/<session>/; newest if several) and Grep it ONLY for: (a) the files/symbols you are about to change, and (b) any recorded DECISIONS or known GOTCHAS relevant to this change. Read only the matching lines — do NOT read the whole index (thousands of lines). This is a lighter dose than the reviewer: a targeted lookup.',
|
|
433
|
+
'Before editing, GROUND yourself using the recall command in the Grounding section appended to this prompt — check for prior DECISIONS and known GOTCHAS on the files/symbols you are about to change.',
|
|
316
434
|
'If a decision or gotcha contradicts your task, STOP and report to the main agent before editing. If you find nothing or no index exists, proceed normally.',
|
|
317
435
|
'',
|
|
318
436
|
'## VERIFY-FIRST workflow (mandatory)',
|
|
@@ -367,7 +485,7 @@ export const NAMED_AGENTS = {
|
|
|
367
485
|
'observed behavior without a matching requirement is a regression — treat behavioral surprise as a defect.',
|
|
368
486
|
'',
|
|
369
487
|
'## Grounding — consult shared context before writing or running tests',
|
|
370
|
-
'Before deciding what to test,
|
|
488
|
+
'Before deciding what to test, check project docs and known-issues files (these live under the working dir and ARE readable). Key doc locations to consult: `/workspace/osborn/CLAUDE.md`, `/workspace/osborn/docs/critical-patterns.md`, the `docs/` directory, `README.md`, and `CHANGELOG.md`. Check these for: (a) KNOWN ISSUES and gotchas already recorded, and (b) what behavior is ALREADY covered by existing tests. (You are adversarial and deliberately NOT given session recall — validate with fresh eyes.)',
|
|
371
489
|
'Purpose: target regression coverage at real GAPS and known-risk areas rather than testing blind or duplicating coverage — and stay IN SYNC with the reviewer, which reads the same sources.',
|
|
372
490
|
'Read only the relevant slice of the index, never the whole file. If you find nothing or no index/docs exist, proceed normally — this is an optimization, not a hard dependency.',
|
|
373
491
|
'If the change adds or renames a feature, flag any doc now out of date (see DOC STALENESS in "What to return").',
|
|
@@ -450,6 +568,7 @@ export const NAMED_AGENTS = {
|
|
|
450
568
|
'GROUNDED: reads the session search-index before planning to respect prior decisions.',
|
|
451
569
|
].join(' '),
|
|
452
570
|
tools: ['Read', 'Glob', 'Grep', 'WebSearch'],
|
|
571
|
+
grounded: true, // applyGrounding() injects the osborn-recall command + adds Bash
|
|
453
572
|
model: 'sonnet',
|
|
454
573
|
prompt: [
|
|
455
574
|
'You are Osborn\'s planning agent. Your job is to decompose complex tasks into clear, atomic steps.',
|
|
@@ -459,7 +578,7 @@ export const NAMED_AGENTS = {
|
|
|
459
578
|
'step by step without guessing. You are the bridge between "what" and "how".',
|
|
460
579
|
'',
|
|
461
580
|
'## Grounding — plan against what already exists',
|
|
462
|
-
'Before drafting a plan,
|
|
581
|
+
'Before drafting a plan, GROUND yourself using the recall command in the Grounding section appended to this prompt — check prior DECISIONS, constraints, and known GOTCHAS relevant to the task.',
|
|
463
582
|
'Purpose: make the plan fit what has already been decided or tried — do not propose an approach the mission already ruled out. If a prior decision conflicts with the obvious plan, surface it in the plan rather than silently contradicting it.',
|
|
464
583
|
'If you cannot find the index, proceed normally.',
|
|
465
584
|
'',
|
|
@@ -630,6 +749,47 @@ export function applyTurbo(agents, turbo) {
|
|
|
630
749
|
}
|
|
631
750
|
return out;
|
|
632
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* Inject session-recall grounding into any agent flagged `grounded: true`.
|
|
754
|
+
*
|
|
755
|
+
* WHY: a sub-agent's file tools (Read/Grep/Glob) are sandboxed to its cwd +
|
|
756
|
+
* additionalDirectories, so it CANNOT read the session index / session.db that live
|
|
757
|
+
* under $HOME/.claude/projects/… — the old "grep search-index.txt" grounding silently
|
|
758
|
+
* failed. Bash, however, is NOT cwd-restricted, so `osborn-recall` reaches the store.
|
|
759
|
+
*
|
|
760
|
+
* So for every grounded agent we (1) hand it the EXACT, absolute-path osborn-recall
|
|
761
|
+
* command (resolved once here — the single dynamic resolver, so we never hardcode a
|
|
762
|
+
* per-agent path; works for named AND user-created custom grounded agents), and
|
|
763
|
+
* (2) guarantee Bash is in its tool set so it can run that command. The `grounded`
|
|
764
|
+
* flag is stripped before the roster reaches the SDK. NEVER mutates the input.
|
|
765
|
+
*
|
|
766
|
+
* Adversarial agents (reviewer/tester) leave `grounded` unset → untouched, stay blind.
|
|
767
|
+
*/
|
|
768
|
+
export function applyGrounding(agents, sessionId, workingDir) {
|
|
769
|
+
const out = {};
|
|
770
|
+
// Resolve the store command ONCE — absolute --db path, cwd-independent.
|
|
771
|
+
const dbPath = (sessionId && sessionId !== 'pending' && workingDir)
|
|
772
|
+
? getStorePath(sessionId, workingDir) : null;
|
|
773
|
+
for (const [name, agent] of Object.entries(agents)) {
|
|
774
|
+
if (!agent?.grounded) {
|
|
775
|
+
out[name] = agent;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const { grounded, ...rest } = agent;
|
|
779
|
+
// Ensure Bash is available so the agent can actually run osborn-recall.
|
|
780
|
+
const tools = Array.isArray(rest.tools) ? [...rest.tools] : [];
|
|
781
|
+
if (!tools.includes('Bash'))
|
|
782
|
+
tools.push('Bash');
|
|
783
|
+
// Push the grounding block into the system prompt (arrives at spawn — sandbox-proof).
|
|
784
|
+
const cmd = dbPath
|
|
785
|
+
? `osborn-recall "<terms from your task>" --db ${dbPath} --top-k 8`
|
|
786
|
+
: `osborn-recall "<terms from your task>" --top-k 8`;
|
|
787
|
+
// Body is centralized + parameterized in ./prompts/grounding-recall.md.
|
|
788
|
+
const groundingBlock = getGroundingBlock(cmd);
|
|
789
|
+
out[name] = { ...rest, tools, prompt: `${rest.prompt || ''}\n${groundingBlock}` };
|
|
790
|
+
}
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
633
793
|
const RESEARCH_TOOLS = [
|
|
634
794
|
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
|
635
795
|
'Bash', 'WebSearch', 'WebFetch',
|
|
@@ -714,6 +874,11 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
714
874
|
// Dedup guard — prevents double-firing reviewer/gate if SubagentStop fires
|
|
715
875
|
// more than once for the same agent_id (e.g. retry edge cases).
|
|
716
876
|
#dispatchedFor = new Set();
|
|
877
|
+
// Embedded session.db write-through guard. The store write is triggered from the
|
|
878
|
+
// UserPromptSubmit hook (once per real user submission, main-thread only) and sweeps
|
|
879
|
+
// the FULL source set — main JSONL + every sub-agent JSONL — exactly like the flat
|
|
880
|
+
// index. Incremental (byte-offset resume) + fire-and-forget, so it never blocks a turn.
|
|
881
|
+
#storeUpdating = false;
|
|
717
882
|
// Turbo mode — when true, every spawned agent (main + sub-agents) runs on
|
|
718
883
|
// FAST_MODEL regardless of individual model config. Default off = no-op.
|
|
719
884
|
#turbo = false;
|
|
@@ -806,6 +971,34 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
806
971
|
// ============================================================
|
|
807
972
|
// MCP SERVER MANAGEMENT - Runtime enable/disable MCP servers
|
|
808
973
|
// ============================================================
|
|
974
|
+
/**
|
|
975
|
+
* Guarded, fire-and-forget write-through to the embedded session.db. Called from the
|
|
976
|
+
* main agent's UserPromptSubmit hook (once per real user submission). Sweeps the FULL
|
|
977
|
+
* source set — main JSONL + every sub-agent JSONL — incrementally (byte-offset resume).
|
|
978
|
+
* The guard lives here (on the long-lived ClaudeLLM, not the per-turn stream) so a slow
|
|
979
|
+
* write can't overlap the next turn's write. Never throws; never blocks the caller.
|
|
980
|
+
*/
|
|
981
|
+
triggerStoreUpdate(sessionId, workingDir) {
|
|
982
|
+
if (this.#storeUpdating || !sessionId || sessionId === 'pending' || !workingDir)
|
|
983
|
+
return;
|
|
984
|
+
if (process.env.OSBORN_STORE === '0')
|
|
985
|
+
return;
|
|
986
|
+
this.#storeUpdating = true;
|
|
987
|
+
(async () => {
|
|
988
|
+
try {
|
|
989
|
+
const embed = process.env.OSBORN_EMBED === '0' ? undefined : (await getEmbedder()) ?? undefined;
|
|
990
|
+
const stats = await updateSessionStore(sessionId, workingDir, { embed });
|
|
991
|
+
if (stats.newRows > 0)
|
|
992
|
+
console.log(`🗄️ [store] +${stats.newRows} rows (${stats.totalRows} total, embedded=${stats.embeddedRows}) across main+subagents`);
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
console.error('🗄️ [store] update failed:', err?.message);
|
|
996
|
+
}
|
|
997
|
+
finally {
|
|
998
|
+
this.#storeUpdating = false;
|
|
999
|
+
}
|
|
1000
|
+
})();
|
|
1001
|
+
}
|
|
809
1002
|
/**
|
|
810
1003
|
* Get all currently enabled MCP servers
|
|
811
1004
|
*/
|
|
@@ -1920,11 +2113,31 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1920
2113
|
turnToolCallCount = 0;
|
|
1921
2114
|
const reminder = readFileSync(TURN_SHAPE_REMINDER_PATH, 'utf-8');
|
|
1922
2115
|
const promptPreview = String(input?.prompt || '').substring(0, 60).replace(/\n/g, ' ');
|
|
1923
|
-
|
|
2116
|
+
// Reliable recall: retrieve prior messages relevant to THIS prompt and inject
|
|
2117
|
+
// them deterministically. MAIN CONDUCTOR ONLY — gate explicitly on agent_id.
|
|
2118
|
+
// Per the SDK: agent_id is present ONLY when a hook fires from within a subagent,
|
|
2119
|
+
// absent on the main thread. So `agent_id` set ⇒ skip (grounded sub-agents pull
|
|
2120
|
+
// via osborn-recall from their own prompt; adversarial ones stay un-grounded).
|
|
2121
|
+
const fromSubagent = Boolean(input?.agent_id);
|
|
2122
|
+
const sid = input?.session_id || this.#sessionId;
|
|
2123
|
+
// Consolidated WRITE trigger: on every real user submission (main thread only),
|
|
2124
|
+
// sweep main + ALL sub-agent JSONLs into session.db. Naturally debounced to the
|
|
2125
|
+
// user's speech cadence (one submission = one sweep); guarded + fire-and-forget
|
|
2126
|
+
// on the long-lived ClaudeLLM so injection below never waits on it. This is the
|
|
2127
|
+
// single canonical write path — mirrors the flat index's sub-agent sweep, but
|
|
2128
|
+
// stores FULL untruncated text + FTS5 + sqlite-vec instead of truncated summaries.
|
|
2129
|
+
if (!fromSubagent && sid && this.#opts.workingDirectory) {
|
|
2130
|
+
this.#llmRef.triggerStoreUpdate(sid, this.#opts.workingDirectory);
|
|
2131
|
+
}
|
|
2132
|
+
const recalled = fromSubagent
|
|
2133
|
+
? ''
|
|
2134
|
+
: await buildRecallInjection(sid, this.#opts.workingDirectory, String(input?.prompt || ''));
|
|
2135
|
+
const additionalContext = recalled ? `${reminder}\n\n${recalled}` : reminder;
|
|
2136
|
+
console.log(`📌 UserPromptSubmit: injected turn-shape reminder (${reminder.length} chars)${recalled ? ` + recall (${recalled.length} chars)` : ''} for prompt="${promptPreview}..." [tool budget reset to 0/${TOOL_CALL_BUDGET}]`);
|
|
1924
2137
|
return {
|
|
1925
2138
|
hookSpecificOutput: {
|
|
1926
2139
|
hookEventName: 'UserPromptSubmit',
|
|
1927
|
-
additionalContext
|
|
2140
|
+
additionalContext,
|
|
1928
2141
|
},
|
|
1929
2142
|
};
|
|
1930
2143
|
}
|
|
@@ -1944,8 +2157,28 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1944
2157
|
try {
|
|
1945
2158
|
this.#opts.onCompactionEvent?.({ type: 'compaction_started', trigger: input?.trigger });
|
|
1946
2159
|
const instructionPath = join(__claudeLlmDir, 'prompts', 'compact-learnings-instruction.md');
|
|
1947
|
-
const
|
|
1948
|
-
|
|
2160
|
+
const instructionRaw = existsSync(instructionPath) ? readFileSync(instructionPath, 'utf-8') : '';
|
|
2161
|
+
// Populate the EXISTING SKILLS section the instruction .md already refers to
|
|
2162
|
+
// (lines 42 & 55) but that nothing used to fill. Without this, the model is told
|
|
2163
|
+
// to "not re-emit skills already shown" against a list it never received — so it
|
|
2164
|
+
// proliferated duplicates. We hand it the current skill set + an adversarial,
|
|
2165
|
+
// self-critical directive to merge/refine rather than create anew.
|
|
2166
|
+
const existingSkills = enumerateSkillsForCompaction();
|
|
2167
|
+
const skillCount = existingSkills ? existingSkills.split('\n').length : 0;
|
|
2168
|
+
const criticBlock = existingSkills
|
|
2169
|
+
? `\n\n---\n\n=== EXISTING SKILLS (${skillCount}) ===\n`
|
|
2170
|
+
+ `These skills ALREADY EXIST for this user (name: description). Before you emit `
|
|
2171
|
+
+ `SKILL_CANDIDATES or BEHAVIORAL_LEARNINGS, act as an ADVERSARIAL reviewer of your own output:\n`
|
|
2172
|
+
+ `1. If a candidate duplicates or substantially overlaps one below, DO NOT create a new skill — `
|
|
2173
|
+
+ `re-emit it under the EXACT SAME kebab-case name, and only if it needs a substantive update; otherwise omit it.\n`
|
|
2174
|
+
+ `2. Propose a brand-new skill ONLY if nothing below covers it, it was CONFIRMED working this session, `
|
|
2175
|
+
+ `and it generalizes to future sessions on different tasks.\n`
|
|
2176
|
+
+ `3. Prefer merging/refining over proliferating. A small set of sharp, non-overlapping skills is the goal — `
|
|
2177
|
+
+ `reject your own low-signal or one-off candidates.\n\n`
|
|
2178
|
+
+ `${existingSkills}\n`
|
|
2179
|
+
: '';
|
|
2180
|
+
const instruction = instructionRaw + criticBlock;
|
|
2181
|
+
console.log(`🧠 PreCompact: injecting instruction (${instruction.length} chars, ${skillCount} existing skills, trigger=${input?.trigger || 'unknown'})`);
|
|
1949
2182
|
return { systemMessage: instruction };
|
|
1950
2183
|
}
|
|
1951
2184
|
catch (err) {
|
|
@@ -2005,7 +2238,7 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
2005
2238
|
const decPath = join(decFolder, 'SKILL.md');
|
|
2006
2239
|
mkdirSync(decFolder, { recursive: true });
|
|
2007
2240
|
const existing = existsSyncFs(decPath) ? readSyncFs(decPath, 'utf-8') : '';
|
|
2008
|
-
const header = existing ? '' :
|
|
2241
|
+
const header = existing ? '' : `---\nname: decisions\ndescription: "Project-scoped architectural and implementation decisions auto-extracted from compaction summaries; consult before revisiting settled choices."\nmetadata:\n type: learned\n source: postcompact\n---\n\n# Project Decisions\n\nAuto-extracted from compact summaries.\n\n`;
|
|
2009
2242
|
const entry = `\n## ${today} (session ${sessionId.substring(0, 8)})\n${projectLines.join('\n')}\n`;
|
|
2010
2243
|
writeSyncFs(decPath, header + existing + entry, 'utf-8');
|
|
2011
2244
|
console.log(`🧠 PostCompact: appended ${projectLines.length} decision(s) to ${decPath}`);
|
|
@@ -2035,8 +2268,14 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
2035
2268
|
const skillFolder = join(skillDir, '.claude', 'skills', name);
|
|
2036
2269
|
const skillPath = join(skillFolder, 'SKILL.md');
|
|
2037
2270
|
mkdirSync(skillFolder, { recursive: true });
|
|
2038
|
-
|
|
2039
|
-
|
|
2271
|
+
// Emit STANDARD skill format: YAML frontmatter (name + description) so
|
|
2272
|
+
// learned skills conform to the same shape the loader/other agents expect.
|
|
2273
|
+
// Description is derived from the candidate's WHEN: line; JSON.stringify
|
|
2274
|
+
// yields a safely-quoted YAML scalar even when it contains colons/quotes.
|
|
2275
|
+
const whenMatch = body.match(/^\s*WHEN:\s*(.+)$/mi);
|
|
2276
|
+
const desc = (whenMatch ? whenMatch[1].trim() : `Learned procedure: ${name}`).replace(/\s+/g, ' ').slice(0, 200);
|
|
2277
|
+
const frontmatter = `---\nname: ${name}\ndescription: ${JSON.stringify(desc)}\nmetadata:\n type: learned\n source: postcompact\n session: ${sessionId.substring(0, 8)}\n updated: ${today}\n---\n\n`;
|
|
2278
|
+
writeSyncFs(skillPath, frontmatter + `# ${name}\n\n` + body + '\n', 'utf-8');
|
|
2040
2279
|
console.log(`🧠 PostCompact: wrote skill '${name}' to ${skillPath}`);
|
|
2041
2280
|
skillsWritten++;
|
|
2042
2281
|
skillNames.push(name);
|
|
@@ -2055,7 +2294,7 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
2055
2294
|
const skillFolder = join(skillDir, '.claude', 'skills', 'learned-behaviors');
|
|
2056
2295
|
const skillPath = join(skillFolder, 'SKILL.md');
|
|
2057
2296
|
mkdirSync(skillFolder, { recursive: true });
|
|
2058
|
-
const header =
|
|
2297
|
+
const header = `---\nname: learned-behaviors\ndescription: "User corrections, preferences, domain knowledge, effective patterns, and anti-patterns learned across sessions; apply to align with how this user works."\nmetadata:\n type: learned\n source: postcompact\n updated: ${today}\n---\n\n# Learned Behaviors\n\nAuto-extracted from voice sessions via PostCompact.\nLast updated: ${today} | Session: ${sessionId.substring(0, 8)}...\n\n`;
|
|
2059
2298
|
writeSyncFs(skillPath, header + learnings + '\n', 'utf-8');
|
|
2060
2299
|
console.log(`🧠 PostCompact: wrote learned behaviors to ${skillPath} (${learnings.length} chars)`);
|
|
2061
2300
|
skillsWritten++;
|
|
@@ -2159,9 +2398,9 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
2159
2398
|
// opts.agents is undefined — the ?? NAMED_AGENTS fallback would skip
|
|
2160
2399
|
// the override entirely. Explicitly apply applyTurbo(NAMED_AGENTS)
|
|
2161
2400
|
// so built-in agents always get FAST_MODEL when turbo is on.
|
|
2162
|
-
agents: this.#llmRef.turbo
|
|
2401
|
+
agents: applyGrounding(this.#llmRef.turbo
|
|
2163
2402
|
? applyTurbo(this.#opts.agents ?? NAMED_AGENTS, true)
|
|
2164
|
-
: (this.#opts.agents ?? NAMED_AGENTS),
|
|
2403
|
+
: (this.#opts.agents ?? NAMED_AGENTS), this.#sessionId, this.#opts.workingDirectory),
|
|
2165
2404
|
};
|
|
2166
2405
|
// Run Claude Agent SDK query() and stream results
|
|
2167
2406
|
let hasOutput = false;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embedder.ts — Local text→vector embedder for the semantic (vec) layer of session-store.
|
|
3
|
+
*
|
|
4
|
+
* Uses all-MiniLM-L6-v2 (384-dim) via @xenova/transformers, running fully local (no API,
|
|
5
|
+
* no per-write network cost). Output is L2-normalized float32, quantized to int8[384]
|
|
6
|
+
* (×127) to match the sqlite-vec `int8[384]` column — 4× smaller than float32 with
|
|
7
|
+
* ~98–99% recall.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN FOR RELIABILITY:
|
|
10
|
+
* • Lazy — the model is loaded on first use, never at import (startup stays fast).
|
|
11
|
+
* • Best-effort — if the package or model can't load, getEmbedder() returns null and
|
|
12
|
+
* the store runs keyword-only. Embeddings never block the keyword write path.
|
|
13
|
+
* • Gated — set OSBORN_EMBED=0 to force keyword-only (e.g. on machines without the
|
|
14
|
+
* model cached, or to avoid the first-run model download).
|
|
15
|
+
*
|
|
16
|
+
* Model cache honors TRANSFORMERS_CACHE / HF_HOME so it can be baked into the image.
|
|
17
|
+
*/
|
|
18
|
+
import { type Embedder } from './session-store.js';
|
|
19
|
+
/**
|
|
20
|
+
* Returns an Embedder, or null if embeddings are unavailable/disabled.
|
|
21
|
+
* The returned function itself also degrades to null on runtime failure.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getEmbedder(): Promise<Embedder | null>;
|
package/dist/embedder.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embedder.ts — Local text→vector embedder for the semantic (vec) layer of session-store.
|
|
3
|
+
*
|
|
4
|
+
* Uses all-MiniLM-L6-v2 (384-dim) via @xenova/transformers, running fully local (no API,
|
|
5
|
+
* no per-write network cost). Output is L2-normalized float32, quantized to int8[384]
|
|
6
|
+
* (×127) to match the sqlite-vec `int8[384]` column — 4× smaller than float32 with
|
|
7
|
+
* ~98–99% recall.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN FOR RELIABILITY:
|
|
10
|
+
* • Lazy — the model is loaded on first use, never at import (startup stays fast).
|
|
11
|
+
* • Best-effort — if the package or model can't load, getEmbedder() returns null and
|
|
12
|
+
* the store runs keyword-only. Embeddings never block the keyword write path.
|
|
13
|
+
* • Gated — set OSBORN_EMBED=0 to force keyword-only (e.g. on machines without the
|
|
14
|
+
* model cached, or to avoid the first-run model download).
|
|
15
|
+
*
|
|
16
|
+
* Model cache honors TRANSFORMERS_CACHE / HF_HOME so it can be baked into the image.
|
|
17
|
+
*/
|
|
18
|
+
import { EMBED_DIM } from './session-store.js';
|
|
19
|
+
const MODEL_ID = process.env.OSBORN_EMBED_MODEL || 'Xenova/all-MiniLM-L6-v2';
|
|
20
|
+
let pipelinePromise = null;
|
|
21
|
+
let disabled = false;
|
|
22
|
+
/** Quantize a normalized float32 embedding to int8[dim] (×127, clamped). */
|
|
23
|
+
function quantizeInt8(floats) {
|
|
24
|
+
const out = new Int8Array(EMBED_DIM);
|
|
25
|
+
const n = Math.min(EMBED_DIM, floats.length);
|
|
26
|
+
for (let i = 0; i < n; i++) {
|
|
27
|
+
let v = Math.round(floats[i] * 127);
|
|
28
|
+
if (v > 127)
|
|
29
|
+
v = 127;
|
|
30
|
+
else if (v < -128)
|
|
31
|
+
v = -128;
|
|
32
|
+
out[i] = v;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
async function loadPipeline() {
|
|
37
|
+
if (disabled)
|
|
38
|
+
return null;
|
|
39
|
+
if (process.env.OSBORN_EMBED === '0') {
|
|
40
|
+
disabled = true;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (!pipelinePromise) {
|
|
44
|
+
pipelinePromise = (async () => {
|
|
45
|
+
// Lazy import — @xenova/transformers is heavy and optional.
|
|
46
|
+
const mod = await import('@xenova/transformers').catch(() => null);
|
|
47
|
+
if (!mod)
|
|
48
|
+
throw new Error('@xenova/transformers not installed');
|
|
49
|
+
if (mod.env) {
|
|
50
|
+
mod.env.allowLocalModels = true;
|
|
51
|
+
// Avoid noisy multi-thread wasm issues in the agent process.
|
|
52
|
+
if (mod.env.backends?.onnx?.wasm)
|
|
53
|
+
mod.env.backends.onnx.wasm.numThreads = 1;
|
|
54
|
+
}
|
|
55
|
+
return mod.pipeline('feature-extraction', MODEL_ID);
|
|
56
|
+
})().catch((err) => {
|
|
57
|
+
disabled = true;
|
|
58
|
+
pipelinePromise = null;
|
|
59
|
+
throw err;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return pipelinePromise;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Returns an Embedder, or null if embeddings are unavailable/disabled.
|
|
66
|
+
* The returned function itself also degrades to null on runtime failure.
|
|
67
|
+
*/
|
|
68
|
+
export async function getEmbedder() {
|
|
69
|
+
if (process.env.OSBORN_EMBED === '0')
|
|
70
|
+
return null;
|
|
71
|
+
let pipe;
|
|
72
|
+
try {
|
|
73
|
+
pipe = await loadPipeline();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!pipe)
|
|
79
|
+
return null;
|
|
80
|
+
const embed = async (texts) => {
|
|
81
|
+
try {
|
|
82
|
+
if (!texts.length)
|
|
83
|
+
return [];
|
|
84
|
+
const out = [];
|
|
85
|
+
// Transformers.js handles batching internally; do them one-by-one to keep
|
|
86
|
+
// memory bounded on long tool outputs.
|
|
87
|
+
for (const t of texts) {
|
|
88
|
+
const res = await pipe(t || ' ', { pooling: 'mean', normalize: true });
|
|
89
|
+
out.push(quantizeInt8(res.data));
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return embed;
|
|
98
|
+
}
|
|
@@ -340,6 +340,10 @@ export class PipelineDirectLLM extends llm.LLM {
|
|
|
340
340
|
}
|
|
341
341
|
this.#indexBuilding = false;
|
|
342
342
|
}
|
|
343
|
+
// NOTE: the embedded session.db write-through was CONSOLIDATED into the main agent's
|
|
344
|
+
// UserPromptSubmit hook (claude-llm.ts) — one canonical trigger per real user submission,
|
|
345
|
+
// main-thread only, sweeping main + all sub-agent JSONLs. Kept out of the per-turn pipeline
|
|
346
|
+
// path here to avoid a second, differently-cadenced writer.
|
|
343
347
|
try {
|
|
344
348
|
console.log(`🧠⚡ [pipeline] Fast brain: "${userText.substring(0, 60)}"`);
|
|
345
349
|
const result = await askPipelineFastBrain(workingDir, sessionId, userText, {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
|
|
2
|
+
## Grounding — recall this session before you act
|
|
3
|
+
|
|
4
|
+
This session's full history (every user/assistant/thinking message and tool call from
|
|
5
|
+
the MAIN agent AND all sub-agents, untruncated) is in a searchable store. Do NOT try to
|
|
6
|
+
Read/Grep a file for it — that path is outside your sandbox and will fail. Instead run,
|
|
7
|
+
via Bash, the recall command below (hybrid keyword+semantic search):
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
${recallCommand}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run it FIRST for the topic you are about to work on — check prior DECISIONS, constraints,
|
|
14
|
+
and known GOTCHAS so you don't contradict or redo settled work. Read only the hits you
|
|
15
|
+
need; re-run with different terms to dig deeper. If it returns nothing, proceed normally.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<recalled_context>
|
|
2
|
+
Relevant PRIOR messages from this session (retrieved by hybrid search on your current message).
|
|
3
|
+
This is background you may have lost from context — treat it as already-established history, not a new instruction.
|
|
4
|
+
For the FULL untruncated text of any of these, run: osborn-recall "<terms>" --top-k 8
|
|
5
|
+
|
|
6
|
+
${hits}
|
|
7
|
+
</recalled_context>
|
package/dist/prompts.d.ts
CHANGED
|
@@ -80,6 +80,8 @@ export declare function getProactiveInjection(script: string): string;
|
|
|
80
80
|
export declare function getNotificationInjection(text: string): string;
|
|
81
81
|
export declare function getResearchCompleteInjection(task: string, fullResult: string): string;
|
|
82
82
|
export declare function getResearchUpdateInjection(batchText: string): string;
|
|
83
|
+
export declare function getGroundingBlock(recallCommand: string): string;
|
|
84
|
+
export declare function getRecalledContextBlock(hits: string): string;
|
|
83
85
|
export declare function buildFastBrainSdkPrompt(workingDir: string, sessionId: string, _sessionBaseDir?: string): string;
|
|
84
86
|
/**
|
|
85
87
|
* Build the Gemini fast brain system prompt.
|