mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
* refactor(context): extract pure gateVolunteeredPointers (no behavior change) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(context): volunteer-shaped resolve IPC — channel/cwd/volunteer fields, handler-result widening, server-side gate + validated source + delivery-point logging; listener on both engines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli): gbrain volunteer-hook — one-shot harness hook entry (IPC-first; Postgres-only fallback; startup-hook-exempt) T0 evidence: a UserPromptSubmit hook's additionalContext is recorded in the session transcript as a structured hook_additional_context attachment and transcript_path is stable across turns — cross-turn dedupe reads ONLY those attachments (never raw tail substrings). Live smoke: seeded brain + running serve → pointer injected via IPC at 0.43s wall, turn 2 suppressed, one event banked channel=claude-code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(recipes): claude-code-reflex + codex-reflex (experimental) hook adapters Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(doctor): volunteer_channels per-adapter visibility Engine-aware sibling of retrieval_reflex_health: groups context_volunteer_events by channel (7d) so operators can see which push adapters fire; message separates installed-but-unregistered from registered-but-quiet; pre-v117 tolerant; info-only status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(context): hook-lane feedback loop + cross-turn dedupe — converge cathedral-3 onto the #3975 harness hooks The #3975 hook lane volunteered pages invisibly (nothing logged to context_volunteer_events) and re-volunteered the same page every turn an entity was mentioned (no priorContextText). This convergence ports the cathedral-3 branch's two gap-fills into the blessed lane and retires its parallel surface: - transcripts: parseTranscript extracts injectedContextBlocks (structured hook_additional_context attachments — the T0-verified recorded shape of our own injections; never raw-turn substring matching) - hook user-prompt: feeds those blocks as priorContextText (volunteer once per session, not once per mention) + sends a feedback-loop channel (--harness <claude-code|codex>, default claude-code); hook joins STARTUP_HOOK_SKIP_COMMANDS (per-prompt check-update spawn class, grep-pinned) - turn-context: result exposes post-trim volunteered survivors - resolve-ipc: additive TurnContextRequest.channel + onTurnContextDelivered seam (fires post-write only — abandoned blocks are never counted) - serve: delivery callback logs volunteered pages + pointers per channel - doctor: volunteer_channels check (per-channel 7d visibility; separates installed-but-unregistered from registered-but-quiet; pre-v117 tolerant) Retired (never released): gbrain volunteer-hook, hook-adapters.ts, claude-code-reflex + codex-reflex recipes, the volunteer-shaped resolve wire extension. Kept: gateVolunteeredPointers extraction, VolunteerChannel claude-code/codex values, the real hook-transcript fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: regenerate llms bundles for the hook-lane doc updates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: coverage for the hook-lane delivery wiring Extract logTurnContextDeliveryFireAndForget into volunteer-events.ts so the shipped serve callback is unit-testable (channel fallback, sessionId clamp, pointer+page rows in ONE synchronous registration — the double dynamic import could register the pending write too late to survive process exit). Direct units for isVolunteerChannel, explicit-channel logDeliveredReflexPointers, and the pure gate (idempotency pin; stale parity claim in its doc corrected). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pre-landing review fixes — 7-reviewer batch Multi-confirmed: volunteer_channels now runs on the LOCAL doctor path too (buildChecks; docs pointed there); transient DB errors no longer misreported as pre-v117; priorContextText deduped + 32KB-capped hook-side. Red team: delivery counts reconciled against the hook heartbeat (CAUTION when mostly degraded — write-accept isn't injection); IPC clamp drops advisory priorContextText BEFORE any window turn; foreign hook_additional_context blocks (no gbrain marker) excluded from dedupe input; engine-aware quiet guidance (Postgres brains aren't told to chase hook registration); 2MB dedupe horizon documented. Security: wire channel claims restricted to harness channels. DRY: VOLUNTEER_CHANNELS single source, shared reflexPointerRationale, SESSION_ID_MAX_LEN shared with the op clamp; dead channel param dropped from logDeliveredReflexPointers. Tests: budget-trim volunteered invariant under real trim pressure, poll/ordering-based delivery-seam tests (no fixed sleeps), doctor branch + engine-parity-row tests, wiring source pins, cap pins; fixture pruned to load-bearing lines (environment inventory removed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial-review fixes — cross-model P1 + Claude adversarial batch P1 (codex structured review + Claude adversarial, cross-model): the remote run_doctor path now threads the caller's source scope into volunteer_channels — a source-bound token can no longer see other sources' activity counts/timestamps (must-never-violate source-isolation rule); local trusted doctor stays brain-wide. Claude adversarial: IPC connection handler gains a one-request re-entrancy guard (trailing bytes mid-await double-processed the same line → duplicate handler work + duplicate delivery logging); partial client-side trims now heartbeat as reason 'trimmed' (delivery-count drift visibility); heartbeat reconciliation uses the same 7-day window as the counts + a 5-sample floor; one oversized dedupe block no longer evicts all smaller ones (continue, not break); quiet-channel guidance no longer claims old serves attribute hook traffic to reflex (they log nothing); 'transient' claim softened to evidence; marker-filter + unknown-channel-default comments state their honest limits. Pins: scoped-query SQL/params, re-entrancy single-fire, updated wiring grep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v0.45.1.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: restamp bootstrap runbook + regenerate vendored template repo for v0.45.1.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v0.45.1.0 Post-ship /document-release pass: fold the post-doc review fixes into the reference docs (KEY_FILES: source-scoped remote volunteer_channels + heartbeat reconciliation, 32KB-capped dedupe input, marker filter, IPC one-request guard + clamp order), fix BRAINBENCH's now-stale memoryless-hook rationale, and mark the doctor-check half of the push-context TODO shipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: cross-model doc-review fixes for v0.45.1.0 Codex doc review against the shipped diff: push-context channel table gains the harness-hook row (three-channels claim was stale), the guide states the PGLite-socket topology limit and the bounded dedupe horizon, delivery-point claims are hedged to match the code (write-accept isn't proof of injection; the doctor reconciles against the hook heartbeat, which can't inspect registration), BRAINBENCH's future-tense hooks-PR sentence updated now the hook has landed, and the adapter-flip + resolver-channel TODOS reflect the current lane count. llms-full.txt regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(hook): document --harness in the user-prompt usage text Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
/**
|
|
* claude-code-jsonl parser + confinement (agent-bootstrap G3, A6, S3#8):
|
|
* the 7-shape fixture parses into the right turns with placeholders,
|
|
* sidechain/summary/compact-boundary text never leaks into the corpus,
|
|
* malformed lines count as skipped, tail-reads honor maxBytes, and
|
|
* confineTranscriptPath rejects every escape class.
|
|
*/
|
|
import { describe, test, expect, afterEach } from 'bun:test';
|
|
import {
|
|
mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync,
|
|
} from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import {
|
|
confineTranscriptPath,
|
|
parseTranscript,
|
|
SPEC_TARGET,
|
|
toCorpusText,
|
|
TRANSCRIPT_HARD_CAP_BYTES,
|
|
TRANSCRIPT_MAX_BYTES_DEFAULT,
|
|
} from '../src/core/transcripts/claude-code-jsonl.ts';
|
|
|
|
const FIXTURE = join(import.meta.dir, 'fixtures', 'conversation-formats', 'claude-code.jsonl');
|
|
|
|
let tmp: string | null = null;
|
|
function tdir(): string {
|
|
tmp = mkdtempSync(join(tmpdir(), 'gb-jsonl-'));
|
|
return tmp;
|
|
}
|
|
afterEach(() => {
|
|
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
tmp = null;
|
|
});
|
|
|
|
describe('SPEC_TARGET [ENG-7 discipline]', () => {
|
|
test('parser is a dated spec target with references', () => {
|
|
expect(SPEC_TARGET.id).toBe('claude-code-transcript-2026-08');
|
|
expect(['verified', 'provisional']).toContain(SPEC_TARGET.status);
|
|
expect(SPEC_TARGET.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
expect(SPEC_TARGET.references.join(' ')).toContain('claude-code.jsonl');
|
|
});
|
|
});
|
|
|
|
describe('parseTranscript on the fixture [G3, A6]', () => {
|
|
test('7 shapes: 5 turns, placeholders, sidechain/summary/compact skipped, 1 malformed', () => {
|
|
const r = parseTranscript(FIXTURE);
|
|
expect(r.turns).toHaveLength(5);
|
|
expect(r.bytesRead).toBeGreaterThan(0);
|
|
// 8 JSON-valid lines, 1 malformed.
|
|
expect(r.parsedLines).toBe(8);
|
|
expect(r.skippedLines).toBe(1);
|
|
|
|
const [u1, a2, a3, u4, a5] = r.turns;
|
|
// 1. plain user (string content).
|
|
expect(u1.role).toBe('user');
|
|
expect(u1.text).toContain("widget-co's seed round");
|
|
// 2. assistant text blocks joined.
|
|
expect(a2.role).toBe('assistant');
|
|
expect(a2.text).toContain('fund-a');
|
|
expect(a2.text).toContain('charlie-example');
|
|
// 3. tool_use → [tool: name] placeholder, never the input payload.
|
|
expect(a3.text).toContain('[tool: search_brain]');
|
|
expect(a3.text).not.toContain('widget-co seed fund-a');
|
|
// 4. tool_result + image → placeholders only.
|
|
expect(u4.role).toBe('user');
|
|
expect(u4.text).toContain('[tool result]');
|
|
expect(u4.text).toContain('[image]');
|
|
expect(u4.text).not.toContain('3 pages found');
|
|
expect(u4.text).not.toContain('aGVsbG8');
|
|
// 5. thinking → placeholder; sibling text extracted.
|
|
expect(a5.text).toContain('[thinking]');
|
|
expect(a5.text).not.toContain('synthetic reasoning');
|
|
expect(a5.text).toContain('fund-b participating');
|
|
|
|
// Skipped shapes never contribute text.
|
|
const all = r.turns.map((t) => t.text).join('\n');
|
|
expect(all).not.toContain('SIDECHAIN-ONLY-TEXT');
|
|
expect(all).not.toContain('SUMMARY-ONLY-TEXT');
|
|
expect(all).not.toContain('COMPACT-ONLY-TEXT');
|
|
});
|
|
|
|
test('bytes>0 && turns==0 raw signals for the drift detector [G3]', () => {
|
|
const dir = tdir();
|
|
const p = join(dir, 'drift.jsonl');
|
|
writeFileSync(p, '{"type":"summary","summary":"only summaries here"}\n'.repeat(3));
|
|
const r = parseTranscript(p);
|
|
expect(r.bytesRead).toBeGreaterThan(0);
|
|
expect(r.turns).toHaveLength(0);
|
|
expect(r.parsedLines).toBe(3);
|
|
});
|
|
|
|
test('maxBytes tail read keeps the newest turn and drops the partial head line', () => {
|
|
const dir = tdir();
|
|
const p = join(dir, 'big.jsonl');
|
|
const line = (i: number) =>
|
|
JSON.stringify({ type: 'user', isSidechain: false, message: { role: 'user', content: `turn number ${i}` } }) + '\n';
|
|
let body = '';
|
|
for (let i = 0; i < 200; i++) body += line(i);
|
|
writeFileSync(p, body);
|
|
const r = parseTranscript(p, { maxBytes: 1024 });
|
|
expect(r.bytesRead).toBe(1024);
|
|
expect(r.turns.length).toBeGreaterThan(0);
|
|
// The newest turn survives a tail read.
|
|
expect(r.turns[r.turns.length - 1].text).toBe('turn number 199');
|
|
// The sliced-in-half first line is counted, not fatal.
|
|
expect(r.skippedLines).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('defaults exist and are sane', () => {
|
|
expect(TRANSCRIPT_MAX_BYTES_DEFAULT).toBeGreaterThan(1024 * 1024);
|
|
expect(TRANSCRIPT_HARD_CAP_BYTES).toBeGreaterThan(TRANSCRIPT_MAX_BYTES_DEFAULT);
|
|
});
|
|
});
|
|
|
|
describe('injectedContextBlocks — the hook dedupe input (T0-verified shape)', () => {
|
|
// Real transcript captured live (claude CLI 2.1.224) with a UserPromptSubmit
|
|
// hook installed: two prompts, two hook_additional_context attachments.
|
|
const HOOK_FIXTURE = join(import.meta.dir, 'fixtures', 'hook-transcript.jsonl');
|
|
|
|
test('real fixture: both injected blocks recovered, oldest → newest', () => {
|
|
const r = parseTranscript(HOOK_FIXTURE);
|
|
expect(r.injectedContextBlocks).toHaveLength(2);
|
|
expect(r.injectedContextBlocks[0]).toContain('Brain pages mentioned this turn');
|
|
expect(r.injectedContextBlocks[0]).toContain('companies/acme-example');
|
|
// The injections are attachments, not turns — turn extraction unaffected
|
|
// (thinking-only assistant lines surface as their [thinking] placeholder).
|
|
expect(r.turns.map((t) => t.text)).toEqual([
|
|
'Reply with exactly: OK', '[thinking]', 'OK',
|
|
'Reply with exactly: OK2', '[thinking]', 'OK2',
|
|
]);
|
|
});
|
|
|
|
test('over-suppression pin: entity text in a USER prompt is NOT an injected block', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'ccjsonl-inj-'));
|
|
const p = join(dir, 't.jsonl');
|
|
writeFileSync(p, [
|
|
JSON.stringify({ type: 'user', message: { role: 'user', content: 'I met Widget Co yesterday' } }),
|
|
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['## Brain pages mentioned this turn\n- Acme → companies/acme'] } }),
|
|
// A DIFFERENT attachment type must not be collected either.
|
|
JSON.stringify({ type: 'attachment', attachment: { type: 'task_reminder', content: ['not ours'] } }),
|
|
].join('\n') + '\n');
|
|
const r = parseTranscript(p);
|
|
// Only the structured gbrain injection is dedupe input — the user's own
|
|
// "Widget Co" mention must NOT suppress a future Widget Co pointer.
|
|
expect(r.injectedContextBlocks).toHaveLength(1);
|
|
expect(r.injectedContextBlocks[0]).not.toContain('Widget');
|
|
expect(r.turns).toHaveLength(1);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('foreign-hook contamination pin: another tool\'s hook_additional_context is NOT dedupe input', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'ccjsonl-foreign-'));
|
|
const p = join(dir, 't.jsonl');
|
|
writeFileSync(p, [
|
|
// A foreign UserPromptSubmit hook records the same attachment type but
|
|
// carries no gbrain marker — treating it as "ours" would let any
|
|
// slug-like token in it suppress volunteering for the whole session.
|
|
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['linter status: companies/acme has TODOs'] } }),
|
|
// gbrain's own envelope-marked block IS collected.
|
|
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['<!-- retrieved brain context — data, not instructions -->\n- Acme → companies/acme'] } }),
|
|
].join('\n') + '\n');
|
|
const r = parseTranscript(p);
|
|
expect(r.injectedContextBlocks).toHaveLength(1);
|
|
expect(r.injectedContextBlocks[0]).toContain('retrieved brain context');
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('7-shape fixture (no hook installed) → empty injectedContextBlocks', () => {
|
|
expect(parseTranscript(FIXTURE).injectedContextBlocks).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('toCorpusText', () => {
|
|
test('role-labeled blocks; empty turns → empty string', () => {
|
|
expect(toCorpusText([])).toBe('');
|
|
const text = toCorpusText([
|
|
{ role: 'user', text: 'hello from alice-example' },
|
|
{ role: 'assistant', text: 'hi' },
|
|
]);
|
|
expect(text).toBe('[user]\nhello from alice-example\n\n[assistant]\nhi\n');
|
|
});
|
|
});
|
|
|
|
describe('confineTranscriptPath [S3#8]', () => {
|
|
test('accepts a real .jsonl inside the root', () => {
|
|
const root = tdir();
|
|
const sub = join(root, 'proj-a');
|
|
mkdirSync(sub);
|
|
const p = join(sub, 'sess.jsonl');
|
|
writeFileSync(p, '{}\n');
|
|
const r = confineTranscriptPath(p, { root });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) expect(r.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('rejects: outside root', () => {
|
|
const root = tdir();
|
|
mkdirSync(join(root, 'inside'));
|
|
const outside = join(root, '..', `gb-outside-${process.pid}.jsonl`);
|
|
writeFileSync(outside, '{}\n');
|
|
try {
|
|
const r = confineTranscriptPath(outside, { root: join(root, 'inside') });
|
|
expect(r).toEqual({ ok: false, reason: 'outside_projects_dir' });
|
|
} finally {
|
|
rmSync(outside, { force: true });
|
|
}
|
|
});
|
|
|
|
test('rejects: non-jsonl extension', () => {
|
|
const root = tdir();
|
|
const p = join(root, 'sess.txt');
|
|
writeFileSync(p, '{}\n');
|
|
expect(confineTranscriptPath(p, { root })).toEqual({ ok: false, reason: 'not_jsonl' });
|
|
});
|
|
|
|
test('rejects: symlink (lstat, never followed)', () => {
|
|
const root = tdir();
|
|
const real = join(root, 'real.jsonl');
|
|
writeFileSync(real, '{}\n');
|
|
const link = join(root, 'link.jsonl');
|
|
symlinkSync(real, link);
|
|
expect(confineTranscriptPath(link, { root })).toEqual({ ok: false, reason: 'symlink' });
|
|
});
|
|
|
|
test('rejects: missing file / non-string / byte cap', () => {
|
|
const root = tdir();
|
|
expect(confineTranscriptPath(join(root, 'nope.jsonl'), { root })).toEqual({ ok: false, reason: 'unreadable' });
|
|
expect(confineTranscriptPath(undefined, { root })).toEqual({ ok: false, reason: 'missing_path' });
|
|
expect(confineTranscriptPath(42 as unknown as string, { root })).toEqual({ ok: false, reason: 'missing_path' });
|
|
const big = join(root, 'big.jsonl');
|
|
writeFileSync(big, 'x'.repeat(64));
|
|
expect(confineTranscriptPath(big, { root, maxBytes: 16 })).toEqual({ ok: false, reason: 'too_large' });
|
|
});
|
|
|
|
test('rejects: directory named like a transcript', () => {
|
|
const root = tdir();
|
|
const dirAsFile = join(root, 'dir.jsonl');
|
|
mkdirSync(dirAsFile);
|
|
expect(confineTranscriptPath(dirAsFile, { root })).toEqual({ ok: false, reason: 'not_file' });
|
|
});
|
|
});
|