mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +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>
524 lines
23 KiB
TypeScript
524 lines
23 KiB
TypeScript
/**
|
|
* v0.43 (#2095) — push-based context core: window parsing, multi-turn
|
|
* extraction, confidence-gated volunteering, slug-only suppression, privacy,
|
|
* and the usage-stats join. Hermetic in-memory PGLite (no file lock),
|
|
* modeled on test/retrieval-reflex.test.ts.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { normalizeAlias } from '../src/core/search/alias-normalize.ts';
|
|
import {
|
|
extractCandidatesFromWindow,
|
|
MAX_CANDIDATES,
|
|
type WindowTurn,
|
|
} from '../src/core/context/entity-salience.ts';
|
|
import { resolveEntitiesToPointers } from '../src/core/context/retrieval-reflex.ts';
|
|
import {
|
|
parseWindow,
|
|
volunteerContext,
|
|
volunteerUsageStats,
|
|
gateVolunteeredPointers,
|
|
candidatesByNorm,
|
|
VOLUNTEER_DEFAULT_MIN_CONFIDENCE,
|
|
} from '../src/core/context/volunteer.ts';
|
|
import type { PointerBlock } from '../src/core/context/retrieval-reflex.ts';
|
|
import { insertVolunteerEvents } from '../src/core/context/volunteer-events.ts';
|
|
import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
async function seed(slug: string, title: string, body: string, source = 'default') {
|
|
if (source !== 'default') {
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name, local_path) VALUES ($1, $1, $2)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
[source, `/tmp/${source}`],
|
|
);
|
|
}
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
|
|
VALUES ($1, $2, 'person', $3, $4, '')`,
|
|
[slug, source, title, body],
|
|
);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM page_aliases').catch(() => {});
|
|
await engine.executeRaw('DELETE FROM context_volunteer_events').catch(() => {});
|
|
await engine.executeRaw('DELETE FROM pages');
|
|
});
|
|
|
|
describe('parseWindow', () => {
|
|
test('role prefixes split turns oldest → newest', () => {
|
|
const turns = parseWindow('user: ask Alice about the deal\nassistant: noted\nuser: what did she say?');
|
|
expect(turns).toEqual([
|
|
{ role: 'user', text: 'ask Alice about the deal' },
|
|
{ role: 'assistant', text: 'noted' },
|
|
{ role: 'user', text: 'what did she say?' },
|
|
]);
|
|
});
|
|
|
|
test('CRLF input parses identically', () => {
|
|
const turns = parseWindow('user: hello\r\nassistant: hi\r\n');
|
|
expect(turns).toEqual([
|
|
{ role: 'user', text: 'hello' },
|
|
{ role: 'assistant', text: 'hi' },
|
|
]);
|
|
});
|
|
|
|
test('unprefixed text is ONE user turn (echo | volunteer-context just works)', () => {
|
|
const turns = parseWindow('met with Alice Example today\nshe asked about acme');
|
|
expect(turns).toEqual([{ role: 'user', text: 'met with Alice Example today\nshe asked about acme' }]);
|
|
});
|
|
|
|
test('continuation lines append to the open turn', () => {
|
|
const turns = parseWindow('user: first line\nsecond line\nassistant: ok');
|
|
expect(turns[0]).toEqual({ role: 'user', text: 'first line\nsecond line' });
|
|
expect(turns[1]).toEqual({ role: 'assistant', text: 'ok' });
|
|
});
|
|
|
|
test('empty / whitespace input → []', () => {
|
|
expect(parseWindow('')).toEqual([]);
|
|
expect(parseWindow(' \n \n')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('extractCandidatesFromWindow', () => {
|
|
test('merges across turns with occurrence + newest-turn metadata', () => {
|
|
const turns: WindowTurn[] = [
|
|
{ role: 'user', text: 'ask Alice Example about the deal' },
|
|
{ role: 'assistant', text: 'Alice Example said she will follow up' },
|
|
{ role: 'user', text: 'and ping Bob Sample too' },
|
|
];
|
|
const cands = extractCandidatesFromWindow(turns);
|
|
const alice = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Alice Example'));
|
|
const bob = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Bob Sample'));
|
|
expect(alice).toBeDefined();
|
|
expect(alice!.occurrences).toBe(2);
|
|
expect(alice!.inNewestTurn).toBe(false);
|
|
expect(alice!.userMention).toBe(true);
|
|
expect(bob).toBeDefined();
|
|
expect(bob!.inNewestTurn).toBe(true);
|
|
});
|
|
|
|
test('assistant-only mention is flagged (userMention=false)', () => {
|
|
const cands = extractCandidatesFromWindow([
|
|
{ role: 'assistant', text: 'You should talk to Charlie Demo about this' },
|
|
{ role: 'user', text: 'good idea' },
|
|
]);
|
|
const charlie = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Charlie Demo'));
|
|
expect(charlie).toBeDefined();
|
|
expect(charlie!.userMention).toBe(false);
|
|
});
|
|
|
|
test('cap holds across a noisy window', () => {
|
|
const noisy = Array.from({ length: 30 }, (_, i) => `Entity Number${i} did something.`).join(' ');
|
|
const cands = extractCandidatesFromWindow([{ role: 'user', text: noisy }]);
|
|
expect(cands.length).toBeLessThanOrEqual(MAX_CANDIDATES);
|
|
});
|
|
});
|
|
|
|
describe('volunteerContext', () => {
|
|
test('assistant-introduced entity two turns back resolves on a pronoun follow-up', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Alice is an early founder.');
|
|
const turns = parseWindow(
|
|
'user: who should I talk to about the seed round?\n' +
|
|
'assistant: Alice Example led a similar round last year.\n' +
|
|
'user: what did she invest in?',
|
|
);
|
|
const pages = await volunteerContext(engine, turns, { sourceIds: ['default'] });
|
|
expect(pages.length).toBe(1);
|
|
expect(pages[0].slug).toBe('people/alice-example');
|
|
expect(pages[0].arm).toBe('title');
|
|
expect(pages[0].rationale).toContain('assistant-introduced');
|
|
});
|
|
|
|
test('excludeSlugs skips BEFORE the cap: an excluded entity never starves a fresh page', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
|
await seed('people/bob-sample', 'Bob Sample', 'Engineer.');
|
|
const turns = parseWindow('user: intro Alice Example to Bob Sample');
|
|
const pages = await volunteerContext(engine, turns, {
|
|
sourceIds: ['default'],
|
|
maxPages: 1,
|
|
excludeSlugs: new Set(['people/alice-example']),
|
|
});
|
|
// A post-call filter would return [] here (Alice burns the single cap
|
|
// slot, then gets filtered). The pre-cap exclusion hands Bob the slot.
|
|
expect(pages.length).toBe(1);
|
|
expect(pages[0].slug).toBe('people/bob-sample');
|
|
});
|
|
|
|
test('confidence gate drops slug-suffix matches at the default threshold', async () => {
|
|
// Page resolvable ONLY via slug-suffix: title differs from the mention.
|
|
await seed('projects/widget-co', 'The Widget Company Project', 'A project page.');
|
|
const turns = parseWindow('user: any updates on Widget-Co this week?');
|
|
const gated = await volunteerContext(engine, turns, { sourceIds: ['default'] });
|
|
expect(gated).toEqual([]);
|
|
// Lowering min_confidence lets it through with honest provenance.
|
|
const loose = await volunteerContext(engine, turns, { sourceIds: ['default'], minConfidence: 0.5 });
|
|
expect(loose.length).toBe(1);
|
|
expect(loose[0].arm).toBe('slug-suffix');
|
|
expect(loose[0].confidence).toBeLessThan(VOLUNTEER_DEFAULT_MIN_CONFIDENCE);
|
|
});
|
|
|
|
test('alias arm volunteers with boost when mentioned in the newest turn', async () => {
|
|
await seed('people/swami-x', 'Swami X', 'A close friend.');
|
|
await engine.setPageAliases('people/swami-x', 'default', [normalizeAlias('Swami')]);
|
|
const pages = await volunteerContext(
|
|
engine,
|
|
parseWindow('user: Spoke with Swami today'),
|
|
{ sourceIds: ['default'] },
|
|
);
|
|
expect(pages.length).toBe(1);
|
|
expect(pages[0].arm).toBe('alias');
|
|
expect(pages[0].confidence).toBeCloseTo(0.95, 5); // 0.9 + newest-turn boost
|
|
expect(pages[0].rationale).toContain('alias match');
|
|
});
|
|
|
|
test('suppression is slug-only under windowing: a prior-turn MENTION does not suppress', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'A founder.');
|
|
// Prior context contains the TITLE (a bare mention from an earlier turn)
|
|
// but NOT the slug — the page was never actually surfaced.
|
|
const pages = await volunteerContext(
|
|
engine,
|
|
parseWindow('user: ping Alice Example again'),
|
|
{ sourceIds: ['default'], priorContext: 'earlier the user said: tell Alice Example the news' },
|
|
);
|
|
expect(pages.length).toBe(1);
|
|
// A slug in prior context (the page WAS surfaced) does suppress.
|
|
const suppressed = await volunteerContext(
|
|
engine,
|
|
parseWindow('user: ping Alice Example again'),
|
|
{ sourceIds: ['default'], priorContext: 'pointer: people/alice-example was injected last turn' },
|
|
);
|
|
expect(suppressed).toEqual([]);
|
|
});
|
|
|
|
test('privacy: takes-fence content never leaks into the synopsis', async () => {
|
|
const body = `${TAKES_FENCE_BEGIN}\nSECRET_HUNCH_DO_NOT_LEAK\n${TAKES_FENCE_END}\nAlice is a founder.`;
|
|
await seed('people/alice-example', 'Alice Example', body);
|
|
const pages = await volunteerContext(engine, parseWindow('user: about Alice Example'), { sourceIds: ['default'] });
|
|
expect(pages.length).toBe(1);
|
|
expect(JSON.stringify(pages)).not.toContain('SECRET_HUNCH_DO_NOT_LEAK');
|
|
});
|
|
|
|
test('multi-source scope: resolves from both sources, never outside the scope', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.', 'default');
|
|
await seed('people/bob-sample', 'Bob Sample', 'Engineer.', 'team-brain');
|
|
await seed('people/eve-other', 'Eve Other', 'Out of scope.', 'private-brain');
|
|
const turns = parseWindow('user: intro Alice Example to Bob Sample and Eve Other');
|
|
const pages = await volunteerContext(engine, turns, { sourceIds: ['default', 'team-brain'] });
|
|
const keys = pages.map((p) => `${p.source_id}:${p.slug}`).sort();
|
|
expect(keys).toEqual(['default:people/alice-example', 'team-brain:people/bob-sample']);
|
|
});
|
|
|
|
test('zero-candidate fast path: no entities → [] without touching resolution', async () => {
|
|
const pages = await volunteerContext(engine, parseWindow('user: ok thanks, sounds good'), {
|
|
sourceIds: ['default'],
|
|
});
|
|
expect(pages).toEqual([]);
|
|
});
|
|
|
|
test('max_pages caps at 5 even when asked for more', async () => {
|
|
for (let i = 0; i < 8; i++) {
|
|
await seed(`people/person-${i}`, `Person Alpha${i}`, 'A person.');
|
|
}
|
|
const text = Array.from({ length: 8 }, (_, i) => `Person Alpha${i}`).join(' and ');
|
|
const pages = await volunteerContext(engine, parseWindow(`user: intro ${text}`), {
|
|
sourceIds: ['default'],
|
|
maxPages: 50,
|
|
});
|
|
expect(pages.length).toBeLessThanOrEqual(5);
|
|
});
|
|
});
|
|
|
|
describe('gateVolunteeredPointers — direct unit (the pure gate step)', () => {
|
|
const BLOCK: PointerBlock = {
|
|
pointers: [
|
|
{ display: 'Alice Example', slug: 'people/alice-example', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9 },
|
|
{ display: 'Widget Co', slug: 'companies/widget-co', source_id: 'default', synopsis: 'y', arm: 'slug-suffix', confidence: 0.6 },
|
|
],
|
|
text: 'BLOCK',
|
|
};
|
|
const CANDS = candidatesByNorm(
|
|
extractCandidatesFromWindow([{ role: 'user', text: 'Alice Example met Widget Co' }]),
|
|
);
|
|
|
|
test('gates below-threshold arms out; passes alias arm with newest-turn boost', () => {
|
|
const pages = gateVolunteeredPointers(BLOCK, CANDS, { windowSize: 1 });
|
|
expect(pages.map((p) => p.slug)).toEqual(['people/alice-example']); // slug-suffix 0.6+0.05 < 0.7
|
|
expect(pages[0].confidence).toBeCloseTo(0.95); // 0.9 alias + 0.05 newest-turn
|
|
});
|
|
|
|
test('idempotent: re-gating the survivors changes nothing', () => {
|
|
const once = gateVolunteeredPointers(BLOCK, CANDS, { windowSize: 1 });
|
|
const survivorsAsBlock: PointerBlock = {
|
|
pointers: once.map((p) => ({ display: p.display, slug: p.slug, source_id: p.source_id, synopsis: p.synopsis, arm: p.arm, confidence: p.confidence })),
|
|
text: 'SURVIVORS',
|
|
};
|
|
const twice = gateVolunteeredPointers(survivorsAsBlock, CANDS, { windowSize: 1 });
|
|
expect(twice.map((p) => p.slug)).toEqual(once.map((p) => p.slug));
|
|
});
|
|
|
|
test('excludeSlugs skips BEFORE the cap so a recurring slug never starves new pages', () => {
|
|
const pages = gateVolunteeredPointers(BLOCK, CANDS, {
|
|
windowSize: 1,
|
|
excludeSlugs: new Set(['people/alice-example']),
|
|
});
|
|
expect(pages).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('volunteerUsageStats', () => {
|
|
test('join math: used = last_retrieved_at > volunteered_at, labeled approximate', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
|
await seed('people/bob-sample', 'Bob Sample', 'Engineer.');
|
|
await insertVolunteerEvents(engine, [
|
|
{ source_id: 'default', slug: 'people/alice-example', confidence: 0.9, match_arm: 'alias', rationale: 'r', channel: 'op' },
|
|
{ source_id: 'default', slug: 'people/bob-sample', confidence: 0.8, match_arm: 'title', rationale: 'r', channel: 'op' },
|
|
]);
|
|
// Alice was opened AFTER being volunteered; Bob never was.
|
|
await engine.executeRaw(
|
|
`UPDATE pages SET last_retrieved_at = now() + interval '1 minute' WHERE slug = 'people/alice-example'`, [],
|
|
);
|
|
const stats = await volunteerUsageStats(engine, ['default'], 30);
|
|
expect(stats.approximate).toBe(true);
|
|
expect(stats.note).toContain('approximate');
|
|
expect(stats.total_volunteered).toBe(2);
|
|
expect(stats.total_used).toBe(1);
|
|
const alias = stats.by_arm.find((a) => a.match_arm === 'alias')!;
|
|
expect(alias.used).toBe(1);
|
|
expect(alias.precision).toBe(1);
|
|
const title = stats.by_arm.find((a) => a.match_arm === 'title')!;
|
|
expect(title.used).toBe(0);
|
|
});
|
|
|
|
test('zero events → zeroed stats, not an error', async () => {
|
|
const stats = await volunteerUsageStats(engine, ['default'], 7);
|
|
expect(stats.total_volunteered).toBe(0);
|
|
expect(stats.by_arm).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('resolveEntitiesToPointers — new provenance surface (back-compat)', () => {
|
|
test('pointers carry arm + confidence + source_id', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
|
const block = await resolveEntitiesToPointers(
|
|
engine,
|
|
'default',
|
|
[{ display: 'Alice Example', query: 'Alice Example' }],
|
|
{},
|
|
);
|
|
expect(block).not.toBeNull();
|
|
expect(block!.pointers[0].arm).toBe('title');
|
|
expect(block!.pointers[0].confidence).toBe(0.8);
|
|
expect(block!.pointers[0].source_id).toBe('default');
|
|
});
|
|
|
|
test('legacy suppression (slug-and-title) still drops a title mention in prior context', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
|
const block = await resolveEntitiesToPointers(
|
|
engine,
|
|
'default',
|
|
[{ display: 'Alice Example', query: 'Alice Example' }],
|
|
{ priorContextText: 'we discussed Alice Example earlier' },
|
|
);
|
|
expect(block).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('volunteer_context op (contract surface)', () => {
|
|
const { operationsByName } = require('../src/core/operations.ts') as typeof import('../src/core/operations.ts');
|
|
const {
|
|
awaitPendingVolunteerEventWrites,
|
|
_resetPendingVolunteerEventWritesForTests,
|
|
} = require('../src/core/context/volunteer-events.ts') as typeof import('../src/core/context/volunteer-events.ts');
|
|
|
|
function mkCtx(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
engine,
|
|
config: {} as never,
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} } as never,
|
|
dryRun: false,
|
|
remote: false,
|
|
sourceId: 'default',
|
|
...overrides,
|
|
} as never;
|
|
}
|
|
|
|
test('registered, read-scope, not localOnly, stdin cliHint on window', () => {
|
|
const op = operationsByName.volunteer_context;
|
|
expect(op).toBeDefined();
|
|
expect(op.scope).toBe('read');
|
|
expect(op.localOnly).toBeFalsy();
|
|
expect(op.cliHints?.name).toBe('volunteer-context');
|
|
expect(op.cliHints?.stdin).toBe('window');
|
|
});
|
|
|
|
test('window required unless stats: true', async () => {
|
|
const op = operationsByName.volunteer_context;
|
|
await expect(op.handler(mkCtx(), {})).rejects.toThrow(/window is required/);
|
|
const stats = (await op.handler(mkCtx(), { stats: true })) as any;
|
|
expect(stats.approximate).toBe(true);
|
|
});
|
|
|
|
test('volunteers pages and logs events through the drained sink', async () => {
|
|
_resetPendingVolunteerEventWritesForTests();
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
|
const op = operationsByName.volunteer_context;
|
|
const result = (await op.handler(mkCtx(), {
|
|
window: 'user: ping Alice Example about the deal',
|
|
session_id: 's-42',
|
|
turn: 7,
|
|
})) as any;
|
|
expect(result.count).toBe(1);
|
|
expect(result.pages[0].slug).toBe('people/alice-example');
|
|
expect(result.window_turns).toBe(1);
|
|
// Event row lands via the fire-and-forget sink once drained.
|
|
const { unfinished } = await awaitPendingVolunteerEventWrites(5_000);
|
|
expect(unfinished).toBe(0);
|
|
const rows = await engine.executeRaw<{ slug: string; channel: string; session_id: string; turn: number }>(
|
|
`SELECT slug, channel, session_id, turn FROM context_volunteer_events`, [],
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].slug).toBe('people/alice-example');
|
|
expect(rows[0].channel).toBe('op');
|
|
expect(rows[0].session_id).toBe('s-42');
|
|
expect(Number(rows[0].turn)).toBe(7);
|
|
});
|
|
|
|
test('event-log failure never fails the op (failing engine injected for the INSERT)', async () => {
|
|
_resetPendingVolunteerEventWritesForTests();
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
|
const op = operationsByName.volunteer_context;
|
|
// Wrap the engine: reads pass through, INSERTs into the events table throw.
|
|
const failingEngine = new Proxy(engine, {
|
|
get(target, prop, receiver) {
|
|
if (prop === 'executeRaw') {
|
|
return (sql: string, params: unknown[]) => {
|
|
if (/INSERT INTO context_volunteer_events/.test(sql)) {
|
|
return Promise.reject(new Error('telemetry db down'));
|
|
}
|
|
return target.executeRaw(sql, params);
|
|
};
|
|
}
|
|
return Reflect.get(target, prop, receiver);
|
|
},
|
|
});
|
|
const result = (await op.handler(mkCtx({ engine: failingEngine }), {
|
|
window: 'user: ping Alice Example',
|
|
})) as any;
|
|
expect(result.count).toBe(1); // the volunteer result is unaffected
|
|
const { unfinished } = await awaitPendingVolunteerEventWrites(5_000);
|
|
expect(unfinished).toBe(0); // failed write settled (swallowed), not stuck
|
|
});
|
|
|
|
test('federated grant scopes the volunteer (allowedSources)', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.', 'default');
|
|
await seed('people/bob-sample', 'Bob Sample', 'Engineer.', 'grant-brain');
|
|
await seed('people/eve-other', 'Eve Other', 'Out of grant.', 'secret-brain');
|
|
const op = operationsByName.volunteer_context;
|
|
const result = (await op.handler(
|
|
mkCtx({ remote: true, auth: { allowedSources: ['grant-brain'] } }),
|
|
{ window: 'user: intro Alice Example to Bob Sample and Eve Other' },
|
|
)) as any;
|
|
expect(result.pages.map((p: any) => p.slug)).toEqual(['people/bob-sample']);
|
|
});
|
|
|
|
test('stats mode is source-scoped and returns the approximate note', async () => {
|
|
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
|
await insertVolunteerEvents(engine, [
|
|
{ source_id: 'default', slug: 'people/alice-example', confidence: 0.9, match_arm: 'alias', rationale: 'r', channel: 'watch' },
|
|
]);
|
|
const op = operationsByName.volunteer_context;
|
|
const stats = (await op.handler(mkCtx(), { stats: true, days: 7 })) as any;
|
|
expect(stats.days).toBe(7);
|
|
expect(stats.total_volunteered).toBe(1);
|
|
expect(stats.by_arm[0].channel).toBe('watch');
|
|
expect(stats.note).toContain('approximate');
|
|
});
|
|
});
|
|
|
|
describe('knob clamps — untrusted MCP caller inputs (review hardening)', () => {
|
|
test('minConfidence outside [0,1] (or NaN) falls back to the 0.7 default gate', async () => {
|
|
await seed('projects/widget-co', 'The Widget Company Project', 'A project.');
|
|
const turns = parseWindow('user: updates on Widget-Co?');
|
|
for (const bad of [5, -1, Number.NaN]) {
|
|
const pages = await volunteerContext(engine, turns, { sourceIds: ['default'], minConfidence: bad });
|
|
expect(pages).toEqual([]); // slug-suffix (0.6+boost) stays gated at the default 0.7
|
|
}
|
|
});
|
|
|
|
test('maxPages 0 / negative / NaN fall back to the 3-page default', async () => {
|
|
for (let i = 0; i < 5; i++) await seed(`people/person-${i}`, `Person Alpha${i}`, 'A person.');
|
|
const text = Array.from({ length: 5 }, (_, i) => `Person Alpha${i}`).join(' and ');
|
|
for (const bad of [0, -3, Number.NaN]) {
|
|
const pages = await volunteerContext(engine, parseWindow(`user: intro ${text}`), {
|
|
sourceIds: ['default'],
|
|
maxPages: bad,
|
|
});
|
|
expect(pages.length).toBeLessThanOrEqual(3);
|
|
expect(pages.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('stats days <= 0 / NaN falls back to 30', async () => {
|
|
const stats = await volunteerUsageStats(engine, ['default'], -5);
|
|
expect(stats.days).toBe(30);
|
|
const stats2 = await volunteerUsageStats(engine, ['default'], Number.NaN);
|
|
expect(stats2.days).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('window-cap ordering — the newest user mention survives the cap', () => {
|
|
test('stale assistant-only chatter is dropped before a newest-turn user entity', async () => {
|
|
const { extractCandidatesFromWindow: extract, MAX_CANDIDATES: CAP } = await import('../src/core/context/entity-salience.ts');
|
|
// 14 stale assistant-introduced entities in turn 1, then the user names
|
|
// ONE entity in the newest turn. The cap (12) must keep the user's.
|
|
const stale = Array.from({ length: 14 }, (_, i) => `Stale Chatter${i}`).join(', ');
|
|
const cands = extract([
|
|
{ role: 'assistant', text: `consider ${stale}.` },
|
|
{ role: 'user', text: 'actually ask Alice Example first' },
|
|
]);
|
|
expect(cands.length).toBeLessThanOrEqual(CAP);
|
|
const alice = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Alice Example'));
|
|
expect(alice).toBeDefined();
|
|
// Recency + user-role weighting puts the newest user mention FIRST.
|
|
expect(normalizeAlias(cands[0].query)).toBe(normalizeAlias('Alice Example'));
|
|
});
|
|
});
|
|
|
|
describe('volunteer-events sink — timeout branch (long-lived process safety)', () => {
|
|
test('a hung write reports unfinished and drops the snapshot (no ghost references)', async () => {
|
|
const {
|
|
logVolunteerEventsFireAndForget,
|
|
awaitPendingVolunteerEventWrites,
|
|
_resetPendingVolunteerEventWritesForTests,
|
|
_peekPendingVolunteerEventWritesForTests,
|
|
} = await import('../src/core/context/volunteer-events.ts');
|
|
_resetPendingVolunteerEventWritesForTests();
|
|
const hangingEngine = {
|
|
executeRaw: () => new Promise(() => { /* never settles */ }),
|
|
} as never;
|
|
logVolunteerEventsFireAndForget(hangingEngine, [
|
|
{ source_id: 'default', slug: 'people/x', confidence: 0.9, match_arm: 'alias', rationale: 'r', channel: 'watch' },
|
|
]);
|
|
const { unfinished } = await awaitPendingVolunteerEventWrites(20);
|
|
expect(unfinished).toBe(1);
|
|
// Snapshot dropped so a long-lived `gbrain watch` never accumulates
|
|
// references to forever-pending work (the last-retrieved C1 class).
|
|
expect(_peekPendingVolunteerEventWritesForTests()).toBe(0);
|
|
_resetPendingVolunteerEventWritesForTests();
|
|
});
|
|
});
|