mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* feat(mcp,context): ambient recall — context_pack + delta frozen verbs + boundary runtime (#1) Two new frozen MEMORY_VERBS (context_pack, delta) on the pull surface + a Claude Code hook boundary runtime on the push surface, sharing one stateless assembler core (assembleTurnContext mode: turn|pack|delta) and a keyset session cursor (migration v126). World-only by default; include_private gated fail-closed to trusted-local. protocol_version stays 1 (additive 5→7 verbs). Survived three adversarial review waves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v0.45.7.0 feat(mcp,context): ambient recall — context_pack + delta frozen verbs + boundary runtime (#1) Two new frozen MEMORY_VERBS (context_pack, delta) grow the frozen set 5→7 without a wire bump — all seven stamp protocol_version: 1. context_pack assembles a deterministic, zero-LLM, budget-packed bundle (entity cards + open threads + hot facts) for a set of standing entities; delta returns only what changed since a timestamp for cheap heartbeats, with a per-session keyset cursor for at-least-once delivery. A boundary runtime wires these into Claude Code lifecycle hooks (SessionStart warm pack, PreCompact entity banking for post-compaction rehydration); Codex and any MCP host pull the same verbs at their own boundaries. World-only by default on all arms; include_private widens only for local trusted callers. Migration v126 adds session_context_state (additive). Includes the coverage close-out wave (~55 tests): real-serve compact→ session-start round trip over the live socket, --surface verbs stdio session pinning exactly 7 tools fail-closed, HTTP-transport verb calls with per-token cursor isolation, Postgres engine-parity for keyset pagination + the session-cursor table, migration v126 shape + rewind test, sub-second latency gates, CLI-level invocations, rendered-protocol boundary assertions, and a live-Codex boundary-call check. The wave caught and fixed three real bugs: the delta CLI wedging on first wake (floating GC promise racing engine teardown), the compact hook probing the PGLite socket on a Postgres config with a leftover database_path, and the verbs-surface banner hardcoding a stale verb count. Also the /document-release sweep: stale "five verbs" → seven across the protocol doc, README, INSTALL, DEPLOY, the Claude Code MCP guide, and the query skill; deferred scope filed in TODOS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(release): bump openclaw.plugin.json to 0.45.7.0 — the sixth version location The #4033 merge auto-resolved the OpenClaw plugin manifest at master's version while the trio moved to 0.45.7.0, failing the manifest drift test on CI shard 4. Register the file in CLAUDE.md's version-locations table (five → six) so every future ship and merge re-bumps it with the trio. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
90 lines
4.0 KiB
TypeScript
90 lines
4.0 KiB
TypeScript
/**
|
|
* v0.45.7 — gcSessionContextState per-(source, client) LRU cap, driven through
|
|
* the REAL function via the injectable `maxRowsPerClient` param (previously
|
|
* only pinned by an inline-SQL mirror in ambient-recall.test.ts). Hermetic
|
|
* in-memory PGLite.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import {
|
|
getSessionContextState,
|
|
upsertSessionContextState,
|
|
gcSessionContextState,
|
|
MAX_ROWS_PER_CLIENT,
|
|
} from '../src/core/context/session-state.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM session_context_state');
|
|
});
|
|
|
|
/** Pin updated_at to a deterministic recent offset (inside the 7-day age
|
|
* window, so the cap arm — not the age arm — decides survival). */
|
|
async function ageRow(sessionId: string, minutesAgo: number): Promise<void> {
|
|
await engine.executeRaw(
|
|
`UPDATE session_context_state SET updated_at = now() - ($1 || ' minutes')::interval WHERE session_id = $2`,
|
|
[String(minutesAgo), sessionId],
|
|
);
|
|
}
|
|
|
|
describe('gcSessionContextState — per-(source, client) LRU cap', () => {
|
|
test('default cap stays MAX_ROWS_PER_CLIENT=1000 (exported for callers/tests)', () => {
|
|
expect(MAX_ROWS_PER_CLIENT).toBe(1000);
|
|
});
|
|
|
|
test('cap=2 keeps the 2 NEWEST rows of the capped lane; other lanes untouched', async () => {
|
|
// 4 sessions in ONE (source, client) lane, deterministically ordered.
|
|
for (const [s, min] of [['c1', 4], ['c2', 3], ['c3', 2], ['c4', 1]] as const) {
|
|
await upsertSessionContextState(engine, 'default', 'capclient', s, { cursorSlug: s });
|
|
await ageRow(s, min);
|
|
}
|
|
// Same source, DIFFERENT client — its lane is partitioned separately.
|
|
await upsertSessionContextState(engine, 'default', 'other-client', 'oc1', { cursorSlug: 'oc1' });
|
|
// Same client id, DIFFERENT source — also a separate lane.
|
|
await upsertSessionContextState(engine, 'other-source', 'capclient', 'os1', { cursorSlug: 'os1' });
|
|
|
|
await gcSessionContextState(engine, 7, 2);
|
|
|
|
// Oldest two evicted, newest two kept.
|
|
expect(await getSessionContextState(engine, 'default', 'capclient', 'c1')).toBeNull();
|
|
expect(await getSessionContextState(engine, 'default', 'capclient', 'c2')).toBeNull();
|
|
expect(await getSessionContextState(engine, 'default', 'capclient', 'c3')).not.toBeNull();
|
|
expect(await getSessionContextState(engine, 'default', 'capclient', 'c4')).not.toBeNull();
|
|
// Single-row lanes are under the cap — untouched.
|
|
expect(await getSessionContextState(engine, 'default', 'other-client', 'oc1')).not.toBeNull();
|
|
expect(await getSessionContextState(engine, 'other-source', 'capclient', 'os1')).not.toBeNull();
|
|
});
|
|
|
|
test('lane exactly AT the cap is untouched (rn > cap, not >=)', async () => {
|
|
for (const [s, min] of [['a1', 2], ['a2', 1]] as const) {
|
|
await upsertSessionContextState(engine, 'default', 'atcap', s, { cursorSlug: s });
|
|
await ageRow(s, min);
|
|
}
|
|
await gcSessionContextState(engine, 7, 2);
|
|
expect(await getSessionContextState(engine, 'default', 'atcap', 'a1')).not.toBeNull();
|
|
expect(await getSessionContextState(engine, 'default', 'atcap', 'a2')).not.toBeNull();
|
|
});
|
|
|
|
test('age-based GC still works alongside the injectable cap', async () => {
|
|
await upsertSessionContextState(engine, 'default', 'ageclient', 'old', { cursorSlug: 'x' });
|
|
await upsertSessionContextState(engine, 'default', 'ageclient', 'fresh', { cursorSlug: 'y' });
|
|
await engine.executeRaw(
|
|
`UPDATE session_context_state SET updated_at = now() - interval '30 days' WHERE session_id = 'old'`,
|
|
);
|
|
await gcSessionContextState(engine, 7, 2);
|
|
expect(await getSessionContextState(engine, 'default', 'ageclient', 'old')).toBeNull(); // aged out
|
|
expect(await getSessionContextState(engine, 'default', 'ageclient', 'fresh')).not.toBeNull();
|
|
});
|
|
});
|