mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
v0.45.7.0 feat(mcp,context): ambient recall — context_pack + delta frozen verbs + boundary runtime (#1) (#4028)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3f22f51e5d
commit
15ecc65b24
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.6.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.45.7.0 -->
|
||||
<!-- This stamp must equal the VERSION file at every release; CI enforces it
|
||||
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
|
||||
the installed binary and warns on skew. -->
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.45.7.0] - 2026-08-12
|
||||
|
||||
**Ambient recall: your brain shows up at the moments that matter, not just when you ask.** Long-lived agents lose the thread at session boundaries — a fresh start with no warm context, a compaction that drops verbatim detail nothing rehydrates, a heartbeat that re-derives state from scratch. This release adds two new memory verbs that assemble a budget-packed, zero-LLM bundle of exactly what a boundary needs, and wires them into the agent's lifecycle hooks so a warm pack lands automatically at session start and after compaction. It's opt-in, fail-open, and reaches every host: Claude Code gets it pushed through hooks; Codex and any MCP host pull the same two verbs at their own boundaries. Whether your brain is embedded (PGLite) or managed (Postgres), the ambient value is the same.
|
||||
|
||||
### Added
|
||||
- **`context_pack` — a deterministic, budget-packed boundary bundle.** `gbrain context-pack --entities a,b,c --budget-tokens 4000` returns entity cards, open threads, and top facts for a set of standing entities, trimmed to the token budget (cards first, then facts) with no model call in the path — sub-second on a large brain. Response reports `budget_used` and `dropped_count`. World-visible by default; private facts are included only for a local trusted caller that passes `--include-private`, and never over a remote connection.
|
||||
- **`delta` — cheap "what changed since".** `gbrain delta --since <ISO8601>` returns only the pages, facts, and thread changes newer than a timestamp — the right shape for a heartbeat that wants to maintain warm state in proportion to what changed, not re-read everything. Pass a stable `--session-id` and each call advances a per-session cursor so the next wake sees only what's new, with at-least-once delivery when a change tail spills past the budget.
|
||||
- **Boundary runtime for Claude Code.** Session start injects a warm context pack; a pre-compaction hook banks the window's standing entities so the session that resumes after a compaction rehydrates what the summary lost. Every boundary hook fails open and honors `GBRAIN_HOOKS=0`.
|
||||
- **Ambient-recall guide + published latency classes.** New `docs/guides/ambient-recall.md` maps where each verb belongs — `entity` per message, `context_pack`/`delta` at boundaries, `synthesize` never in the ambient path — with per-harness recipes. The memory-verbs protocol doc now carries a latency table for all seven verbs.
|
||||
|
||||
### Changed
|
||||
- The frozen memory-verb set grows from five to seven — `context_pack` and `delta` join `recall`/`remember`/`entity`/`synthesize`/`forget`. The wire protocol is unchanged: all seven verbs stamp `protocol_version: 1`, so existing harnesses keep working untouched and simply gain two tools.
|
||||
|
||||
### Fixed
|
||||
- `gbrain delta --session-id <id>` no longer hangs after printing its response — the CLI now exits cleanly on first wake (a background cleanup task raced process teardown). This is the exact command the heartbeat template tells agents to run.
|
||||
- On a Postgres brain whose config carries a leftover local database path, the pre-compaction hook now degrades cleanly instead of probing a local socket that has no server behind it — matching the session-start hook's behavior.
|
||||
- The `--surface verbs` startup banner now reports the actual verb count instead of a hardcoded five.
|
||||
|
||||
### Hardening
|
||||
- The boundary behavior is now pinned end to end, not just in units: a real spawned serve answers the compact→session-start warm-pack round trip over its real socket; a real stdio MCP session on `--surface verbs` advertises and serves exactly the seven verbs fail-closed; the new verbs are exercised over real HTTP with per-token session-cursor isolation; keyset pagination and the session-cursor table are parity-pinned on real Postgres; migration shape, sub-second latency gates, CLI invocations, and a live-Codex boundary-call check round it out (~55 new tests).
|
||||
|
||||
To take advantage of v0.45.7.0: upgrade with `bun install -g github:garrytan/gbrain#latest-stable`. A schema migration runs automatically on first use — a new per-session cursor table, additive, no existing data touched. Codex and other MCP hosts see the two new verbs immediately; Claude Code installs pick up the boundary hooks on the next `gbrain bootstrap`. Read `docs/guides/ambient-recall.md` for where each verb belongs and how to wire your heartbeat to `delta`.
|
||||
## [0.45.6.0] - 2026-08-12
|
||||
|
||||
**Seventeen new production skills, distilled from a 324-skill audit of a mature personal-agent deployment.** The built-in pack grows from ~52 to 69 skills and picks up the trust disciplines a memory product lives or dies by: corrections that fix the source instead of papering over it, a confirmation gate before anything irreversible, claim verification before anything ships, an ingest gate that stops duplicate and misfiled pages at the door, and a sanitization procedure for turning a personal brain into a team brain. Every import was adversarially reviewed, privacy-scrubbed onto generic placeholders, pinned to its upstream source, and shipped with routing fixtures.
|
||||
|
||||
@@ -38,7 +38,7 @@ mount, CEO-class with multiple team brains) and
|
||||
|
||||
## Architecture
|
||||
|
||||
Contract-first: `src/core/operations.ts` defines 100+ shared operations (including `volunteer_context` — push-based context, see `docs/guides/push-context.md` — and the five frozen MEMORY_VERBS `recall`/`remember`/`entity`/`synthesize`/`forget`, servable alone via `gbrain serve --surface verbs`, see `docs/protocol/MEMORY_VERBS_v1.md`). CLI and MCP
|
||||
Contract-first: `src/core/operations.ts` defines 100+ shared operations (including `volunteer_context` — push-based context, see `docs/guides/push-context.md` — and the seven frozen MEMORY_VERBS `recall`/`remember`/`entity`/`synthesize`/`forget`/`context_pack`/`delta` — the last two are v0.45.7 ambient-recall boundary verbs (budget-packed pack + "what changed since"), all seven stamp `protocol_version: 1`, servable alone via `gbrain serve --surface verbs`, see `docs/protocol/MEMORY_VERBS_v1.md` + `docs/guides/ambient-recall.md`). CLI and MCP
|
||||
server are both generated from this single source. Engine factory (`src/core/engine-factory.ts`)
|
||||
dynamically imports the configured engine (`'pglite'` or `'postgres'`). Skills are fat
|
||||
markdown files (tool-agnostic, work with both CLI and plugin contexts).
|
||||
@@ -481,7 +481,7 @@ ms, max waiters) for `--json`; a one-line summary prints to stderr.
|
||||
|
||||
## Version locations (single source of truth: `VERSION` file)
|
||||
|
||||
Every release advances the version in **five files at once**. Keep these in
|
||||
Every release advances the version in **six files at once**. Keep these in
|
||||
sync. `/ship` enforces this via Step 12's idempotency check (VERSION vs
|
||||
package.json drift), but the canonical list lives here so future runs and
|
||||
the auto-update agent know where to look.
|
||||
@@ -497,7 +497,7 @@ four numeric segments are required first. Historical 3-segment versions
|
||||
(`0.31.3`, `0.22.1`) remain valid in `git log` and migration filenames
|
||||
(`skills/migrations/v0.21.0.md`); do NOT rewrite them. Going forward only.
|
||||
|
||||
**Required (every release must update all five):**
|
||||
**Required (every release must update all six):**
|
||||
|
||||
| File | What lives there | Format |
|
||||
|---|---|---|
|
||||
@@ -506,6 +506,7 @@ four numeric segments are required first. Historical 3-segment versions
|
||||
| `CHANGELOG.md` | Top entry header `## [0.31.4.1] - YYYY-MM-DD` plus the "To take advantage of v0.31.4.1" block. | Standard Keep-a-Changelog header. |
|
||||
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z.W" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z.W` references in TODO bodies. |
|
||||
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z.W (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z.W (#NNN, contributed by @user)` references. |
|
||||
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.7.0"` |
|
||||
|
||||
**Auto-derived (no manual edit; refreshed by their own commands):**
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ Postgres-at-scale, Supabase, and thin-client setup paths live in [`docs/INSTALL.
|
||||
|
||||
### Connect GBrain to your AI client (MCP)
|
||||
|
||||
GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a handful of local-only ops stay CLI-side) — or exactly the five memory verbs with `--surface verbs`. The specific snippet depends on which client you use:
|
||||
GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a handful of local-only ops stay CLI-side) — or exactly the seven memory verbs with `--surface verbs`. The specific snippet depends on which client you use:
|
||||
|
||||
- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token).
|
||||
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# TODOS
|
||||
|
||||
## Ambient recall follow-ups (filed v0.45.7.0, issue #1)
|
||||
|
||||
Deferred from the ambient-recall wave (`context_pack` + `delta` frozen verbs +
|
||||
boundary runtime; CEO+ENG cleared, plan at
|
||||
`~/.claude/plans/system-instruction-you-are-working-vectorized-gem.md`). Each was
|
||||
explicitly scoped OUT with a one-line rationale — none is a bug, all are additive.
|
||||
|
||||
- [ ] **Autonomous transcript watchers (D3=B).** The shipped event contract covers session boundaries (start, compaction, heartbeat) but relies on the harness emitting a lifecycle event. A per-harness transcript watcher would drive ambient recall for harnesses that can't emit — but watchers are fragile and compaction is often invisible on disk. Add per harness that proves it can't emit a boundary event. Priority: P3.
|
||||
- [ ] **Materialized `thread_state` table.** `delta`'s thread-change arm derives open-thread deltas from facts/timeline `updated_at` scans. If a perf gate ever forces it, materialize a `thread_state` table instead of deriving. Not needed until the derive-path SLO is threatened. Priority: P3.
|
||||
- [ ] **Codex native boundary hooks.** Codex has no hooks upstream (`CODEX_HAS_HOOKS=false`), so its ambient path is pull-only (AGENTS.md gate tells it to call `context_pack`/`delta` at boundaries). When Codex ships a hook mechanism, register the boundary events the way the Claude Code lane does; the IPC `context_pack` kind + `--harness codex` attribution channel are already reserved for it. Priority: P3.
|
||||
## Brain-currency harness-e2e follow-ups (filed with the PR-A wave)
|
||||
|
||||
- [ ] **P1 — Extend engine-identity convergence to the other long-lived planes.**
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ The agent spawns `gbrain serve` as a stdio subprocess against your local brain.
|
||||
|
||||
```bash
|
||||
gbrain serve # stdio MCP (Claude Desktop / Code / Cursor)
|
||||
gbrain serve --surface verbs # stdio MCP, just the 5 memory verbs (quickstart)
|
||||
gbrain serve --surface verbs # stdio MCP, just the 7 memory verbs (quickstart)
|
||||
gbrain serve --http # HTTP MCP with OAuth 2.1 + admin dashboard
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Ambient recall — placing retrieval at session boundaries
|
||||
|
||||
Long-lived agent harnesses (your OpenClaw, Hermes, Codex, Claude Code) get the
|
||||
most value from the brain not on every message, but at the moments where a fresh
|
||||
question rarely fires on its own: **session start, right after compaction, and
|
||||
on heartbeats.** This guide is the Pareto frontier of where to place each verb.
|
||||
|
||||
The bottleneck for a long-lived agent is not retrieval quality — the corpus
|
||||
answers well when asked. It is **placement**: the misses come from moments when
|
||||
no question fires. Two frozen verbs close that gap with 2-3 deterministic calls
|
||||
per session instead of per-message overhead.
|
||||
|
||||
## The frontier — which verb goes where
|
||||
|
||||
| Moment | Call | Why | Cost |
|
||||
|---|---|---|---|
|
||||
| Any entity-bearing message | `entity(name)` | Zero-LLM, p99 < 100ms. Safe to run synchronously almost anywhere. | negligible |
|
||||
| **Session start** | `context_pack(entities, budget_tokens)` | Warm the thread's 1-3 standing entities before the first message. | zero-LLM, sub-second |
|
||||
| **After compaction** | `context_pack(entities, budget_tokens)` | Rehydrate the verbatim detail the summary dropped. | zero-LLM, sub-second |
|
||||
| **Heartbeat / periodic wake** | `delta(session_id, budget_tokens)` | "What changed since my last wake" in O(changes), deduped. | zero-LLM, sub-second |
|
||||
| Explicit memory question | `recall(query \| entity, budget_tokens)` | The budget-packed read for "what do we know that we SAVED about X". | sub-second (+1 embedding if `query`) |
|
||||
| Answer needs cross-page reasoning | `synthesize(question)` | LLM-backed. **Never** on a hot or ambient path. | seconds-to-minutes, $$ |
|
||||
|
||||
Observed shape: per-message retrieval beyond `entity` cards adds latency faster
|
||||
than insight; session-start packs and post-compaction rehydration are nearly
|
||||
pure win. See the per-verb latency table in
|
||||
[`docs/protocol/MEMORY_VERBS_v1.md`](../protocol/MEMORY_VERBS_v1.md#latency-classes-per-verb).
|
||||
|
||||
## Two integration surfaces
|
||||
|
||||
- **Pull (works everywhere, including Codex + Postgres/Supabase):** the harness
|
||||
calls `context_pack` / `delta` over MCP (they are on `--surface verbs`) or the
|
||||
CLI (`gbrain context-pack`, `gbrain delta`) at the boundary and injects the
|
||||
returned `text` (or renders the structured arms). This is the portable path —
|
||||
no hooks required. It is the primary path for Codex (which has no hooks) and
|
||||
for Postgres brains (which have no local IPC socket).
|
||||
- **Push (PGLite + Claude Code):** the bundled hook framework fires
|
||||
automatically at `SessionStart` (injects a warm pack — including the
|
||||
post-compaction re-entry, `source=compact`) and `PreCompact` (banks the
|
||||
window's standing entities for that rehydration pack). Heartbeat deltas are
|
||||
the PULL path — there is deliberately no push heartbeat; call `delta` per
|
||||
the HEARTBEAT cadence table.
|
||||
|
||||
## Visibility — world-only by default
|
||||
|
||||
A pack is injected into an agent context window that may be logged or synced to a
|
||||
cloud model, so **every arm is world-visibility by default.** To pull private
|
||||
facts in, pass `include_private` — and it is honored ONLY for trusted-local
|
||||
callers (`remote === false`, i.e. the CLI/hook path). A remote MCP caller never
|
||||
widens, even if it asks (fail-closed). When it does widen, all arms widen
|
||||
together, so a pack is never a mix of private facts beside world-stripped
|
||||
synopses.
|
||||
|
||||
## Budgets
|
||||
|
||||
Every pack/delta call takes `budget_tokens`. The server packs highest-priority
|
||||
arms first (cards → facts for packs; pages → facts for deltas) and reports
|
||||
`budget_used` + `dropped_count`; the injectable `text` field is rendered from
|
||||
the packed sets, so it honors the same budget the structured arrays report. It
|
||||
never trims client-side — you always know what was left out (`dropped_count`,
|
||||
and `has_more` on deltas). Pick a budget to fit the boundary: a session-start
|
||||
pack can afford more than a heartbeat delta.
|
||||
|
||||
## Heartbeat cursor + dedup
|
||||
|
||||
Pass a stable `session_id` to `delta` and the brain keeps a per-session cursor:
|
||||
the first wake establishes it, each wake advances it. Dedup is **cursor-based**
|
||||
— a delivered page reappears only if it changes again after delivery (and then
|
||||
it should). Delivery is **at-least-once**: pages arrive oldest-first, and when
|
||||
a budget or the fetch limit drops some, the response sets `has_more: true` and
|
||||
the cursor advances only to the newest *delivered* page, so the tail surfaces
|
||||
on the next wake — nothing is silently lost. With no `session_id` you can still
|
||||
pass an explicit `since` for a stateless delta. The cursor is namespaced per
|
||||
caller (`(source_id, client_id, session_id)`; authenticated remotes use their
|
||||
client id, auth-less remotes share a `remote` namespace, and `local` is
|
||||
reserved for the trusted CLI/hook lane), so a remote harness can never read or
|
||||
advance the local lane's cursor. Idle session cursors are garbage-collected
|
||||
after **7 days** — a wake on an expired session re-establishes the cursor at
|
||||
now and returns an empty delta, so a harness returning from a long sleep
|
||||
should run one stateless `since`-based catch-up first.
|
||||
|
||||
## Example — a cold session start (pull)
|
||||
|
||||
```bash
|
||||
gbrain context-pack --entities "acme-example,alice-example" --budget-tokens 4000
|
||||
```
|
||||
|
||||
Returns entity cards + open threads + hot facts, budget-packed, world-only. Inject
|
||||
the `text` field into the model's context before the first user message.
|
||||
+19
-1
@@ -93,11 +93,29 @@ You should see results from your GBrain knowledge base.
|
||||
> older release stay OFF until you opt in. Enable it on the host with
|
||||
> `gbrain config set mcp.publish_skills true`. Skill discovery and the core tools
|
||||
> named here (search, query, get_page, put_page, think, find_experts) are
|
||||
> full-surface — on `--surface verbs` the agent sees only the five memory verbs,
|
||||
> full-surface — on `--surface verbs` the agent sees only the seven memory verbs,
|
||||
> and `list_skills` isn't on the surface at all. Note: `capture` is a
|
||||
> CLI-only command, not an MCP tool — the agent writes over MCP with `put_page`.
|
||||
> Why brains differ on the default: [tutorial A1](../tutorials/connect-coding-agent.md#a1-on-the-host-serve-over-http).
|
||||
|
||||
## Ambient recall at session boundaries (v0.45.7)
|
||||
|
||||
Two frozen verbs close the "no question fired" gap for long-lived sessions:
|
||||
`context_pack` (session-start warm-up + post-compaction rehydration) and
|
||||
`delta` ("what changed since my last wake" for heartbeats). Both are zero-LLM,
|
||||
sub-second, world-visibility by default, and available on `--surface verbs`.
|
||||
|
||||
- **Automatic (PGLite brains via `gbrain bootstrap`):** the bootstrap hook
|
||||
installer wires `SessionStart` (injects a warm pack; also fires on
|
||||
post-compaction re-entry, `source=compact`) and `PreCompact` (banks the
|
||||
window's standing entities so that rehydration pack is warm) into
|
||||
`.claude/settings.local.json`. Nothing to call; `GBRAIN_HOOKS=0` disables.
|
||||
- **Manual (any brain, incl. remote/Postgres):** call the verbs yourself at
|
||||
boundaries — `context_pack(entities, budget_tokens)` at session start /
|
||||
after compaction, `delta(session_id, budget_tokens)` on wakes. See
|
||||
[ambient recall](../guides/ambient-recall.md) for the placement frontier
|
||||
and the per-verb latency table.
|
||||
|
||||
## Remove
|
||||
|
||||
```bash
|
||||
|
||||
+8
-1
@@ -72,6 +72,13 @@ codex mcp remove gbrain
|
||||
- The token is a long-lived, full-access secret. Keep `GBRAIN_REMOTE_TOKEN` out of
|
||||
version control and prefer a scoped token if your host supports one.
|
||||
- Local stdio also works if you run the brain on the same machine:
|
||||
`codex mcp add gbrain -- gbrain serve --surface verbs` — the five-verb memory
|
||||
`codex mcp add gbrain -- gbrain serve --surface verbs` — the memory-verb
|
||||
protocol ([MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)); drop the flag
|
||||
for the full operation catalog.
|
||||
- **Ambient recall (Codex has no lifecycle hooks — use the pull path).** At the
|
||||
start of a topical thread and after a compaction, call
|
||||
`context_pack(entities, budget_tokens)` to warm the standing entities; on a
|
||||
periodic wake call `delta(session_id, budget_tokens)` for "what changed since
|
||||
my last wake" (deduped per session). Both are zero-LLM, sub-second, world-only
|
||||
by default, and on `--surface verbs`. See
|
||||
[ambient recall](../guides/ambient-recall.md) for the placement frontier.
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ clients over OAuth 2.1.
|
||||
|
||||
```bash
|
||||
gbrain serve # full operation catalog (default)
|
||||
gbrain serve --surface verbs # just the 5 memory verbs (quickstart surface)
|
||||
gbrain serve --surface verbs # just the 7 memory verbs (quickstart surface)
|
||||
```
|
||||
|
||||
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# MEMORY_VERBS v1 — the memory wire protocol
|
||||
|
||||
GBrain's frozen five-verb memory interface over MCP: `recall`, `remember`,
|
||||
`entity`, `synthesize`, `forget`. The contract every harness can rely on the
|
||||
GBrain's frozen memory-verb interface over MCP: `recall`, `remember`,
|
||||
`entity`, `synthesize`, `forget`, plus (v0.45.7, additive) `context_pack` and
|
||||
`delta` — seven verbs, all at `protocol_version: 1`. The contract every harness can rely on the
|
||||
way every Postgres client relies on the wire protocol — and the contract any
|
||||
OTHER memory server can implement and certify against
|
||||
(`gbrain protocol conformance --target <endpoint>`).
|
||||
@@ -10,7 +11,7 @@ OTHER memory server can implement and certify against
|
||||
agent (any MCP harness)
|
||||
│ remember("picked Stripe over Adyen", provenance: "chat 2026-06-11")
|
||||
▼
|
||||
five verbs ── recall ── remember ── entity ── synthesize ── forget
|
||||
seven verbs recall ─ remember ─ entity ─ synthesize ─ forget ─ context_pack ─ delta
|
||||
│ self-describing envelopes: protocol_version, evidence, provenance,
|
||||
│ budget meta, cost block, enumerated error codes + a populated fix
|
||||
▼
|
||||
@@ -38,12 +39,17 @@ the same registry.
|
||||
- Enum values are part of the contract. Where an enum's DERIVATION is
|
||||
implementation-defined (noted per field), implementations may improve the
|
||||
derivation without a version bump; the values and their meanings stay fixed.
|
||||
- **Adding a VERB is additive, not a version bump.** v0.45.7 grew the frozen set
|
||||
from 5 to 7 (`context_pack`, `delta`) at `protocol_version: 1`. New verbs are
|
||||
new optional surface a v1 client discovers via tool-listing; the existing five
|
||||
keep stamping `1`. Bumping `protocol_version` would rewrite the frozen five's
|
||||
wire output and break every client that pins `== 1` — so we don't.
|
||||
|
||||
## Install (the 4-command quickstart)
|
||||
|
||||
```bash
|
||||
gbrain init --pglite # 2-second local brain
|
||||
claude mcp add gbrain -- gbrain serve --surface verbs # the five-verb surface
|
||||
claude mcp add gbrain -- gbrain serve --surface verbs # the memory-verb surface
|
||||
gbrain remember "I prefer dark mode in every editor" --provenance demo --entity people/me
|
||||
gbrain recall --entity people/me # …now ask your agent in a NEW session
|
||||
```
|
||||
@@ -63,7 +69,7 @@ codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
|
||||
each client.
|
||||
|
||||
**Surface modes:** `--surface verbs` exposes EXACTLY the five verbs —
|
||||
**Surface modes:** `--surface verbs` exposes EXACTLY the seven verbs —
|
||||
advertised list AND dispatch are filtered fail-closed (a hidden op returns
|
||||
`unknown_tool` even when called by name). `--surface full` (the default)
|
||||
exposes every operation, verbs included. Why default full: verbs is for
|
||||
@@ -186,7 +192,71 @@ already-expired fact returns `expired: false` (success); unknown id ⇒
|
||||
|
||||
Response: `{ id, expired, reason, protocol_version }`.
|
||||
|
||||
## Error contract (uniform across all five verbs)
|
||||
### context_pack(entities, budget_tokens?, since?, session_id?, include_private?) — read, zero LLM
|
||||
|
||||
v0.45.7 (issue #1). One deterministic, budget-packed bundle for a set of standing
|
||||
entities — entity cards + open threads + hot facts. Built for **session
|
||||
boundaries**: call it at session start to warm cold context, and immediately
|
||||
after compaction to rehydrate what the summary dropped. Composes existing arms
|
||||
(`entity` card builder + the hot-facts arm); never calls an LLM.
|
||||
|
||||
`entities` is comma-separated, capped at 8 (the response echoes the capped list). `budget_tokens` packs
|
||||
server-side (cards first, then facts) and the response reports
|
||||
`budget_used` + `dropped_count` — it never trims client-side. `since` filters
|
||||
open-thread events to those after the cursor. **Visibility is WORLD-ONLY by
|
||||
default** on every arm (a pack is injected into an agent context window that may
|
||||
be logged or synced to a cloud model). `include_private` widens ALL arms in
|
||||
lockstep, and is honored ONLY for trusted-local callers (`remote === false`); a
|
||||
remote caller never widens (fail-closed).
|
||||
|
||||
Response: `{ protocol_version, entities, cards[], open_threads[], facts[], text,
|
||||
degraded_reason?, budget_tokens?, budget_used?, dropped_count? }`. `text` is the
|
||||
pre-rendered, envelope-wrapped injectable block.
|
||||
|
||||
### delta(since?, entities?, budget_tokens?, session_id?, include_private?) — read, zero LLM
|
||||
|
||||
v0.45.7 (issue #1). "What changed since T" for heartbeats — pages updated after
|
||||
the cursor (oldest first) + facts recorded after the cursor + open-thread
|
||||
events after the cursor. Lets a periodic wake maintain warm state in
|
||||
O(changes) instead of re-deriving. Provide `since` (ISO 8601) OR a
|
||||
`session_id` whose cursor carries the last wake. Delivery is **at-least-once**:
|
||||
when a budget or the fetch limit drops pages, `has_more: true` is set and the
|
||||
session cursor advances only to the newest DELIVERED page — the undelivered
|
||||
tail surfaces on the next wake, never silently lost. Dedup is cursor-based (a
|
||||
delivered page reappears only if it changes again). Same world-only-default +
|
||||
`include_private` fail-closed rule as `context_pack`. The session cursor is
|
||||
keyed `(source_id, client_id, session_id)` — authenticated remote callers are
|
||||
namespaced by their auth client id, auth-less remotes share the `'remote'`
|
||||
sentinel, and `'local'` is RESERVED for the trusted CLI/hook lane, so a remote
|
||||
harness can never read or advance the local lane's cursor.
|
||||
|
||||
Delivery is at-least-once via a **keyset cursor `(updated_at, slug)`**: a cluster
|
||||
of pages sharing one `updated_at` (bulk syncs stamp identical timestamps) pages
|
||||
deterministically by slug, so a >fetch-limit cluster drains across wakes instead
|
||||
of livelocking. Stateless callers resume by passing the response's
|
||||
`next_cursor.since` + `next_cursor.slug` back as `since` + `since_slug`;
|
||||
`session_id` callers get this automatically.
|
||||
|
||||
Response: `{ protocol_version, since, pages[], facts[], threads[], text,
|
||||
has_more, next_cursor: { since, slug }, degraded_reason?, budget_tokens?,
|
||||
budget_used?, dropped_count? }`. `text` is rendered from the budget-packed sets
|
||||
(it honors the declared budget) and `since` is always normalized ISO (never the
|
||||
raw input string).
|
||||
|
||||
## Latency classes (per verb)
|
||||
|
||||
Published so harness authors place calls by cost, not by learning at timeout:
|
||||
|
||||
| Verb | Class | Notes |
|
||||
|---|---|---|
|
||||
| `entity` | zero-LLM, **p99 < 100ms** | CI-gated on a 20K-page corpus (below). Safe per entity-bearing message. |
|
||||
| `context_pack` | zero-LLM, sub-second | Fan-out capped at 8 entities. Session boundaries, not per-message. Push path passes a wall-clock deadline and returns a PARTIAL pack (`degraded_reason`) rather than overrun. |
|
||||
| `delta` | zero-LLM, sub-second | O(changes). Heartbeats — pull path only (there is no push heartbeat); session cursors expire after 7 idle days. |
|
||||
| `recall` | zero-LLM (keyword) to one embedding call (when `query` is passed) | Sub-second typical; the `query` arm adds one embedding round-trip. |
|
||||
| `remember` / `forget` | write, sub-second | One durable write; `remember` adds one embedding call for dedup when a provider is configured. |
|
||||
| `synthesize` | **EXPENSIVE / SLOW** | LLM calls, seconds-to-minutes, costs money. Never place on a hot or ambient path. |
|
||||
|
||||
## Error contract (uniform across all verbs)
|
||||
|
||||
```json
|
||||
{ "error": "<code>", "message": "...", "suggestion": "problem + cause + fix",
|
||||
|
||||
+82
-11
@@ -193,7 +193,7 @@ mount, CEO-class with multiple team brains) and
|
||||
|
||||
## Architecture
|
||||
|
||||
Contract-first: `src/core/operations.ts` defines 100+ shared operations (including `volunteer_context` — push-based context, see `docs/guides/push-context.md` — and the five frozen MEMORY_VERBS `recall`/`remember`/`entity`/`synthesize`/`forget`, servable alone via `gbrain serve --surface verbs`, see `docs/protocol/MEMORY_VERBS_v1.md`). CLI and MCP
|
||||
Contract-first: `src/core/operations.ts` defines 100+ shared operations (including `volunteer_context` — push-based context, see `docs/guides/push-context.md` — and the seven frozen MEMORY_VERBS `recall`/`remember`/`entity`/`synthesize`/`forget`/`context_pack`/`delta` — the last two are v0.45.7 ambient-recall boundary verbs (budget-packed pack + "what changed since"), all seven stamp `protocol_version: 1`, servable alone via `gbrain serve --surface verbs`, see `docs/protocol/MEMORY_VERBS_v1.md` + `docs/guides/ambient-recall.md`). CLI and MCP
|
||||
server are both generated from this single source. Engine factory (`src/core/engine-factory.ts`)
|
||||
dynamically imports the configured engine (`'pglite'` or `'postgres'`). Skills are fat
|
||||
markdown files (tool-agnostic, work with both CLI and plugin contexts).
|
||||
@@ -636,7 +636,7 @@ ms, max waiters) for `--json`; a one-line summary prints to stderr.
|
||||
|
||||
## Version locations (single source of truth: `VERSION` file)
|
||||
|
||||
Every release advances the version in **five files at once**. Keep these in
|
||||
Every release advances the version in **six files at once**. Keep these in
|
||||
sync. `/ship` enforces this via Step 12's idempotency check (VERSION vs
|
||||
package.json drift), but the canonical list lives here so future runs and
|
||||
the auto-update agent know where to look.
|
||||
@@ -652,7 +652,7 @@ four numeric segments are required first. Historical 3-segment versions
|
||||
(`0.31.3`, `0.22.1`) remain valid in `git log` and migration filenames
|
||||
(`skills/migrations/v0.21.0.md`); do NOT rewrite them. Going forward only.
|
||||
|
||||
**Required (every release must update all five):**
|
||||
**Required (every release must update all six):**
|
||||
|
||||
| File | What lives there | Format |
|
||||
|---|---|---|
|
||||
@@ -661,6 +661,7 @@ four numeric segments are required first. Historical 3-segment versions
|
||||
| `CHANGELOG.md` | Top entry header `## [0.31.4.1] - YYYY-MM-DD` plus the "To take advantage of v0.31.4.1" block. | Standard Keep-a-Changelog header. |
|
||||
| `TODOS.md` | Any TODO entries that mention "follow-up from vX.Y.Z.W" use the version of the release that filed them. Update only when filing NEW follow-up TODOs. | Inline `vX.Y.Z.W` references in TODO bodies. |
|
||||
| `CLAUDE.md` | The Key Files section's per-file annotations carry `vX.Y.Z.W (#NNN)` tags noting which release introduced a behavior. Update whenever a wave's annotations get folded in. | Inline `vX.Y.Z.W (#NNN, contributed by @user)` references. |
|
||||
| `openclaw.plugin.json` | OpenClaw plugin manifest (v0.45.6.0, #4033). Hand-maintained; `test/openclaw-plugin-manifest.test.ts` fails the suite if it drifts from `package.json`. Merges from master auto-resolve it to master's version — re-bump it with the trio. | `"version": "0.45.7.0"` |
|
||||
|
||||
**Auto-derived (no manual edit; refreshed by their own commands):**
|
||||
|
||||
@@ -1743,7 +1744,7 @@ Postgres-at-scale, Supabase, and thin-client setup paths live in [`docs/INSTALL.
|
||||
|
||||
### Connect GBrain to your AI client (MCP)
|
||||
|
||||
GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a handful of local-only ops stay CLI-side) — or exactly the five memory verbs with `--surface verbs`. The specific snippet depends on which client you use:
|
||||
GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a handful of local-only ops stay CLI-side) — or exactly the seven memory verbs with `--surface verbs`. The specific snippet depends on which client you use:
|
||||
|
||||
- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token).
|
||||
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
|
||||
@@ -3978,7 +3979,7 @@ clients over OAuth 2.1.
|
||||
|
||||
```bash
|
||||
gbrain serve # full operation catalog (default)
|
||||
gbrain serve --surface verbs # just the 5 memory verbs (quickstart surface)
|
||||
gbrain serve --surface verbs # just the 7 memory verbs (quickstart surface)
|
||||
```
|
||||
|
||||
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
|
||||
@@ -4309,8 +4310,9 @@ Source: https://raw.githubusercontent.com/garrytan/gbrain/master/docs/protocol/M
|
||||
|
||||
# MEMORY_VERBS v1 — the memory wire protocol
|
||||
|
||||
GBrain's frozen five-verb memory interface over MCP: `recall`, `remember`,
|
||||
`entity`, `synthesize`, `forget`. The contract every harness can rely on the
|
||||
GBrain's frozen memory-verb interface over MCP: `recall`, `remember`,
|
||||
`entity`, `synthesize`, `forget`, plus (v0.45.7, additive) `context_pack` and
|
||||
`delta` — seven verbs, all at `protocol_version: 1`. The contract every harness can rely on the
|
||||
way every Postgres client relies on the wire protocol — and the contract any
|
||||
OTHER memory server can implement and certify against
|
||||
(`gbrain protocol conformance --target <endpoint>`).
|
||||
@@ -4319,7 +4321,7 @@ OTHER memory server can implement and certify against
|
||||
agent (any MCP harness)
|
||||
│ remember("picked Stripe over Adyen", provenance: "chat 2026-06-11")
|
||||
▼
|
||||
five verbs ── recall ── remember ── entity ── synthesize ── forget
|
||||
seven verbs recall ─ remember ─ entity ─ synthesize ─ forget ─ context_pack ─ delta
|
||||
│ self-describing envelopes: protocol_version, evidence, provenance,
|
||||
│ budget meta, cost block, enumerated error codes + a populated fix
|
||||
▼
|
||||
@@ -4347,12 +4349,17 @@ the same registry.
|
||||
- Enum values are part of the contract. Where an enum's DERIVATION is
|
||||
implementation-defined (noted per field), implementations may improve the
|
||||
derivation without a version bump; the values and their meanings stay fixed.
|
||||
- **Adding a VERB is additive, not a version bump.** v0.45.7 grew the frozen set
|
||||
from 5 to 7 (`context_pack`, `delta`) at `protocol_version: 1`. New verbs are
|
||||
new optional surface a v1 client discovers via tool-listing; the existing five
|
||||
keep stamping `1`. Bumping `protocol_version` would rewrite the frozen five's
|
||||
wire output and break every client that pins `== 1` — so we don't.
|
||||
|
||||
## Install (the 4-command quickstart)
|
||||
|
||||
```bash
|
||||
gbrain init --pglite # 2-second local brain
|
||||
claude mcp add gbrain -- gbrain serve --surface verbs # the five-verb surface
|
||||
claude mcp add gbrain -- gbrain serve --surface verbs # the memory-verb surface
|
||||
gbrain remember "I prefer dark mode in every editor" --provenance demo --entity people/me
|
||||
gbrain recall --entity people/me # …now ask your agent in a NEW session
|
||||
```
|
||||
@@ -4372,7 +4379,7 @@ codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
|
||||
each client.
|
||||
|
||||
**Surface modes:** `--surface verbs` exposes EXACTLY the five verbs —
|
||||
**Surface modes:** `--surface verbs` exposes EXACTLY the seven verbs —
|
||||
advertised list AND dispatch are filtered fail-closed (a hidden op returns
|
||||
`unknown_tool` even when called by name). `--surface full` (the default)
|
||||
exposes every operation, verbs included. Why default full: verbs is for
|
||||
@@ -4495,7 +4502,71 @@ already-expired fact returns `expired: false` (success); unknown id ⇒
|
||||
|
||||
Response: `{ id, expired, reason, protocol_version }`.
|
||||
|
||||
## Error contract (uniform across all five verbs)
|
||||
### context_pack(entities, budget_tokens?, since?, session_id?, include_private?) — read, zero LLM
|
||||
|
||||
v0.45.7 (issue #1). One deterministic, budget-packed bundle for a set of standing
|
||||
entities — entity cards + open threads + hot facts. Built for **session
|
||||
boundaries**: call it at session start to warm cold context, and immediately
|
||||
after compaction to rehydrate what the summary dropped. Composes existing arms
|
||||
(`entity` card builder + the hot-facts arm); never calls an LLM.
|
||||
|
||||
`entities` is comma-separated, capped at 8 (the response echoes the capped list). `budget_tokens` packs
|
||||
server-side (cards first, then facts) and the response reports
|
||||
`budget_used` + `dropped_count` — it never trims client-side. `since` filters
|
||||
open-thread events to those after the cursor. **Visibility is WORLD-ONLY by
|
||||
default** on every arm (a pack is injected into an agent context window that may
|
||||
be logged or synced to a cloud model). `include_private` widens ALL arms in
|
||||
lockstep, and is honored ONLY for trusted-local callers (`remote === false`); a
|
||||
remote caller never widens (fail-closed).
|
||||
|
||||
Response: `{ protocol_version, entities, cards[], open_threads[], facts[], text,
|
||||
degraded_reason?, budget_tokens?, budget_used?, dropped_count? }`. `text` is the
|
||||
pre-rendered, envelope-wrapped injectable block.
|
||||
|
||||
### delta(since?, entities?, budget_tokens?, session_id?, include_private?) — read, zero LLM
|
||||
|
||||
v0.45.7 (issue #1). "What changed since T" for heartbeats — pages updated after
|
||||
the cursor (oldest first) + facts recorded after the cursor + open-thread
|
||||
events after the cursor. Lets a periodic wake maintain warm state in
|
||||
O(changes) instead of re-deriving. Provide `since` (ISO 8601) OR a
|
||||
`session_id` whose cursor carries the last wake. Delivery is **at-least-once**:
|
||||
when a budget or the fetch limit drops pages, `has_more: true` is set and the
|
||||
session cursor advances only to the newest DELIVERED page — the undelivered
|
||||
tail surfaces on the next wake, never silently lost. Dedup is cursor-based (a
|
||||
delivered page reappears only if it changes again). Same world-only-default +
|
||||
`include_private` fail-closed rule as `context_pack`. The session cursor is
|
||||
keyed `(source_id, client_id, session_id)` — authenticated remote callers are
|
||||
namespaced by their auth client id, auth-less remotes share the `'remote'`
|
||||
sentinel, and `'local'` is RESERVED for the trusted CLI/hook lane, so a remote
|
||||
harness can never read or advance the local lane's cursor.
|
||||
|
||||
Delivery is at-least-once via a **keyset cursor `(updated_at, slug)`**: a cluster
|
||||
of pages sharing one `updated_at` (bulk syncs stamp identical timestamps) pages
|
||||
deterministically by slug, so a >fetch-limit cluster drains across wakes instead
|
||||
of livelocking. Stateless callers resume by passing the response's
|
||||
`next_cursor.since` + `next_cursor.slug` back as `since` + `since_slug`;
|
||||
`session_id` callers get this automatically.
|
||||
|
||||
Response: `{ protocol_version, since, pages[], facts[], threads[], text,
|
||||
has_more, next_cursor: { since, slug }, degraded_reason?, budget_tokens?,
|
||||
budget_used?, dropped_count? }`. `text` is rendered from the budget-packed sets
|
||||
(it honors the declared budget) and `since` is always normalized ISO (never the
|
||||
raw input string).
|
||||
|
||||
## Latency classes (per verb)
|
||||
|
||||
Published so harness authors place calls by cost, not by learning at timeout:
|
||||
|
||||
| Verb | Class | Notes |
|
||||
|---|---|---|
|
||||
| `entity` | zero-LLM, **p99 < 100ms** | CI-gated on a 20K-page corpus (below). Safe per entity-bearing message. |
|
||||
| `context_pack` | zero-LLM, sub-second | Fan-out capped at 8 entities. Session boundaries, not per-message. Push path passes a wall-clock deadline and returns a PARTIAL pack (`degraded_reason`) rather than overrun. |
|
||||
| `delta` | zero-LLM, sub-second | O(changes). Heartbeats — pull path only (there is no push heartbeat); session cursors expire after 7 idle days. |
|
||||
| `recall` | zero-LLM (keyword) to one embedding call (when `query` is passed) | Sub-second typical; the `query` arm adds one embedding round-trip. |
|
||||
| `remember` / `forget` | write, sub-second | One durable write; `remember` adds one embedding call for dedup when a provider is configured. |
|
||||
| `synthesize` | **EXPENSIVE / SLOW** | LLM calls, seconds-to-minutes, costs money. Never place on a hot or ambient path. |
|
||||
|
||||
## Error contract (uniform across all verbs)
|
||||
|
||||
```json
|
||||
{ "error": "<code>", "message": "...", "suggestion": "problem + cause + fix",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.45.6.0",
|
||||
"version": "0.45.7.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.6.0",
|
||||
"version": "0.45.7.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -34,7 +34,7 @@ mutating: false
|
||||
Answer questions using the brain's knowledge with 3-layer search and synthesis.
|
||||
|
||||
> **Memory verbs (MEMORY_VERBS v1, gbrain ≥ 0.43).** When connected to a brain
|
||||
> over MCP, prefer the five frozen memory verbs for memory work — they carry
|
||||
> over MCP, prefer the seven frozen memory verbs for memory work — they carry
|
||||
> provenance, evidence, and a server-enforced token budget:
|
||||
> - **`recall(query | entity, budget_tokens)`** — the budget-packed memory read.
|
||||
> Use it instead of bare `search` for "what do we know that we SAVED about X".
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"perplexity-research/routing-eval.jsonl": "f1a40d87e710d5d2acd602a372d83f46c95da022b6e635228fffeaacb3bb2b27",
|
||||
"plugin-exclusions.json": "585486aaaf9a87ec4b13bea5d03f5e9af34a9ac64283c878c234ba094126f793",
|
||||
"publish/SKILL.md": "e06b609db780a3cc93a1755a87b30ff08ffdc0fdbc834c1422b2ad2489b57497",
|
||||
"query/SKILL.md": "8672fb9c9315f01274b1a7d3ad35f903df9705b2e806e2fa4c6add027ecef96f",
|
||||
"query/SKILL.md": "b12aae4e86b893038b1d9e97a977bd6a7939db9f5c57dde12c11d8d7451e0762",
|
||||
"query/routing-eval.jsonl": "74f5a91e52fabc54e0e9403fa17db87ee26bb7ebb8ae8005148c51142abc62fe",
|
||||
"repo-architecture/SKILL.md": "4ec2b8f45d168aaa55f17ecd1ed404ab04217a75c2317f0710c71705846f5394",
|
||||
"reports/SKILL.md": "02b67964afa4543050ea42631ed0a4a0f2946c73541e766bdf6370b6471192d2",
|
||||
|
||||
+132
-1
@@ -53,8 +53,11 @@ import {
|
||||
IPC_UNAVAILABLE,
|
||||
readIpcSecret,
|
||||
requestTurnContext,
|
||||
requestContextPack,
|
||||
resolveSocketPath,
|
||||
CONTEXT_PACK_CLIENT_TIMEOUT_MS,
|
||||
type TurnContextResponse,
|
||||
type ContextPackResponse,
|
||||
} from '../core/context/resolve-ipc.ts';
|
||||
import type { WindowTurn } from '../core/context/entity-salience.ts';
|
||||
import {
|
||||
@@ -157,6 +160,9 @@ Events (wired into .claude/settings.local.json by gbrain bootstrap):
|
||||
stop append to the per-session live buffer
|
||||
session-end ingest the session transcript into the dream corpus
|
||||
(secret-scanned), prune old corpus files, push the workspace
|
||||
compact (PreCompact) bank the window's standing entities into the
|
||||
session cursor so the post-compaction session-start serves
|
||||
a warm context pack; emits nothing
|
||||
|
||||
Env: GBRAIN_HOOKS=0 disables all events (immediate exit 0).
|
||||
All events fail open: errors exit 0 with empty stdout and a heartbeat entry at
|
||||
@@ -177,7 +183,7 @@ export async function runHook(args: string[], io: HookIo = {}): Promise<number>
|
||||
const v = args[harnessIdx + 1];
|
||||
if (v === 'claude-code' || v === 'codex') io = { ...io, harness: v };
|
||||
}
|
||||
if (!event || !['session-start', 'user-prompt', 'stop', 'session-end'].includes(event)) {
|
||||
if (!event || !['session-start', 'user-prompt', 'stop', 'session-end', 'compact'].includes(event)) {
|
||||
process.stderr.write(USAGE + '\n');
|
||||
return 1;
|
||||
}
|
||||
@@ -194,6 +200,8 @@ export async function runHook(args: string[], io: HookIo = {}): Promise<number>
|
||||
return hookStop(io);
|
||||
case 'session-end':
|
||||
return hookSessionEnd(io);
|
||||
case 'compact':
|
||||
return hookCompact(io);
|
||||
default:
|
||||
return 1; // unreachable
|
||||
}
|
||||
@@ -452,6 +460,42 @@ async function hookSessionStart(io: HookIo): Promise<number> {
|
||||
reason = reasonCode(dirty.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. v0.45.7 ambient recall — boundary context pack over IPC. SessionStart
|
||||
// is Claude Code's cold-start AND post-compaction re-entry point
|
||||
// (source=compact/resume), so this one arm covers both. The server
|
||||
// owns the intelligence (banked entities + since-cursor + advance);
|
||||
// world-only always. Every failure is a silent skip — the file-only
|
||||
// digest above must never be hostage to the brain being down.
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
if (cfg?.engine === 'pglite' && cfg.database_path) {
|
||||
const secret = readIpcSecret(cfg.database_path);
|
||||
if (secret) {
|
||||
// Same sanitizer as the compact banking path — a raw vs sanitized
|
||||
// id would split the cursor key and the warm pack would miss the
|
||||
// banked entities (adversarial review).
|
||||
const sessionId = sanitizeSessionId(j?.session_id);
|
||||
const trigger = typeof j?.source === 'string' ? `session-start:${j.source as string}` : 'session-start';
|
||||
// Clamp the IPC timeout to the REMAINING hook deadline (minus a
|
||||
// 100ms write margin) so the pack call can never be the thing
|
||||
// that blows SESSION_START_DEADLINE_MS.
|
||||
const remaining = SESSION_START_DEADLINE_MS - (Date.now() - t0) - 100;
|
||||
if (remaining > 100) {
|
||||
const res = await requestContextPack(resolveSocketPath(cfg.database_path), {
|
||||
secret,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(process.env.GBRAIN_SOURCE ? { sourceId: process.env.GBRAIN_SOURCE } : {}),
|
||||
trigger,
|
||||
}, { timeoutMs: Math.min(CONTEXT_PACK_CLIENT_TIMEOUT_MS, remaining) });
|
||||
if (res !== IPC_UNAVAILABLE && !('degraded' in res)) {
|
||||
const pack = res as ContextPackResponse;
|
||||
if (pack.ok && pack.block?.text) out.push(pack.block.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* fail-open: no pack, digest stands alone */ }
|
||||
})();
|
||||
|
||||
const res = await withDeadline(SESSION_START_DEADLINE_MS, work);
|
||||
@@ -894,6 +938,93 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── compact (PreCompact banking, v0.45.7 ambient recall) ─────────────────────
|
||||
|
||||
/** Self-deadline for the compact event (harness timeout is 5s). */
|
||||
export const COMPACT_DEADLINE_MS = 3000;
|
||||
/** Banking wants breadth, not the 4-turn prompt window. */
|
||||
const COMPACT_WINDOW_TURNS = 20;
|
||||
|
||||
/**
|
||||
* PreCompact fires BEFORE Claude Code compacts the transcript. Its stdout is
|
||||
* NOT context-injected — the useful work is the WRITE: extract the window's
|
||||
* standing entities (server-side) and bank them into the session cursor so the
|
||||
* post-compaction SessionStart (source=compact) serves a warm rehydration
|
||||
* pack. Engine-free: transcript parse + one IPC round trip. Fail-open always.
|
||||
*/
|
||||
async function hookCompact(io: HookIo): Promise<number> {
|
||||
const t0 = Date.now();
|
||||
let outcome: HookHeartbeatEntry['outcome'] = 'ok';
|
||||
let reason: string | undefined;
|
||||
|
||||
const work = (async () => {
|
||||
const j = await readStdinJson(io, 300);
|
||||
if (!j) { outcome = 'degraded'; reason = 'no_stdin'; return; }
|
||||
|
||||
// S3#8 posture matches user-prompt: an unconfined transcript path aborts.
|
||||
let turns: WindowTurn[] = [];
|
||||
if (j.transcript_path !== undefined && j.transcript_path !== null) {
|
||||
const conf = confineTranscriptPath(j.transcript_path, {
|
||||
...(io.transcriptRoot ? { root: io.transcriptRoot } : {}),
|
||||
});
|
||||
if (!conf.ok) { outcome = 'degraded'; reason = `transcript_${conf.reason}`; return; }
|
||||
try {
|
||||
const parsed = parseTranscript(conf.path, { maxBytes: USER_PROMPT_TRANSCRIPT_MAX_BYTES });
|
||||
turns = parsed.turns.slice(-COMPACT_WINDOW_TURNS);
|
||||
} catch {
|
||||
turns = [];
|
||||
}
|
||||
}
|
||||
// sanitizeSessionId maps a MISSING id to the 'unknown' sentinel (fine for
|
||||
// the stop buffer's filenames, wrong for cursor banking — a shared
|
||||
// 'unknown' bucket would cross-pollinate sessions). Treat it as absent.
|
||||
const sid = sanitizeSessionId(j?.session_id);
|
||||
const sessionId = sid === 'unknown' ? null : sid;
|
||||
if (!sessionId || turns.length === 0) {
|
||||
// Nothing to bank against — not an error, just nothing to do.
|
||||
if (outcome === 'ok') reason = sessionId ? 'empty_window' : 'no_session';
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
// Same engine gate as the session-start pack arm (v0.45.7 symmetry): a
|
||||
// Postgres config carrying a leftover database_path must not probe the
|
||||
// PGLite socket — there is no serve behind it for this brain.
|
||||
if (cfg?.engine !== 'pglite' || !cfg.database_path) { outcome = 'degraded'; reason = 'no_pglite_path'; return; }
|
||||
const secret = readIpcSecret(cfg.database_path);
|
||||
if (!secret) { outcome = 'degraded'; reason = 'no_serve'; return; }
|
||||
|
||||
const res = await requestContextPack(resolveSocketPath(cfg.database_path), {
|
||||
secret,
|
||||
sessionId,
|
||||
window: turns,
|
||||
bankOnly: true,
|
||||
trigger: 'compact-bank',
|
||||
...(process.env.GBRAIN_SOURCE ? { sourceId: process.env.GBRAIN_SOURCE } : {}),
|
||||
});
|
||||
if (res === IPC_UNAVAILABLE) { outcome = 'degraded'; reason = 'ipc_unavailable'; return; }
|
||||
if ('degraded' in res && res.degraded === 'stale_serve') { outcome = 'degraded'; reason = 'stale_serve'; return; }
|
||||
const resp = res as ContextPackResponse;
|
||||
if (!resp.ok) { outcome = 'degraded'; reason = reasonCode(resp.error ?? 'server_error'); }
|
||||
})();
|
||||
|
||||
try {
|
||||
const raced = await withDeadline(COMPACT_DEADLINE_MS, work);
|
||||
if (raced === DEADLINE && outcome === 'ok') { outcome = 'degraded'; reason = 'deadline'; }
|
||||
} catch (e) {
|
||||
outcome = 'error';
|
||||
reason = errorCode(e); // fail-open: exit 0
|
||||
}
|
||||
await writeHeartbeat({
|
||||
ts: new Date().toISOString(),
|
||||
event: 'compact',
|
||||
outcome,
|
||||
...(reason ? { reason } : {}),
|
||||
duration_ms: Date.now() - t0,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── stop [G15] ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function hookStop(io: HookIo): Promise<number> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { startMcpServer } from '../mcp/server.ts';
|
||||
import { VERB_NAMES } from '../core/verbs.ts';
|
||||
|
||||
// Maximum time the stdio path will wait for engine.disconnect() (PGLite
|
||||
// close + advisory lock release) before forcing exit. Keeps a wedged
|
||||
@@ -236,7 +237,9 @@ export async function runServe(
|
||||
// and is intentionally NOT wired into this stdio plumbing.
|
||||
console.error(
|
||||
surface === 'verbs'
|
||||
? 'Starting GBrain MCP server (stdio) — serving 5 memory verbs (MEMORY_VERBS v1)...'
|
||||
// v0.45.7: count derives from VERB_NAMES (7 with context_pack + delta)
|
||||
// so the banner can't drift from the frozen set again.
|
||||
? `Starting GBrain MCP server (stdio) — serving ${VERB_NAMES.length} memory verbs (MEMORY_VERBS v1)...`
|
||||
: 'Starting GBrain MCP server (stdio)...',
|
||||
);
|
||||
|
||||
|
||||
@@ -43,26 +43,36 @@ export const TARGETS: Record<string, HostSpecTarget> = {
|
||||
[CLAUDE_CODE_SPEC_ID]: {
|
||||
id: CLAUDE_CODE_SPEC_ID,
|
||||
status: 'verified',
|
||||
verifiedAt: '2026-08-08',
|
||||
verifiedAt: '2026-08-12',
|
||||
references: [
|
||||
'https://code.claude.com/docs/en/hooks',
|
||||
'https://code.claude.com/docs/en/hooks-guide',
|
||||
'https://code.claude.com/docs/en/settings',
|
||||
],
|
||||
note:
|
||||
'Hook events used: SessionStart / UserPromptSubmit / Stop / SessionEnd. ' +
|
||||
'Hooks are written to <workspace>/.claude/settings.local.json (gitignored ' +
|
||||
'by Claude Code by default) as hooks.<Event> → [{matcher?, hooks: ' +
|
||||
'[{type:"command", command, timeout}]}] with timeout in SECONDS. Hook ' +
|
||||
'commands are shell strings (no env map) — env vars are embedded via an ' +
|
||||
'`env K=V …` prefix. Unknown properties on the command object are ' +
|
||||
'Hook events used: SessionStart / UserPromptSubmit / Stop / SessionEnd / ' +
|
||||
'PreCompact. Hooks are written to <workspace>/.claude/settings.local.json ' +
|
||||
'(gitignored by Claude Code by default) as hooks.<Event> → [{matcher?, ' +
|
||||
'hooks: [{type:"command", command, timeout}]}] with timeout in SECONDS. ' +
|
||||
'Hook commands are shell strings (no env map) — env vars are embedded via ' +
|
||||
'an `env K=V …` prefix. Unknown properties on the command object are ' +
|
||||
'tolerated by the harness, which is what makes the `_gbrain` marker key ' +
|
||||
'safe. UserPromptSubmit context injection: stdout JSON ' +
|
||||
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", ' +
|
||||
'additionalContext}}; SessionStart plain stdout becomes context. Hook ' +
|
||||
'stdout is capped at 10000 chars — overflow is diverted to a file and ' +
|
||||
'NOT injected [ENG-1]. Transcripts live under ~/.claude/projects/ as ' +
|
||||
'.jsonl (transcript_path in the hook stdin payload).',
|
||||
'.jsonl (transcript_path in the hook stdin payload). PreCompact stdin ' +
|
||||
'(verified against the published hooks reference, not live capture): the ' +
|
||||
'common fields (session_id, transcript_path, cwd, hook_event_name) plus ' +
|
||||
'trigger:"manual"|"auto" and custom_instructions (the /compact argument ' +
|
||||
'for manual, empty for auto). PreCompact stdout is never context-injected ' +
|
||||
'— only UserPromptSubmit and SessionStart stdout reach the model — and ' +
|
||||
'exit 2 blocks compaction, so the v0.45.7 banking hook must exit 0. ' +
|
||||
'SessionStart stdin carries source:"startup"|"resume"|"clear"|"compact" ' +
|
||||
'(+"fork" since Claude Code v2.1.214); source:"compact" fires after auto ' +
|
||||
'or manual compaction and is the rehydration re-entry the PreCompact ' +
|
||||
'bank serves a warm pack through.',
|
||||
},
|
||||
[CODEX_SPEC_ID]: {
|
||||
id: CODEX_SPEC_ID,
|
||||
@@ -89,12 +99,18 @@ export const TARGETS: Record<string, HostSpecTarget> = {
|
||||
/** Settings file the hook writer targets, relative to the workspace root. */
|
||||
export const CLAUDE_SETTINGS_FILE_RELPATH = join('.claude', 'settings.local.json');
|
||||
|
||||
/** Hook events bootstrap wires (plan D5 + hook events table). */
|
||||
/** Hook events bootstrap wires (plan D5 + hook events table).
|
||||
* v0.45.7 ambient recall adds PreCompact: it BANKS the window's standing
|
||||
* entities into session_context_state so the post-compaction SessionStart
|
||||
* (source=compact — Claude Code's actual rehydration re-entry point) can
|
||||
* serve a warm context pack. PreCompact stdout is NOT context-injected by
|
||||
* the harness; the banking write is the point. */
|
||||
export const CLAUDE_HOOK_EVENTS = [
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'SessionEnd',
|
||||
'PreCompact',
|
||||
] as const;
|
||||
export type ClaudeHookEvent = (typeof CLAUDE_HOOK_EVENTS)[number];
|
||||
|
||||
@@ -104,6 +120,7 @@ export const CLAUDE_HOOK_SUBCOMMAND: Record<ClaudeHookEvent, string> = {
|
||||
UserPromptSubmit: 'user-prompt',
|
||||
Stop: 'stop',
|
||||
SessionEnd: 'session-end',
|
||||
PreCompact: 'compact',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,6 +141,7 @@ export const CLAUDE_HOOK_DEFAULT_TIMEOUT_SECS: Record<ClaudeHookEvent, number> =
|
||||
UserPromptSubmit: 3,
|
||||
Stop: 10,
|
||||
SessionEnd: 60,
|
||||
PreCompact: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,13 +16,13 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'autopilot': ['--aliases', '--all', '--auto-fix', '--batch', '--brain', '--break-lock', '--by-type', '--check', '--dimensions', '--dir', '--drain', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--ff-only', '--fix', '--force', '--force-break-lock', '--force-retry', '--from-pages', '--help', '--http', '--include-null-signature', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--json', '--markdown', '--max-age', '--max-rss', '--max-usd', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-inject', '--no-mutate', '--no-worker', '--non-interactive', '--now', '--once', '--output', '--path', '--pattern', '--pending', '--phase', '--refresh-cache', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--source', '--stale', '--status', '--supersessions', '--surface', '--swap-only', '--target', '--target-score', '--thin', '--timeout', '--to', '--uninstall', '--unsafe-bypass-dream-guard', '--user', '--version', '--yes'],
|
||||
'backfill': ['--aliases', '--all', '--batch-size', '--brain', '--concurrency', '--dry-run', '--fresh', '--help', '--include-null-signature', '--json', '--keep-index', '--list', '--max-errors', '--max-rows', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'bench': ['--baseline', '--brain', '--explain', '--force', '--from', '--help', '--json', '--label', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--restore-only', '--source', '--stale', '--symbol-kind', '--thin', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-top1', '--to', '--tool'],
|
||||
'book-mirror': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--author', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--budget-usd-per-day', '--by-mention', '--chapters-dir', '--content', '--context-file', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-turns', '--max-usd', '--mode', '--model', '--multimodal', '--no-confirm', '--no-embedding', '--no-extract', '--no-follow', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--save', '--session', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--timeout-ms', '--title', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'book-mirror': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--author', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--budget-usd-per-day', '--by-mention', '--chapters-dir', '--content', '--context-file', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-turns', '--max-usd', '--mode', '--model', '--multimodal', '--no-confirm', '--no-embedding', '--no-extract', '--no-follow', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--timeout', '--timeout-ms', '--title', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'bootstrap': ['--abbrev-ref', '--abort', '--accept-visibility-change-consequences', '--all', '--brain', '--branch', '--cached', '--compile', '--confirm', '--delete-brain', '--env', '--exclude-standard', '--fast', '--file', '--flag', '--force', '--from-pages', '--full', '--gbrain-bin', '--get', '--git-dir', '--harness', '--heads', '--help', '--home', '--http', '--init', '--isolated', '--jq', '--json', '--local', '--minimal', '--name-only', '--no-cron', '--no-embedding', '--no-hooks', '--no-verify', '--once', '--only', '--others', '--pat-file', '--path', '--pglite', '--porcelain', '--private', '--push', '--push-only', '--quiet', '--rebase', '--repair', '--scope', '--set', '--short', '--show', '--show-toplevel', '--skip', '--source', '--status', '--surface', '--unset-all', '--verify', '--version', '--visibility', '--workspace', '--yes'],
|
||||
'brainstorm': ['--aliases', '--all', '--brain', '--chunker-debug', '--code', '--compile', '--fast', '--fix', '--force', '--force-rechunk', '--force-resume', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--judge-model', '--lang', '--limit', '--list-runs', '--markdown', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--model', '--no-embed', '--no-embedding', '--no-extract', '--no-save', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--retry-failed', '--retry-judge', '--save', '--source', '--stale', '--strict-budget', '--supersessions', '--surface', '--thin', '--timeout', '--yes'],
|
||||
'cache': ['--brain', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--source', '--surface', '--yes'],
|
||||
'calibration': ['--ab', '--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--holder', '--http', '--image', '--include-null-signature', '--json', '--key-prefix', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--phase', '--progress-interval', '--progress-json', '--quiet', '--regenerate', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scrub-gstack', '--session', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--trusted-extraction', '--undo-wave', '--url', '--with-calibration', '--with-db', '--yes'],
|
||||
'calibration': ['--ab', '--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--dry-run', '--entities', '--explain', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--holder', '--http', '--image', '--include-null-signature', '--json', '--key-prefix', '--kind', '--lang', '--limit', '--markdown', '--max-usd', '--mode', '--multimodal', '--near-symbol', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--phase', '--progress-interval', '--progress-json', '--quiet', '--regenerate', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scrub-gstack', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--trusted-extraction', '--undo-wave', '--url', '--with-calibration', '--with-db', '--yes'],
|
||||
'call': ['--aliases', '--all', '--all-sources', '--as-context', '--auto-fix', '--background', '--brain', '--by-mention', '--catch-up', '--concurrency', '--confirm-destructive', '--content', '--cost-estimate', '--count', '--days', '--depth', '--dim', '--dir', '--direction', '--enable-dcr', '--enable-dcr-insecure', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--from', '--from-meetings', '--grant-types', '--grep', '--hard-deadline', '--help', '--http', '--image', '--include-expired', '--include-frontmatter', '--include-null-signature', '--infer-dates', '--install', '--interval', '--json', '--key', '--kind', '--lang', '--limit', '--link-source', '--link-type', '--llm', '--migrate-only', '--missing-path', '--multimodal', '--ner', '--no-embed', '--no-expand', '--no-extract', '--no-federated', '--no-hard-deadline', '--no-retry-connect', '--no-save', '--older-than', '--page', '--param', '--params', '--password', '--path', '--pattern', '--pending', '--pglite', '--port', '--progress-interval', '--progress-json', '--public-url', '--queue', '--quiet', '--reenrich-after', '--refresh-cache', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scopes', '--session', '--sigma', '--since', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--status', '--stdin', '--strategy', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--synthesize', '--tag', '--thin', '--timeout', '--to', '--today', '--token', '--token-ttl', '--tools-json', '--type', '--uninstall', '--url', '--version', '--watch', '--with-calibration', '--workers', '--yes'],
|
||||
'capture': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--depth', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scopes', '--session', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--stdin', '--supersessions', '--surface', '--thin', '--timeout', '--trusted-extraction', '--type', '--url', '--what', '--where', '--who', '--with-db', '--yes'],
|
||||
'capture': ['--aliases', '--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--depth', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mcp-only', '--mode', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--offset', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--repo', '--reset', '--resolve', '--restore-only', '--save', '--scopes', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--stdin', '--supersessions', '--surface', '--thin', '--timeout', '--trusted-extraction', '--type', '--url', '--what', '--where', '--who', '--with-db', '--yes'],
|
||||
'check-backlinks': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dir', '--dry-run', '--explain', '--follow', '--help', '--include-frontmatter', '--json', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--timeout', '--type'],
|
||||
'check-resolvable': ['--brain', '--dry-run', '--fix', '--help', '--json', '--skills-dir', '--source', '--strict', '--verbose'],
|
||||
'check-update': ['--all', '--brain', '--check', '--ff-only', '--help', '--json', '--markdown', '--migrate-only', '--non-interactive', '--refresh-cache', '--source', '--swap-only', '--to', '--version', '--yes'],
|
||||
@@ -38,7 +38,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--version', '--window', '--yes'],
|
||||
'edges-backfill': ['--aliases', '--all', '--all-sources', '--brain', '--concurrency', '--federated', '--help', '--include-null-signature', '--json', '--max-age', '--max-chunks', '--max-cost-usd', '--no-extract', '--no-federated', '--older-than', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--workers'],
|
||||
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--version'],
|
||||
'enrich': ['--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd-per-day', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--content', '--date', '--days', '--dry-run', '--embedding-dimensions', '--embedding-model', '--entities', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--judge-model', '--kind', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-usd', '--min-context', '--mode', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--offset', '--older-than', '--order', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--reenrich-after', '--remediate', '--reset', '--resolve', '--restore-only', '--resume', '--save', '--session', '--since', '--slug', '--slugs', '--source', '--source-id', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--thin-threshold', '--timeout', '--trusted-extraction', '--types', '--url', '--url-managed', '--version', '--with-db', '--workers', '--yes'],
|
||||
'enrich': ['--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd-per-day', '--by-mention', '--clone-dir', '--code', '--concurrency', '--confirm-destructive', '--content', '--date', '--days', '--dry-run', '--embedding-dimensions', '--embedding-model', '--entities', '--explain', '--fast', '--federated', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--image', '--include-null-signature', '--json', '--judge-model', '--kind', '--lang', '--limit', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-usd', '--min-context', '--mode', '--model', '--multimodal', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--offset', '--older-than', '--order', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--reenrich-after', '--remediate', '--reset', '--resolve', '--restore-only', '--resume', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--source-id', '--stale', '--stats', '--supersessions', '--surface', '--symbol-kind', '--thin', '--thin-threshold', '--timeout', '--trusted-extraction', '--types', '--url', '--url-managed', '--version', '--with-db', '--workers', '--yes'],
|
||||
'eval': ['--ab-relational', '--against', '--aliases', '--all', '--allow-regression', '--background', '--baseline', '--batch', '--brain', '--brain-wide-max-cost-usd', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--committed-baseline', '--compare', '--compare-limit', '--concurrent', '--config-a', '--config-b', '--corpus', '--cycles', '--days', '--dedup-cosine', '--dedup-max-per-page', '--dedup-type-ratio', '--dimensions', '--distance-min', '--embedding-dimensions', '--embedding-model', '--expand', '--explain', '--fast', '--fixtures', '--follow', '--force', '--from-capture', '--from-db', '--from-pages', '--gold', '--grounding-min', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--json', '--judge', '--justification', '--k', '--limit', '--llm', '--max-pair-chars', '--max-tokens', '--max-usd', '--md', '--metric', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--no', '--no-cache', '--no-embed', '--no-embedding', '--no-expand', '--no-extract', '--no-llm', '--older-than', '--out', '--output', '--output-dir', '--parallel', '--pattern', '--pending', '--progress-interval', '--progress-json', '--qrels', '--queries-file', '--query', '--questions', '--quiet', '--receipt-dir', '--refresh-cache', '--remediate', '--reset', '--resolve', '--rrf-k', '--rubric-version', '--runs', '--sampling', '--save', '--seed', '--severity', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--stale', '--strategy', '--strict', '--suite', '--suites', '--supersessions', '--surface', '--task', '--thin', '--threshold', '--threshold-expected-top1', '--threshold-first-relevant-hit', '--threshold-jaccard', '--threshold-latency-multiplier', '--threshold-recall-at-k', '--threshold-top1', '--timeout', '--tool', '--top-k', '--top-regressions', '--until', '--update-baseline', '--usefulness-min', '--verbose', '--version', '--with-code-intel', '--yes'],
|
||||
'export': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dir', '--explain', '--federated', '--fix', '--follow', '--help', '--include-null-signature', '--json', '--lang', '--markdown', '--multimodal', '--near-symbol', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--slug-prefix', '--source', '--stale', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--type'],
|
||||
'extract': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--catch-up', '--code', '--concurrency', '--dir', '--dry-run', '--explain', '--federated', '--follow', '--from-meetings', '--help', '--include-frontmatter', '--include-null-signature', '--infer-dates', '--json', '--kind', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--multimodal', '--name-status', '--near-symbol', '--ner', '--no-extract', '--no-federated', '--older-than', '--pack', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--run-id', '--since', '--slug', '--source', '--source-id', '--stale', '--strategy', '--supersessions', '--symbol-kind', '--thin', '--timeout', '--type', '--verbose', '--workers', '--yes'],
|
||||
@@ -55,7 +55,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--target', '--to', '--touchpoint', '--url', '--version'],
|
||||
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target'],
|
||||
'integrity': ['--aliases', '--all', '--auto', '--backend', '--background', '--brain', '--brain-wide-max-cost-usd', '--check', '--confidence', '--cost', '--dry-run', '--explain', '--fast', '--follow', '--force', '--fresh', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--limit', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--review-lower', '--skip-bare-tweet', '--skip-urls', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--type', '--url'],
|
||||
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
'lint': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--exclude', '--explain', '--fast', '--fix', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout'],
|
||||
'lsd': ['--brain', '--force-resume', '--help', '--json', '--judge-model', '--limit', '--list-runs', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--no-save', '--resume', '--retry-judge', '--save', '--source', '--strict-budget', '--yes'],
|
||||
'maintain': ['--aliases', '--all', '--background', '--brain', '--break-lock', '--by-mention', '--catch-up', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-meetings', '--full', '--help', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--index-audit', '--infer-dates', '--input', '--json', '--kind', '--lang', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--migrate-only', '--multimodal', '--near-symbol', '--ner', '--nice', '--no-extract', '--no-mutate', '--older-than', '--once', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resolve', '--restore-only', '--resume', '--run-id', '--safe', '--scope', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--supersessions', '--symbol-kind', '--target', '--target-score', '--thin', '--to', '--top-k', '--type', '--unsafe-bypass-dream-guard', '--url', '--verbose', '--window', '--workers', '--yes'],
|
||||
@@ -63,12 +63,12 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'models': ['--aliases', '--all', '--brain', '--embedding-dimensions', '--embedding-model', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--judge-model', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reranking', '--reset', '--resolve', '--skip', '--source', '--stale', '--supersessions', '--surface', '--thin', '--undo', '--version'],
|
||||
'mounts': ['--alias', '--brain', '--cache', '--database-path', '--database-url', '--db-path', '--db-url', '--engine', '--explain', '--help', '--id', '--json', '--lang', '--lock', '--markdown', '--mcp-url', '--multimodal', '--near-symbol', '--path', '--restore-only', '--skills-dir', '--source', '--stale', '--symbol-kind', '--thin', '--verbose'],
|
||||
'notability-eval': ['--aliases', '--all', '--brain', '--embedding-dimensions', '--embedding-model', '--help', '--in', '--include-null-signature', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-extract', '--out', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--skip-llm', '--source', '--stale', '--supersessions', '--target-high', '--target-low', '--target-medium', '--thin', '--version'],
|
||||
'onboard': ['--aliases', '--all', '--allow-empty', '--allow-protected', '--apply', '--asof', '--auto', '--auto-with-prompt', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--check', '--content', '--date', '--days', '--entities', '--explain', '--federated', '--file', '--follow', '--from-pages', '--help', '--history', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-extract', '--offset', '--params', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--remediation-plan', '--reset', '--resolve', '--resume', '--save', '--session', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--target-score', '--thin', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'onboard': ['--aliases', '--all', '--allow-empty', '--allow-protected', '--apply', '--asof', '--auto', '--auto-with-prompt', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--check', '--content', '--date', '--days', '--entities', '--explain', '--federated', '--file', '--follow', '--from-pages', '--help', '--history', '--http', '--image', '--include-null-signature', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-extract', '--offset', '--params', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--remediation-plan', '--reset', '--resolve', '--resume', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stale', '--stats', '--supersessions', '--surface', '--target-score', '--thin', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'orphans': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--count', '--explain', '--follow', '--help', '--include-null-signature', '--include-pseudo', '--json', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'pages': ['--aliases', '--all', '--brain', '--dry-run', '--help', '--include-null-signature', '--json', '--no-extract', '--older-than', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'pglite-repair': ['--brain', '--break-lock', '--dry-rnu', '--dry-run', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--path', '--quiet', '--source', '--surface', '--yes'],
|
||||
'post-upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--quiet', '--repo', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--surface', '--swap-only', '--target', '--to', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'protocol': ['--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-embedding', '--offset', '--path', '--progress-interval', '--progress-json', '--quiet', '--save', '--session', '--since', '--slug', '--slugs', '--source', '--stats', '--surface', '--synthesize', '--target', '--timeout', '--token', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'protocol': ['--all', '--allow-empty', '--apply', '--asof', '--auto', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--budget-usd-per-day', '--by-mention', '--content', '--date', '--days', '--entities', '--fast', '--federated', '--file', '--follow', '--force', '--from-pages', '--help', '--http', '--image', '--json', '--kind', '--limit', '--max-usd', '--mode', '--multimodal', '--no-embedding', '--offset', '--path', '--progress-interval', '--progress-json', '--quiet', '--save', '--session', '--session-id', '--since', '--slug', '--slugs', '--source', '--stats', '--surface', '--synthesize', '--target', '--timeout', '--token', '--trusted-extraction', '--url', '--with-db', '--yes'],
|
||||
'providers': ['--brain', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--reranking', '--source', '--surface', '--touchpoint', '--version'],
|
||||
'publish': ['--accent', '--bg', '--border', '--brain', '--card-bg', '--code-bg', '--error', '--fg', '--help', '--json', '--link', '--muted', '--out', '--password', '--source', '--title'],
|
||||
'quarantine': ['--aliases', '--all', '--apply', '--brain', '--code', '--compile', '--explain', '--fast', '--fix', '--force', '--force-rechunk', '--from-pages', '--help', '--http', '--include-flagged', '--include-null-signature', '--json', '--lang', '--limit', '--markdown', '--multimodal', '--near-symbol', '--no-embed', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--surface', '--symbol-kind', '--thin'],
|
||||
@@ -96,7 +96,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'skillpack-check': ['--background', '--brain', '--brain-wide-max-cost-usd', '--explain', '--fast', '--follow', '--help', '--json', '--list', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--strict', '--timeout', '--yes'],
|
||||
'smoke-test': ['--brain', '--help', '--json', '--source'],
|
||||
'sources': ['--abbrev-ref', '--abort', '--aliases', '--all', '--all-sources', '--allow-unverified-remote', '--brain', '--branch', '--break-lock', '--cached', '--clone-dir', '--compile', '--confirm-destructive', '--count', '--detect', '--diff-filter', '--dry-run', '--exclude-standard', '--explain', '--fast', '--federated', '--file', '--fix', '--force', '--force-break-lock', '--format', '--from-pages', '--full', '--get', '--git-dir', '--github-repo', '--help', '--http', '--include-null-signature', '--include-warns', '--jq', '--json', '--keep-storage', '--lang', '--local', '--markdown', '--max-age', '--max-cost-usd', '--message', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--no-cron', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-harden', '--no-verify', '--others', '--params', '--pat-file', '--path', '--pattern', '--pending', '--porcelain', '--push-only', '--quiet', '--rebase', '--repo', '--reset', '--resolve', '--restore-only', '--secret', '--set', '--short', '--show-toplevel', '--source', '--source-id', '--stale', '--status', '--strategy', '--supersessions', '--surface', '--symbol-kind', '--thin', '--unset-all', '--url', '--url-managed', '--yes'],
|
||||
'status': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--budget-usd-per-day', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content', '--content-audit', '--count', '--date', '--days', '--deadline-ms', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--image', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--install', '--interval', '--is-ancestor', '--json', '--judge-model', '--kind', '--lang', '--limit', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-runtime', '--max-sources', '--max-usd', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--offset', '--older-than', '--order', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--reenrich-after', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--scopes', '--section', '--serial', '--session', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--to', '--top-k', '--trusted-extraction', '--type', '--types', '--url', '--url-managed', '--verbose', '--verify', '--version', '--watch', '--what', '--where', '--who', '--window', '--with-db', '--workers', '--yes'],
|
||||
'status': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--apply', '--asof', '--auto', '--background', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--budget-usd-per-day', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content', '--content-audit', '--count', '--date', '--days', '--deadline-ms', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--image', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--install', '--interval', '--is-ancestor', '--json', '--judge-model', '--kind', '--lang', '--limit', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-runtime', '--max-sources', '--max-usd', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--offset', '--older-than', '--order', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--reenrich-after', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--scopes', '--section', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--to', '--top-k', '--trusted-extraction', '--type', '--types', '--url', '--url-managed', '--verbose', '--verify', '--version', '--watch', '--what', '--where', '--who', '--window', '--with-db', '--workers', '--yes'],
|
||||
'storage': ['--aliases', '--all', '--brain', '--federated', '--fix', '--help', '--include-null-signature', '--json', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--to'],
|
||||
'sweep': ['--aliases', '--all', '--batch-limit', '--brain', '--budget-ms', '--help', '--include-null-signature', '--json', '--no-extract', '--once', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'sync': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--asof', '--auto', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content-audit', '--count', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-sources', '--max-usd', '--migrate-only', '--missing-path', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--ner', '--nice', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--older-than', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--serial', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--url', '--url-managed', '--verbose', '--verify', '--watch', '--window', '--workers', '--yes'],
|
||||
|
||||
@@ -57,6 +57,18 @@ const CLIENT_TIMEOUT_MS = 250;
|
||||
export const TURN_CONTEXT_CLIENT_TIMEOUT_MS = 600;
|
||||
/** Server-side self-budget for turn_context assembly (< client timeout). */
|
||||
export const TURN_CONTEXT_SERVER_BUDGET_MS = 400;
|
||||
/**
|
||||
* v0.45.7 ambient recall — context_pack budgets. Packs build entity CARDS
|
||||
* (heavier than turn_context's three arms), and their consumer is the
|
||||
* session-start hook (1.5s self-deadline, 5s harness timeout), so they get a
|
||||
* wider budget. The server passes its budget into the assembler as a
|
||||
* wall-clock deadline, so overrun returns a PARTIAL pack (degradedReason
|
||||
* 'deadline'), never an empty hard failure (eng 4A).
|
||||
*/
|
||||
export const CONTEXT_PACK_CLIENT_TIMEOUT_MS = 1000;
|
||||
/** Assembler deadline. Backstop = +200; client 1000 leaves a real transport
|
||||
* margin (adversarial review: 800+200 == client timeout was zero margin). */
|
||||
export const CONTEXT_PACK_SERVER_BUDGET_MS = 600;
|
||||
const MAX_MSG_BYTES = 256 * 1024;
|
||||
|
||||
/** Marker the client returns when no server is reachable (vs. a real null result). */
|
||||
@@ -106,7 +118,39 @@ export interface TurnContextRequest {
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export type IpcRequest = ResolveRequest | TurnContextRequest;
|
||||
/**
|
||||
* v0.45.7 ambient recall — boundary context pack over IPC. Two modes:
|
||||
* - assembly (default): the server resolves standing entities (request
|
||||
* `entities` + window extraction + the session row's banked set), assembles
|
||||
* a pack (cards + open threads + hot facts + since-delta vs the session
|
||||
* cursor), advances the cursor, and returns the injectable block.
|
||||
* - bankOnly: PreCompact banking — extract entities from `window`, merge
|
||||
* them into the session row's standing set, return an empty block. The
|
||||
* post-compaction SessionStart (source=compact) then serves a warm pack.
|
||||
* Same secret + source-binding posture as turn_context. World-only ALWAYS
|
||||
* (the push path never widens — include_private is a pull-verb affordance).
|
||||
*/
|
||||
export interface ContextPackRequest {
|
||||
kind: 'context_pack';
|
||||
protocol: 2;
|
||||
secret: string;
|
||||
sessionId?: string;
|
||||
sourceId?: string;
|
||||
/** Explicit standing entities (names/slugs); merged with the session row's banked set. */
|
||||
entities?: string[];
|
||||
/** Recent turns for entity extraction (compact banking / cold-start fallback). */
|
||||
window?: WindowTurn[];
|
||||
maxBytes?: number;
|
||||
/** Trigger discriminator ('session-start:<source>' | 'compact-bank').
|
||||
* RESERVED: carried on the wire for future server-side telemetry; no
|
||||
* server-side consumer yet (the hook-side heartbeat is the current
|
||||
* observability channel). */
|
||||
trigger?: string;
|
||||
/** PreCompact banking mode: persist entities, skip assembly. */
|
||||
bankOnly?: boolean;
|
||||
}
|
||||
|
||||
export type IpcRequest = ResolveRequest | TurnContextRequest | ContextPackRequest;
|
||||
|
||||
export interface ResolveResponse {
|
||||
ok: boolean;
|
||||
@@ -123,13 +167,24 @@ export interface TurnContextResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ContextPackResponse {
|
||||
ok: boolean;
|
||||
/** Always 2 on a v2 server (stale-serve detector, same as turn_context [A9]). */
|
||||
protocol: 2;
|
||||
block?: TurnContextResult | null;
|
||||
degradedReason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ResolveHandler = (req: ResolveRequest) => Promise<PointerBlock | null>;
|
||||
export type TurnContextHandler = (req: TurnContextRequest) => Promise<TurnContextResult | null>;
|
||||
export type ContextPackHandler = (req: ContextPackRequest) => Promise<TurnContextResult | null>;
|
||||
|
||||
/** Handler MAP replacing the single closure [ENG-3]. */
|
||||
export interface IpcHandlers {
|
||||
resolve: ResolveHandler;
|
||||
turn_context?: TurnContextHandler;
|
||||
context_pack?: ContextPackHandler;
|
||||
}
|
||||
|
||||
export interface IpcServerOpts {
|
||||
@@ -303,6 +358,50 @@ export async function requestTurnContext(
|
||||
return resp as TurnContextResponse;
|
||||
}
|
||||
|
||||
/** Client-facing context_pack request shape (kind/protocol filled in by the helper). */
|
||||
export type ContextPackClientRequest = Omit<ContextPackRequest, 'kind' | 'protocol'>;
|
||||
|
||||
export type ContextPackIpcResult =
|
||||
| ContextPackResponse
|
||||
| TurnContextStaleServe
|
||||
| typeof IPC_UNAVAILABLE;
|
||||
|
||||
/**
|
||||
* v0.45.7 client: request a boundary context pack (or a PreCompact entity bank)
|
||||
* from a running serve. Same fail-soft ladder as requestTurnContext: transport
|
||||
* trouble → IPC_UNAVAILABLE; missing protocol echo → stale_serve; otherwise
|
||||
* the server's typed response. Never throws. Window trims oldest-first under
|
||||
* the message cap [G11].
|
||||
*/
|
||||
export async function requestContextPack(
|
||||
socketPath: string,
|
||||
req: ContextPackClientRequest,
|
||||
opts: { timeoutMs?: number } = {},
|
||||
): Promise<ContextPackIpcResult> {
|
||||
const full: ContextPackRequest = {
|
||||
kind: 'context_pack',
|
||||
protocol: 2,
|
||||
...req,
|
||||
...(req.window ? { window: [...req.window] } : {}),
|
||||
};
|
||||
let line = JSON.stringify(full);
|
||||
while (
|
||||
Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES &&
|
||||
Array.isArray(full.window) &&
|
||||
full.window.length > 0
|
||||
) {
|
||||
full.window.shift();
|
||||
line = JSON.stringify(full);
|
||||
}
|
||||
if (Buffer.byteLength(line, 'utf8') + 1 > MAX_MSG_BYTES) return IPC_UNAVAILABLE;
|
||||
|
||||
const resp = await roundTrip(socketPath, line, opts.timeoutMs ?? CONTEXT_PACK_CLIENT_TIMEOUT_MS);
|
||||
if (resp === IPC_UNAVAILABLE) return IPC_UNAVAILABLE;
|
||||
if (!resp || typeof resp !== 'object') return IPC_UNAVAILABLE;
|
||||
if ((resp as { protocol?: unknown }).protocol !== 2) return { degraded: 'stale_serve' };
|
||||
return resp as ContextPackResponse;
|
||||
}
|
||||
|
||||
/** One request line out, one response line back. Fail-soft to IPC_UNAVAILABLE. */
|
||||
function roundTrip(
|
||||
socketPath: string,
|
||||
@@ -438,6 +537,10 @@ export async function startResolveIpcServer(
|
||||
if (tcResp.ok && tcResp.block && tcResp.block.text) {
|
||||
deliveredTurnContext = { result: tcResp.block, req };
|
||||
}
|
||||
} else if (kind === 'context_pack') {
|
||||
resp = JSON.stringify(
|
||||
await handleContextPack(parsed as ContextPackRequest, handlers, opts),
|
||||
);
|
||||
} else {
|
||||
resp = JSON.stringify({ ok: false, error: `unknown_kind:${String(kind)}` });
|
||||
}
|
||||
@@ -509,6 +612,50 @@ async function handleTurnContext(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* context_pack server path — same ladder as turn_context (auth [S3#6] →
|
||||
* source binding [CX2-10] → budgeted work), with a WIDER budget (cards) and
|
||||
* partial-pack semantics: the registered handler passes the budget into the
|
||||
* assembler as a wall-clock deadline, so the race below is only the backstop
|
||||
* for a hung handler, not the primary degrade mechanism (eng 4A).
|
||||
*/
|
||||
async function handleContextPack(
|
||||
req: ContextPackRequest,
|
||||
handlers: IpcHandlers,
|
||||
opts: IpcServerOpts,
|
||||
): Promise<ContextPackResponse> {
|
||||
if (!handlers.context_pack) {
|
||||
return { ok: false, protocol: 2, error: 'unsupported_kind' };
|
||||
}
|
||||
if (req.protocol !== 2) {
|
||||
return { ok: false, protocol: 2, error: 'unsupported_protocol' };
|
||||
}
|
||||
if (!opts.secret || !secretMatches(req.secret, opts.secret)) {
|
||||
return { ok: false, protocol: 2, error: 'unauthorized' };
|
||||
}
|
||||
if (req.sourceId && opts.boundSourceId && req.sourceId !== opts.boundSourceId) {
|
||||
return { ok: false, protocol: 2, error: 'source_mismatch' };
|
||||
}
|
||||
try {
|
||||
const budget = new Promise<'__budget__'>((r) => {
|
||||
const t = setTimeout(() => r('__budget__'), CONTEXT_PACK_SERVER_BUDGET_MS + 200);
|
||||
t.unref?.();
|
||||
});
|
||||
const result = await Promise.race([handlers.context_pack(req), budget]);
|
||||
if (result === '__budget__') {
|
||||
return { ok: true, protocol: 2, block: null, degradedReason: 'server_budget' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
protocol: 2,
|
||||
block: result,
|
||||
...(result?.degradedReason ? { degradedReason: result.degradedReason } : {}),
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, protocol: 2, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a socket file whose owning process is gone (or any leftover file). */
|
||||
export function cleanupStaleSocket(socketPath: string): void {
|
||||
try {
|
||||
|
||||
@@ -294,14 +294,22 @@ function displayForRow(row: PageRow, displayByNorm: Map<string, string>): string
|
||||
* Exported for the MEMORY_VERBS v1 entity card (verbs/entity-card.ts) — the
|
||||
* card's `summary` field runs through THIS boundary, not a parallel one.
|
||||
*/
|
||||
export function safeSynopsis(row: PageRow): string {
|
||||
export function safeSynopsis(
|
||||
row: PageRow,
|
||||
opts: { keepVisibility?: ('private' | 'world')[] } = {},
|
||||
): string {
|
||||
// v0.45.7 ambient recall: world-only by default (the injected-context posture).
|
||||
// The ONLY widening caller is the entity-card builder for a trusted-local
|
||||
// include_private pack (entity-card.ts) — the pointer/volunteer arms always
|
||||
// run world-only (turn mode never widens).
|
||||
const keepVisibility = opts.keepVisibility ?? ['world'];
|
||||
const fmSummary = row.frontmatter?.summary;
|
||||
if (typeof fmSummary === 'string' && fmSummary.trim()) {
|
||||
return clip(collapse(fmSummary), SYNOPSIS_MAX);
|
||||
}
|
||||
const body = row.compiled_truth ?? '';
|
||||
if (!body) return '';
|
||||
const stripped = stripFactsFence(stripTakesFence(body), { keepVisibility: ['world'] });
|
||||
const stripped = stripFactsFence(stripTakesFence(body), { keepVisibility });
|
||||
// Drop frontmatter block, markdown headings, and blank lines; first real prose line.
|
||||
const firstProse = stripped
|
||||
.replace(/^---[\s\S]*?---\s*/m, '')
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* v0.45.7 ambient recall (issue #1) — per-session cursor + boundary-tie dedup
|
||||
* for the `delta` verb and the heartbeat runtime.
|
||||
*
|
||||
* Parity-free by construction: both engines run the SAME `engine.executeRaw`
|
||||
* SQL (no per-engine method, so there is no parity surface to drift). jsonb
|
||||
* columns are written through the sanctioned `$N::text::jsonb` positional path
|
||||
* (binds as text, the cast parses it — dodges the postgres.js ::jsonb
|
||||
* double-encode trap; guarded by scripts/check-jsonb-params.mjs). The table is
|
||||
* `session_context_state` (migration v126); its schema parity is covered by the
|
||||
* schema-drift e2e.
|
||||
*
|
||||
* Key is (source_id, client_id, session_id). `client_id` is the caller's OAuth
|
||||
* client id for remote callers, or the 'local' sentinel for the trusted CLI/hook
|
||||
* path — so two remote harnesses in one source can never stomp/read each other's
|
||||
* cursor (eng 1B). All reads/writes are FAIL-OPEN: state is an optimization, and
|
||||
* a failure here must never block the recall read path.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
|
||||
export const LOCAL_CLIENT_SENTINEL = 'local';
|
||||
|
||||
/** Cap on untrusted opaque ids used as PK components. */
|
||||
const ID_MAX_LEN = 200;
|
||||
/** Cap on a boundary-slug batch (defense in depth — the natural bound is the
|
||||
* delta fetch limit, since boundary slugs are ties at ONE timestamp and the
|
||||
* set is REPLACED on every cursor advance, never accumulated). */
|
||||
const SURFACED_SLUGS_CAP = 500;
|
||||
|
||||
export interface SessionContextState {
|
||||
standing_entities: string[];
|
||||
/** Keyset slug component: `[cursorSlug]` (or `[]`). Column name is historical. */
|
||||
surfaced_slugs: string[];
|
||||
last_wake_at: string | null;
|
||||
}
|
||||
|
||||
export interface SessionContextPatch {
|
||||
/** Replace the standing-entity set (omit to leave unchanged). */
|
||||
standingEntities?: string[];
|
||||
/**
|
||||
* The wake cursor's TIMESTAMP component (ISO; omit to leave unchanged). Paired
|
||||
* with `cursorSlug` this forms the keyset `(updatedAt, slug)` the `delta`
|
||||
* verb resumes from. Last-writer-wins (no monotonic guard): a keyset is a
|
||||
* two-part cursor, so a raw-timestamp GREATEST can't express its ordering;
|
||||
* an out-of-order write only risks bounded RE-delivery (cursor-dedup
|
||||
* tolerates it), never loss.
|
||||
*/
|
||||
lastWakeAt?: string;
|
||||
/**
|
||||
* REPLACE the keyset slug — the slug of the last DELIVERED page at
|
||||
* `lastWakeAt` (omit to leave unchanged; `''` = start of the timestamp
|
||||
* bucket). Stored in the surfaced_slugs jsonb column (single-element).
|
||||
*/
|
||||
cursorSlug?: string;
|
||||
}
|
||||
|
||||
/** 'local' sentinel for the trusted CLI/hook path; the auth client id otherwise. */
|
||||
export function resolveClientId(clientId?: string | null): string {
|
||||
return typeof clientId === 'string' && clientId.trim()
|
||||
? clientId.trim().slice(0, ID_MAX_LEN)
|
||||
: LOCAL_CLIENT_SENTINEL;
|
||||
}
|
||||
|
||||
function normSession(sessionId: string): string {
|
||||
return String(sessionId).slice(0, ID_MAX_LEN);
|
||||
}
|
||||
|
||||
/** Normalize a DB timestamp text to ISO 8601 so downstream cursor comparisons
|
||||
* are format-stable (PGLite's ::text cast returns local-tz text, not ISO). */
|
||||
function toIso(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
const t = Date.parse(v);
|
||||
return Number.isFinite(t) ? new Date(t).toISOString() : v;
|
||||
}
|
||||
|
||||
function toStringArray(v: unknown): string[] {
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string');
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(v);
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Read the session cursor. Returns null when absent or on any error (fail-open). */
|
||||
export async function getSessionContextState(
|
||||
engine: BrainEngine,
|
||||
sourceId: string,
|
||||
clientId: string | null | undefined,
|
||||
sessionId: string,
|
||||
): Promise<SessionContextState | null> {
|
||||
try {
|
||||
const rows = await engine.executeRaw<{
|
||||
standing_entities: unknown;
|
||||
surfaced_slugs: unknown;
|
||||
last_wake_at: string | null;
|
||||
}>(
|
||||
`SELECT standing_entities, surfaced_slugs, last_wake_at::text AS last_wake_at
|
||||
FROM session_context_state
|
||||
WHERE source_id = $1 AND client_id = $2 AND session_id = $3`,
|
||||
[sourceId, resolveClientId(clientId), normSession(sessionId)],
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
const r = rows[0];
|
||||
return {
|
||||
standing_entities: toStringArray(r.standing_entities),
|
||||
surfaced_slugs: toStringArray(r.surfaced_slugs),
|
||||
last_wake_at: toIso(r.last_wake_at ?? null),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the session cursor. SINGLE-STATEMENT atomic (adversarial review):
|
||||
* keep-if-absent and monotonic-cursor semantics run inside the UPDATE itself,
|
||||
* so concurrent pack/delta writers can't wipe banked entities or rewind the
|
||||
* cursor (the old read-modify-write raced). One round-trip — this call sits
|
||||
* inside the IPC push budget. jsonb via `$N::text::jsonb`. Fail-open.
|
||||
*
|
||||
* Param semantics in SQL:
|
||||
* $4 standing_entities (jsonb) — applied only when $7 (replace flag) true
|
||||
* $5 boundarySlugs (jsonb) — REPLACES the set only when $8 true
|
||||
* $6 lastWakeAt or null — GREATEST with the stored cursor (monotonic;
|
||||
* null keeps it)
|
||||
*/
|
||||
export async function upsertSessionContextState(
|
||||
engine: BrainEngine,
|
||||
sourceId: string,
|
||||
clientId: string | null | undefined,
|
||||
sessionId: string,
|
||||
patch: SessionContextPatch,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const replaceStanding = Array.isArray(patch.standingEntities);
|
||||
const replaceCursorSlug = typeof patch.cursorSlug === 'string';
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO session_context_state
|
||||
(source_id, client_id, session_id, standing_entities, surfaced_slugs, last_wake_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::text::jsonb, $5::text::jsonb, $6, now())
|
||||
ON CONFLICT (source_id, client_id, session_id) DO UPDATE SET
|
||||
standing_entities = CASE WHEN $7::boolean THEN EXCLUDED.standing_entities
|
||||
ELSE session_context_state.standing_entities END,
|
||||
surfaced_slugs = CASE WHEN $8::boolean THEN EXCLUDED.surfaced_slugs
|
||||
ELSE session_context_state.surfaced_slugs END,
|
||||
last_wake_at = COALESCE(EXCLUDED.last_wake_at, session_context_state.last_wake_at),
|
||||
updated_at = now()`,
|
||||
[
|
||||
sourceId,
|
||||
resolveClientId(clientId),
|
||||
normSession(sessionId),
|
||||
JSON.stringify(patch.standingEntities ?? []),
|
||||
JSON.stringify(typeof patch.cursorSlug === 'string' ? [patch.cursorSlug.slice(0, ID_MAX_LEN)] : []),
|
||||
patch.lastWakeAt ?? null,
|
||||
replaceStanding,
|
||||
replaceCursorSlug,
|
||||
],
|
||||
);
|
||||
} catch {
|
||||
/* fail-open: a state-write failure must never block the recall read path */
|
||||
}
|
||||
}
|
||||
|
||||
/** Max session rows retained per (source_id, client_id) — bounds a remote
|
||||
* caller minting session ids (red-team F3: authed ≠ trusted; a read token
|
||||
* could otherwise create unbounded rows inside the 7-day age window). */
|
||||
export const MAX_ROWS_PER_CLIENT = 1000;
|
||||
|
||||
/**
|
||||
* Age out stale session rows (default 7 days) AND evict the oldest rows past
|
||||
* `maxRowsPerClient` (default MAX_ROWS_PER_CLIENT) per (source_id, client_id).
|
||||
* The cap is injectable (v0.45.7) so tests drive the REAL windowed DELETE with
|
||||
* a small cap instead of mirroring the SQL. Best-effort — runs at serve boot
|
||||
* and opportunistically on first-wake row creation.
|
||||
*/
|
||||
export async function gcSessionContextState(
|
||||
engine: BrainEngine,
|
||||
olderThanDays = 7,
|
||||
maxRowsPerClient = MAX_ROWS_PER_CLIENT,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await engine.executeRaw(
|
||||
`DELETE FROM session_context_state WHERE updated_at < now() - ($1 || ' days')::interval`,
|
||||
[String(Math.max(1, Math.floor(olderThanDays)))],
|
||||
);
|
||||
// Per-client LRU cap: keep the newest `maxRowsPerClient` rows per lane.
|
||||
await engine.executeRaw(
|
||||
`DELETE FROM session_context_state s
|
||||
USING (
|
||||
SELECT source_id, client_id, session_id,
|
||||
row_number() OVER (
|
||||
PARTITION BY source_id, client_id ORDER BY updated_at DESC
|
||||
) AS rn
|
||||
FROM session_context_state
|
||||
) ranked
|
||||
WHERE s.source_id = ranked.source_id
|
||||
AND s.client_id = ranked.client_id
|
||||
AND s.session_id = ranked.session_id
|
||||
AND ranked.rn > $1`,
|
||||
[String(Math.max(1, Math.floor(maxRowsPerClient)))],
|
||||
);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,25 @@ import {
|
||||
} from './retrieval-reflex.ts';
|
||||
import { volunteerContext, type VolunteeredPage } from './volunteer.ts';
|
||||
import { getBrainHotMemoryMeta } from '../facts/meta-hook.ts';
|
||||
import { buildEntityCard, type EntityCard, type EntityOpenThread } from '../verbs/entity-card.ts';
|
||||
|
||||
/**
|
||||
* v0.45.7 ambient recall (issue #1). The per-turn assembler is extended into the
|
||||
* shared core for the two new frozen verbs (`context_pack`, `delta`) AND the
|
||||
* boundary hook runtime, via `mode`:
|
||||
* - 'turn' — the existing per-turn push path (UNCHANGED; window-driven,
|
||||
* world-only always).
|
||||
* - 'pack' — session-start / post-compaction bundle: entity cards +
|
||||
* open-threads + hot facts for a set of standing entities.
|
||||
* - 'delta' — heartbeat "what changed since T": pages updated after `since`
|
||||
* + facts newer than `since` + open-thread events after `since`.
|
||||
*
|
||||
* Visibility is WORLD-ONLY by default on every arm (a pack is injected into an
|
||||
* agent context window that may be logged or synced to a cloud model). The
|
||||
* `includePrivate` opt widens ALL arms in lockstep (never a partial widen);
|
||||
* the push hook path NEVER sets it. See D2=A in the plan.
|
||||
*/
|
||||
export type ContextMode = 'turn' | 'pack' | 'delta';
|
||||
|
||||
/** [CX-P1.2] The subordinate envelope every injected block begins with. */
|
||||
export const TURN_CONTEXT_ENVELOPE =
|
||||
@@ -53,9 +72,19 @@ export interface TurnContextFact {
|
||||
notability?: string | null;
|
||||
entity_slug: string | null;
|
||||
valid_from?: string;
|
||||
/** Recording time (v0.45.7) — delta's "new since" filter prefers this over valid_from. */
|
||||
created_at?: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** One page in a `delta` result (updated after the cursor). */
|
||||
export interface DeltaPage {
|
||||
slug: string;
|
||||
source_id: string;
|
||||
title: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TurnContextResult {
|
||||
/** Rendered block ('' when there is nothing to inject). */
|
||||
text: string;
|
||||
@@ -72,19 +101,65 @@ export interface TurnContextResult {
|
||||
/** Hot facts included after budget trimming. */
|
||||
factsCount: number;
|
||||
degradedReason?: string;
|
||||
/** pack mode — entity cards assembled for the standing entities. */
|
||||
cards?: EntityCard[];
|
||||
/** pack/delta mode — open-thread events (post-`since` in delta mode). */
|
||||
openThreads?: EntityOpenThread[];
|
||||
/** delta mode — pages updated after the cursor, OLDEST first (at-least-once cursor semantics). */
|
||||
deltaPages?: DeltaPage[];
|
||||
/**
|
||||
* delta mode — true when MORE pages changed than the fetch limit returned.
|
||||
* The caller must advance its cursor only to the newest DELIVERED page
|
||||
* (never to now()), so the overflow surfaces on the next wake.
|
||||
*/
|
||||
deltaOverflow?: boolean;
|
||||
/** pack/delta mode — the hot facts included (structured, for the verb JSON). */
|
||||
facts?: TurnContextFact[];
|
||||
/** The mode this result was assembled in. */
|
||||
mode?: ContextMode;
|
||||
}
|
||||
|
||||
export interface AssembleTurnContextOpts {
|
||||
sourceId: string;
|
||||
/** Recent turns, oldest → newest. */
|
||||
window: WindowTurn[];
|
||||
/** Recent turns, oldest → newest. Optional for pack/delta (may run cold). */
|
||||
window?: WindowTurn[];
|
||||
/** Already-surfaced context — drives slug-only suppression + volunteer dedupe. */
|
||||
priorContextText?: string;
|
||||
/** Opaque session identity — keys the hot-memory cache (CX2-11). */
|
||||
sessionId?: string;
|
||||
maxBytes?: number;
|
||||
// ── v0.45.7 ambient recall ──────────────────────────────────────────────
|
||||
/** Assembly mode. Default 'turn' (existing behavior). */
|
||||
mode?: ContextMode;
|
||||
/** pack/delta — standing entity names to bundle (resolved to cards). */
|
||||
entities?: string[];
|
||||
/** delta — ISO cursor; only pages/facts/threads newer than this are returned. */
|
||||
since?: string;
|
||||
/**
|
||||
* delta — keyset slug paired with `since` (v0.45.7): pages are fetched with
|
||||
* `(updated_at, slug) > (since, sinceSlug)` so a >limit cluster at one
|
||||
* timestamp pages deterministically. Facts/threads still use `since` (time).
|
||||
*/
|
||||
sinceSlug?: string;
|
||||
/**
|
||||
* Widen ALL arms to include private facts. Default false = world-only
|
||||
* (the safe injected-context posture). Fail-closed: only an explicit `true`
|
||||
* widens; anything else is world. The push hook path never sets this.
|
||||
*/
|
||||
includePrivate?: boolean;
|
||||
/** pack — cap on entity-card fan-out (default 8; push path passes smaller). */
|
||||
maxEntities?: number;
|
||||
/**
|
||||
* Wall-clock budget (ms). When set, arms race the deadline and whatever has
|
||||
* resolved is returned as a PARTIAL pack (degradedReason 'deadline'); the
|
||||
* push path passes ~TURN_CONTEXT_SERVER_BUDGET_MS so it never overruns.
|
||||
*/
|
||||
deadlineMs?: number;
|
||||
}
|
||||
|
||||
/** Default entity-card fan-out cap for pack mode. */
|
||||
export const PACK_DEFAULT_MAX_ENTITIES = 8;
|
||||
|
||||
const noopLogger = { info: () => {}, warn: () => {}, error: () => {} };
|
||||
|
||||
/**
|
||||
@@ -96,6 +171,12 @@ export async function assembleTurnContext(
|
||||
engine: BrainEngine,
|
||||
opts: AssembleTurnContextOpts,
|
||||
): Promise<TurnContextResult> {
|
||||
// v0.45.7 — mode dispatch. pack/delta run through the ambient-recall arms;
|
||||
// 'turn' (default) keeps the original per-turn path below, byte-identical.
|
||||
const mode = opts.mode ?? 'turn';
|
||||
if (mode === 'pack') return assemblePack(engine, opts);
|
||||
if (mode === 'delta') return assembleDelta(engine, opts);
|
||||
|
||||
const maxBytes =
|
||||
typeof opts.maxBytes === 'number' && Number.isFinite(opts.maxBytes) && opts.maxBytes > 0
|
||||
? Math.floor(opts.maxBytes)
|
||||
@@ -253,3 +334,334 @@ function render(
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// v0.45.7 ambient recall — pack / delta modes (issue #1)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function clampPositive(n: number | undefined, dflt: number): number {
|
||||
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor comparison robust to timestamp FORMAT differences (ISO vs PG local-tz
|
||||
* text vs bare 'YYYY-MM-DD' dates). Parses both to epoch when possible; falls
|
||||
* back to lexicographic only when neither parses. Exported: the verb handlers
|
||||
* apply the same filter when they recompute budget-packed sets.
|
||||
*/
|
||||
export function isAfter(value: string | null | undefined, since: string): boolean {
|
||||
if (typeof value !== 'string' || !value) return false;
|
||||
const v = Date.parse(value);
|
||||
const s = Date.parse(since);
|
||||
if (Number.isFinite(v) && Number.isFinite(s)) return v > s;
|
||||
return value > since;
|
||||
}
|
||||
|
||||
/**
|
||||
* Race `work` against a wall-clock budget. Returns 'deadline' if the timer
|
||||
* fired first (the caller then renders whatever its accumulator collected — a
|
||||
* PARTIAL pack), or undefined if the work finished in time. This is the
|
||||
* substrate the push path's 400ms budget needs; assembleTurnContext has no
|
||||
* built-in abort, so arms must mutate a shared accumulator as they resolve.
|
||||
*/
|
||||
async function raceDeadline(work: Promise<void>, ms?: number): Promise<string | undefined> {
|
||||
if (!ms || ms <= 0) {
|
||||
await work.catch(() => {});
|
||||
return undefined;
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<'deadline'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('deadline'), ms);
|
||||
});
|
||||
const done = work.then(() => undefined).catch(() => undefined);
|
||||
const result = await Promise.race([done, timeout]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return result === 'deadline' ? 'deadline' : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-facts arm shared by pack/delta. World-only unless `remote === false`
|
||||
* (include_private). Fail-soft: any error empties the arm, never throws.
|
||||
*/
|
||||
async function fetchHotFacts(
|
||||
engine: BrainEngine,
|
||||
opts: AssembleTurnContextOpts,
|
||||
remote: boolean,
|
||||
): Promise<TurnContextFact[]> {
|
||||
try {
|
||||
const metaCtx: OperationContext = {
|
||||
engine,
|
||||
config: {} as GBrainConfig,
|
||||
logger: noopLogger,
|
||||
dryRun: false,
|
||||
remote, // false only when include_private explicitly widened the pack
|
||||
sourceId: opts.sourceId,
|
||||
sessionId: opts.sessionId,
|
||||
takesHoldersAllowList: ['world'],
|
||||
};
|
||||
const meta = await getBrainHotMemoryMeta('turn_context', metaCtx);
|
||||
const hot = meta?.brain_hot_memory as { facts?: TurnContextFact[] } | undefined;
|
||||
return Array.isArray(hot?.facts) ? [...hot.facts] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pack mode — session-start / post-compaction bundle for a set of standing
|
||||
* entities: entity cards + open-threads + hot facts. World-only by default;
|
||||
* include_private widens the card + facts arms in lockstep. Sequential card
|
||||
* builds (PGLite is single-connection) so a deadline keeps the cards already built.
|
||||
*/
|
||||
async function assemblePack(
|
||||
engine: BrainEngine,
|
||||
opts: AssembleTurnContextOpts,
|
||||
): Promise<TurnContextResult> {
|
||||
const remote = opts.includePrivate !== true; // fail-closed: only explicit true widens
|
||||
const maxEntities = clampPositive(opts.maxEntities, PACK_DEFAULT_MAX_ENTITIES);
|
||||
const entities = (opts.entities ?? [])
|
||||
.filter((e) => typeof e === 'string' && e.trim())
|
||||
.slice(0, maxEntities);
|
||||
|
||||
const acc: { cards: EntityCard[]; facts: TurnContextFact[] } = { cards: [], facts: [] };
|
||||
// Cooperative deadline (perf review): raceDeadline abandons but cannot stop
|
||||
// the build, and on PGLite's single connection orphaned card queries would
|
||||
// queue AHEAD of the caller's next work. Check between iterations so no new
|
||||
// query is issued after the deadline fires.
|
||||
const deadlineAt =
|
||||
typeof opts.deadlineMs === 'number' && opts.deadlineMs > 0 ? Date.now() + opts.deadlineMs : null;
|
||||
const build = (async () => {
|
||||
for (const name of entities) {
|
||||
if (deadlineAt !== null && Date.now() >= deadlineAt) return;
|
||||
try {
|
||||
const res = await buildEntityCard(engine, opts.sourceId, name, { remote });
|
||||
if (res.found && res.card) acc.cards.push(res.card);
|
||||
} catch {
|
||||
/* fail-soft: skip this entity */
|
||||
}
|
||||
}
|
||||
if (deadlineAt !== null && Date.now() >= deadlineAt) return;
|
||||
acc.facts = await fetchHotFacts(engine, opts, remote);
|
||||
})();
|
||||
|
||||
const degradedReason = await raceDeadline(build, opts.deadlineMs);
|
||||
// Snapshot copies (adversarial review P3): on a deadline return, `build` is
|
||||
// still running and keeps MUTATING acc — a live array reference in the
|
||||
// response could diverge from the rendered text after the first await
|
||||
// downstream. Copies freeze the delivered view.
|
||||
const cards = [...acc.cards];
|
||||
const facts = [...acc.facts];
|
||||
// `since` filter (adversarial review: was documented but dead) — open-thread
|
||||
// events are cut to those after the cursor, matching the verb contract.
|
||||
const since = typeof opts.since === 'string' && opts.since.trim() ? opts.since : undefined;
|
||||
const openThreads = cards
|
||||
.flatMap((c) => c.open_threads ?? [])
|
||||
.filter((t) => !since || (t.date !== null && isAfter(t.date, since)));
|
||||
const text = renderPack(cards, openThreads, facts);
|
||||
return {
|
||||
text,
|
||||
pointers: [],
|
||||
factsCount: facts.length,
|
||||
cards,
|
||||
openThreads,
|
||||
facts,
|
||||
mode: 'pack',
|
||||
...(degradedReason ? { degradedReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* delta mode — "what changed since `since`": pages updated after the cursor +
|
||||
* hot facts newer than the cursor + open-thread events after the cursor.
|
||||
*/
|
||||
/** Max changed pages fetched per delta call (+1 probe row detects overflow). */
|
||||
export const DELTA_PAGE_FETCH_LIMIT = 50;
|
||||
|
||||
async function assembleDelta(
|
||||
engine: BrainEngine,
|
||||
opts: AssembleTurnContextOpts,
|
||||
): Promise<TurnContextResult> {
|
||||
const remote = opts.includePrivate !== true;
|
||||
const since = typeof opts.since === 'string' && opts.since.trim() ? opts.since : undefined;
|
||||
const acc: {
|
||||
pages: DeltaPage[];
|
||||
overflow: boolean;
|
||||
facts: TurnContextFact[];
|
||||
threads: EntityOpenThread[];
|
||||
} = { pages: [], overflow: false, facts: [], threads: [] };
|
||||
const deadlineAt =
|
||||
typeof opts.deadlineMs === 'number' && opts.deadlineMs > 0 ? Date.now() + opts.deadlineMs : null;
|
||||
|
||||
const build = (async () => {
|
||||
if (since) {
|
||||
try {
|
||||
// OLDEST first + limit+1 probe: with the (updated_at, slug) TOTAL order
|
||||
// the delivered set is a contiguous prefix from the cursor, so the
|
||||
// caller advances its keyset to the last DELIVERED (ts, slug) and the
|
||||
// tail surfaces on the next wake — at-least-once, and a >limit cluster
|
||||
// at ONE timestamp pages cleanly via the slug keyset (red-team F1).
|
||||
const pages = await engine.listPages({
|
||||
...(opts.sinceSlug !== undefined
|
||||
? { updatedAfterKeyset: { updatedAt: since, slug: opts.sinceSlug } }
|
||||
: { updated_after: since }),
|
||||
sourceId: opts.sourceId,
|
||||
limit: DELTA_PAGE_FETCH_LIMIT + 1,
|
||||
sort: 'updated_asc',
|
||||
});
|
||||
acc.overflow = pages.length > DELTA_PAGE_FETCH_LIMIT;
|
||||
acc.pages = pages.slice(0, DELTA_PAGE_FETCH_LIMIT).map((p) => ({
|
||||
slug: p.slug,
|
||||
source_id: opts.sourceId,
|
||||
title: p.title,
|
||||
updated_at: p.updated_at instanceof Date ? p.updated_at.toISOString() : String(p.updated_at),
|
||||
}));
|
||||
} catch {
|
||||
acc.pages = [];
|
||||
}
|
||||
}
|
||||
// Facts arm: query the store DIRECTLY by recording time (pre-landing
|
||||
// review): the hot-memory meta hook's fallback window is 24h/topK-25, so a
|
||||
// cursor older than a day would silently miss facts recorded between the
|
||||
// cursor and yesterday — the exact O(changes) contract violation delta
|
||||
// exists to prevent. "New since" means created_at (recording time).
|
||||
if (deadlineAt === null || Date.now() < deadlineAt) {
|
||||
try {
|
||||
const sinceDate = since ? new Date(since) : new Date(0);
|
||||
const visibility = remote ? (['world'] as ('private' | 'world')[]) : undefined;
|
||||
const rows = await engine.listFactsSince(opts.sourceId, sinceDate, {
|
||||
activeOnly: true,
|
||||
limit: 50,
|
||||
visibility,
|
||||
});
|
||||
acc.facts = rows
|
||||
.filter((r) => !since || isAfter(r.created_at.toISOString(), since))
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
fact: r.fact,
|
||||
kind: r.kind,
|
||||
notability: r.notability,
|
||||
entity_slug: r.entity_slug,
|
||||
valid_from: r.valid_from.toISOString(),
|
||||
created_at: r.created_at.toISOString(),
|
||||
confidence: r.confidence,
|
||||
}));
|
||||
} catch {
|
||||
acc.facts = [];
|
||||
}
|
||||
}
|
||||
|
||||
const entities = (opts.entities ?? [])
|
||||
.filter((e) => typeof e === 'string' && e.trim())
|
||||
.slice(0, clampPositive(opts.maxEntities, PACK_DEFAULT_MAX_ENTITIES));
|
||||
for (const name of entities) {
|
||||
if (deadlineAt !== null && Date.now() >= deadlineAt) return;
|
||||
try {
|
||||
const res = await buildEntityCard(engine, opts.sourceId, name, { remote });
|
||||
if (res.found && res.card) {
|
||||
for (const t of res.card.open_threads ?? []) {
|
||||
if (!since || (t.date && isAfter(t.date, since))) acc.threads.push(t);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fail-soft */
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const degradedReason = await raceDeadline(build, opts.deadlineMs);
|
||||
// Snapshot copies — same post-deadline mutation hazard as assemblePack.
|
||||
const pages = [...acc.pages];
|
||||
const facts = [...acc.facts];
|
||||
const threads = [...acc.threads];
|
||||
const text = renderDelta(pages, facts, threads, since);
|
||||
return {
|
||||
text,
|
||||
pointers: [],
|
||||
factsCount: facts.length,
|
||||
deltaPages: pages,
|
||||
deltaOverflow: acc.overflow,
|
||||
openThreads: threads,
|
||||
facts,
|
||||
mode: 'delta',
|
||||
...(degradedReason ? { degradedReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Thin wrappers so the verb + hook layers read intent-first. */
|
||||
export function assembleContextPack(
|
||||
engine: BrainEngine,
|
||||
opts: Omit<AssembleTurnContextOpts, 'mode'>,
|
||||
): Promise<TurnContextResult> {
|
||||
return assembleTurnContext(engine, { ...opts, mode: 'pack' });
|
||||
}
|
||||
export function assembleDeltaContext(
|
||||
engine: BrainEngine,
|
||||
opts: Omit<AssembleTurnContextOpts, 'mode'>,
|
||||
): Promise<TurnContextResult> {
|
||||
return assembleTurnContext(engine, { ...opts, mode: 'delta' });
|
||||
}
|
||||
|
||||
/** Exported (v0.45.7 adversarial review): the verb handlers re-render `text`
|
||||
* from the FINAL (budget-packed) sets — the injectable field must honor the
|
||||
* same budget + dedup contract as the structured arrays. */
|
||||
export function renderPack(
|
||||
cards: EntityCard[],
|
||||
openThreads: EntityOpenThread[],
|
||||
facts: TurnContextFact[],
|
||||
): string {
|
||||
if (!cards.length && !openThreads.length && !facts.length) return '';
|
||||
const lines: string[] = [TURN_CONTEXT_ENVELOPE];
|
||||
if (cards.length) {
|
||||
lines.push('', '## Standing entities');
|
||||
for (const c of cards) {
|
||||
const syn = c.summary ? ` — ${c.summary}` : '';
|
||||
lines.push(`- **${c.entity.title}** → \`${c.entity.slug}\`${syn} (use get_page/entity before relying on details)`);
|
||||
}
|
||||
}
|
||||
if (openThreads.length) {
|
||||
lines.push('', '## Open threads');
|
||||
for (const t of openThreads) {
|
||||
const d = t.date ? ` (${t.date})` : '';
|
||||
lines.push(`- [${t.kind}] ${t.text}${d}`);
|
||||
}
|
||||
}
|
||||
if (facts.length) {
|
||||
lines.push('', '## Hot memory (recent facts)');
|
||||
for (const f of facts) {
|
||||
const ent = f.entity_slug ? ` [${f.entity_slug}]` : '';
|
||||
lines.push(`- ${f.fact}${ent} (${f.confidence.toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Exported (v0.45.7 adversarial review) — see renderPack. */
|
||||
export function renderDelta(
|
||||
pages: DeltaPage[],
|
||||
facts: TurnContextFact[],
|
||||
threads: EntityOpenThread[],
|
||||
since?: string,
|
||||
): string {
|
||||
if (!pages.length && !facts.length && !threads.length) return '';
|
||||
const lines: string[] = [TURN_CONTEXT_ENVELOPE];
|
||||
const sinceNote = since ? ` since ${since}` : '';
|
||||
if (pages.length) {
|
||||
lines.push('', `## Pages changed${sinceNote}`);
|
||||
for (const p of pages) lines.push(`- **${p.title}** → \`${p.slug}\` (${p.updated_at})`);
|
||||
}
|
||||
if (facts.length) {
|
||||
lines.push('', `## New facts${sinceNote}`);
|
||||
for (const f of facts) {
|
||||
const ent = f.entity_slug ? ` [${f.entity_slug}]` : '';
|
||||
lines.push(`- ${f.fact}${ent} (${f.confidence.toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
if (threads.length) {
|
||||
lines.push('', `## Thread updates${sinceNote}`);
|
||||
for (const t of threads) {
|
||||
const d = t.date ? ` (${t.date})` : '';
|
||||
lines.push(`- [${t.kind}] ${t.text}${d}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -81,7 +81,16 @@ export async function getBrainHotMemoryMeta(
|
||||
?? (ctx as { source_session?: string }).source_session
|
||||
?? null;
|
||||
const allowListHash = hashAllowList(ctx.takesHoldersAllowList);
|
||||
const cacheKey = `${sourceId}::${sessionId ?? '_'}::${allowListHash}`;
|
||||
// v0.45.7 (ambient-recall adversarial review, P1): the visibility TIER is part
|
||||
// of the key. Without it, a trusted-local call (remote:false → all rows,
|
||||
// private included) warms the cache and a later remote/world-only call with
|
||||
// the same source+session+allowList is SERVED the private payload — a
|
||||
// cross-tier leak through the cache, not through the query.
|
||||
const tier = ctx.remote === false ? 'all' : 'world';
|
||||
// encodeCacheField (F5): source_id / session_id are caller-controlled and
|
||||
// may contain the '::' delimiter; percent-encode ':' so bumpHotMemoryCache's
|
||||
// split('::') can never mis-slice a component.
|
||||
const cacheKey = `${encodeCacheField(sourceId)}::${tier}::${encodeCacheField(sessionId ?? '_')}::${allowListHash}`;
|
||||
|
||||
const ttl = Math.max(1000, opts.ttlMs ?? DEFAULT_TTL_MS);
|
||||
const topK = Math.max(1, Math.min(opts.topK ?? DEFAULT_TOP_K, 25));
|
||||
@@ -135,6 +144,10 @@ export async function getBrainHotMemoryMeta(
|
||||
notability: r.notability,
|
||||
entity_slug: r.entity_slug,
|
||||
valid_from: r.valid_from.toISOString(),
|
||||
// v0.45.7 ambient recall: recording time, so delta's "new facts since my
|
||||
// last wake" filters on WHEN the fact was learned, not its semantic
|
||||
// validity date (a fact recorded today about last month is NEW).
|
||||
created_at: r.created_at.toISOString(),
|
||||
confidence: Number(effectiveConfidence(r, now).toFixed(3)),
|
||||
})),
|
||||
},
|
||||
@@ -145,15 +158,25 @@ export async function getBrainHotMemoryMeta(
|
||||
|
||||
/** Invalidate the cache for a (source_id, session_id) pair after extraction. */
|
||||
export function bumpHotMemoryCache(sourceId: string, sessionId: string | null): void {
|
||||
// Walk the cache and prune any entry matching this source+session prefix
|
||||
// (regardless of allow-list hash). Visitors with different visibility
|
||||
// tiers all get fresh data on next read.
|
||||
const prefix = `${sourceId}::${sessionId ?? '_'}::`;
|
||||
// Walk the cache and prune any entry matching this source+session
|
||||
// (regardless of visibility tier or allow-list hash — key layout is
|
||||
// encField(source)::tier::encField(session)::allowHash since v0.45.7).
|
||||
// Components are ':'-encoded, so split('::') slices cleanly even when the
|
||||
// source/session id itself contains '::' (F5).
|
||||
const encSource = encodeCacheField(sourceId);
|
||||
const encSession = encodeCacheField(sessionId ?? '_');
|
||||
for (const k of _cache.keys()) {
|
||||
if (k.startsWith(prefix)) _cache.delete(k);
|
||||
const parts = k.split('::');
|
||||
if (parts[0] === encSource && parts[2] === encSession) _cache.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
/** Percent-encode ':' so a caller-controlled id can't inject the '::' key
|
||||
* delimiter (F5). Cheap, reversible, and keeps keys human-readable. */
|
||||
function encodeCacheField(v: string): string {
|
||||
return v.replace(/:/g, '%3A');
|
||||
}
|
||||
|
||||
/** Test helper: clear the cache. */
|
||||
export function __resetHotMemoryCacheForTests(): void {
|
||||
_cache.clear();
|
||||
|
||||
@@ -5618,6 +5618,40 @@ export const MIGRATIONS: Migration[] = [
|
||||
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 126,
|
||||
name: 'session_context_state',
|
||||
// v0.45.7 (issue #1) ambient recall — per-session cursor + boundary-tie
|
||||
// dedup for the `delta` verb and the heartbeat runtime. Key is
|
||||
// (source_id, client_id, session_id): client_id defaults to the 'local'
|
||||
// sentinel for the CLI/hook path; REMOTE callers pass their auth client id
|
||||
// so two harnesses in one source can't stomp/read each other's cursor
|
||||
// (eng 1B). ONE key shape — no split key, no NULL branch. surfaced_slugs
|
||||
// holds the boundary set: page slugs delivered AT the cursor timestamp,
|
||||
// REPLACED on every advance (bounded by the delta fetch limit). jsonb
|
||||
// columns default to '[]'::jsonb (a DDL LITERAL default — the ::jsonb
|
||||
// param double-encode trap only bites INSERT/UPDATE binds, not DDL
|
||||
// defaults; writes bind via executeRaw + $N::text::jsonb, guarded by
|
||||
// scripts/check-jsonb-params.mjs). Created empty; plain CREATE INDEX
|
||||
// is instant — no CONCURRENTLY. RLS: covered by the v35
|
||||
// auto_rls_on_create_table event trigger on Postgres. Keep in sync with
|
||||
// src/schema.sql, src/core/pglite-schema.ts, src/core/schema-embedded.ts.
|
||||
idempotent: true,
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS session_context_state (
|
||||
source_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL DEFAULT 'local',
|
||||
session_id TEXT NOT NULL,
|
||||
standing_entities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
surfaced_slugs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_wake_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (source_id, client_id, session_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS session_context_state_updated_idx
|
||||
ON session_context_state (updated_at);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
+302
-1
@@ -4772,6 +4772,307 @@ const recall: Operation = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Parse an `entities` param (comma-string or array) to a trimmed name list. */
|
||||
function parseEntityList(v: unknown): string[] {
|
||||
if (Array.isArray(v)) return v.filter((x) => typeof x === 'string' && x.trim()).map((x) => (x as string).trim());
|
||||
if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
const context_pack: Operation = {
|
||||
name: 'context_pack',
|
||||
description:
|
||||
'MEMORY VERB (v1): budget-packed session-boundary bundle for a set of standing entities — entity cards + open threads + hot facts, zero-LLM, sub-second. Call at session start (warm cold context) and after compaction (rehydrate what the summary lost). WORLD-ONLY by default; pass include_private (honored for LOCAL trusted callers only) to widen all arms. budget_tokens packs server-side (response reports budget_used + dropped_count; cards pack first, then facts). Branch on structured fields, never prose. protocol_version rides every response.',
|
||||
params: {
|
||||
entities: { type: 'string', required: true, description: 'Comma-separated entity names/slugs to bundle. Capped at 8.' },
|
||||
budget_tokens: { type: 'number', description: 'Server-side token budget (char/4). Cards pack first, then facts. Response adds budget_tokens, budget_used, dropped_count.' },
|
||||
since: { type: 'string', description: 'ISO 8601 datetime. When set, open-thread events are filtered to those after this cursor.' },
|
||||
session_id: { type: 'string', description: 'Opaque session id; keys the hot-memory cache and (on the push path) the session cursor.' },
|
||||
include_private: { type: 'boolean', description: 'Local trusted callers only: widen ALL arms to include private facts. Ignored (world-only) for remote callers. Default false.' },
|
||||
},
|
||||
scope: 'read',
|
||||
verb: true,
|
||||
cliHints: { name: 'context-pack' },
|
||||
annotations: { title: 'context_pack (boundary bundle)', readOnlyHint: true },
|
||||
handler: async (ctx, p) => {
|
||||
const { assembleContextPack, renderPack, isAfter, PACK_DEFAULT_MAX_ENTITIES } = await import('./context/turn-context.ts');
|
||||
const sourceId = ctx.sourceId ?? 'default';
|
||||
const rawSince = typeof p.since === 'string' && p.since.trim() ? p.since : undefined;
|
||||
if (rawSince !== undefined && !Number.isFinite(Date.parse(rawSince))) {
|
||||
throw verbError(
|
||||
'invalid_params',
|
||||
`context_pack: since is not a parseable timestamp: "${rawSince.slice(0, 60)}"`,
|
||||
'Pass an ISO 8601 datetime, e.g. since: "2026-08-11T00:00:00Z".',
|
||||
);
|
||||
}
|
||||
// Normalize to ISO (red-team F4): the filter + rendered text use it.
|
||||
const since = rawSince !== undefined ? new Date(Date.parse(rawSince)).toISOString() : undefined;
|
||||
// Echo the CAPPED list (pre-landing review): the assembler bundles at most
|
||||
// PACK_DEFAULT_MAX_ENTITIES, so echoing more would claim entities were
|
||||
// bundled that produced no cards.
|
||||
const entities = parseEntityList(p.entities).slice(0, PACK_DEFAULT_MAX_ENTITIES);
|
||||
// Fail-closed: private only when EXPLICITLY requested AND the caller is
|
||||
// trusted-local (ctx.remote === false). A remote caller never widens.
|
||||
const includePrivate = p.include_private === true && ctx.remote === false;
|
||||
const budgetTokens =
|
||||
typeof p.budget_tokens === 'number' && Number.isFinite(p.budget_tokens) && p.budget_tokens > 0
|
||||
? Math.floor(p.budget_tokens)
|
||||
: null;
|
||||
const res = await assembleContextPack(ctx.engine, {
|
||||
sourceId,
|
||||
entities,
|
||||
since,
|
||||
sessionId: typeof p.session_id === 'string' ? p.session_id : undefined,
|
||||
includePrivate,
|
||||
maxEntities: PACK_DEFAULT_MAX_ENTITIES,
|
||||
});
|
||||
|
||||
let cards = res.cards ?? [];
|
||||
let facts = res.facts ?? [];
|
||||
let budgetUsed: number | undefined;
|
||||
let droppedCount: number | undefined;
|
||||
if (budgetTokens !== null) {
|
||||
const cardCost = (c: (typeof cards)[number]) =>
|
||||
estimateTokens(`${c.entity.title} ${c.summary} ${(c.open_threads ?? []).map((t) => t.text).join(' ')}`);
|
||||
const cardPack = packToBudget(cards, cardCost, budgetTokens);
|
||||
cards = cardPack.items;
|
||||
const remaining = budgetTokens - cardPack.meta.used;
|
||||
const factPack =
|
||||
remaining > 0
|
||||
? packToBudget(facts, (f) => estimateTokens(f.fact), remaining)
|
||||
: { items: [] as typeof facts, meta: { budget: 0, used: 0, dropped: facts.length, kept: 0 } };
|
||||
facts = factPack.items;
|
||||
budgetUsed = cardPack.meta.used + factPack.meta.used;
|
||||
droppedCount = cardPack.meta.dropped + factPack.meta.dropped;
|
||||
}
|
||||
// Recompute open_threads with the SAME since filter the assembler applied
|
||||
// (pre-landing review: the raw flatMap silently dropped the documented
|
||||
// `since` contract from the structured array whenever budget packing ran).
|
||||
const open_threads = cards
|
||||
.flatMap((c) => c.open_threads ?? [])
|
||||
.filter((t) => !since || (t.date !== null && isAfter(t.date, since)));
|
||||
// Re-render the injectable block from the FINAL sets (adversarial review):
|
||||
// `text` is what harnesses inject, so it must honor the same budget the
|
||||
// structured arrays report — the assembler's pre-budget rendering would
|
||||
// overrun the declared budget_tokens.
|
||||
const text = budgetTokens !== null ? renderPack(cards, open_threads, facts) : res.text;
|
||||
|
||||
return {
|
||||
protocol_version: MEMORY_VERBS_VERSION,
|
||||
entities,
|
||||
cards: cards.map((c) => ({
|
||||
slug: c.entity.slug,
|
||||
title: c.entity.title,
|
||||
type: c.entity.type,
|
||||
summary: c.summary,
|
||||
open_threads: c.open_threads,
|
||||
edges: c.edges,
|
||||
backlink_count: c.backlink_count,
|
||||
})),
|
||||
open_threads,
|
||||
facts: facts.map((f) => ({
|
||||
fact: f.fact,
|
||||
kind: f.kind,
|
||||
entity_slug: f.entity_slug,
|
||||
valid_from: f.valid_from,
|
||||
confidence: f.confidence,
|
||||
})),
|
||||
text,
|
||||
...(res.degradedReason ? { degraded_reason: res.degradedReason } : {}),
|
||||
...(budgetTokens !== null
|
||||
? { budget_tokens: budgetTokens, budget_used: budgetUsed, dropped_count: droppedCount }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const delta: Operation = {
|
||||
name: 'delta',
|
||||
description:
|
||||
'MEMORY VERB (v1): "what changed since T" for heartbeats — pages updated after `since` + hot facts newer than `since` + open-thread events after `since`, zero-LLM. Lets a periodic wake maintain warm state in O(changes) instead of re-deriving. Optionally scope thread deltas to `entities`. WORLD-ONLY by default; include_private honored for local trusted callers only. budget_tokens packs server-side (pages first, then facts). protocol_version rides every response.',
|
||||
params: {
|
||||
since: { type: 'string', description: 'ISO 8601 cursor. Returns pages/facts/thread-events newer than this timestamp. Optional when session_id carries an established cursor.' },
|
||||
since_slug: { type: 'string', description: 'Stateless keyset resume: pass back `next_cursor.slug` from the previous response (paired with `since`=next_cursor.since) to page through pages sharing one timestamp. Ignored when session_id is set (the session cursor carries it).' },
|
||||
entities: { type: 'string', description: 'Optional comma-separated entity scope for thread-event deltas. Capped at 8.' },
|
||||
budget_tokens: { type: 'number', description: 'Server-side token budget (char/4). Pages pack first, then facts. Response adds budget_tokens, budget_used, dropped_count.' },
|
||||
session_id: { type: 'string', description: 'Opaque session id. Drives the per-session cursor: the first call establishes it, each call advances it to the newest DELIVERED change (at-least-once — with has_more:true the undelivered tail returns on the next wake). Without it, pass an explicit `since` for a stateless delta.' },
|
||||
include_private: { type: 'boolean', description: 'Local trusted callers only: widen ALL arms to include private facts. Ignored (world-only) for remote callers. Default false.' },
|
||||
},
|
||||
scope: 'read',
|
||||
verb: true,
|
||||
cliHints: { name: 'delta' },
|
||||
annotations: { title: 'delta (what changed since)', readOnlyHint: true },
|
||||
handler: async (ctx, p) => {
|
||||
const { assembleDeltaContext, renderDelta, PACK_DEFAULT_MAX_ENTITIES } = await import('./context/turn-context.ts');
|
||||
const { getSessionContextState, upsertSessionContextState } = await import('./context/session-state.ts');
|
||||
const sourceId = ctx.sourceId ?? 'default';
|
||||
const rawSince = typeof p.since === 'string' && p.since.trim() ? p.since : null;
|
||||
if (rawSince !== null && !Number.isFinite(Date.parse(rawSince))) {
|
||||
throw verbError(
|
||||
'invalid_params',
|
||||
`delta: since is not a parseable timestamp: "${rawSince.slice(0, 60)}"`,
|
||||
'Pass an ISO 8601 datetime, e.g. since: "2026-08-11T00:00:00Z".',
|
||||
);
|
||||
}
|
||||
// NORMALIZE to ISO immediately (red-team F4): the raw string is echoed
|
||||
// into the injectable `text` block, so an attacker-shaped-but-parseable
|
||||
// `since` must never reach rendering verbatim.
|
||||
const explicitSince = rawSince !== null ? new Date(Date.parse(rawSince)).toISOString() : null;
|
||||
const sessionId = typeof p.session_id === 'string' && p.session_id.trim() ? p.session_id : null;
|
||||
// Cursor namespace (pre-landing review, fail-closed): 'local' is RESERVED
|
||||
// for the trusted CLI/hook lane, gated on STRICT ctx.remote === false —
|
||||
// anything else (true, undefined via cast bypass) is remote. Remote callers
|
||||
// use their auth client id; an auth-LESS or blank-id remote (stdio MCP)
|
||||
// gets the shared 'remote' sentinel — never collapsed into 'local'.
|
||||
const clientId = ctx.remote === false ? null : ctx.auth?.clientId?.trim() || 'remote';
|
||||
const includePrivate = p.include_private === true && ctx.remote === false;
|
||||
const budgetTokens =
|
||||
typeof p.budget_tokens === 'number' && Number.isFinite(p.budget_tokens) && p.budget_tokens > 0
|
||||
? Math.floor(p.budget_tokens)
|
||||
: null;
|
||||
|
||||
const state = sessionId ? await getSessionContextState(ctx.engine, sourceId, clientId, sessionId) : null;
|
||||
const effectiveSince = explicitSince ?? state?.last_wake_at ?? null;
|
||||
|
||||
if (!effectiveSince) {
|
||||
if (!sessionId) {
|
||||
throw verbError(
|
||||
'invalid_params',
|
||||
'delta requires `since` (ISO 8601) or a `session_id` with an established cursor.',
|
||||
'Pass since ("2026-08-11T00:00:00Z") for a stateless delta, or a stable session_id — the first call establishes the cursor and later calls return only newer changes.',
|
||||
);
|
||||
}
|
||||
// First wake for this session: establish the cursor at now and report an
|
||||
// empty delta (there is no prior point to diff against yet). Opportunistic
|
||||
// GC on row creation bounds session-row accumulation on serve-less CLI
|
||||
// lanes and remote read callers minting session ids (pre-landing review).
|
||||
// AWAITED (v0.45.7): a floating engine promise here races the CLI lane's
|
||||
// engine teardown and wedges the process — `gbrain delta --session-id`
|
||||
// printed its response but never exited (the exact command the shipped
|
||||
// HEARTBEAT.md ambient-delta row tells agents to run). GC is two fast
|
||||
// DELETEs on a capped table and internally fail-open, so awaiting costs
|
||||
// one first-wake round-trip, never an error. The serve-boot call site
|
||||
// (src/mcp/server.ts) stays fire-and-forget — that process is long-lived.
|
||||
const now = new Date().toISOString();
|
||||
const { gcSessionContextState } = await import('./context/session-state.ts');
|
||||
await upsertSessionContextState(ctx.engine, sourceId, clientId, sessionId, { lastWakeAt: now });
|
||||
await gcSessionContextState(ctx.engine);
|
||||
return {
|
||||
protocol_version: MEMORY_VERBS_VERSION,
|
||||
since: now, pages: [], facts: [], threads: [], text: '', has_more: false,
|
||||
next_cursor: { since: now, slug: '' },
|
||||
...(budgetTokens !== null
|
||||
? { budget_tokens: budgetTokens, budget_used: 0, dropped_count: 0 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Keyset cursor (red-team F1/F2 fix): pages page by (updated_at, slug), so
|
||||
// a >limit cluster at one timestamp is reachable and a delivered page never
|
||||
// re-appears unless it changes. The keyset slug lives in the session row
|
||||
// (surfaced_slugs[0]); an explicit-`since` caller has no stored slug and
|
||||
// resumes via the returned `next_cursor`.
|
||||
const cursorSlug = sessionId ? state?.surfaced_slugs?.[0] : undefined;
|
||||
const explicitSlug = typeof p.since_slug === 'string' ? p.since_slug : undefined;
|
||||
const sinceSlug = explicitSlug ?? cursorSlug;
|
||||
|
||||
const res = await assembleDeltaContext(ctx.engine, {
|
||||
sourceId,
|
||||
since: effectiveSince,
|
||||
...(sinceSlug !== undefined ? { sinceSlug } : {}),
|
||||
entities: parseEntityList(p.entities),
|
||||
sessionId: sessionId ?? undefined,
|
||||
includePrivate,
|
||||
maxEntities: PACK_DEFAULT_MAX_ENTITIES,
|
||||
});
|
||||
|
||||
// Pages arrive OLDEST first by (updated_at, slug) — no client-side dedup
|
||||
// needed; the keyset already excludes everything at/before the cursor.
|
||||
let pages = res.deltaPages ?? [];
|
||||
let facts = res.facts ?? [];
|
||||
const threads = res.openThreads ?? [];
|
||||
let budgetUsed: number | undefined;
|
||||
let droppedCount: number | undefined;
|
||||
let factsDropped = 0;
|
||||
const fetchedPages = pages.length;
|
||||
if (budgetTokens !== null) {
|
||||
// packToBudget keeps a contiguous PREFIX (order-preserving, stops at the
|
||||
// first overflow) — with oldest-first pages the kept set stays contiguous
|
||||
// from the cursor, which the advance logic below depends on.
|
||||
const pagePack = packToBudget(pages, (pg) => estimateTokens(`${pg.title} ${pg.slug}`), budgetTokens);
|
||||
pages = pagePack.items;
|
||||
const remaining = budgetTokens - pagePack.meta.used;
|
||||
const factPack =
|
||||
remaining > 0
|
||||
? packToBudget(facts, (f) => estimateTokens(f.fact), remaining)
|
||||
: { items: [] as typeof facts, meta: { budget: 0, used: 0, dropped: facts.length, kept: 0 } };
|
||||
facts = factPack.items;
|
||||
budgetUsed = pagePack.meta.used + factPack.meta.used;
|
||||
droppedCount = pagePack.meta.dropped + factPack.meta.dropped;
|
||||
factsDropped = factPack.meta.dropped;
|
||||
}
|
||||
const pagesDropped = fetchedPages - pages.length;
|
||||
// has_more covers ALL undelivered content — fetch-limit overflow, budget-
|
||||
// dropped pages, AND budget-dropped facts (pre-landing review: facts were
|
||||
// silently lost when pages fit but facts overflowed).
|
||||
const hasMore = res.deltaOverflow === true || pagesDropped > 0 || factsDropped > 0;
|
||||
|
||||
// Cursor advance (keyset, at-least-once): advance to the last DELIVERED
|
||||
// (updated_at, slug). The keyset's strict `>` means the next wake starts
|
||||
// exactly after it — a >limit same-timestamp cluster drains one page at a
|
||||
// time across wakes (F1), and a delivered page never re-appears (F2). On a
|
||||
// page-less wake with nothing dropped, advance the TIME cursor to now()
|
||||
// minus a safety lag (in-flight write txns stamp updated_at at txn START)
|
||||
// and clear the keyset slug. If nothing delivered but something dropped, do
|
||||
// NOT advance (deliver-before-advance; a too-small budget must not eat it).
|
||||
const nextCursor =
|
||||
pages.length > 0
|
||||
? { since: pages[pages.length - 1].updated_at, slug: pages[pages.length - 1].slug }
|
||||
: { since: effectiveSince, slug: sinceSlug ?? '' };
|
||||
if (sessionId) {
|
||||
if (pages.length > 0) {
|
||||
await upsertSessionContextState(ctx.engine, sourceId, clientId, sessionId, {
|
||||
lastWakeAt: nextCursor.since,
|
||||
cursorSlug: nextCursor.slug,
|
||||
});
|
||||
} else if (!hasMore) {
|
||||
await upsertSessionContextState(ctx.engine, sourceId, clientId, sessionId, {
|
||||
lastWakeAt: new Date(Date.now() - 2000).toISOString(),
|
||||
cursorSlug: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render the injectable block from the FINAL sets (adversarial review):
|
||||
// `text` must honor the budget AND the boundary-tie exclusion the
|
||||
// structured arrays reflect — the assembler's render predates both.
|
||||
const text = renderDelta(pages, facts, threads, effectiveSince);
|
||||
|
||||
return {
|
||||
protocol_version: MEMORY_VERBS_VERSION,
|
||||
since: effectiveSince,
|
||||
pages,
|
||||
facts: facts.map((f) => ({
|
||||
fact: f.fact,
|
||||
kind: f.kind,
|
||||
entity_slug: f.entity_slug,
|
||||
valid_from: f.valid_from,
|
||||
confidence: f.confidence,
|
||||
})),
|
||||
threads,
|
||||
text,
|
||||
has_more: hasMore,
|
||||
// Stateless resume: a caller with no session_id passes these back as
|
||||
// `since` + `since_slug` on the next call to page deterministically.
|
||||
next_cursor: nextCursor,
|
||||
...(res.degradedReason ? { degraded_reason: res.degradedReason } : {}),
|
||||
...(budgetTokens !== null
|
||||
? { budget_tokens: budgetTokens, budget_used: budgetUsed, dropped_count: droppedCount }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const forget_fact: Operation = {
|
||||
name: 'forget_fact',
|
||||
description: 'v0.32.2: forget a fact. Rewrites the page\'s `## Facts` fence to strike through the row and set valid_until=today (the DB\'s expired_at derives via valid_until + now() on the next reconcile so the forget survives `gbrain rebuild`). Falls back to legacy DB-only expire for pre-v51 / thin-client rows. Idempotent on already-expired or unknown ids.',
|
||||
@@ -6432,7 +6733,7 @@ export const operations: Operation[] = [
|
||||
// Extraction quarantine lane (#160): gated entity extraction + review queue
|
||||
extract_entities, extraction_pending, extraction_review,
|
||||
// v0.31: hot memory (facts table)
|
||||
extract_facts, recall, forget_fact,
|
||||
extract_facts, recall, context_pack, delta, forget_fact,
|
||||
// v0.32.6: contradiction probe MCP surface (M3)
|
||||
find_contradictions,
|
||||
// v0.33: expertise + relationship-proximity routing
|
||||
|
||||
@@ -1508,7 +1508,16 @@ export class PGLiteEngine implements BrainEngine {
|
||||
params.push(filters.tag);
|
||||
where.push(`t.tag = $${params.length}`);
|
||||
}
|
||||
if (filters?.updated_after) {
|
||||
if (filters?.updatedAfterKeyset) {
|
||||
// v0.45.7 keyset: (updated_at, slug) strict-greater — supersedes updated_after.
|
||||
params.push(filters.updatedAfterKeyset.updatedAt);
|
||||
const tsIdx = params.length;
|
||||
params.push(filters.updatedAfterKeyset.slug);
|
||||
const slugIdx = params.length;
|
||||
where.push(
|
||||
`(p.updated_at > $${tsIdx}::timestamptz OR (p.updated_at = $${tsIdx}::timestamptz AND p.slug > $${slugIdx}))`,
|
||||
);
|
||||
} else if (filters?.updated_after) {
|
||||
params.push(filters.updated_after);
|
||||
where.push(`p.updated_at > $${params.length}::timestamptz`);
|
||||
}
|
||||
|
||||
@@ -991,6 +991,20 @@ CREATE INDEX IF NOT EXISTS context_volunteer_events_src_time_idx
|
||||
CREATE INDEX IF NOT EXISTS context_volunteer_events_src_slug_idx
|
||||
ON context_volunteer_events (source_id, slug);
|
||||
|
||||
-- session_context_state (v0.45.7 / migration v126 — ambient recall issue #1).
|
||||
CREATE TABLE IF NOT EXISTS session_context_state (
|
||||
source_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL DEFAULT 'local',
|
||||
session_id TEXT NOT NULL,
|
||||
standing_entities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
surfaced_slugs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_wake_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (source_id, client_id, session_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS session_context_state_updated_idx
|
||||
ON session_context_state (updated_at);
|
||||
|
||||
-- ============================================================
|
||||
-- migration_impact_log (v0.41.18.0 — gbrain onboard wave)
|
||||
-- ============================================================
|
||||
|
||||
@@ -1366,7 +1366,13 @@ export class PostgresEngine implements BrainEngine {
|
||||
const typeCondition = filters?.type ? sql`AND p.type = ${filters.type}` : sql``;
|
||||
const tagJoin = filters?.tag ? sql`JOIN tags t ON t.page_id = p.id` : sql``;
|
||||
const tagCondition = filters?.tag ? sql`AND t.tag = ${filters.tag}` : sql``;
|
||||
const updatedCondition = updatedAfter ? sql`AND p.updated_at > ${updatedAfter}::timestamptz` : sql``;
|
||||
// v0.45.7 keyset (updated_at, slug) supersedes updated_after when set.
|
||||
const keyset = filters?.updatedAfterKeyset;
|
||||
const updatedCondition = keyset
|
||||
? sql`AND (p.updated_at > ${keyset.updatedAt}::timestamptz OR (p.updated_at = ${keyset.updatedAt}::timestamptz AND p.slug > ${keyset.slug}))`
|
||||
: updatedAfter
|
||||
? sql`AND p.updated_at > ${updatedAfter}::timestamptz`
|
||||
: sql``;
|
||||
// slugPrefix uses the (source_id, slug) UNIQUE btree index for range scans.
|
||||
// Escape LIKE metacharacters so the user prefix is treated as a literal.
|
||||
const slugPrefix = filters?.slugPrefix;
|
||||
|
||||
@@ -765,6 +765,23 @@ CREATE INDEX IF NOT EXISTS context_volunteer_events_src_time_idx
|
||||
CREATE INDEX IF NOT EXISTS context_volunteer_events_src_slug_idx
|
||||
ON context_volunteer_events (source_id, slug);
|
||||
|
||||
-- session_context_state (v0.45.7 / migration v126 — ambient recall issue #1):
|
||||
-- per-session cursor + surfaced-slug dedup for the \`delta\` verb + heartbeat
|
||||
-- runtime. Key (source_id, client_id, session_id); client_id 'local' sentinel
|
||||
-- for CLI/hook, remote auth client id otherwise. jsonb DDL-literal defaults.
|
||||
CREATE TABLE IF NOT EXISTS session_context_state (
|
||||
source_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL DEFAULT 'local',
|
||||
session_id TEXT NOT NULL,
|
||||
standing_entities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
surfaced_slugs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_wake_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (source_id, client_id, session_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS session_context_state_updated_idx
|
||||
ON session_context_state (updated_at);
|
||||
|
||||
-- migration_impact_log moved BELOW minion_jobs (was here, lines 645-676)
|
||||
-- because its \`job_id BIGINT REFERENCES minion_jobs(id)\` FK requires
|
||||
-- minion_jobs to exist FIRST during SCHEMA_SQL replay. v0.41.25.0 fix.
|
||||
|
||||
@@ -117,6 +117,14 @@ export interface ParsedTranscript {
|
||||
parsedLines: number;
|
||||
/** Non-blank lines that failed JSON.parse (includes a tail-truncated partial first line). */
|
||||
skippedLines: number;
|
||||
/**
|
||||
* v0.45.7 ambient recall — {type:'system', subtype:'compact_boundary'} entries
|
||||
* seen in the read range. Still excluded from `turns` (they carry no
|
||||
* conversation text); SURFACED here so boundary consumers (post-compaction
|
||||
* rehydration, telemetry, future transcript watchers) can detect that a
|
||||
* compaction happened without re-scanning the file.
|
||||
*/
|
||||
compactBoundaries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +164,7 @@ export function parseTranscript(
|
||||
const injectedContextBlocks: string[] = [];
|
||||
let parsedLines = 0;
|
||||
let skippedLines = 0;
|
||||
let compactBoundaries = 0;
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
@@ -169,6 +178,9 @@ export function parseTranscript(
|
||||
continue;
|
||||
}
|
||||
parsedLines++;
|
||||
// v0.45.7: count compaction boundaries (system entries — disjoint from
|
||||
// attachments and turns) so post-compaction rehydration can detect them.
|
||||
if (isCompactBoundary(entry)) compactBoundaries++;
|
||||
const injected = entryToInjectedBlock(entry);
|
||||
if (injected) {
|
||||
injectedContextBlocks.push(injected);
|
||||
@@ -177,7 +189,14 @@ export function parseTranscript(
|
||||
const turn = entryToTurn(entry);
|
||||
if (turn) turns.push(turn);
|
||||
}
|
||||
return { turns, injectedContextBlocks, bytesRead, parsedLines, skippedLines };
|
||||
return { turns, injectedContextBlocks, bytesRead, parsedLines, skippedLines, compactBoundaries };
|
||||
}
|
||||
|
||||
/** {type:'system', subtype:'compact_boundary'} — Claude Code's on-disk compaction marker (v0.45.7). */
|
||||
function isCompactBoundary(entry: unknown): boolean {
|
||||
if (typeof entry !== 'object' || entry === null) return false;
|
||||
const e = entry as Record<string, unknown>;
|
||||
return e.type === 'system' && e.subtype === 'compact_boundary';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+15
-1
@@ -293,6 +293,15 @@ export interface PageFilters {
|
||||
offset?: number;
|
||||
/** ISO date string (YYYY-MM-DD or full ISO timestamp). Filter to pages updated_at > value. */
|
||||
updated_after?: string;
|
||||
/**
|
||||
* v0.45.7 — keyset cursor for deterministic pagination through pages sharing
|
||||
* one `updated_at`. `WHERE p.updated_at > ts OR (p.updated_at = ts AND
|
||||
* p.slug > slug)`. Supersedes `updated_after` when set; pair with
|
||||
* `sort: 'updated_asc'` (total order). Used by the `delta` verb's session
|
||||
* cursor so a >limit same-timestamp cluster pages cleanly instead of
|
||||
* livelocking. `slug` empty ⇒ start of the `ts` bucket.
|
||||
*/
|
||||
updatedAfterKeyset?: { updatedAt: string; slug: string };
|
||||
/**
|
||||
* Prefix-match filter on slug. Implemented as `WHERE slug LIKE prefix || '%'`
|
||||
* in both engines so it uses the (source_id, slug) UNIQUE constraint's btree
|
||||
@@ -349,7 +358,12 @@ export interface GetPageOpts {
|
||||
/** v0.29: literal ORDER BY fragments for the PageFilters.sort enum. Whitelisted. */
|
||||
export const PAGE_SORT_SQL: Record<NonNullable<PageFilters['sort']>, string> = {
|
||||
updated_desc: 'p.updated_at DESC',
|
||||
updated_asc: 'p.updated_at ASC',
|
||||
// v0.45.7: slug tiebreaker makes updated_asc a TOTAL order, so keyset
|
||||
// pagination (updatedAfterKeyset) can page deterministically through a
|
||||
// cluster of pages sharing one updated_at (bulk syncs stamp identical
|
||||
// now() across a transaction). Without the tiebreaker, rows at the same
|
||||
// timestamp order arbitrarily and a >limit tie cluster is unpageable.
|
||||
updated_asc: 'p.updated_at ASC, p.slug ASC',
|
||||
created_desc: 'p.created_at DESC',
|
||||
slug: 'p.slug ASC',
|
||||
};
|
||||
|
||||
+145
-2
@@ -28,7 +28,11 @@ import type { Operation } from './operations.ts';
|
||||
/** Frozen protocol version for the MEMORY_VERBS v1 verb set. Single source of truth. */
|
||||
export const MEMORY_VERBS_VERSION = 1;
|
||||
|
||||
export const VERB_NAMES = ['recall', 'remember', 'entity', 'synthesize', 'forget'] as const;
|
||||
// v0.45.7 (issue #1): the frozen set grows from 5 to 7 with two ambient-recall
|
||||
// verbs. The wire protocol_version STAYS 1 (additive) — MEMORY_VERBS_VERSION is
|
||||
// unchanged so the five existing schemas + handlers keep stamping 1 and their
|
||||
// conformance assertions (protocol_version === 1) hold.
|
||||
export const VERB_NAMES = ['recall', 'remember', 'entity', 'synthesize', 'forget', 'context_pack', 'delta'] as const;
|
||||
export type VerbName = (typeof VERB_NAMES)[number];
|
||||
|
||||
const FACT_KINDS = ['event', 'preference', 'commitment', 'belief', 'fact'] as const;
|
||||
@@ -532,9 +536,148 @@ export const RESPONSE_SCHEMAS: Record<VerbName, Record<string, unknown>> = {
|
||||
reason: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
// v0.45.7 (issue #1) — ambient recall. World-only by default; include_private
|
||||
// widens all arms (local trusted callers only). protocol_version stays 1.
|
||||
context_pack: {
|
||||
type: 'object',
|
||||
required: ['protocol_version', 'entities', 'cards', 'open_threads', 'facts'],
|
||||
properties: {
|
||||
protocol_version: { type: 'integer', const: MEMORY_VERBS_VERSION },
|
||||
entities: { type: 'array', items: { type: 'string' } },
|
||||
cards: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['slug', 'title', 'summary', 'open_threads'],
|
||||
properties: {
|
||||
slug: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
type: { type: ['string', 'null'] },
|
||||
summary: { type: 'string' },
|
||||
open_threads: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['kind', 'text', 'date'],
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['commitment', 'recent_event'] },
|
||||
text: { type: 'string' },
|
||||
date: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['type', 'direction', 'slug'],
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
direction: { type: 'string', enum: ['out', 'in'] },
|
||||
slug: { type: 'string' },
|
||||
context: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
backlink_count: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
open_threads: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['kind', 'text', 'date'],
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['commitment', 'recent_event'] },
|
||||
text: { type: 'string' },
|
||||
date: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
facts: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['fact', 'kind'],
|
||||
properties: {
|
||||
fact: { type: 'string' },
|
||||
kind: { type: 'string' },
|
||||
entity_slug: { type: ['string', 'null'] },
|
||||
valid_from: { type: 'string' },
|
||||
confidence: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
text: { type: 'string', description: 'Pre-rendered injectable block (envelope-wrapped).' },
|
||||
degraded_reason: { type: 'string', description: 'Present when a wall-clock deadline returned a partial pack.' },
|
||||
budget_tokens: { type: 'integer', description: 'Present when budget_tokens was passed.' },
|
||||
budget_used: { type: 'integer' },
|
||||
dropped_count: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
delta: {
|
||||
type: 'object',
|
||||
required: ['protocol_version', 'since', 'pages', 'facts', 'threads'],
|
||||
properties: {
|
||||
protocol_version: { type: 'integer', const: MEMORY_VERBS_VERSION },
|
||||
since: { type: 'string', description: 'The ISO cursor this delta was computed against.' },
|
||||
has_more: { type: 'boolean', description: 'True when changes beyond the fetch limit or budget were NOT delivered; with session_id the cursor advanced only to the last delivered page, so the tail surfaces on the next wake.' },
|
||||
next_cursor: {
|
||||
type: 'object',
|
||||
required: ['since', 'slug'],
|
||||
description: 'Keyset to resume from (stateless callers pass back as since + since_slug).',
|
||||
properties: { since: { type: 'string' }, slug: { type: 'string' } },
|
||||
},
|
||||
pages: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['slug', 'title', 'updated_at'],
|
||||
properties: {
|
||||
slug: { type: 'string' },
|
||||
source_id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
updated_at: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
facts: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['fact', 'kind'],
|
||||
properties: {
|
||||
fact: { type: 'string' },
|
||||
kind: { type: 'string' },
|
||||
entity_slug: { type: ['string', 'null'] },
|
||||
valid_from: { type: 'string' },
|
||||
confidence: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
threads: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['kind', 'text', 'date'],
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['commitment', 'recent_event'] },
|
||||
text: { type: 'string' },
|
||||
date: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
text: { type: 'string' },
|
||||
degraded_reason: { type: 'string' },
|
||||
budget_tokens: { type: 'integer' },
|
||||
budget_used: { type: 'integer' },
|
||||
dropped_count: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Error envelope schema (uniform across all five verbs). */
|
||||
/** Error envelope schema (uniform across all verbs). */
|
||||
export const ERROR_SCHEMA: Record<string, unknown> = {
|
||||
type: 'object',
|
||||
required: ['error', 'message'],
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
export interface ConformanceCase {
|
||||
name: string;
|
||||
verb: 'recall' | 'remember' | 'entity' | 'synthesize' | 'forget';
|
||||
verb: 'recall' | 'remember' | 'entity' | 'synthesize' | 'forget' | 'context_pack' | 'delta';
|
||||
/** `{{marker}}` and `{{id:<key>}}` substitute at run time. */
|
||||
params: Record<string, unknown>;
|
||||
/** Validate the (parsed) response body against RESPONSE_SCHEMAS[verb]. */
|
||||
@@ -223,4 +223,35 @@ export const CONFORMANCE_CASES: ConformanceCase[] = [
|
||||
// Either a schema-valid answer (key configured) or a clean unavailable
|
||||
// error (no key). The runner accepts both; anything else fails.
|
||||
},
|
||||
|
||||
// ── v0.45.7 additive verbs: context_pack + delta ──────────────────────────
|
||||
// The runner SKIPS these against endpoints that don't advertise the verbs
|
||||
// (they are additive — a pre-v0.45.7 v1 endpoint must still certify).
|
||||
{
|
||||
name: 'context_pack returns a schema-valid bundle for unknown entities (empty, not an error)',
|
||||
verb: 'context_pack',
|
||||
params: { entities: 'conformance-nonexistent-{{marker}}', budget_tokens: 500 },
|
||||
validateSchema: true,
|
||||
expect: [
|
||||
{ path: 'protocol_version', equals: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'delta with an explicit epoch since returns a schema-valid delta',
|
||||
verb: 'delta',
|
||||
params: { since: '1970-01-01T00:00:00Z', budget_tokens: 500 },
|
||||
validateSchema: true,
|
||||
expect: [
|
||||
{ path: 'protocol_version', equals: 1 },
|
||||
// `since` is normalized to ISO (v0.45.7 F4 — never echoed raw).
|
||||
{ path: 'since', equals: '1970-01-01T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'delta without since or session_id is invalid_params with a suggestion',
|
||||
verb: 'delta',
|
||||
params: {},
|
||||
expectErrorCode: 'invalid_params',
|
||||
expectSuggestion: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -166,16 +166,26 @@ export async function runConformance(
|
||||
seededEntity = false;
|
||||
}
|
||||
|
||||
// List-level checks first: the five verbs are advertised, synthesize is
|
||||
// List-level checks first: the CORE five verbs are advertised, synthesize is
|
||||
// marked expensive (description prefix is the load-bearing channel).
|
||||
// v0.45.7: context_pack/delta are ADDITIVE — a v1 endpoint that predates them
|
||||
// must still certify (the versioning policy this runner enforces), so their
|
||||
// absence is a 'skip', never a 'fail'. When advertised, they are exercised
|
||||
// like any other verb.
|
||||
const CORE_VERBS: VerbName[] = ['recall', 'remember', 'entity', 'synthesize', 'forget'];
|
||||
const advertised = new Set<string>();
|
||||
try {
|
||||
const tools = await client.listTools();
|
||||
const byName = new Map(tools.map(t => [t.name, t]));
|
||||
for (const name of byName.keys()) advertised.add(name);
|
||||
for (const verb of Object.keys(RESPONSE_SCHEMAS) as VerbName[]) {
|
||||
const required = CORE_VERBS.includes(verb);
|
||||
results.push(
|
||||
byName.has(verb)
|
||||
? { name: `tools/list advertises ${verb}`, verb, status: 'pass', detail: '' }
|
||||
: { name: `tools/list advertises ${verb}`, verb, status: 'fail', detail: 'not advertised' },
|
||||
: required
|
||||
? { name: `tools/list advertises ${verb}`, verb, status: 'fail', detail: 'not advertised' }
|
||||
: { name: `tools/list advertises ${verb}`, verb, status: 'skip', detail: 'optional additive verb (v0.45.7) not advertised by this endpoint' },
|
||||
);
|
||||
}
|
||||
const synth = byName.get('synthesize');
|
||||
@@ -200,6 +210,11 @@ export async function runConformance(
|
||||
results.push({ name: c.name, verb: c.verb, status: 'skip', detail: 'costs money — pass --synthesize' });
|
||||
continue;
|
||||
}
|
||||
// v0.45.7: cases for additive verbs run only where the verb is advertised.
|
||||
if (!CORE_VERBS.includes(c.verb as VerbName) && advertised.size > 0 && !advertised.has(c.verb)) {
|
||||
results.push({ name: c.name, verb: c.verb, status: 'skip', detail: 'optional additive verb not advertised by this endpoint' });
|
||||
continue;
|
||||
}
|
||||
if (c.requiresSeededEntity && !seededEntity) {
|
||||
results.push({ name: c.name, verb: c.verb, status: 'skip', detail: 'target has no put_page to seed the entity page (verbs-only surface)' });
|
||||
continue;
|
||||
|
||||
@@ -293,7 +293,9 @@ async function assembleCard(
|
||||
return {
|
||||
entity: { slug: pageSlug, title: row.title ?? pageSlug, type: row.type ?? null },
|
||||
aka,
|
||||
summary: safeSynopsis(row),
|
||||
// v0.45.7: summary widens in lockstep with the card's fact visibility —
|
||||
// remote (world-only) keeps ['world']; a local include_private card widens.
|
||||
summary: safeSynopsis(row, { keepVisibility: remote ? ['world'] : ['private', 'world'] }),
|
||||
last_touched: {
|
||||
updated_at: toIso(row.updated_at),
|
||||
last_retrieved_at: toIso(row.last_retrieved_at),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* v0.45.7 ambient recall — the serve-side context_pack IPC handler, extracted
|
||||
* from startMcpServer's closure so it is directly testable against a real
|
||||
* engine (ship coverage audit: the closure form was only ever exercised via
|
||||
* canned mock handlers).
|
||||
*
|
||||
* The RUNTIME owns the intelligence — entity resolution (request entities +
|
||||
* window extraction + the session row's banked set), the since-cursor, and
|
||||
* cursor advancement — the hook just fires the event and injects stdout.
|
||||
* World-only ALWAYS (the push path never widens; include_private is a
|
||||
* pull-verb affordance). Fail-open: session-state trouble degrades to a
|
||||
* stateless pack, never an error.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import type { ContextPackHandler } from '../core/context/resolve-ipc.ts';
|
||||
import { CONTEXT_PACK_SERVER_BUDGET_MS } from '../core/context/resolve-ipc.ts';
|
||||
import { assembleContextPack } from '../core/context/turn-context.ts';
|
||||
import { extractCandidatesFromWindow } from '../core/context/entity-salience.ts';
|
||||
import {
|
||||
getSessionContextState,
|
||||
upsertSessionContextState,
|
||||
} from '../core/context/session-state.ts';
|
||||
|
||||
/** Tighter entity-card fan-out on the PUSH path (eng 4A): the server budget
|
||||
* can't absorb the pull path's 8-card ceiling on a cold cache. */
|
||||
export const PUSH_PACK_MAX_ENTITIES = 4;
|
||||
|
||||
export function makeContextPackIpcHandler(
|
||||
engine: BrainEngine,
|
||||
defaultSource: string,
|
||||
): ContextPackHandler {
|
||||
return async (req) => {
|
||||
const sessionId =
|
||||
typeof req.sessionId === 'string' && req.sessionId.trim() ? req.sessionId : null;
|
||||
// Merge: fresh request entities first, then window-extracted candidates,
|
||||
// then the session row's banked standing set.
|
||||
const fromReq = Array.isArray(req.entities)
|
||||
? req.entities.filter((e): e is string => typeof e === 'string' && !!e.trim())
|
||||
: [];
|
||||
let fromWindow: string[] = [];
|
||||
try {
|
||||
if (Array.isArray(req.window) && req.window.length) {
|
||||
fromWindow = extractCandidatesFromWindow(req.window).map((c) => c.query);
|
||||
}
|
||||
} catch { /* extraction is best-effort */ }
|
||||
const state = sessionId
|
||||
? await getSessionContextState(engine, defaultSource, null, sessionId)
|
||||
: null;
|
||||
const banked = state?.standing_entities ?? [];
|
||||
const entities = Array.from(new Set([...fromReq, ...fromWindow, ...banked]));
|
||||
|
||||
if (req.bankOnly === true) {
|
||||
// PreCompact banking: persist the standing set for the post-compaction
|
||||
// SessionStart; no assembly, empty block.
|
||||
if (sessionId && entities.length) {
|
||||
await upsertSessionContextState(engine, defaultSource, null, sessionId, {
|
||||
standingEntities: entities.slice(0, PUSH_PACK_MAX_ENTITIES * 2),
|
||||
});
|
||||
}
|
||||
return { text: '', pointers: [], factsCount: 0, mode: 'pack' as const };
|
||||
}
|
||||
|
||||
const result = await assembleContextPack(engine, {
|
||||
sourceId: defaultSource,
|
||||
entities,
|
||||
sessionId: sessionId ?? undefined,
|
||||
since: state?.last_wake_at ?? undefined,
|
||||
maxEntities: PUSH_PACK_MAX_ENTITIES,
|
||||
deadlineMs: CONTEXT_PACK_SERVER_BUDGET_MS,
|
||||
// includePrivate NEVER set on the push path (D2=A).
|
||||
});
|
||||
if (sessionId) {
|
||||
// Bank the standing set; advance the wake cursor only on a COMPLETE
|
||||
// pack — a deadline-partial pack may have dropped the delta section,
|
||||
// and advancing past it would lose those changes. (A client-side drop
|
||||
// after a complete server response can still skip one delta window —
|
||||
// known at-most-once edge of the push path; the pull `delta` verb is
|
||||
// the lossless channel.) The cursor upsert is monotonic (GREATEST), so
|
||||
// an interleaved delta advance is never rewound.
|
||||
await upsertSessionContextState(engine, defaultSource, null, sessionId, {
|
||||
standingEntities: entities.slice(0, PUSH_PACK_MAX_ENTITIES * 2),
|
||||
...(result.degradedReason ? {} : { lastWakeAt: new Date().toISOString() }),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from '../core/context/resolve-ipc.ts';
|
||||
import { resolveEntitiesToPointers, logDeliveredReflexPointers } from '../core/context/retrieval-reflex.ts';
|
||||
import { assembleTurnContext } from '../core/context/turn-context.ts';
|
||||
import { gcSessionContextState } from '../core/context/session-state.ts';
|
||||
import { makeContextPackIpcHandler } from './context-pack-handler.ts';
|
||||
import { logTurnContextDeliveryFireAndForget } from '../core/context/volunteer-events.ts';
|
||||
|
||||
export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpSurface } = {}) {
|
||||
@@ -150,6 +152,11 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS
|
||||
sessionId: req.sessionId,
|
||||
maxBytes: req.maxBytes,
|
||||
}),
|
||||
// v0.45.7 ambient recall: boundary context pack. Extracted to
|
||||
// context-pack-handler.ts (directly testable against a real engine);
|
||||
// the runtime owns entity merge, banking, the since-cursor, and the
|
||||
// complete-pack-only monotonic cursor advance.
|
||||
context_pack: makeContextPackIpcHandler(engine, defaultSource),
|
||||
},
|
||||
{
|
||||
// The IPC resolve path IS the ambient reflex channel. Logging happens
|
||||
@@ -173,6 +180,10 @@ export async function startMcpServer(engine: BrainEngine, opts: { surface?: McpS
|
||||
/* resolve IPC is best-effort; never block serve */
|
||||
}
|
||||
|
||||
// v0.45.7 ambient recall: age out stale session cursors once per serve boot
|
||||
// (7-day TTL, indexed DELETE). Best-effort — GC failure never blocks serve.
|
||||
gcSessionContextState(engine).catch(() => {});
|
||||
|
||||
// Startup maintenance sweep [ENG-5][CX-P0.1+P0.3]: the serve process is
|
||||
// the lock owner, so it runs the bounded sweep that ingests the corpus +
|
||||
// reconciles fences/links/timeline for recent workspace writes. Same
|
||||
|
||||
@@ -761,6 +761,23 @@ CREATE INDEX IF NOT EXISTS context_volunteer_events_src_time_idx
|
||||
CREATE INDEX IF NOT EXISTS context_volunteer_events_src_slug_idx
|
||||
ON context_volunteer_events (source_id, slug);
|
||||
|
||||
-- session_context_state (v0.45.7 / migration v126 — ambient recall issue #1):
|
||||
-- per-session cursor + boundary-tie dedup for the `delta` verb + heartbeat
|
||||
-- runtime. Key (source_id, client_id, session_id); client_id 'local' sentinel
|
||||
-- for CLI/hook, remote auth client id otherwise. jsonb DDL-literal defaults.
|
||||
CREATE TABLE IF NOT EXISTS session_context_state (
|
||||
source_id TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL DEFAULT 'local',
|
||||
session_id TEXT NOT NULL,
|
||||
standing_entities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
surfaced_slugs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_wake_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (source_id, client_id, session_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS session_context_state_updated_idx
|
||||
ON session_context_state (updated_at);
|
||||
|
||||
-- migration_impact_log moved BELOW minion_jobs (was here, lines 645-676)
|
||||
-- because its `job_id BIGINT REFERENCES minion_jobs(id)` FK requires
|
||||
-- minion_jobs to exist FIRST during SCHEMA_SQL replay. v0.41.25.0 fix.
|
||||
|
||||
@@ -32,6 +32,7 @@ enable two jobs in the same session; you cannot tell which one earned its keep.
|
||||
| memory-prune | weekly-equivalent | no | The MEMORY.md maintenance ritual (promote / demote / cut). |
|
||||
| brain-hygiene | weekly-equivalent | no | `gbrain doctor`; relay anything red. |
|
||||
| morning-briefing | first session after 06:00 | no | One screen: due today, waiting on, worth knowing. No filler — a skipped briefing costs less than an empty one. |
|
||||
| ambient-delta | every session start + turn boundary | no | `gbrain delta --session-id <thread> --budget-tokens 2000` — pull "what changed since my last wake" (new/updated pages, new facts, thread updates), deduped per session. Zero-LLM. Inject the returned block; stay silent when empty. Pair with `gbrain context-pack --entities <standing> --budget-tokens 4000` at session start / after compaction to warm context. See docs/guides/ambient-recall.md. |
|
||||
|
||||
Cadence bookkeeping lives in `state/heartbeat-state.local.json` (machine-local,
|
||||
not committed).
|
||||
|
||||
@@ -32,6 +32,7 @@ enable two jobs in the same session; you cannot tell which one earned its keep.
|
||||
| memory-prune | weekly-equivalent | no | The MEMORY.md maintenance ritual (promote / demote / cut). |
|
||||
| brain-hygiene | weekly-equivalent | no | `gbrain doctor`; relay anything red. |
|
||||
| morning-briefing | first session after 06:00 | no | One screen: due today, waiting on, worth knowing. No filler — a skipped briefing costs less than an empty one. |
|
||||
| ambient-delta | every session start + turn boundary | no | `gbrain delta --session-id <thread> --budget-tokens 2000` — pull "what changed since my last wake" (new/updated pages, new facts, thread updates), deduped per session. Zero-LLM. Inject the returned block; stay silent when empty. Pair with `gbrain context-pack --entities <standing> --budget-tokens 4000` at session start / after compaction to warm context. See docs/guides/ambient-recall.md. |
|
||||
|
||||
Cadence bookkeeping lives in `state/heartbeat-state.local.json` (machine-local,
|
||||
not committed).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.45.6.0 -->
|
||||
<!-- gbrain-template-stamp: 0.45.7.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* v0.45.7 — ambient recall CLI surface: the commands the bootstrap templates
|
||||
* tell agents to run (`gbrain context-pack …`, `gbrain delta …`) actually
|
||||
* execute end-to-end against the real cli.ts entrypoint. Subprocess tests
|
||||
* against a shared temp PGLite home (schema init is paid once in beforeAll;
|
||||
* later spawns reuse the persisted brain). Pins:
|
||||
* - exit 0 + parseable JSON envelope with protocol_version 1 on both verbs
|
||||
* - `since` echoed NORMALIZED to ISO (never the raw string)
|
||||
* - unparseable --since → exit 1 + the verbError rendering on stderr
|
||||
* (`Error [invalid_params]: …` + the `Fix:` suggestion line)
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
let home: string;
|
||||
|
||||
function run(args: string[]): { stdout: string; stderr: string; status: number } {
|
||||
const r = spawnSync('bun', ['run', 'src/cli.ts', ...args], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GBRAIN_HOME: home,
|
||||
DATABASE_URL: '',
|
||||
GBRAIN_DATABASE_URL: '',
|
||||
GBRAIN_SKIP_STARTUP_HOOKS: '1', // no detached check-update child
|
||||
},
|
||||
timeout: 60_000,
|
||||
});
|
||||
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'gbrain-ambient-cli-'));
|
||||
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, '.gbrain', 'config.json'),
|
||||
JSON.stringify({ engine: 'pglite', database_path: join(home, '.gbrain', 'brain.pglite') }),
|
||||
);
|
||||
// Warm the brain once: the first CLI touch runs initSchema/migrations; the
|
||||
// tests below then measure command behavior, not schema-init behavior.
|
||||
const warm = run(['delta', '--since', '1970-01-01T00:00:00Z', '--json']);
|
||||
expect(warm.status).toBe(0);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('gbrain context-pack (CLI)', () => {
|
||||
test('--entities + --budget-tokens --json: exit 0, protocol_version 1, budget fields', () => {
|
||||
const { stdout, status } = run([
|
||||
'context-pack', '--entities', 'alice-example', '--budget-tokens', '2000', '--json',
|
||||
]);
|
||||
expect(status).toBe(0);
|
||||
const env = JSON.parse(stdout);
|
||||
expect(env.protocol_version).toBe(1);
|
||||
expect(env.entities).toEqual(['alice-example']);
|
||||
// Empty brain → empty pack, but the budget contract still rides the envelope.
|
||||
expect(env.budget_tokens).toBe(2000);
|
||||
expect(env.budget_used).toBe(0);
|
||||
expect(env.dropped_count).toBe(0);
|
||||
expect(Array.isArray(env.cards)).toBe(true);
|
||||
expect(Array.isArray(env.facts)).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('gbrain delta (CLI)', () => {
|
||||
test('--since <ISO> --json: exit 0, protocol_version 1, ISO-normalized echo', () => {
|
||||
const { stdout, status } = run(['delta', '--since', '1970-01-01T00:00:00Z', '--json']);
|
||||
expect(status).toBe(0);
|
||||
const env = JSON.parse(stdout);
|
||||
expect(env.protocol_version).toBe(1);
|
||||
expect(env.since).toBe('1970-01-01T00:00:00.000Z');
|
||||
expect(env.pages).toEqual([]);
|
||||
expect(env.has_more).toBe(false);
|
||||
expect(env.next_cursor).toEqual({ since: '1970-01-01T00:00:00.000Z', slug: '' });
|
||||
}, 60_000);
|
||||
|
||||
test('unparseable --since: exit 1, invalid_params rendering on stderr', () => {
|
||||
const { stdout, stderr, status } = run(['delta', '--since', 'not-a-date', '--json']);
|
||||
expect(status).toBe(1);
|
||||
// verbError CLI rendering: `Error [code]: message` + the Fix line (stderr).
|
||||
expect(stderr).toContain('Error [invalid_params]:');
|
||||
expect(stderr).toContain('not a parseable timestamp');
|
||||
expect(stderr).toContain('Fix:');
|
||||
expect(stdout).toBe(''); // no envelope on the error path
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* Ambient recall — Layer 3 boundary runtime (issue #1): the context_pack IPC
|
||||
* kind, the PreCompact banking hook, the session-start pack arm, and the
|
||||
* compact_boundary transcript signal. In-process IPC servers (never a spawned
|
||||
* serve), same seams as hook-command.serial.test.ts. Serial: env + sockets.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { runHook, readHeartbeatTail } from '../src/commands/hook.ts';
|
||||
import {
|
||||
ensureIpcSecret,
|
||||
resolveSocketPath,
|
||||
startResolveIpcServer,
|
||||
requestContextPack,
|
||||
IPC_UNAVAILABLE,
|
||||
type ContextPackRequest,
|
||||
type ContextPackResponse,
|
||||
} from '../src/core/context/resolve-ipc.ts';
|
||||
import type { WindowTurn } from '../src/core/context/entity-salience.ts';
|
||||
import { parseTranscript } from '../src/core/transcripts/claude-code-jsonl.ts';
|
||||
|
||||
const FIXTURE = join(import.meta.dir, 'fixtures', 'conversation-formats', 'claude-code.jsonl');
|
||||
const ENV_KEYS = ['GBRAIN_HOME', 'DATABASE_URL', 'GBRAIN_DATABASE_URL', 'GBRAIN_SOURCE', 'GBRAIN_HOOKS'] as const;
|
||||
|
||||
let tmp: string;
|
||||
let saved: Record<string, string | undefined>;
|
||||
let servers: net.Server[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'gb-ar-'));
|
||||
saved = {};
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
process.env.GBRAIN_HOME = tmp;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of servers) {
|
||||
try { s.close(); } catch { /* noop */ }
|
||||
}
|
||||
servers = [];
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const home = () => join(tmp, '.gbrain');
|
||||
|
||||
function writePgliteConfig(dataDir: string): void {
|
||||
mkdirSync(home(), { recursive: true });
|
||||
writeFileSync(join(home(), 'config.json'), JSON.stringify({ engine: 'pglite', database_path: dataDir }));
|
||||
}
|
||||
|
||||
/** A Postgres brain carrying a LEFTOVER database_path (e.g. a pglite→postgres migration). */
|
||||
function writePostgresConfigWithLeftoverPath(dataDir: string): void {
|
||||
mkdirSync(home(), { recursive: true });
|
||||
writeFileSync(join(home(), 'config.json'), JSON.stringify({ engine: 'postgres', database_path: dataDir }));
|
||||
}
|
||||
|
||||
function collectStdout(): { io: { write: (s: string) => void }; get: () => string } {
|
||||
let buf = '';
|
||||
return { io: { write: (s: string) => { buf += s; } }, get: () => buf };
|
||||
}
|
||||
|
||||
/** In-process v2 IPC server with a canned context_pack block. */
|
||||
async function startPackServer(opts: {
|
||||
dataDir: string;
|
||||
blockText?: string | null;
|
||||
onRequest?: (req: ContextPackRequest) => void;
|
||||
}): Promise<void> {
|
||||
mkdirSync(opts.dataDir, { recursive: true });
|
||||
const secret = ensureIpcSecret(opts.dataDir);
|
||||
const server = await startResolveIpcServer(
|
||||
resolveSocketPath(opts.dataDir),
|
||||
{
|
||||
resolve: async () => null,
|
||||
context_pack: async (req) => {
|
||||
opts.onRequest?.(req);
|
||||
if (opts.blockText == null) return null;
|
||||
return { text: opts.blockText, pointers: [], factsCount: 0, mode: 'pack' as const };
|
||||
},
|
||||
},
|
||||
{ secret },
|
||||
);
|
||||
expect(server).not.toBeNull();
|
||||
servers.push(server!);
|
||||
}
|
||||
|
||||
// ── IPC context_pack kind ───────────────────────────────────────────────────
|
||||
|
||||
describe('context_pack over IPC', () => {
|
||||
test('round-trips a block; bankOnly + window + sessionId pass through', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
let seen: ContextPackRequest | undefined;
|
||||
await startPackServer({ dataDir, blockText: 'PACKED', onRequest: (r) => { seen = r; } });
|
||||
const secret = ensureIpcSecret(dataDir);
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), {
|
||||
secret,
|
||||
sessionId: 'sess-9',
|
||||
window: [{ role: 'user', text: 'talk about acme-example' }],
|
||||
bankOnly: false,
|
||||
trigger: 'session-start:startup',
|
||||
});
|
||||
expect(res).not.toBe(IPC_UNAVAILABLE);
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.protocol).toBe(2);
|
||||
expect(resp.block?.text).toBe('PACKED');
|
||||
expect(seen?.sessionId).toBe('sess-9');
|
||||
expect(seen?.window?.length).toBe(1);
|
||||
expect(seen?.trigger).toBe('session-start:startup');
|
||||
});
|
||||
|
||||
test('wrong secret → unauthorized (fail closed)', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
await startPackServer({ dataDir, blockText: 'X' });
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), { secret: 'wrong' });
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(resp.error).toBe('unauthorized');
|
||||
});
|
||||
|
||||
test('server without a context_pack handler → unsupported_kind', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const secret = ensureIpcSecret(dataDir);
|
||||
const server = await startResolveIpcServer(
|
||||
resolveSocketPath(dataDir),
|
||||
{ resolve: async () => null },
|
||||
{ secret },
|
||||
);
|
||||
servers.push(server!);
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), { secret });
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(resp.error).toBe('unsupported_kind');
|
||||
});
|
||||
|
||||
test('no server → IPC_UNAVAILABLE (never throws)', async () => {
|
||||
const res = await requestContextPack(join(tmp, 'nope', '.gbrain-resolve.sock'), { secret: 's' });
|
||||
expect(res).toBe(IPC_UNAVAILABLE);
|
||||
});
|
||||
|
||||
test('source binding fails closed: cross-source request → source_mismatch', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const secret = ensureIpcSecret(dataDir);
|
||||
const server = await startResolveIpcServer(
|
||||
resolveSocketPath(dataDir),
|
||||
{ resolve: async () => null, context_pack: async () => ({ text: 'X', pointers: [], factsCount: 0 }) },
|
||||
{ secret, boundSourceId: 'brain-a' },
|
||||
);
|
||||
servers.push(server!);
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), { secret, sourceId: 'brain-b' });
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(resp.error).toBe('source_mismatch');
|
||||
});
|
||||
|
||||
test('handler that exceeds the server backstop → server_budget degrade, never a hang', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const secret = ensureIpcSecret(dataDir);
|
||||
const server = await startResolveIpcServer(
|
||||
resolveSocketPath(dataDir),
|
||||
{
|
||||
resolve: async () => null,
|
||||
context_pack: async () => {
|
||||
await new Promise((r) => setTimeout(r, 60_000));
|
||||
return { text: 'late', pointers: [], factsCount: 0 };
|
||||
},
|
||||
},
|
||||
{ secret },
|
||||
);
|
||||
servers.push(server!);
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), { secret }, { timeoutMs: 5000 });
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.block).toBeNull();
|
||||
expect(resp.degradedReason).toBe('server_budget');
|
||||
}, 10_000);
|
||||
|
||||
test('handler throw → ok:false error, connection survives', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const secret = ensureIpcSecret(dataDir);
|
||||
const server = await startResolveIpcServer(
|
||||
resolveSocketPath(dataDir),
|
||||
{ resolve: async () => null, context_pack: async () => { throw new Error('boom-pack'); } },
|
||||
{ secret },
|
||||
);
|
||||
servers.push(server!);
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), { secret });
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(resp.error).toContain('boom-pack');
|
||||
});
|
||||
|
||||
test('oversized window is trimmed oldest-first below the 256KB cap [G11]', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
let received: WindowTurn[] | undefined;
|
||||
await startPackServer({ dataDir, blockText: 'OK', onRequest: (r) => { received = r.window; } });
|
||||
const secret = ensureIpcSecret(dataDir);
|
||||
const turns: WindowTurn[] = [];
|
||||
for (let i = 0; i < 600; i++) {
|
||||
turns.push({ role: 'user', text: `turn-${i} ${'x'.repeat(1000)}` });
|
||||
}
|
||||
const res = await requestContextPack(
|
||||
resolveSocketPath(dataDir),
|
||||
{ secret, window: turns },
|
||||
{ timeoutMs: 5000 },
|
||||
);
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(received!.length).toBeGreaterThan(0);
|
||||
expect(received!.length).toBeLessThan(600);
|
||||
// Oldest-first trim: the NEWEST turn always survives.
|
||||
expect(received![received!.length - 1].text.startsWith('turn-599 ')).toBe(true);
|
||||
expect(received![0].text.startsWith('turn-0 ')).toBe(false);
|
||||
});
|
||||
|
||||
test('request that cannot fit even after trimming → IPC_UNAVAILABLE, never a throw [G11]', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
let handlerHit = false;
|
||||
await startPackServer({ dataDir, blockText: 'OK', onRequest: () => { handlerHit = true; } });
|
||||
const secret = ensureIpcSecret(dataDir);
|
||||
// The clamp loop only evicts window turns; a giant non-window field
|
||||
// (entities) can never fit under the message cap, so the client bails
|
||||
// to IPC_UNAVAILABLE without ever touching the socket.
|
||||
const entities = Array.from({ length: 300 }, (_, i) => `e${i}-${'y'.repeat(1000)}`);
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), {
|
||||
secret,
|
||||
entities,
|
||||
window: [{ role: 'user', text: 'small' }],
|
||||
});
|
||||
expect(res).toBe(IPC_UNAVAILABLE);
|
||||
expect(handlerHit).toBe(false);
|
||||
});
|
||||
|
||||
test('server with a context_pack handler but NO configured secret → unauthorized (fail closed)', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const server = await startResolveIpcServer(
|
||||
resolveSocketPath(dataDir),
|
||||
{ resolve: async () => null, context_pack: async () => ({ text: 'X', pointers: [], factsCount: 0 }) },
|
||||
{}, // no opts.secret — no configured secret means NO service, not open service
|
||||
);
|
||||
servers.push(server!);
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), { secret: 'anything' });
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(resp.error).toBe('unauthorized');
|
||||
});
|
||||
|
||||
test('partial pack propagates: block text AND top-level degradedReason survive the wire', async () => {
|
||||
// eng 4A: an assembler deadline overrun returns a PARTIAL pack, never an
|
||||
// empty hard failure — the server spreads block.degradedReason to the top.
|
||||
const dataDir = join(tmp, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const secret = ensureIpcSecret(dataDir);
|
||||
const server = await startResolveIpcServer(
|
||||
resolveSocketPath(dataDir),
|
||||
{
|
||||
resolve: async () => null,
|
||||
context_pack: async () => ({ text: 'PARTIAL', pointers: [], factsCount: 0, degradedReason: 'deadline' }),
|
||||
},
|
||||
{ secret },
|
||||
);
|
||||
servers.push(server!);
|
||||
const res = await requestContextPack(resolveSocketPath(dataDir), { secret });
|
||||
const resp = res as ContextPackResponse;
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.block?.text).toBe('PARTIAL');
|
||||
expect(resp.degradedReason).toBe('deadline');
|
||||
});
|
||||
});
|
||||
|
||||
// ── compact (PreCompact banking) ────────────────────────────────────────────
|
||||
|
||||
describe('hook compact', () => {
|
||||
test('banks the window: bankOnly=true + non-empty window + sessionId reach the server', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
let seen: ContextPackRequest | undefined;
|
||||
await startPackServer({ dataDir, blockText: '', onRequest: (r) => { seen = r; } });
|
||||
writePgliteConfig(dataDir);
|
||||
const tr = join(tmp, 'tr');
|
||||
mkdirSync(tr, { recursive: true });
|
||||
const transcript = join(tr, 'sess.jsonl');
|
||||
copyFileSync(FIXTURE, transcript);
|
||||
|
||||
const out = collectStdout();
|
||||
const code = await runHook(['compact'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ transcript_path: transcript, session_id: 'sess-c1' }),
|
||||
transcriptRoot: tr,
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(out.get()).toBe(''); // PreCompact emits nothing
|
||||
expect(seen?.bankOnly).toBe(true);
|
||||
expect(seen?.sessionId).toBe('sess-c1');
|
||||
expect((seen?.window?.length ?? 0)).toBeGreaterThan(0);
|
||||
expect(seen?.trigger).toBe('compact-bank');
|
||||
const hb = (await readHeartbeatTail(1))[0];
|
||||
expect(hb?.event).toBe('compact');
|
||||
expect(hb?.outcome).toBe('ok');
|
||||
});
|
||||
|
||||
test('fail-open without config: exit 0, degraded heartbeat, no stdout', async () => {
|
||||
const tr = join(tmp, 'tr');
|
||||
mkdirSync(tr, { recursive: true });
|
||||
const transcript = join(tr, 'sess.jsonl');
|
||||
copyFileSync(FIXTURE, transcript);
|
||||
const out = collectStdout();
|
||||
const code = await runHook(['compact'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ transcript_path: transcript, session_id: 's' }),
|
||||
transcriptRoot: tr,
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(out.get()).toBe('');
|
||||
const hb = (await readHeartbeatTail(1))[0];
|
||||
expect(hb?.event).toBe('compact');
|
||||
expect(hb?.outcome).toBe('degraded');
|
||||
expect(hb?.reason).toBe('no_pglite_path');
|
||||
});
|
||||
|
||||
test('GBRAIN_HOOKS=0 short-circuits compact: exit 0, no output, no heartbeat', async () => {
|
||||
process.env.GBRAIN_HOOKS = '0';
|
||||
const out = collectStdout();
|
||||
expect(await runHook(['compact'], { ...out.io, stdin: '{}' })).toBe(0);
|
||||
expect(out.get()).toBe('');
|
||||
expect((await readHeartbeatTail(1)).length).toBe(0);
|
||||
});
|
||||
|
||||
test('config present but no IPC secret → degraded no_serve', async () => {
|
||||
const dataDir = join(tmp, 'data-nosecret');
|
||||
mkdirSync(dataDir, { recursive: true }); // no ensureIpcSecret call
|
||||
writePgliteConfig(dataDir);
|
||||
const tr = join(tmp, 'tr');
|
||||
mkdirSync(tr, { recursive: true });
|
||||
const transcript = join(tr, 'sess.jsonl');
|
||||
copyFileSync(FIXTURE, transcript);
|
||||
const out = collectStdout();
|
||||
expect(await runHook(['compact'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ transcript_path: transcript, session_id: 's' }),
|
||||
transcriptRoot: tr,
|
||||
})).toBe(0);
|
||||
const hb = (await readHeartbeatTail(1))[0];
|
||||
expect(hb?.outcome).toBe('degraded');
|
||||
expect(hb?.reason).toBe('no_serve');
|
||||
});
|
||||
|
||||
test('stdin without session_id → reason no_session, exit 0', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
await startPackServer({ dataDir, blockText: '' });
|
||||
writePgliteConfig(dataDir);
|
||||
const tr = join(tmp, 'tr');
|
||||
mkdirSync(tr, { recursive: true });
|
||||
const transcript = join(tr, 'sess.jsonl');
|
||||
copyFileSync(FIXTURE, transcript);
|
||||
const out = collectStdout();
|
||||
expect(await runHook(['compact'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ transcript_path: transcript }),
|
||||
transcriptRoot: tr,
|
||||
})).toBe(0);
|
||||
const hb = (await readHeartbeatTail(1))[0];
|
||||
expect(hb?.reason).toBe('no_session');
|
||||
});
|
||||
|
||||
test('Postgres engine with a leftover database_path never probes the socket (v0.45.7 symmetry)', async () => {
|
||||
// Same engine gate as the session-start pack arm: a live serve behind the
|
||||
// leftover path must NOT be reached — there is no PGLite brain here.
|
||||
const dataDir = join(tmp, 'data');
|
||||
let handlerHit = false;
|
||||
await startPackServer({ dataDir, blockText: '', onRequest: () => { handlerHit = true; } });
|
||||
writePostgresConfigWithLeftoverPath(dataDir);
|
||||
const tr = join(tmp, 'tr');
|
||||
mkdirSync(tr, { recursive: true });
|
||||
const transcript = join(tr, 'sess.jsonl');
|
||||
copyFileSync(FIXTURE, transcript);
|
||||
const out = collectStdout();
|
||||
const code = await runHook(['compact'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ transcript_path: transcript, session_id: 's' }),
|
||||
transcriptRoot: tr,
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(out.get()).toBe('');
|
||||
expect(handlerHit).toBe(false);
|
||||
const hb = (await readHeartbeatTail(1))[0];
|
||||
expect(hb?.outcome).toBe('degraded');
|
||||
expect(hb?.reason).toBe('no_pglite_path');
|
||||
});
|
||||
|
||||
test('unconfined transcript path aborts (S3#8), never best-effort', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
await startPackServer({ dataDir, blockText: '' });
|
||||
writePgliteConfig(dataDir);
|
||||
const out = collectStdout();
|
||||
const code = await runHook(['compact'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({ transcript_path: '/etc/passwd', session_id: 's' }),
|
||||
transcriptRoot: join(tmp, 'tr'),
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
const hb = (await readHeartbeatTail(1))[0];
|
||||
expect(hb?.outcome).toBe('degraded');
|
||||
expect(String(hb?.reason)).toStartWith('transcript_');
|
||||
});
|
||||
});
|
||||
|
||||
// ── session-start pack arm ──────────────────────────────────────────────────
|
||||
|
||||
describe('hook session-start pack arm', () => {
|
||||
test('appends the pack block after the digest when serve is up', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
await startPackServer({ dataDir, blockText: 'BRAIN-PACK-BLOCK' });
|
||||
writePgliteConfig(dataDir);
|
||||
const out = collectStdout();
|
||||
const code = await runHook(['session-start'], {
|
||||
...out.io,
|
||||
cwd: tmp,
|
||||
stdin: JSON.stringify({ session_id: 'sess-s1', source: 'compact' }),
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(out.get()).toContain('BRAIN-PACK-BLOCK');
|
||||
});
|
||||
|
||||
test('fail-open when no serve: exit 0, no pack, no crash', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
ensureIpcSecret(dataDir); // secret exists but no server listening
|
||||
writePgliteConfig(dataDir);
|
||||
const out = collectStdout();
|
||||
const code = await runHook(['session-start'], {
|
||||
...out.io,
|
||||
cwd: tmp,
|
||||
stdin: JSON.stringify({ session_id: 'sess-s2', source: 'startup' }),
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(out.get()).not.toContain('BRAIN-PACK-BLOCK');
|
||||
});
|
||||
|
||||
test('Postgres engine with a leftover database_path: digest-only stdout, no pack attempt (v0.45.7 symmetry)', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
let handlerHit = false;
|
||||
await startPackServer({ dataDir, blockText: 'BRAIN-PACK-BLOCK', onRequest: () => { handlerHit = true; } });
|
||||
writePostgresConfigWithLeftoverPath(dataDir);
|
||||
writeFileSync(join(tmp, 'MEMORY.md'), '## Standing rules\n- always ship tests\n');
|
||||
const out = collectStdout();
|
||||
const code = await runHook(['session-start'], {
|
||||
...out.io,
|
||||
cwd: tmp,
|
||||
stdin: JSON.stringify({ session_id: 'sess-pg', source: 'startup' }),
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(out.get()).toContain('From MEMORY.md');
|
||||
expect(out.get()).not.toContain('BRAIN-PACK-BLOCK');
|
||||
expect(handlerHit).toBe(false);
|
||||
});
|
||||
|
||||
test('trigger carries the SessionStart source discriminator', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
let seen: ContextPackRequest | undefined;
|
||||
await startPackServer({ dataDir, blockText: null, onRequest: (r) => { seen = r; } });
|
||||
writePgliteConfig(dataDir);
|
||||
const out = collectStdout();
|
||||
await runHook(['session-start'], {
|
||||
...out.io,
|
||||
cwd: tmp,
|
||||
stdin: JSON.stringify({ session_id: 'sess-s3', source: 'compact' }),
|
||||
});
|
||||
expect(seen?.trigger).toBe('session-start:compact');
|
||||
expect(seen?.sessionId).toBe('sess-s3');
|
||||
});
|
||||
});
|
||||
|
||||
// ── documented harness payload shapes ───────────────────────────────────────
|
||||
|
||||
describe('documented hook payload shapes (extra fields tolerated)', () => {
|
||||
test('full PreCompact payload banks identically to the minimal shape', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
let seen: ContextPackRequest | undefined;
|
||||
await startPackServer({ dataDir, blockText: '', onRequest: (r) => { seen = r; } });
|
||||
writePgliteConfig(dataDir);
|
||||
const tr = join(tmp, 'tr');
|
||||
mkdirSync(tr, { recursive: true });
|
||||
const transcript = join(tr, 'sess.jsonl');
|
||||
copyFileSync(FIXTURE, transcript);
|
||||
const out = collectStdout();
|
||||
const code = await runHook(['compact'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({
|
||||
session_id: 'sess-full',
|
||||
transcript_path: transcript,
|
||||
cwd: tmp,
|
||||
hook_event_name: 'PreCompact',
|
||||
trigger: 'auto',
|
||||
custom_instructions: '',
|
||||
}),
|
||||
transcriptRoot: tr,
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(out.get()).toBe(''); // PreCompact emits nothing
|
||||
expect(seen?.bankOnly).toBe(true);
|
||||
expect(seen?.sessionId).toBe('sess-full');
|
||||
expect((seen?.window?.length ?? 0)).toBeGreaterThan(0);
|
||||
const hb = (await readHeartbeatTail(1))[0];
|
||||
expect(hb?.event).toBe('compact');
|
||||
expect(hb?.outcome).toBe('ok');
|
||||
});
|
||||
|
||||
test('full SessionStart payload packs identically to the minimal shape', async () => {
|
||||
const dataDir = join(tmp, 'data');
|
||||
let seen: ContextPackRequest | undefined;
|
||||
await startPackServer({ dataDir, blockText: 'BRAIN-PACK-BLOCK', onRequest: (r) => { seen = r; } });
|
||||
writePgliteConfig(dataDir);
|
||||
const out = collectStdout();
|
||||
// No io.cwd — the documented payload's cwd field carries the workspace.
|
||||
const code = await runHook(['session-start'], {
|
||||
...out.io,
|
||||
stdin: JSON.stringify({
|
||||
session_id: 'sess-full-ss',
|
||||
transcript_path: join(tmp, 'unused.jsonl'),
|
||||
cwd: tmp,
|
||||
hook_event_name: 'SessionStart',
|
||||
source: 'compact',
|
||||
}),
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(out.get()).toContain('BRAIN-PACK-BLOCK');
|
||||
expect(seen?.trigger).toBe('session-start:compact');
|
||||
expect(seen?.sessionId).toBe('sess-full-ss');
|
||||
});
|
||||
});
|
||||
|
||||
// ── compact_boundary transcript signal ──────────────────────────────────────
|
||||
|
||||
describe('compact_boundary surfacing', () => {
|
||||
test('parseTranscript counts compact boundaries without turning them into turns', () => {
|
||||
const p = join(tmp, 'cb.jsonl');
|
||||
writeFileSync(
|
||||
p,
|
||||
[
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' } }),
|
||||
JSON.stringify({ type: 'system', subtype: 'compact_boundary' }),
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'after compaction' } }),
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
const parsed = parseTranscript(p);
|
||||
expect(parsed.compactBoundaries).toBe(1);
|
||||
expect(parsed.turns.length).toBe(2);
|
||||
});
|
||||
|
||||
test('counts the shared fixture’s known boundary (and still yields turns)', () => {
|
||||
// The conversation-formats fixture deliberately carries ONE
|
||||
// compact_boundary line (it exercises entryToTurn's skip path).
|
||||
const parsed = parseTranscript(FIXTURE);
|
||||
expect(parsed.compactBoundaries).toBe(1);
|
||||
expect(parsed.turns.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Ambient recall (v0.45.7) — template + doc content pins.
|
||||
*
|
||||
* The context_pack/delta verbs only deliver value if the shipped guidance
|
||||
* points agents at them. These pins keep the four guidance surfaces from
|
||||
* silently dropping the boundary instructions:
|
||||
* - HEARTBEAT.md.template carries the ambient-delta row (heartbeats pull
|
||||
* `gbrain delta`; session start / post-compaction pairs with
|
||||
* `gbrain context-pack`)
|
||||
* - the RENDERED template-repo HEARTBEAT.md carries the same row
|
||||
* - docs/mcp/CODEX.md names context_pack for the session boundary (Codex
|
||||
* has no lifecycle hooks — the pull path is the only path)
|
||||
* - docs/guides/ambient-recall.md exists and names both verbs
|
||||
*
|
||||
* Assertions pin stable substrings (verb + command names), not full sentences.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const ROOT = dirname(import.meta.dir);
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(ROOT, rel), 'utf8');
|
||||
}
|
||||
|
||||
describe('HEARTBEAT ambient-delta row', () => {
|
||||
test('source template points heartbeats at gbrain delta + context-pack', () => {
|
||||
const tpl = read('templates/bootstrap/HEARTBEAT.md.template');
|
||||
expect(tpl).toContain('ambient-delta');
|
||||
expect(tpl).toContain('gbrain delta');
|
||||
expect(tpl).toContain('gbrain context-pack');
|
||||
expect(tpl).toContain('docs/guides/ambient-recall.md');
|
||||
});
|
||||
|
||||
test('rendered template-repo HEARTBEAT.md carries the same row', () => {
|
||||
const rendered = read('templates/bootstrap/template-repo/HEARTBEAT.md');
|
||||
expect(rendered).toContain('ambient-delta');
|
||||
expect(rendered).toContain('gbrain delta');
|
||||
expect(rendered).toContain('gbrain context-pack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs surfaces', () => {
|
||||
test('CODEX.md session-boundary instruction names both verbs (pull path)', () => {
|
||||
const codex = read('docs/mcp/CODEX.md');
|
||||
expect(codex).toContain('context_pack');
|
||||
expect(codex).toContain('delta');
|
||||
// Codex has no lifecycle hooks, so the doc must route boundary calls to
|
||||
// the guide's placement frontier.
|
||||
expect(codex).toContain('ambient-recall.md');
|
||||
});
|
||||
|
||||
test('ambient-recall guide exists and names both verbs', () => {
|
||||
expect(existsSync(join(ROOT, 'docs/guides/ambient-recall.md'))).toBe(true);
|
||||
const guide = read('docs/guides/ambient-recall.md');
|
||||
expect(guide).toContain('context_pack');
|
||||
expect(guide).toContain('delta');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,677 @@
|
||||
/**
|
||||
* Ambient recall hooks (issue #1) — context_pack + delta frozen verbs, the
|
||||
* shared session cursor, world-only-by-default visibility, and cross-caller
|
||||
* isolation. Hermetic in-memory PGLite.
|
||||
*
|
||||
* Coverage map (plan verification + eng-review findings):
|
||||
* - session-state round-trip, surfaced-slug union, ISO cursor, GC
|
||||
* - eng 1B: cross-caller isolation via the (source_id, client_id, session_id) key
|
||||
* - eng 1A / D2=A: world-only default; include_private widens only for
|
||||
* trusted-local (ctx.remote === false); remote NEVER widens (fail-closed)
|
||||
* - delta cursor lifecycle (establish → advance), page dedup, since|session gate
|
||||
* - MEMORY_VERBS_VERSION stays 1 (additive; the P1 fix)
|
||||
* - v0.45.7 gap-closure wave: delta visibility fail-closed mirror, stateless
|
||||
* keyset resume + explicit since_slug precedence, forced budget overflow,
|
||||
* session-state outage fail-open, banked-entity warm-pack visibility
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { operations } from '../src/core/operations.ts';
|
||||
import { MEMORY_VERBS_VERSION, VERB_NAMES } from '../src/core/verbs.ts';
|
||||
import {
|
||||
getSessionContextState,
|
||||
upsertSessionContextState,
|
||||
gcSessionContextState,
|
||||
resolveClientId,
|
||||
LOCAL_CLIENT_SENTINEL,
|
||||
} from '../src/core/context/session-state.ts';
|
||||
import { __resetHotMemoryCacheForTests } from '../src/core/facts/meta-hook.ts';
|
||||
import type { OperationContext } from '../src/core/operations.ts';
|
||||
import type { GBrainConfig } from '../src/core/config.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
const noopLogger = { info: () => {}, warn: () => {}, error: () => {} };
|
||||
|
||||
function ctxFor(opts: { remote: boolean; clientId?: string }): OperationContext {
|
||||
return {
|
||||
engine,
|
||||
config: {} as GBrainConfig,
|
||||
logger: noopLogger,
|
||||
dryRun: false,
|
||||
remote: opts.remote,
|
||||
sourceId: 'default',
|
||||
...(opts.clientId ? { auth: { clientId: opts.clientId, scopes: [] } as never } : {}),
|
||||
} as OperationContext;
|
||||
}
|
||||
|
||||
const contextPack = operations.find((o) => o.name === 'context_pack')!;
|
||||
const del = operations.find((o) => o.name === 'delta')!;
|
||||
const remember = operations.find((o) => o.name === 'remember')!;
|
||||
|
||||
/** Handler results are `unknown` on the Operation type; tests branch on fields. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type VerbResult = Record<string, any>;
|
||||
async function call(
|
||||
op: typeof contextPack,
|
||||
ctx: OperationContext,
|
||||
p: Record<string, unknown>,
|
||||
): Promise<VerbResult> {
|
||||
return (await op.handler(ctx, p)) as VerbResult;
|
||||
}
|
||||
|
||||
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');
|
||||
__resetHotMemoryCacheForTests();
|
||||
});
|
||||
|
||||
describe('protocol additivity (P1 fix)', () => {
|
||||
test('MEMORY_VERBS_VERSION stays 1 with 7 frozen verbs', () => {
|
||||
expect(MEMORY_VERBS_VERSION).toBe(1);
|
||||
expect(VERB_NAMES).toContain('context_pack');
|
||||
expect(VERB_NAMES).toContain('delta');
|
||||
expect(VERB_NAMES.length).toBe(7);
|
||||
});
|
||||
|
||||
test('context_pack + delta are verbs on the surface, scope read', () => {
|
||||
expect(contextPack.verb).toBe(true);
|
||||
expect(contextPack.scope).toBe('read');
|
||||
expect(del.verb).toBe(true);
|
||||
expect(del.scope).toBe('read');
|
||||
});
|
||||
|
||||
test('responses stamp protocol_version 1', async () => {
|
||||
const pack = await call(contextPack, ctxFor({ remote: false }), { entities: 'a,b' });
|
||||
expect(pack.protocol_version).toBe(1);
|
||||
const d = await call(del, ctxFor({ remote: false }), { since: '1970-01-01T00:00:00Z' });
|
||||
expect(d.protocol_version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-state cursor', () => {
|
||||
test('absent → null; round-trips + ISO cursor + keyset slug', async () => {
|
||||
expect(await getSessionContextState(engine, 'default', null, 's1')).toBeNull();
|
||||
await upsertSessionContextState(engine, 'default', null, 's1', {
|
||||
standingEntities: ['people/alice-example'],
|
||||
lastWakeAt: '2026-08-01T00:00:00Z',
|
||||
cursorSlug: 'notes/x',
|
||||
});
|
||||
const st = (await getSessionContextState(engine, 'default', null, 's1'))!;
|
||||
expect(st.standing_entities).toEqual(['people/alice-example']);
|
||||
expect(st.surfaced_slugs).toEqual(['notes/x']); // keyset slug, single element
|
||||
// ISO-normalized regardless of the engine's ::text format
|
||||
expect(st.last_wake_at).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/);
|
||||
expect(new Date(st.last_wake_at!).toISOString()).toBe('2026-08-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
test('cursorSlug REPLACES on each advance; omission keeps standing + wake', async () => {
|
||||
await upsertSessionContextState(engine, 'default', null, 's1', {
|
||||
standingEntities: ['e1'],
|
||||
cursorSlug: 'notes/a',
|
||||
lastWakeAt: '2026-08-01T00:00:00Z',
|
||||
});
|
||||
// replace cursor slug; omit standing + wake → both kept
|
||||
await upsertSessionContextState(engine, 'default', null, 's1', { cursorSlug: 'notes/c' });
|
||||
const st = (await getSessionContextState(engine, 'default', null, 's1'))!;
|
||||
expect(st.surfaced_slugs).toEqual(['notes/c']);
|
||||
expect(st.standing_entities).toEqual(['e1']); // keep-if-absent
|
||||
expect(new Date(st.last_wake_at!).toISOString()).toBe('2026-08-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
test('cursor is last-writer-wins on lastWakeAt (keyset ordering lives in the handler)', async () => {
|
||||
await upsertSessionContextState(engine, 'default', null, 'lww', { lastWakeAt: '2026-08-05T00:00:00Z' });
|
||||
await upsertSessionContextState(engine, 'default', null, 'lww', { lastWakeAt: '2026-08-01T00:00:00Z' });
|
||||
const st = (await getSessionContextState(engine, 'default', null, 'lww'))!;
|
||||
// A null lastWakeAt keeps the prior; a concrete one always wins.
|
||||
expect(new Date(st.last_wake_at!).toISOString()).toBe('2026-08-01T00:00:00.000Z');
|
||||
await upsertSessionContextState(engine, 'default', null, 'lww', { cursorSlug: 'notes/z' });
|
||||
expect(new Date((await getSessionContextState(engine, 'default', null, 'lww'))!.last_wake_at!).toISOString())
|
||||
.toBe('2026-08-01T00:00:00.000Z'); // null wake → kept
|
||||
});
|
||||
|
||||
test('eng 1B: cross-caller isolation — local sentinel vs auth client are separate rows', async () => {
|
||||
await upsertSessionContextState(engine, 'default', null, 's1', { cursorSlug: 'local-only' });
|
||||
await upsertSessionContextState(engine, 'default', 'client-XYZ', 's1', { cursorSlug: 'remote-only' });
|
||||
const local = (await getSessionContextState(engine, 'default', null, 's1'))!;
|
||||
const remote = (await getSessionContextState(engine, 'default', 'client-XYZ', 's1'))!;
|
||||
expect(local.surfaced_slugs).toEqual(['local-only']);
|
||||
expect(remote.surfaced_slugs).toEqual(['remote-only']);
|
||||
expect(resolveClientId(null)).toBe(LOCAL_CLIENT_SENTINEL);
|
||||
expect(resolveClientId('client-XYZ')).toBe('client-XYZ');
|
||||
});
|
||||
|
||||
test('GC ages out stale rows', async () => {
|
||||
await upsertSessionContextState(engine, 'default', null, 'old', { cursorSlug: 'x' });
|
||||
await engine.executeRaw(`UPDATE session_context_state SET updated_at = now() - interval '30 days' WHERE session_id = 'old'`);
|
||||
await gcSessionContextState(engine, 7);
|
||||
expect(await getSessionContextState(engine, 'default', null, 'old')).toBeNull();
|
||||
});
|
||||
|
||||
test('GC caps rows per (source, client) — oldest evicted past the cap', async () => {
|
||||
// Seed 3 rows, age them so ordering is deterministic, cap to 2.
|
||||
for (const s of ['c1', 'c2', 'c3']) {
|
||||
await upsertSessionContextState(engine, 'default', 'capclient', s, { cursorSlug: s });
|
||||
}
|
||||
await engine.executeRaw(`UPDATE session_context_state SET updated_at = '2026-08-01T00:00:00Z' WHERE session_id = 'c1'`);
|
||||
await engine.executeRaw(`UPDATE session_context_state SET updated_at = '2026-08-02T00:00:00Z' WHERE session_id = 'c2'`);
|
||||
await engine.executeRaw(`UPDATE session_context_state SET updated_at = '2026-08-03T00:00:00Z' WHERE session_id = 'c3'`);
|
||||
// Directly exercise the windowed cap via a tiny inline DELETE mirror (the
|
||||
// real gc uses MAX_ROWS_PER_CLIENT=1000; prove the ORDER here with cap 2).
|
||||
await engine.executeRaw(
|
||||
`DELETE FROM session_context_state s USING (
|
||||
SELECT source_id, client_id, session_id,
|
||||
row_number() OVER (PARTITION BY source_id, client_id ORDER BY updated_at DESC) AS rn
|
||||
FROM session_context_state
|
||||
) ranked
|
||||
WHERE s.source_id = ranked.source_id AND s.client_id = ranked.client_id
|
||||
AND s.session_id = ranked.session_id AND ranked.rn > 2`,
|
||||
);
|
||||
expect(await getSessionContextState(engine, 'default', 'capclient', 'c1')).toBeNull(); // oldest evicted
|
||||
expect(await getSessionContextState(engine, 'default', 'capclient', 'c3')).not.toBeNull(); // newest kept
|
||||
});
|
||||
});
|
||||
|
||||
describe('delta cursor lifecycle', () => {
|
||||
test('first wake with session establishes an ISO cursor and empty delta', async () => {
|
||||
const r = await call(del, ctxFor({ remote: false }), { session_id: 'sess-1' });
|
||||
expect(r.pages).toEqual([]);
|
||||
expect(r.since).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/);
|
||||
// cursor persisted
|
||||
const st = await getSessionContextState(engine, 'default', null, 'sess-1');
|
||||
expect(st?.last_wake_at).toBeTruthy();
|
||||
});
|
||||
|
||||
test('since OR session required; else invalid_params', async () => {
|
||||
await expect(del.handler(ctxFor({ remote: false }), {})).rejects.toThrow(/requires|since|session/i);
|
||||
});
|
||||
|
||||
test('explicit since is echoed, NORMALIZED to ISO (F4 — never the raw string)', async () => {
|
||||
const r = await call(del, ctxFor({ remote: false }), { since: '1970-01-01T00:00:00Z' });
|
||||
expect(r.since).toBe('1970-01-01T00:00:00.000Z');
|
||||
// A parseable NON-ISO form is normalized to canonical ISO, never echoed raw
|
||||
// into the injectable `text` block.
|
||||
const r2 = await call(del, ctxFor({ remote: false }), { since: 'January 1, 2000 00:00:00 GMT' });
|
||||
expect(r2.since).toBe('2000-01-01T00:00:00.000Z');
|
||||
expect(r2.text as string).not.toContain('January 1, 2000');
|
||||
// An UNPARSEABLE string is rejected outright (defense in depth).
|
||||
await expect(call(del, ctxFor({ remote: false }), { since: 'definitely not a date' })).rejects.toThrow(/parseable|ISO/i);
|
||||
});
|
||||
|
||||
test('remote caller cursor is namespaced by auth client', async () => {
|
||||
await call(del, ctxFor({ remote: true, clientId: 'harness-A' }), { session_id: 'shared' });
|
||||
await call(del, ctxFor({ remote: true, clientId: 'harness-B' }), { session_id: 'shared' });
|
||||
const a = await getSessionContextState(engine, 'default', 'harness-A', 'shared');
|
||||
const b = await getSessionContextState(engine, 'default', 'harness-B', 'shared');
|
||||
expect(a).not.toBeNull();
|
||||
expect(b).not.toBeNull();
|
||||
// 'local' sentinel row must NOT exist for a remote-only session
|
||||
expect(await getSessionContextState(engine, 'default', null, 'shared')).toBeNull();
|
||||
});
|
||||
|
||||
test('auth-less remote (stdio) lands in the "remote" namespace, never "local" (adversarial P2)', async () => {
|
||||
await call(del, ctxFor({ remote: true }), { session_id: 'anon-sess' });
|
||||
expect(await getSessionContextState(engine, 'default', 'remote', 'anon-sess')).not.toBeNull();
|
||||
expect(await getSessionContextState(engine, 'default', null, 'anon-sess')).toBeNull();
|
||||
});
|
||||
|
||||
test('at-least-once: budget-dropped pages surface on the next wake (adversarial P2)', async () => {
|
||||
const putPage = operations.find((o) => o.name === 'put_page')!;
|
||||
const local = ctxFor({ remote: false });
|
||||
// establish cursor first
|
||||
await call(del, local, { session_id: 'alo' });
|
||||
// three page changes after the cursor
|
||||
for (const s of ['alo-a', 'alo-b', 'alo-c']) {
|
||||
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\ncontent for ${s}` });
|
||||
}
|
||||
// tiny budget: deliver a strict subset, has_more = true, cursor lags
|
||||
const r1 = await call(del, local, { session_id: 'alo', budget_tokens: 8 });
|
||||
expect(r1.has_more).toBe(true);
|
||||
expect(r1.pages.length).toBeGreaterThan(0);
|
||||
expect(r1.pages.length).toBeLessThan(3);
|
||||
// big budget: the remaining pages arrive — nothing was lost
|
||||
const r2 = await call(del, local, { session_id: 'alo', budget_tokens: 100000 });
|
||||
const delivered = new Set([...r1.pages, ...r2.pages].map((p: { slug: string }) => p.slug));
|
||||
expect(delivered.has('notes/alo-a')).toBe(true);
|
||||
expect(delivered.has('notes/alo-b')).toBe(true);
|
||||
expect(delivered.has('notes/alo-c')).toBe(true);
|
||||
expect(r2.has_more).toBe(false);
|
||||
});
|
||||
|
||||
test('text is rendered from the budget-packed sets, not the full sets (adversarial P2)', async () => {
|
||||
const putPage = operations.find((o) => o.name === 'put_page')!;
|
||||
const local = ctxFor({ remote: false });
|
||||
await call(del, local, { session_id: 'txt' });
|
||||
for (const s of ['txt-a', 'txt-b', 'txt-c']) {
|
||||
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\ncontent` });
|
||||
}
|
||||
const r = await call(del, local, { session_id: 'txt', budget_tokens: 8 });
|
||||
const inText = ['txt-a', 'txt-b', 'txt-c'].filter((s) => (r.text as string).includes(s));
|
||||
expect(inText.length).toBe(r.pages.length); // text lists exactly the delivered pages
|
||||
});
|
||||
|
||||
test('equal-timestamp ties survive across wakes (boundary-tie dedup, pre-landing P2)', async () => {
|
||||
const putPage = operations.find((o) => o.name === 'put_page')!;
|
||||
const local = ctxFor({ remote: false });
|
||||
for (const s of ['tie-a', 'tie-b', 'tie-c', 'tie-d']) {
|
||||
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\nbody` });
|
||||
}
|
||||
// Force all four onto ONE identical timestamp (the bulk-sync shape).
|
||||
await engine.executeRaw(
|
||||
`UPDATE pages SET updated_at = '2026-08-10T12:00:00Z' WHERE slug LIKE 'notes/tie-%'`,
|
||||
);
|
||||
// Seed the keyset cursor BEFORE the tie cluster (empty slug = start of the
|
||||
// bucket) via the INSERT path.
|
||||
await upsertSessionContextState(engine, 'default', null, 'tie', {
|
||||
lastWakeAt: '2026-08-10T11:00:00Z', cursorSlug: '',
|
||||
});
|
||||
// Tiny budget: deliver a strict subset of the tie cluster (all 4 share one
|
||||
// timestamp — the keyset paginates within it by slug).
|
||||
const r1 = await call(del, local, { session_id: 'tie', budget_tokens: 8 });
|
||||
expect(r1.pages.length).toBeGreaterThan(0);
|
||||
expect(r1.pages.length).toBeLessThan(4);
|
||||
expect(r1.has_more).toBe(true);
|
||||
// Next wake MUST deliver the remaining ties (timestamp-only cursors lost them).
|
||||
const r2 = await call(del, local, { session_id: 'tie', budget_tokens: 100000 });
|
||||
const delivered = new Set([...r1.pages, ...r2.pages].map((p: { slug: string }) => p.slug));
|
||||
for (const s of ['tie-a', 'tie-b', 'tie-c', 'tie-d']) expect(delivered.has(`notes/${s}`)).toBe(true);
|
||||
// And already-delivered ties must NOT re-deliver on a third wake.
|
||||
const r3 = await call(del, local, { session_id: 'tie', budget_tokens: 100000 });
|
||||
const redelivered = (r3.pages as Array<{ slug: string }>).filter((p) => p.slug.startsWith('notes/tie-'));
|
||||
expect(redelivered).toEqual([]);
|
||||
});
|
||||
|
||||
test('F1: a tie cluster LARGER than the fetch limit fully drains (no livelock)', async () => {
|
||||
const putPage = operations.find((o) => o.name === 'put_page')!;
|
||||
const local = ctxFor({ remote: false });
|
||||
const N = 55; // > DELTA_PAGE_FETCH_LIMIT (50) at ONE timestamp
|
||||
for (let i = 0; i < N; i++) {
|
||||
await call(putPage, local, { slug: `notes/big-${String(i).padStart(3, '0')}`, content: `# big-${i}\n\nb` });
|
||||
}
|
||||
await engine.executeRaw(`UPDATE pages SET updated_at = '2026-08-11T09:00:00Z' WHERE slug LIKE 'notes/big-%'`);
|
||||
await upsertSessionContextState(engine, 'default', null, 'big', {
|
||||
lastWakeAt: '2026-08-11T08:00:00Z', cursorSlug: '',
|
||||
});
|
||||
const seenBig = new Set<string>();
|
||||
let guard = 0;
|
||||
let more = true;
|
||||
while (more && guard < 10) {
|
||||
const r = await call(del, local, { session_id: 'big', budget_tokens: 100000 });
|
||||
for (const pg of r.pages as Array<{ slug: string }>) {
|
||||
if (pg.slug.startsWith('notes/big-')) seenBig.add(pg.slug);
|
||||
}
|
||||
more = r.has_more === true;
|
||||
guard++;
|
||||
}
|
||||
expect(guard).toBeLessThan(10); // did NOT livelock (the F1 failure was a stuck has_more)
|
||||
expect(more).toBe(false);
|
||||
expect(seenBig.size).toBe(N); // every tied page in the >limit cluster delivered, across wakes
|
||||
});
|
||||
|
||||
test('zero-delivery wake does not advance the cursor (deliver-before-advance)', async () => {
|
||||
const putPage = operations.find((o) => o.name === 'put_page')!;
|
||||
const local = ctxFor({ remote: false });
|
||||
await call(del, local, { session_id: 'zd' });
|
||||
const before = (await getSessionContextState(engine, 'default', null, 'zd'))!.last_wake_at;
|
||||
await call(putPage, local, { slug: 'notes/zd-page-with-a-very-long-title-to-cost-tokens', content: '# long\n\nbody' });
|
||||
const r = await call(del, local, { session_id: 'zd', budget_tokens: 1 });
|
||||
expect(r.pages).toEqual([]);
|
||||
expect(r.has_more).toBe(true);
|
||||
expect((await getSessionContextState(engine, 'default', null, 'zd'))!.last_wake_at).toBe(before);
|
||||
});
|
||||
|
||||
test('remote caller with an empty-string clientId lands in "remote", never "local"', async () => {
|
||||
const ctx = ctxFor({ remote: true, clientId: '' });
|
||||
await call(del, ctx, { session_id: 'blank-cid' });
|
||||
expect(await getSessionContextState(engine, 'default', null, 'blank-cid')).toBeNull();
|
||||
expect(await getSessionContextState(engine, 'default', 'remote', 'blank-cid')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('malformed since → invalid_params with a suggestion (both verbs)', async () => {
|
||||
await expect(call(del, ctxFor({ remote: false }), { since: 'not-a-date' })).rejects.toThrow(/parseable|ISO/i);
|
||||
await expect(
|
||||
call(contextPack, ctxFor({ remote: false }), { entities: 'a', since: 'yesterday-ish' }),
|
||||
).rejects.toThrow(/parseable|ISO/i);
|
||||
});
|
||||
|
||||
test('first wake echoes the budget footer when budget_tokens was passed', async () => {
|
||||
const r = await call(del, ctxFor({ remote: false }), { session_id: 'fw-budget', budget_tokens: 500 });
|
||||
expect(r.budget_tokens).toBe(500);
|
||||
expect(r.budget_used).toBe(0);
|
||||
expect(r.dropped_count).toBe(0);
|
||||
});
|
||||
|
||||
test('context_pack echoes the CAPPED entity list (max 8)', async () => {
|
||||
const many = Array.from({ length: 12 }, (_, i) => `entity-${i}`).join(',');
|
||||
const r = await call(contextPack, ctxFor({ remote: false }), { entities: many });
|
||||
expect((r.entities as string[]).length).toBe(8);
|
||||
});
|
||||
|
||||
test('delta facts filter uses RECORDING time and reaches past 24h (pre-landing P2)', async () => {
|
||||
const local = ctxFor({ remote: false });
|
||||
await call(remember, local, { fact: 'old recorded fact for delta window test', provenance: 't', visibility: 'world' });
|
||||
// Age the fact's created_at 3 days back — the old hot-memory fallback
|
||||
// window (24h) would have silently missed it for a 5-day-old cursor.
|
||||
await engine.executeRaw(
|
||||
`UPDATE facts SET created_at = now() - interval '3 days' WHERE fact = 'old recorded fact for delta window test'`,
|
||||
);
|
||||
const since = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const r = await call(del, local, { since });
|
||||
const found = (r.facts as Array<{ fact: string }>).some((f) => f.fact.includes('old recorded fact'));
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
test('stateless keyset resume: next_cursor.since/.slug drains a tie cluster, no re-delivery (v0.45.7)', async () => {
|
||||
const putPage = operations.find((o) => o.name === 'put_page')!;
|
||||
const local = ctxFor({ remote: false });
|
||||
for (const s of ['sr-a', 'sr-b', 'sr-c', 'sr-d']) {
|
||||
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\nbody` });
|
||||
}
|
||||
// One identical timestamp, earlier than every other fixture cluster.
|
||||
await engine.executeRaw(`UPDATE pages SET updated_at = '2026-08-09T10:00:00Z' WHERE slug LIKE 'notes/sr-%'`);
|
||||
// NO session_id anywhere: resume rides next_cursor.since/.slug alone.
|
||||
// budget 8 ≈ 2 pages per call, so the 4-page cluster needs ≥2 wakes.
|
||||
const seen: string[] = [];
|
||||
let since = '2026-08-09T09:59:00Z';
|
||||
let sinceSlug: string | undefined;
|
||||
let guard = 0;
|
||||
while (seen.length < 4 && guard < 10) {
|
||||
const r = await call(del, ctxFor({ remote: false }), {
|
||||
since,
|
||||
...(sinceSlug !== undefined ? { since_slug: sinceSlug } : {}),
|
||||
budget_tokens: 8,
|
||||
});
|
||||
for (const pg of r.pages as Array<{ slug: string }>) {
|
||||
if (pg.slug.startsWith('notes/sr-')) seen.push(pg.slug);
|
||||
}
|
||||
since = (r.next_cursor as { since: string }).since;
|
||||
sinceSlug = (r.next_cursor as { slug: string }).slug;
|
||||
guard++;
|
||||
}
|
||||
// Full drain, exactly once each — a re-delivery would duplicate in `seen`.
|
||||
expect(seen.sort()).toEqual(['notes/sr-a', 'notes/sr-b', 'notes/sr-c', 'notes/sr-d']);
|
||||
// One more resumed call: the drained cluster must NOT re-deliver.
|
||||
const r = await call(del, ctxFor({ remote: false }), { since, since_slug: sinceSlug, budget_tokens: 100000 });
|
||||
const redelivered = (r.pages as Array<{ slug: string }>).filter((p) => p.slug.startsWith('notes/sr-'));
|
||||
expect(redelivered).toEqual([]);
|
||||
});
|
||||
|
||||
test('explicit since_slug wins over the session cursor slug when both are present (v0.45.7)', async () => {
|
||||
const putPage = operations.find((o) => o.name === 'put_page')!;
|
||||
const local = ctxFor({ remote: false });
|
||||
for (const s of ['ow-a', 'ow-b', 'ow-c', 'ow-d']) {
|
||||
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\nbody` });
|
||||
}
|
||||
await engine.executeRaw(`UPDATE pages SET updated_at = '2026-08-08T10:00:00Z' WHERE slug LIKE 'notes/ow-%'`);
|
||||
// Session cursor at the START of the tie bucket — on its own it would
|
||||
// deliver the whole cluster from ow-a.
|
||||
await upsertSessionContextState(engine, 'default', null, 'ow', {
|
||||
lastWakeAt: '2026-08-08T10:00:00Z', cursorSlug: '',
|
||||
});
|
||||
const r = await call(del, local, { session_id: 'ow', since_slug: 'notes/ow-b', budget_tokens: 100000 });
|
||||
const cluster = (r.pages as Array<{ slug: string }>)
|
||||
.map((p) => p.slug)
|
||||
.filter((s) => s.startsWith('notes/ow-'));
|
||||
// Strictly after the EXPLICIT slug — the session's '' slug would have
|
||||
// re-delivered ow-a/ow-b.
|
||||
expect(cluster).toEqual(['notes/ow-c', 'notes/ow-d']);
|
||||
});
|
||||
|
||||
test('fail-open: a session_context_state outage never blocks the delta read path (v0.45.7)', async () => {
|
||||
// Proxy engine: executeRaw throws ONLY for statements touching
|
||||
// session_context_state; everything else hits the real engine (the delta
|
||||
// page/fact reads must keep working through the outage).
|
||||
const failing = new Proxy(engine, {
|
||||
get(t, k) {
|
||||
if (k === 'executeRaw') {
|
||||
return (sql: unknown, params?: unknown[]) => {
|
||||
if (typeof sql === 'string' && sql.includes('session_context_state')) {
|
||||
throw new Error('injected session-state outage');
|
||||
}
|
||||
return t.executeRaw(sql as never, params as never);
|
||||
};
|
||||
}
|
||||
const v = (t as unknown as Record<string | symbol, unknown>)[k];
|
||||
return typeof v === 'function' && k !== 'constructor'
|
||||
? (v as (...a: unknown[]) => unknown).bind(t)
|
||||
: v;
|
||||
},
|
||||
});
|
||||
// The state READ degrades to null, never a throw.
|
||||
await expect(getSessionContextState(failing as never, 'default', null, 'outage')).resolves.toBeNull();
|
||||
const ctx = { ...ctxFor({ remote: false }), engine: failing } as OperationContext;
|
||||
// First-wake path: cursor read + establish-write + GC all fail — the verb
|
||||
// still answers with a complete payload.
|
||||
const r1 = await call(del, ctx, { session_id: 'outage' });
|
||||
expect(r1.protocol_version).toBe(1);
|
||||
expect(r1.pages).toEqual([]);
|
||||
expect(r1.since).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/);
|
||||
// Full delta path: assembly reads run fine; the cursor-advance write fails
|
||||
// silently and the payload is still complete.
|
||||
const r2 = await call(del, ctx, { session_id: 'outage', since: '1970-01-01T00:00:00Z' });
|
||||
expect(r2.protocol_version).toBe(1);
|
||||
expect(Array.isArray(r2.pages)).toBe(true);
|
||||
expect(r2.since).toBe('1970-01-01T00:00:00.000Z');
|
||||
expect(r2.next_cursor).toBeTruthy();
|
||||
// The writes really did fail: nothing persisted for this session.
|
||||
expect(await getSessionContextState(engine, 'default', null, 'outage')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('push-path IPC handler (extracted, real engine)', () => {
|
||||
test('bankOnly persists standing entities under the local lane; assembly merges them back', async () => {
|
||||
const { makeContextPackIpcHandler } = await import('../src/mcp/context-pack-handler.ts');
|
||||
const handler = makeContextPackIpcHandler(engine, 'default');
|
||||
const bank = await handler({
|
||||
kind: 'context_pack', protocol: 2, secret: 's',
|
||||
sessionId: 'push-1',
|
||||
window: [{ role: 'user', text: 'we should follow up with Acme Example about the pilot' }],
|
||||
bankOnly: true,
|
||||
});
|
||||
expect(bank?.text).toBe('');
|
||||
const st = await getSessionContextState(engine, 'default', null, 'push-1');
|
||||
expect((st?.standing_entities ?? []).length).toBeGreaterThan(0);
|
||||
// Assembly path picks the banked set back up (cards may be empty — the
|
||||
// entities need not resolve — but the merge itself must not throw and the
|
||||
// cursor must advance on a complete pack).
|
||||
const before = st?.last_wake_at ?? null;
|
||||
const res = await handler({ kind: 'context_pack', protocol: 2, secret: 's', sessionId: 'push-1' });
|
||||
expect(res).not.toBeNull();
|
||||
const after = await getSessionContextState(engine, 'default', null, 'push-1');
|
||||
expect(after?.last_wake_at).not.toBe(before); // complete pack advances the cursor
|
||||
});
|
||||
|
||||
test('a deadline-degraded pack does NOT advance the wake cursor', async () => {
|
||||
const { makeContextPackIpcHandler } = await import('../src/mcp/context-pack-handler.ts');
|
||||
// Freeze a known cursor + banked entities first (INSERT path) so the
|
||||
// handler's assembly has real card work to blow the deadline on.
|
||||
await upsertSessionContextState(engine, 'default', null, 'push-2', {
|
||||
lastWakeAt: '2026-08-01T00:00:00Z',
|
||||
standingEntities: ['slow-ent-a', 'slow-ent-b', 'slow-ent-c'],
|
||||
});
|
||||
// A proxy engine that delays EVERY method call well past the pack budget
|
||||
// (arms use engine methods, not just executeRaw).
|
||||
const DELAY = 250;
|
||||
const slowEngine = new Proxy(engine, {
|
||||
get(t, k) {
|
||||
const v = (t as unknown as Record<string | symbol, unknown>)[k];
|
||||
if (typeof v === 'function' && k !== 'constructor') {
|
||||
return async (...a: unknown[]) => {
|
||||
await new Promise((r) => setTimeout(r, DELAY));
|
||||
return (v as (...x: unknown[]) => unknown).apply(t, a);
|
||||
};
|
||||
}
|
||||
return v;
|
||||
},
|
||||
});
|
||||
const { assembleContextPack } = await import('../src/core/context/turn-context.ts');
|
||||
const res = await assembleContextPack(slowEngine as never, {
|
||||
sourceId: 'default',
|
||||
entities: ['slow-ent-a', 'slow-ent-b', 'slow-ent-c'],
|
||||
deadlineMs: 100,
|
||||
});
|
||||
expect(res.degradedReason).toBe('deadline');
|
||||
// Snapshot semantics: the returned arrays must not grow after resolution.
|
||||
const lenCards = res.cards!.length;
|
||||
await new Promise((r) => setTimeout(r, 4 * DELAY));
|
||||
expect(res.cards!.length).toBe(lenCards);
|
||||
// And the handler skips the cursor advance on a degraded result — proven
|
||||
// via the real handler against the slow engine. (The handler's own state
|
||||
// read/write use the REAL engine here so we can assert directly.)
|
||||
const handler = makeContextPackIpcHandler(slowEngine as never, 'default');
|
||||
await handler({ kind: 'context_pack', protocol: 2, secret: 's', sessionId: 'push-2' });
|
||||
const st = await getSessionContextState(engine, 'default', null, 'push-2');
|
||||
expect(new Date(st!.last_wake_at!).toISOString()).toBe('2026-08-01T00:00:00.000Z');
|
||||
}, 20_000);
|
||||
|
||||
test('banked entities surface as VISIBLE warm-pack content on the next wake (v0.45.7)', async () => {
|
||||
const { makeContextPackIpcHandler } = await import('../src/mcp/context-pack-handler.ts');
|
||||
const putPage = operations.find((o) => o.name === 'put_page')!;
|
||||
// A REAL page the banked window entity resolves to (title + slug-suffix arms).
|
||||
await call(putPage, ctxFor({ remote: false }), {
|
||||
slug: 'people/dana-example',
|
||||
content: '# Dana Example\n\nFounder of widget-co; met at the retreat.',
|
||||
});
|
||||
const handler = makeContextPackIpcHandler(engine, 'default');
|
||||
// PreCompact banking: the window NAMES the seeded page.
|
||||
const bank = await handler({
|
||||
kind: 'context_pack', protocol: 2, secret: 's',
|
||||
sessionId: 'push-vis',
|
||||
window: [{ role: 'user', text: 'we should sync with Dana Example before the board meeting' }],
|
||||
bankOnly: true,
|
||||
});
|
||||
expect(bank?.text).toBe('');
|
||||
const st = await getSessionContextState(engine, 'default', null, 'push-vis');
|
||||
expect(st?.standing_entities ?? []).toContain('Dana Example');
|
||||
// Post-compaction wake: the banked entity resolves to its page and lands in
|
||||
// the rendered pack — traceable CONTENT, not merely a cursor advance.
|
||||
const res = await handler({ kind: 'context_pack', protocol: 2, secret: 's', sessionId: 'push-vis' });
|
||||
expect(res).not.toBeNull();
|
||||
expect((res!.cards ?? []).some((c) => c.entity.slug === 'people/dana-example')).toBe(true);
|
||||
expect(res!.text).toContain('## Standing entities');
|
||||
expect(res!.text).toContain('people/dana-example');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hot-memory cache: cross-tier isolation (adversarial P1 regression)', () => {
|
||||
test('a local include_private call must not warm the cache the remote read hits', async () => {
|
||||
const local = ctxFor({ remote: false });
|
||||
await call(remember, local, { fact: 'tier-secret burn detail', provenance: 't', entity: 'tier-acme', visibility: 'private' });
|
||||
__resetHotMemoryCacheForTests();
|
||||
// Warm the cache at the trusted-local tier (private facts included).
|
||||
const warm = await call(contextPack, local, { entities: 'tier-acme', include_private: true });
|
||||
expect((warm.facts as Array<{ fact: string }>).map((f) => f.fact).join('|')).toContain('tier-secret');
|
||||
// NO cache reset here — the remote call with the same source/session must
|
||||
// MISS the local-tier entry (the leak was: same key, served private).
|
||||
const r = await call(contextPack, ctxFor({ remote: true, clientId: 'c1' }), { entities: 'tier-acme', include_private: true });
|
||||
expect((r.facts as Array<{ fact: string }>).map((f) => f.fact).join('|')).not.toContain('tier-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibility (eng 1A / D2=A): world-only default, fail-closed widen', () => {
|
||||
beforeEach(async () => {
|
||||
// seed one world + one private fact about the same entity
|
||||
const local = ctxFor({ remote: false });
|
||||
await call(remember, local, { fact: 'acme-example raised a seed round', provenance: 'test', entity: 'acme-example', visibility: 'world' });
|
||||
await call(remember, local, { fact: 'acme-example secret burn rate detail', provenance: 'test', entity: 'acme-example', visibility: 'private' });
|
||||
__resetHotMemoryCacheForTests();
|
||||
});
|
||||
|
||||
test('local include_private=true widens facts to include private', async () => {
|
||||
const r = await call(contextPack, ctxFor({ remote: false }), { entities: 'acme-example', include_private: true });
|
||||
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
|
||||
expect(facts).toContain('secret burn rate');
|
||||
});
|
||||
|
||||
test('local default (no include_private) is world-only', async () => {
|
||||
const r = await call(contextPack, ctxFor({ remote: false }), { entities: 'acme-example' });
|
||||
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
|
||||
expect(facts).not.toContain('secret burn rate');
|
||||
});
|
||||
|
||||
test('remote caller NEVER widens even with include_private=true (fail-closed)', async () => {
|
||||
const r = await call(contextPack, ctxFor({ remote: true, clientId: 'c1' }), { entities: 'acme-example', include_private: true });
|
||||
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
|
||||
expect(facts).not.toContain('secret burn rate');
|
||||
});
|
||||
|
||||
// v0.45.7 gap closure: `delta` mirrors the same fail-closed ladder — the
|
||||
// facts arm routes through listFactsSince's visibility filter, not the
|
||||
// hot-memory tier, so it needs its own pins.
|
||||
test('delta local default (no include_private) is world-only in facts[] AND text', async () => {
|
||||
const since = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const r = await call(del, ctxFor({ remote: false }), { since });
|
||||
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
|
||||
expect(facts).toContain('raised a seed round');
|
||||
expect(facts).not.toContain('secret burn rate');
|
||||
expect(r.text as string).not.toContain('secret burn rate');
|
||||
});
|
||||
|
||||
test('delta local include_private=true widens facts to include private', async () => {
|
||||
const since = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const r = await call(del, ctxFor({ remote: false }), { since, include_private: true });
|
||||
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
|
||||
expect(facts).toContain('secret burn rate');
|
||||
});
|
||||
|
||||
test('delta remote caller NEVER widens even with include_private=true (fail-closed)', async () => {
|
||||
const since = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const r = await call(del, ctxFor({ remote: true, clientId: 'c1' }), { since, include_private: true });
|
||||
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
|
||||
expect(facts).not.toContain('secret burn rate');
|
||||
expect(r.text as string).not.toContain('secret burn rate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('budget packing + drop footer', () => {
|
||||
beforeEach(async () => {
|
||||
const local = ctxFor({ remote: false });
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await call(remember, local, { fact: `fact number ${i} with some length to consume tokens`, provenance: 'test', visibility: 'world' });
|
||||
}
|
||||
__resetHotMemoryCacheForTests();
|
||||
});
|
||||
|
||||
test('tiny budget reports dropped_count and never trims client-side', async () => {
|
||||
const r = await call(contextPack, ctxFor({ remote: false }), { entities: 'acme-example', budget_tokens: 5 });
|
||||
expect(r.budget_tokens).toBe(5);
|
||||
expect(typeof r.budget_used).toBe('number');
|
||||
expect(r.dropped_count).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('no budget → no footer fields', async () => {
|
||||
const r = await call(contextPack, ctxFor({ remote: false }), { entities: 'acme-example' });
|
||||
expect(r.budget_tokens).toBeUndefined();
|
||||
expect(r.dropped_count).toBeUndefined();
|
||||
});
|
||||
|
||||
test('forced overflow: dropped_count > 0 and budget_used stays within budget_tokens (v0.45.7)', async () => {
|
||||
const local = ctxFor({ remote: false });
|
||||
// Long facts (~55 tokens each): even ONE cannot fit the 20-token budget,
|
||||
// so overflow is GUARANTEED, not merely allowed (the >= 0 escape hatch).
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await call(remember, local, {
|
||||
fact: `overflow filler fact ${i} ${'x'.repeat(200)}`,
|
||||
provenance: 'test',
|
||||
visibility: 'world',
|
||||
});
|
||||
}
|
||||
__resetHotMemoryCacheForTests();
|
||||
// Precondition: the unbudgeted pack actually carries facts to drop.
|
||||
const full = await call(contextPack, local, { entities: 'acme-example' });
|
||||
expect((full.facts as unknown[]).length).toBeGreaterThan(0);
|
||||
const r = await call(contextPack, local, { entities: 'acme-example', budget_tokens: 20 });
|
||||
expect(r.budget_tokens).toBe(20);
|
||||
expect(r.dropped_count).toBeGreaterThan(0);
|
||||
expect(r.budget_used).toBeLessThanOrEqual(20);
|
||||
expect((r.facts as unknown[]).length).toBeLessThan((full.facts as unknown[]).length);
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ describe('host-specs [ENG-7]', () => {
|
||||
});
|
||||
|
||||
describe('writeClaudeHooks [G5, CX2-17]', () => {
|
||||
test('fresh workspace: all four events wired with marker + env + timeout', () => {
|
||||
test('fresh workspace: all five events wired with marker + env + timeout', () => {
|
||||
const dir = ws();
|
||||
const res = writeClaudeHooks(dir, { gbrainBin: BIN, env: ENV });
|
||||
expect(res.settingsPath).toBe(claudeSettingsPath(dir));
|
||||
@@ -94,6 +94,8 @@ describe('writeClaudeHooks [G5, CX2-17]', () => {
|
||||
expect(markerEntries(settings, 'SessionStart')[0].command).toContain('hook session-start');
|
||||
expect(markerEntries(settings, 'Stop')[0].command).toContain('hook stop');
|
||||
expect(markerEntries(settings, 'SessionEnd')[0].command).toContain('hook session-end');
|
||||
// v0.45.7 ambient recall: PreCompact banks standing entities pre-compaction.
|
||||
expect(markerEntries(settings, 'PreCompact')[0].command).toContain('hook compact');
|
||||
});
|
||||
|
||||
test('timeoutSecs override + GBRAIN_HOME env embedding', () => {
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
* without LiveServeLockError while a direct engine open
|
||||
* (createEngine + connect — the narrowest engine-open helper) DOES
|
||||
* throw LiveServeLockError, proving the lock is really held.
|
||||
* Pin 3 — stale serve: SIGKILL the serve, leave the socket file behind →
|
||||
* Pin 3 — v0.45.7 ambient recall: the compact→session-start warm-pack
|
||||
* round trip against the REAL serve. `hook compact` banks the
|
||||
* window's standing entities over the real socket (bankOnly), then
|
||||
* `hook session-start` (source=compact) gets a warm pack back whose
|
||||
* text carries the seeded entity — the real
|
||||
* makeContextPackIpcHandler + session_context_state row, end to end.
|
||||
* Pin 4 — stale serve: SIGKILL the serve, leave the socket file behind →
|
||||
* the hook fails open (exit 0, empty stdout) with an
|
||||
* ipc_unavailable/no_serve-class degradation.
|
||||
*
|
||||
@@ -309,7 +315,84 @@ describe('bootstrap hook under a live serve (serial e2e) [A7]', () => {
|
||||
expect((lockErr as Error).message).toContain('gbrain serve');
|
||||
}, 90_000);
|
||||
|
||||
test('Pin 3: stale serve (killed, socket left behind) → fail-open exit 0 with ipc_unavailable/no_serve degradation', async () => {
|
||||
test('Pin 3: compact banks the window into the session cursor; the post-compaction session-start serves a warm pack carrying the seeded entity', async () => {
|
||||
// Transcript under the confinement seam root (same shape as Pin 1). The
|
||||
// turns are written so 'Alice Example' — the corpus person page seeded in
|
||||
// beforeAll — is the ONLY extractable candidate (everything else stays
|
||||
// lowercase / stopworded), making the banked standing set deterministic.
|
||||
const projRoot = join(tmpParent, 'projects');
|
||||
mkdirSync(join(projRoot, 'p3'), { recursive: true });
|
||||
const transcript = join(projRoot, 'p3', 'session.jsonl');
|
||||
writeFileSync(
|
||||
transcript,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'quick recap please — what is Alice Example driving right now?' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'she leads retrieval quality — Alice Example has a roadmap review pending.' }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'keep tracking Alice Example after the compaction.' },
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
|
||||
// 3a — PreCompact banking: bankOnly over the REAL socket. The serve's
|
||||
// context_pack handler extracts the window entities and persists them into
|
||||
// session_context_state ('workspace', 'local', session id). PreCompact
|
||||
// stdout is not context-injected — the WRITE is the whole point.
|
||||
const bankOut = collectStdout();
|
||||
const bankCode = await runHook(['compact'], {
|
||||
stdin: JSON.stringify({ transcript_path: transcript, session_id: 'e2e-pack-sess' }),
|
||||
write: bankOut.write,
|
||||
cwd: ws,
|
||||
transcriptRoot: projRoot,
|
||||
});
|
||||
expect(bankCode).toBe(0);
|
||||
expect(bankOut.get()).toBe(''); // PreCompact emits nothing
|
||||
const [bankHb] = await readHeartbeatTail(1);
|
||||
expect(bankHb).toBeDefined();
|
||||
expect(bankHb.event).toBe('compact');
|
||||
expect(bankHb.outcome).toBe('ok'); // a degradation here means banking never reached the serve
|
||||
|
||||
// 3b — post-compaction SessionStart (source=compact): the SAME session id
|
||||
// pulls the banked standing set back through the real
|
||||
// makeContextPackIpcHandler (assembleContextPack → entity cards) and the
|
||||
// hook appends the pack after the digest. The rendered card line carries
|
||||
// the seeded entity's title + slug — the direct-DB row check is off the
|
||||
// table by design (Pin 2: the serve holds the PGLite lock), so the stdout
|
||||
// content IS the proof the session_context_state round trip worked.
|
||||
const packOut = collectStdout();
|
||||
const packCode = await runHook(['session-start'], {
|
||||
stdin: JSON.stringify({ session_id: 'e2e-pack-sess', source: 'compact' }),
|
||||
write: packOut.write,
|
||||
cwd: ws,
|
||||
});
|
||||
expect(packCode).toBe(0);
|
||||
const pack = packOut.get();
|
||||
expect(pack).toContain('Alice Example');
|
||||
expect(pack).toContain('people/alice-example');
|
||||
const [packHb] = await readHeartbeatTail(1);
|
||||
expect(packHb).toBeDefined();
|
||||
expect(packHb.event).toBe('session-start');
|
||||
expect(packHb.outcome).not.toBe('error');
|
||||
|
||||
// The pack path never widens visibility (world-only ALWAYS, D2=A): no
|
||||
// fragment of a seeded PRIVATE belief may ride along in the warm pack.
|
||||
const privateFragments = loadCorpusBeliefData()
|
||||
.filter((b) => b.visibility === 'private')
|
||||
.map((b) => b.text);
|
||||
for (const frag of privateFragments) expect(pack).not.toContain(frag);
|
||||
}, 60_000);
|
||||
|
||||
test('Pin 4: stale serve (killed, socket left behind) → fail-open exit 0 with ipc_unavailable/no_serve degradation', async () => {
|
||||
// SIGKILL: the shutdown handler never runs, so the socket file survives
|
||||
// as a stale artifact — exactly the crashed-serve shape.
|
||||
expect(serveProc).not.toBeNull();
|
||||
|
||||
@@ -20,9 +20,28 @@
|
||||
* shell instruction (`gbrain query`) if headless stdio-MCP is unreliable;
|
||||
* the assertion documents which path proved out.
|
||||
*
|
||||
* 3. BOUNDARY — a second live `codex exec` turn at a SESSION BOUNDARY
|
||||
* (v0.45.7 ambient recall). The real bootstrap protocol is rendered into
|
||||
* the cwd, the HEARTBEAT.md ambient-delta due-job is enabled (the
|
||||
* documented operator ritual), gbrain is registered on `--surface verbs`
|
||||
* (the seven frozen memory verbs, context_pack + delta included), and
|
||||
* codex is told to follow its AGENTS.md session-start protocol. Asserts a
|
||||
* boundary verb landed against OUR gbrain — an `mcp_tool_call` naming
|
||||
* context_pack OR delta (either counts: boundary behavior, not one exact
|
||||
* tool), or the CLI spelling via SMOKE's shell-fallback contract (the
|
||||
* evidence documents which path proved out). Proves: rendered protocol →
|
||||
* real codex → boundary verb → brain.
|
||||
*
|
||||
* A codex-FREE companion describe pins the rendered protocol content itself:
|
||||
* AGENTS.md routes session start through HEARTBEAT.md's due-job list, whose
|
||||
* ambient-delta row names context-pack (session start) + delta (heartbeat).
|
||||
* That block ALWAYS runs — template-source/docs pins live in
|
||||
* test/ambient-recall-templates.test.ts; this file owns the WORKSPACE-RENDERED
|
||||
* artifacts the codex door actually reads.
|
||||
*
|
||||
* EVERYTHING is hermetic (temp HOME / CODEX_HOME / GBRAIN_HOME per test) and the
|
||||
* whole file self-SKIPS via describe.skipIf when the codex binary or its auth is
|
||||
* absent, so it is a clean no-op on a runner without them. Serial: PGLite cold
|
||||
* live-codex describe self-SKIPS via describe.skipIf when the codex binary or
|
||||
* its auth is absent, so it is a clean no-op on a runner without them. Serial: PGLite cold
|
||||
* starts + a real codex spawn would starve parallel siblings; every test carries
|
||||
* an explicit timeout. Real turns cost API + take 30s–2min — prompts are minimal
|
||||
* (one seeded fact, one question) and capped at 240s.
|
||||
@@ -160,6 +179,73 @@ afterAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Scripted interview (REQUIRED_ANSWERS) + full render into `ws` — the exact
|
||||
* INSTALL steps (b)+(c), reused by the codex-free render pin and the live
|
||||
* BOUNDARY turn. Caller pins GBRAIN_HOME first (render reads the repo
|
||||
* receipt/config from it) and git-inits `ws` (no origin → the public-origin
|
||||
* gate is a no-op). */
|
||||
async function interviewAndRender(ws: string): Promise<void> {
|
||||
const init = initState(ws);
|
||||
if (!init.ok) throw new Error(init.message);
|
||||
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
|
||||
const r = setAnswer(ws, key, value);
|
||||
if (!r.ok) throw new Error(r.message);
|
||||
}
|
||||
const h = readBackHash(ws);
|
||||
if (!h.ok) throw new Error(h.message);
|
||||
const c = confirm(ws, h.hash);
|
||||
if (!c.ok) throw new Error(c.message);
|
||||
const code = await runBootstrap(['render', '--workspace', ws]);
|
||||
if (code !== 0) throw new Error(`bootstrap render exited ${code}`);
|
||||
}
|
||||
|
||||
// ── 0. RENDERED PROTOCOL PIN (always runs — needs NO codex binary) ──────────
|
||||
// Codex has no hook system, so ambient recall (v0.45.7) reaches it ONLY via
|
||||
// the rendered pull protocol. Pin the WORKSPACE-RENDERED chain the codex door
|
||||
// reads: AGENTS.md's session startup routes through HEARTBEAT.md's due-job
|
||||
// list, and the rendered ambient-delta row binds both boundary verbs to their
|
||||
// boundaries. (Template-SOURCE + docs pins are owned by
|
||||
// test/ambient-recall-templates.test.ts — deliberately not repeated here.)
|
||||
describe('bootstrap rendered protocol — ambient boundaries (always runs)', () => {
|
||||
test('rendered AGENTS.md + HEARTBEAT.md name context-pack (session start) and delta (heartbeat)', async () => {
|
||||
const gbHome = mkdtempSync(join(tmpdir(), 'gb-rc-render-home-'));
|
||||
const ws = mkdtempSync(join(tmpdir(), 'gb-rc-render-ws-'));
|
||||
const savedHome = process.env.GBRAIN_HOME;
|
||||
try {
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: ws });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: ws });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: ws });
|
||||
process.env.GBRAIN_HOME = gbHome;
|
||||
await interviewAndRender(ws);
|
||||
expect(readManifest(ws).state).toBe('initialized');
|
||||
|
||||
// AGENTS.md — Codex's ONLY per-turn mechanism — wires the session
|
||||
// boundary to HEARTBEAT.md's due-job list (session start + turn
|
||||
// boundaries). This is the link the boundary verbs hang off.
|
||||
const agents = readFileSync(join(ws, 'AGENTS.md'), 'utf8');
|
||||
expect(agents).toContain('## Session startup');
|
||||
expect(agents).toMatch(/HEARTBEAT\.md[^\n]*due-job list/);
|
||||
expect(agents).toContain('turn boundaries');
|
||||
|
||||
// ...and the RENDERED HEARTBEAT.md row it points to binds BOTH verbs to
|
||||
// their boundaries on one line: delta at every session start + turn
|
||||
// boundary (the heartbeat), context-pack paired at session start.
|
||||
const heartbeat = readFileSync(join(ws, 'HEARTBEAT.md'), 'utf8');
|
||||
const row = heartbeat.split('\n').find((l) => l.startsWith('| ambient-delta |'));
|
||||
expect(row, 'rendered HEARTBEAT.md lost its ambient-delta due-job row').toBeDefined();
|
||||
expect(row!).toContain('every session start + turn boundary');
|
||||
expect(row!).toMatch(/`gbrain delta[^`]*`/);
|
||||
expect(row!).toMatch(/`gbrain context-pack[^`]*` at session start/);
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = savedHome;
|
||||
for (const d of [gbHome, ws]) {
|
||||
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe.skipIf(!CAN_RUN)('bootstrap real-codex door (serial e2e)', () => {
|
||||
// ── 1. INSTALL ────────────────────────────────────────────────────────────
|
||||
test('INSTALL: keyless init → interview → render → real `codex mcp add` → verify', async () => {
|
||||
@@ -354,4 +440,114 @@ describe.skipIf(!CAN_RUN)('bootstrap real-codex door (serial e2e)', () => {
|
||||
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}, 480_000);
|
||||
|
||||
// ── 3. BOUNDARY ─────────────────────────────────────────────────────────────
|
||||
test('BOUNDARY: real `codex exec` session start → context_pack/delta over MCP', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'gb-rc-boundary-'));
|
||||
const savedHome = process.env.GBRAIN_HOME;
|
||||
try {
|
||||
// Trusted-cwd git repo (same `codex exec` requirement as SMOKE).
|
||||
execFileSync('git', ['init', '-q', home]);
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: home });
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: home });
|
||||
|
||||
// A real brain behind the MCP server: context_pack/delta reach
|
||||
// migration v126's session_context_state through the production engine.
|
||||
const sourceId = 'workspace';
|
||||
await seedBrainForAgent(home, sourceId);
|
||||
|
||||
// Hermetic ~/.codex + real `codex mcp add` (SMOKE's registration shape,
|
||||
// but on `--surface verbs`: the SEVEN frozen memory verbs — context_pack
|
||||
// + delta included — are servable alone, and the small tool list keeps a
|
||||
// codex client from truncating them out of a 100+-tool full surface).
|
||||
seedCodexHome(home);
|
||||
const runner = makeCodexRunner(home);
|
||||
const server = resolveGbrainServerCommand(REPO_ROOT, ['--surface', 'verbs']);
|
||||
const add = await runner([
|
||||
'codex', 'mcp', 'add', 'gbrain',
|
||||
'--env', `GBRAIN_HOME=${home}`,
|
||||
'--env', `GBRAIN_SOURCE=${sourceId}`,
|
||||
'--', server.command, ...server.args,
|
||||
]);
|
||||
expect(add.code).toBe(0);
|
||||
|
||||
// Render the REAL bootstrap protocol into the cwd — the same
|
||||
// AGENTS.md + HEARTBEAT.md chain the always-run pin above asserts on is
|
||||
// what this codex actually reads at its session boundary.
|
||||
process.env.GBRAIN_HOME = home;
|
||||
await interviewAndRender(home);
|
||||
|
||||
// Enable the ambient-delta due-job (every job ships DISABLED; flipping
|
||||
// the Enabled cell is the documented operator ritual) so the
|
||||
// session-start protocol has a due boundary job to run.
|
||||
const hbPath = join(home, 'HEARTBEAT.md');
|
||||
const hb = readFileSync(hbPath, 'utf8');
|
||||
const enabled = hb.replace(/^(\| ambient-delta \|[^|]*\|) no \|/m, '$1 yes |');
|
||||
expect(enabled).not.toBe(hb);
|
||||
writeFileSync(hbPath, enabled);
|
||||
|
||||
// The per-turn steer mirrors SMOKE's: name the MCP path explicitly (the
|
||||
// HEARTBEAT row spells the CLI form) AND give SMOKE's shell fallback —
|
||||
// headless codex stdio-MCP is unreliable (the SMOKE flake), and the CLI
|
||||
// spelling exercises the exact command the shipped HEARTBEAT row tells
|
||||
// agents to run.
|
||||
const prompt =
|
||||
'You are starting a new session. Follow your AGENTS.md session-start protocol for memory: ' +
|
||||
`check HEARTBEAT.md's due-job list and run what is due at a session-start boundary. ` +
|
||||
'The gbrain MCP server is connected; its context_pack and delta tools are the MCP form of the ' +
|
||||
'`gbrain context-pack` / `gbrain delta` commands. Use "codex-boundary" as the session id. ' +
|
||||
'If no gbrain MCP tool is available, run the shell command ' +
|
||||
`\`bun run ${CLI} delta --session-id codex-boundary --budget-tokens 2000\` instead. ` +
|
||||
'After the boundary pull, reply with one line.';
|
||||
|
||||
// Bounded retry (max 2, same as SMOKE) rides out a transient
|
||||
// MCP-startup cancellation. PASS only when an attempt lands a boundary
|
||||
// verb against OUR gbrain — over MCP (an `mcp_tool_call` naming
|
||||
// context_pack or delta; EITHER verb counts, boundary behavior over
|
||||
// exact tool) or through the real CLI (SMOKE's fallback contract; the
|
||||
// evidence line documents which path proved out). Never pass on zero
|
||||
// boundary calls, never soften.
|
||||
const perAttemptTimeout = server.kind === 'compiled' ? 190_000 : 230_000;
|
||||
const maxAttempts = 2;
|
||||
let passed = false;
|
||||
let lastEvidence = '';
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts && !passed; attempt++) {
|
||||
if (attempt > 1) await new Promise((r) => setTimeout(r, 3_000));
|
||||
const turn = await codexExecTurn({ prompt, cwd: home, home, timeoutMs: perAttemptTimeout });
|
||||
// A Codex MCP tool call is a distinct `mcp_tool_call` item on the raw
|
||||
// stream (the harness parser only captures command_execution/
|
||||
// agent_message/reasoning). Field ORDER and the tool-name key inside
|
||||
// the item are not pinned across codex versions, so match the parts
|
||||
// per line rather than one order-dependent regex.
|
||||
const usedMcp = turn.rawLines.some(
|
||||
(l) =>
|
||||
/"type"\s*:\s*"mcp_tool_call"/.test(l) &&
|
||||
l.includes('gbrain') &&
|
||||
/context_pack|\bdelta\b/.test(l),
|
||||
);
|
||||
// Shell fallback: codex INVOKED a boundary verb through the real CLI
|
||||
// (`… cli.ts delta --session-id …` / `… context-pack …`), surfaced in
|
||||
// the parsed command_execution toolCalls. Anchored on the CLI so a
|
||||
// mere `grep ambient-delta HEARTBEAT.md` read can never count.
|
||||
const usedShell = turn.toolCalls.some((c) =>
|
||||
/(?:cli\.ts|gbrain)\s+(?:delta|context-pack)\b/i.test(c),
|
||||
);
|
||||
const boundaryCall = usedMcp || usedShell;
|
||||
lastEvidence =
|
||||
`[boundary codex attempt ${attempt}/${maxAttempts}] server=${server.kind} ` +
|
||||
`exit=${turn.exitCode} timedOut=${turn.timedOut} usedMcp=${usedMcp} usedShell=${usedShell}\n` +
|
||||
`toolCalls=${JSON.stringify(turn.toolCalls)}\n` +
|
||||
`finalText=${turn.finalText.slice(0, 800)}`;
|
||||
console.log(lastEvidence);
|
||||
if (boundaryCall) passed = true;
|
||||
}
|
||||
|
||||
expect(passed, `BOUNDARY failed on all ${maxAttempts} attempts.\n${lastEvidence}`).toBe(true);
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = savedHome;
|
||||
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}, 480_000);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
||||
import type { ChunkInput, SearchResult } from '../../src/core/types.ts';
|
||||
import type { BrainEngine } from '../../src/core/engine.ts';
|
||||
import { getSessionContextState, upsertSessionContextState } from '../../src/core/context/session-state.ts';
|
||||
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
|
||||
|
||||
const SKIP_PG = !hasDatabase();
|
||||
@@ -962,3 +963,161 @@ describeBoth('Engine parity — federated sourceIds[] secondary reads (#2200)',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── ambient recall parity (v0.45.7, issue #1) ───────────────────────────
|
||||
// Two seams that only real Postgres can vet:
|
||||
// 1. The keyset-pagination WHERE clause (PageFilters.updatedAfterKeyset) —
|
||||
// Postgres composes it from postgres.js sql`` fragments, PGLite from
|
||||
// positional $N params. A drift here means the `delta` verb's session
|
||||
// cursor drops or re-delivers pages after `gbrain migrate --to supabase`.
|
||||
// 2. The session_context_state $N::text::jsonb upsert (session-state.ts) —
|
||||
// the postgres.js jsonb double-encode trap PGLite structurally cannot
|
||||
// surface (the CLAUDE.md #2339 class).
|
||||
const KS_TIE_TS = '2026-08-05T12:00:00.000Z';
|
||||
const KS_EARLY_TS = '2026-08-01T00:00:00.000Z';
|
||||
const KS_LATE_TS = '2026-08-09T00:00:00.000Z';
|
||||
// 10-page tie cluster: bulk syncs stamp identical now() across a transaction,
|
||||
// so a >limit same-timestamp cluster is the exact shape the slug tiebreaker
|
||||
// exists for (limit 3 below forces two cursor advances INSIDE the cluster).
|
||||
const KS_TIE_SLUGS = Array.from({ length: 10 }, (_, i) => `ks/tie-${String(i).padStart(2, '0')}`);
|
||||
|
||||
async function seedKeyset(eng: BrainEngine) {
|
||||
const stamp = async (slug: string, ts: string) => {
|
||||
await eng.putPage(slug, { type: 'note', title: slug, compiled_truth: `${slug} body`, timeline: '' });
|
||||
// Direct updated_at stamp (same precedent as the stale-parity test) —
|
||||
// putPage server-stamps now(), which can't produce a controlled tie.
|
||||
await eng.executeRaw(
|
||||
`UPDATE pages SET updated_at = $1::timestamptz WHERE slug = $2 AND source_id = 'default'`,
|
||||
[ts, slug],
|
||||
);
|
||||
};
|
||||
for (const slug of KS_TIE_SLUGS) await stamp(slug, KS_TIE_TS);
|
||||
await stamp('ks/early-1', KS_EARLY_TS);
|
||||
await stamp('ks/early-2', KS_EARLY_TS);
|
||||
await stamp('ks/late-1', KS_LATE_TS);
|
||||
await stamp('ks/late-2', KS_LATE_TS);
|
||||
}
|
||||
|
||||
/** Page through listPages exactly the way the delta verb does (turn-context.ts):
|
||||
* anchor at (updated_at, slug) of the last DELIVERED row, sort updated_asc. */
|
||||
async function drainKeyset(
|
||||
eng: BrainEngine,
|
||||
start: { updatedAt: string; slug: string },
|
||||
): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
let cursor = start;
|
||||
// Iteration guard: a strict-greater bug that fails to advance the cursor
|
||||
// would livelock the loop instead of failing the assertion below.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const batch = await eng.listPages({
|
||||
updatedAfterKeyset: cursor,
|
||||
sort: 'updated_asc',
|
||||
limit: 3,
|
||||
slugPrefix: 'ks/',
|
||||
sourceId: 'default',
|
||||
});
|
||||
if (batch.length === 0) break;
|
||||
for (const p of batch) out.push(p.slug);
|
||||
const last = batch[batch.length - 1];
|
||||
cursor = { updatedAt: last.updated_at.toISOString(), slug: last.slug };
|
||||
if (batch.length < 3) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describeBoth('Engine parity — ambient recall keyset + session cursor (v0.45.7)', () => {
|
||||
let pgEngine: BrainEngine;
|
||||
let pgliteEngine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
pgEngine = await setupDB();
|
||||
await seedKeyset(pgEngine);
|
||||
pgliteEngine = new PGLiteEngine();
|
||||
await pgliteEngine.connect({});
|
||||
await pgliteEngine.initSchema();
|
||||
await seedKeyset(pgliteEngine);
|
||||
// session_context_state is not in helpers' TRUNCATE list — clear this
|
||||
// block's key space so a prior run's rows can't leak into assertions.
|
||||
await pgEngine.executeRaw(`DELETE FROM session_context_state WHERE session_id LIKE 'parity-%'`);
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await pgliteEngine.disconnect();
|
||||
await teardownDB();
|
||||
}, 30_000);
|
||||
|
||||
test('keyset drain from bucket start: identical ordered sequence, no dupes/omissions', async () => {
|
||||
// slug '' ⇒ start of the tie bucket (every tie slug > ''). Earlier pages
|
||||
// are strictly excluded (updated_at < ts); later pages follow the cluster.
|
||||
const start = { updatedAt: KS_TIE_TS, slug: '' };
|
||||
const pg = await drainKeyset(pgEngine, start);
|
||||
const pglite = await drainKeyset(pgliteEngine, start);
|
||||
expect(pg).toEqual(pglite);
|
||||
expect(pg).toEqual([...KS_TIE_SLUGS, 'ks/late-1', 'ks/late-2']);
|
||||
expect(new Set(pg).size).toBe(pg.length); // no duplicates across batches
|
||||
});
|
||||
|
||||
test('keyset strict-greater: anchor slug excluded, mid-cluster resume identical', async () => {
|
||||
// Resuming from tie-04 must exclude tie-04 itself (strict >, not >=) and
|
||||
// everything before it in the (updated_at, slug) total order.
|
||||
const anchor = { updatedAt: KS_TIE_TS, slug: 'ks/tie-04' };
|
||||
const pg = await drainKeyset(pgEngine, anchor);
|
||||
const pglite = await drainKeyset(pgliteEngine, anchor);
|
||||
expect(pg).toEqual(pglite);
|
||||
expect(pg).toEqual([...KS_TIE_SLUGS.slice(5), 'ks/late-1', 'ks/late-2']);
|
||||
expect(pg).not.toContain('ks/tie-04');
|
||||
});
|
||||
|
||||
test('session_context_state round trip: jsonb arrays stay arrays + keep-if-absent', async () => {
|
||||
const sess = 'parity-sess-1';
|
||||
const entities = ['people/alice-example', 'companies/acme-example'];
|
||||
for (const eng of [pgEngine, pgliteEngine]) {
|
||||
await upsertSessionContextState(eng, 'default', null, sess, {
|
||||
standingEntities: entities,
|
||||
lastWakeAt: KS_TIE_TS,
|
||||
cursorSlug: 'ks/tie-04',
|
||||
});
|
||||
}
|
||||
|
||||
const pg = await getSessionContextState(pgEngine, 'default', null, sess);
|
||||
const pglite = await getSessionContextState(pgliteEngine, 'default', null, sess);
|
||||
expect(pg).not.toBeNull();
|
||||
expect(pglite).not.toBeNull();
|
||||
expect(pg!.standing_entities).toEqual(entities);
|
||||
expect(pglite!.standing_entities).toEqual(pg!.standing_entities);
|
||||
expect(pg!.surfaced_slugs).toEqual(['ks/tie-04']); // single-element keyset slug
|
||||
expect(pglite!.surfaced_slugs).toEqual(pg!.surfaced_slugs);
|
||||
expect(pg!.last_wake_at).toBe(KS_TIE_TS);
|
||||
expect(pglite!.last_wake_at).toBe(pg!.last_wake_at);
|
||||
|
||||
// The read helper JSON.parses string scalars (fail-open), so it would MASK
|
||||
// a double-encoded write. jsonb_typeof is the unmaskable probe — a
|
||||
// JSON.stringify'd value bound straight into ::jsonb stores typeof
|
||||
// 'string', not 'array'. Only the real-Postgres arm can actually surface
|
||||
// the postgres.js trap; PGLite is asserted for stored-shape parity.
|
||||
for (const eng of [pgEngine, pgliteEngine]) {
|
||||
const rows = await eng.executeRaw<{ se: string; ss: string }>(
|
||||
`SELECT jsonb_typeof(standing_entities) AS se, jsonb_typeof(surfaced_slugs) AS ss
|
||||
FROM session_context_state
|
||||
WHERE source_id = 'default' AND client_id = 'local' AND session_id = $1`,
|
||||
[sess],
|
||||
);
|
||||
expect(rows[0]?.se).toBe('array');
|
||||
expect(rows[0]?.ss).toBe('array');
|
||||
}
|
||||
|
||||
// keep-if-absent: a patch omitting standingEntities/cursorSlug must leave
|
||||
// both stored sets untouched while the wake cursor advances.
|
||||
for (const eng of [pgEngine, pgliteEngine]) {
|
||||
await upsertSessionContextState(eng, 'default', null, sess, { lastWakeAt: KS_LATE_TS });
|
||||
}
|
||||
for (const st of [
|
||||
await getSessionContextState(pgEngine, 'default', null, sess),
|
||||
await getSessionContextState(pgliteEngine, 'default', null, sess),
|
||||
]) {
|
||||
expect(st!.standing_entities).toEqual(entities);
|
||||
expect(st!.surfaced_slugs).toEqual(['ks/tie-04']);
|
||||
expect(st!.last_wake_at).toBe(KS_LATE_TS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,12 @@ describeE2E('http-transport E2E (real Postgres)', () => {
|
||||
let validToken: string;
|
||||
let revokedToken: string;
|
||||
let validTokenName: string;
|
||||
// v0.45.7 ambient recall: two tokens with DISTINCT clientIds (the auth
|
||||
// clientId is the access_tokens row id) for the delta session-cursor test.
|
||||
let tokenA: string;
|
||||
let tokenB: string;
|
||||
let tokenAId: string;
|
||||
let tokenBId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupDB();
|
||||
@@ -69,6 +75,20 @@ describeE2E('http-transport E2E (real Postgres)', () => {
|
||||
'INSERT INTO access_tokens (name, token_hash, revoked_at) VALUES ($1, $2, now())',
|
||||
['e2e-revoked-' + randomBytes(4).toString('hex'), hashToken(revokedToken)],
|
||||
);
|
||||
// Two more valid tokens — RETURNING id captures each token's clientId
|
||||
// (http-transport sets auth.clientId to the access_tokens row id).
|
||||
tokenA = generateToken();
|
||||
const [rowA] = await conn.unsafe(
|
||||
'INSERT INTO access_tokens (name, token_hash) VALUES ($1, $2) RETURNING id',
|
||||
['e2e-client-a-' + randomBytes(4).toString('hex'), hashToken(tokenA)],
|
||||
) as { id: string }[];
|
||||
tokenAId = rowA.id;
|
||||
tokenB = generateToken();
|
||||
const [rowB] = await conn.unsafe(
|
||||
'INSERT INTO access_tokens (name, token_hash) VALUES ($1, $2) RETURNING id',
|
||||
['e2e-client-b-' + randomBytes(4).toString('hex'), hashToken(tokenB)],
|
||||
) as { id: string }[];
|
||||
tokenBId = rowB.id;
|
||||
|
||||
srv = await startServer();
|
||||
}, 30_000);
|
||||
@@ -230,4 +250,98 @@ describeE2E('http-transport E2E (real Postgres)', () => {
|
||||
expect(body.result.isError).toBe(true);
|
||||
expect(body.result.content[0].text).toContain('invalid_params');
|
||||
});
|
||||
|
||||
// ── v0.45.7 ambient recall: the two boundary verbs over real HTTP ─────────
|
||||
|
||||
test('9. tools/list on the default surface includes the boundary verbs (context_pack + delta)', async () => {
|
||||
const r = await fetch(`http://localhost:${srv.port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${validToken}`, 'Content-Type': 'application/json' },
|
||||
body: rpc('tools/list'),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const body = await r.json();
|
||||
const names = body.result.tools.map((t: { name: string }) => t.name);
|
||||
expect(names).toContain('context_pack');
|
||||
expect(names).toContain('delta');
|
||||
});
|
||||
|
||||
test('10. delta keys the session cursor by auth clientId — two tokens, same session_id → two rows, neither "local"', async () => {
|
||||
const conn = getConn();
|
||||
const sessionId = 'e2e-delta-' + randomBytes(6).toString('hex');
|
||||
|
||||
// First wake per (client, session): establishes the cursor, empty delta.
|
||||
// Same session_id under BOTH tokens — the auth clientId must namespace the
|
||||
// cursor rows or the two harnesses would stomp each other's state.
|
||||
for (const token of [tokenA, tokenB]) {
|
||||
const r = await fetch(`http://localhost:${srv.port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: rpc('tools/call', { name: 'delta', arguments: { session_id: sessionId } }),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const body = await r.json();
|
||||
expect(body.result.isError).toBeUndefined();
|
||||
const parsed = JSON.parse(body.result.content[0].text);
|
||||
expect(parsed.protocol_version).toBe(1);
|
||||
expect(parsed.pages).toEqual([]);
|
||||
}
|
||||
|
||||
// Query the engine directly: one row per clientId, keyed by the tokens'
|
||||
// access_tokens row ids — and never the 'local' trusted-CLI sentinel.
|
||||
const rows = await conn.unsafe(
|
||||
'SELECT client_id, source_id FROM session_context_state WHERE session_id = $1 ORDER BY client_id',
|
||||
[sessionId],
|
||||
) as { client_id: string; source_id: string }[];
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows.map(row => row.client_id).sort()).toEqual([tokenAId, tokenBId].sort());
|
||||
for (const row of rows) {
|
||||
expect(row.client_id).not.toBe('local');
|
||||
expect(row.source_id).toBe('default');
|
||||
}
|
||||
});
|
||||
|
||||
test('11. context_pack with include_private:true over HTTP is fail-closed — no private fact in facts[] or text', async () => {
|
||||
const conn = getConn();
|
||||
const marker = randomBytes(6).toString('hex');
|
||||
const worldFact = `E2E world fact ${marker}`;
|
||||
const privateFact = `E2E private fact SECRET-${marker}`;
|
||||
await conn.unsafe(
|
||||
`INSERT INTO facts (source_id, fact, kind, visibility, source) VALUES
|
||||
('default', $1, 'fact', 'world', 'e2e-http'),
|
||||
('default', $2, 'fact', 'private', 'e2e-http')`,
|
||||
[worldFact, privateFact],
|
||||
);
|
||||
|
||||
// include_private only widens for trusted-local callers (ctx.remote ===
|
||||
// false); this transport always dispatches remote:true, so the flag must
|
||||
// be a no-op over real HTTP.
|
||||
const r = await fetch(`http://localhost:${srv.port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${validToken}`, 'Content-Type': 'application/json' },
|
||||
body: rpc('tools/call', {
|
||||
name: 'context_pack',
|
||||
arguments: {
|
||||
entities: 'nonexistent-entity-' + marker,
|
||||
include_private: true,
|
||||
// Fresh session id → fresh hot-memory cache key (no cross-test reuse).
|
||||
session_id: 'e2e-pack-' + marker,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const body = await r.json();
|
||||
expect(body.result.isError).toBeUndefined();
|
||||
const rawText = body.result.content[0].text;
|
||||
const parsed = JSON.parse(rawText);
|
||||
expect(parsed.protocol_version).toBe(1);
|
||||
// World fact present — proves the facts arm actually ran (non-vacuous).
|
||||
const factTexts = parsed.facts.map((f: { fact: string }) => f.fact);
|
||||
expect(factTexts).toContain(worldFact);
|
||||
// Private fact absent from facts[], from the injectable text, and from
|
||||
// the entire serialized payload (covers every additive field at once).
|
||||
expect(factTexts).not.toContain(privateFact);
|
||||
expect(parsed.text).not.toContain('SECRET-' + marker);
|
||||
expect(rawText).not.toContain('SECRET-' + marker);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
* No Postgres / Docker. PGLite, hermetic temp HOME. Drives the real
|
||||
* StdioClientTransport, so the MCP SDK spawns `gbrain serve` for us, runs the
|
||||
* `initialize` handshake, and round-trips `tools/list` + `tools/call`.
|
||||
*
|
||||
* The second session (v0.45.7) covers `gbrain serve --surface verbs`: the
|
||||
* frozen 7-verb MEMORY_VERBS surface over the same real stdio transport —
|
||||
* exact tool list, the two ambient-recall verbs (context_pack + delta),
|
||||
* fail-closed dispatch on hidden ops, and the delta session cursor advancing
|
||||
* across two wakes.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
@@ -26,6 +32,7 @@ import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { VERB_NAMES } from '../../src/core/verbs.ts';
|
||||
|
||||
// Distinctive token so keyword search can't accidentally match anything else.
|
||||
const MARKER = 'qantani-marker-9f3z';
|
||||
@@ -125,3 +132,180 @@ describe('serve stdio round-trip E2E (local PGLite → real MCP tool calls)', ()
|
||||
expect(text).toContain(MARKER);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// Distinct marker for the verbs-surface session (own temp brain, own token).
|
||||
const VERBS_MARKER = 'veridian-verbs-7q2x';
|
||||
|
||||
describe('serve --surface verbs stdio E2E (the 7 frozen memory verbs over a real MCP session)', () => {
|
||||
let home: string;
|
||||
let client: Client | null = null;
|
||||
let transport: StdioClientTransport | null = null;
|
||||
let connected = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'gbrain-stdio-verbs-e2e-'));
|
||||
// Same hermetic-PGLite env dance as the full-surface session above.
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
|
||||
env.GBRAIN_HOME = home;
|
||||
delete env.DATABASE_URL;
|
||||
delete env.GBRAIN_DATABASE_URL;
|
||||
// Quiescent brain: the startup sweep could touch pages BETWEEN the two
|
||||
// delta wakes and legitimately re-surface them (a changed page is SUPPOSED
|
||||
// to re-appear), turning the cursor-advance assertion flaky. Kill switch
|
||||
// keeps the only writers the test's own tool calls.
|
||||
env.GBRAIN_SWEEP = '0';
|
||||
|
||||
// 1. Init a local PGLite brain.
|
||||
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
|
||||
cwd: process.cwd(), env, stdio: 'ignore',
|
||||
});
|
||||
|
||||
// 2. Seed two pages BEFORE serve spawns so the first delta wake has pages
|
||||
// to deliver (the cursor-advance assertion needs a non-empty delivery).
|
||||
const notes = join(home, 'notes');
|
||||
mkdirSync(notes, { recursive: true });
|
||||
writeFileSync(
|
||||
join(notes, 'pack-a.md'),
|
||||
`---\ntitle: ${VERBS_MARKER} alpha\n---\n\n# ${VERBS_MARKER} alpha\n\nSeed page A for the verbs-surface delta cursor.\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(notes, 'pack-b.md'),
|
||||
`---\ntitle: ${VERBS_MARKER} beta\n---\n\n# ${VERBS_MARKER} beta\n\nSeed page B for the verbs-surface delta cursor.\n`,
|
||||
);
|
||||
execFileSync('bun', ['run', 'src/cli.ts', 'import', notes, '--no-embed'], {
|
||||
cwd: process.cwd(), env, stdio: 'ignore',
|
||||
});
|
||||
|
||||
// 3. Spawn the QUICKSTART surface — exactly the 7 protocol verbs.
|
||||
transport = new StdioClientTransport({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/cli.ts', 'serve', '--surface', 'verbs'],
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
});
|
||||
client = new Client({ name: 'gbrain-stdio-verbs-e2e', version: '1.0.0' }, { capabilities: {} });
|
||||
await client.connect(transport);
|
||||
connected = true;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) { try { await client.close(); } catch { /* best-effort */ } }
|
||||
if (transport) { try { await transport.close(); } catch { /* best-effort */ } }
|
||||
if (home) { try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
||||
});
|
||||
|
||||
test('tools/list advertises EXACTLY the 7 frozen verbs — nothing more, nothing less', async () => {
|
||||
expect(connected).toBe(true);
|
||||
const { tools } = await client!.listTools();
|
||||
const names = tools.map((t) => t.name).sort();
|
||||
expect(names).toEqual([...VERB_NAMES].sort());
|
||||
}, 30_000);
|
||||
|
||||
test('tools/call context_pack on an unknown entity → schema-valid empty pack, protocol_version 1', async () => {
|
||||
expect(connected).toBe(true);
|
||||
const res = await client!.callTool({ name: 'context_pack', arguments: { entities: 'nonexistent-entity' } });
|
||||
expect((res as { isError?: boolean }).isError).not.toBe(true);
|
||||
const pack = JSON.parse(textOf(res)) as {
|
||||
protocol_version: number;
|
||||
entities: string[];
|
||||
cards: unknown[];
|
||||
open_threads: unknown[];
|
||||
facts: unknown[];
|
||||
text: string;
|
||||
};
|
||||
expect(pack.protocol_version).toBe(1);
|
||||
// The handler echoes the CAPPED entity list even when nothing resolves.
|
||||
expect(pack.entities).toEqual(['nonexistent-entity']);
|
||||
expect(Array.isArray(pack.cards)).toBe(true);
|
||||
expect(pack.cards.length).toBe(0); // unknown entity → no card, no error
|
||||
expect(Array.isArray(pack.open_threads)).toBe(true);
|
||||
expect(Array.isArray(pack.facts)).toBe(true);
|
||||
expect(typeof pack.text).toBe('string');
|
||||
}, 30_000);
|
||||
|
||||
test('tools/call delta with an explicit epoch cursor → protocol_version 1 + has_more + next_cursor', async () => {
|
||||
expect(connected).toBe(true);
|
||||
const res = await client!.callTool({ name: 'delta', arguments: { since: '1970-01-01T00:00:00Z' } });
|
||||
expect((res as { isError?: boolean }).isError).not.toBe(true);
|
||||
const d = JSON.parse(textOf(res)) as {
|
||||
protocol_version: number;
|
||||
since: string;
|
||||
pages: Array<{ slug: string; title: string; updated_at: string }>;
|
||||
facts: unknown[];
|
||||
threads: unknown[];
|
||||
has_more: boolean;
|
||||
next_cursor: { since: string; slug: string };
|
||||
};
|
||||
expect(d.protocol_version).toBe(1);
|
||||
// `since` is NORMALIZED to ISO before it reaches rendering (red-team F4).
|
||||
expect(d.since).toBe('1970-01-01T00:00:00.000Z');
|
||||
expect(typeof d.has_more).toBe('boolean');
|
||||
expect(typeof d.next_cursor.since).toBe('string');
|
||||
expect(typeof d.next_cursor.slug).toBe('string');
|
||||
// From the epoch, both seeded pages are "changed since".
|
||||
const titles = d.pages.map((p) => p.title).join('\n');
|
||||
expect(titles).toContain(`${VERBS_MARKER} alpha`);
|
||||
expect(titles).toContain(`${VERBS_MARKER} beta`);
|
||||
}, 30_000);
|
||||
|
||||
test('hidden op stays fail-closed: tools/call list_pages returns the unknown_tool envelope', async () => {
|
||||
expect(connected).toBe(true);
|
||||
// list_pages exists in the full catalog but is NOT a verb — surface
|
||||
// enforcement must reject it at dispatch (codex c2), with the same
|
||||
// envelope as a truly unknown op so the surface doesn't leak names.
|
||||
const res = await client!.callTool({ name: 'list_pages', arguments: {} });
|
||||
expect((res as { isError?: boolean }).isError).toBe(true);
|
||||
const err = JSON.parse(textOf(res)) as { error: string; message: string };
|
||||
expect(err.error).toBe('unknown_tool');
|
||||
expect(err.message).toContain('list_pages');
|
||||
}, 30_000);
|
||||
|
||||
test('remember → delta ×2 with one session_id: the cursor advances (no page re-delivery)', async () => {
|
||||
expect(connected).toBe(true);
|
||||
const SESSION = 'verbs-e2e-session-1';
|
||||
|
||||
// Write through the protocol write verb — proves the verbs surface carries
|
||||
// writes over stdio and gives the first wake a fact to deliver.
|
||||
const remembered = await client!.callTool({
|
||||
name: 'remember',
|
||||
arguments: {
|
||||
fact: `${VERBS_MARKER} chose PGLite for the verbs-surface e2e`,
|
||||
provenance: 'e2e: serve-stdio-roundtrip',
|
||||
},
|
||||
});
|
||||
expect((remembered as { isError?: boolean }).isError).not.toBe(true);
|
||||
const rem = JSON.parse(textOf(remembered)) as { id: string; status: string; protocol_version: number };
|
||||
expect(rem.protocol_version).toBe(1);
|
||||
expect(['inserted', 'duplicate', 'superseded']).toContain(rem.status);
|
||||
expect(typeof rem.id).toBe('string');
|
||||
|
||||
// Wake 1: explicit epoch cursor + session_id — delivers every seeded page
|
||||
// and establishes the per-session keyset cursor server-side.
|
||||
const first = await client!.callTool({
|
||||
name: 'delta',
|
||||
arguments: { since: '1970-01-01T00:00:00Z', session_id: SESSION },
|
||||
});
|
||||
const d1 = JSON.parse(textOf(first)) as {
|
||||
protocol_version: number;
|
||||
pages: Array<{ slug: string }>;
|
||||
facts: Array<{ fact: string }>;
|
||||
has_more: boolean;
|
||||
};
|
||||
expect(d1.protocol_version).toBe(1);
|
||||
expect(d1.pages.length).toBeGreaterThanOrEqual(2); // the seeded pages
|
||||
expect(d1.has_more).toBe(false); // tiny brain: everything fits in one wake
|
||||
// The remembered fact (world visibility) rides the facts arm.
|
||||
expect(d1.facts.some((f) => f.fact.includes(VERBS_MARKER))).toBe(true);
|
||||
|
||||
// Wake 2: session_id ONLY — the stored cursor must exclude everything
|
||||
// wake 1 delivered (keyset advance over the real transport, red-team F2:
|
||||
// a delivered page never re-appears unless it changes).
|
||||
const second = await client!.callTool({ name: 'delta', arguments: { session_id: SESSION } });
|
||||
const d2 = JSON.parse(textOf(second)) as { protocol_version: number; pages: Array<{ slug: string }> };
|
||||
expect(d2.protocol_version).toBe(1);
|
||||
const delivered = new Set(d1.pages.map((p) => p.slug));
|
||||
for (const pg of d2.pages) expect(delivered.has(pg.slug)).toBe(false);
|
||||
expect(d2.pages.length).toBe(0); // nothing changed between wakes
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -25,12 +25,28 @@
|
||||
* The 200K-page validation is a documented MANUAL recipe in
|
||||
* docs/protocol/MEMORY_VERBS_v1.md — not CI-gated (seed time would dominate).
|
||||
*
|
||||
* v0.45.7 ambient recall (issue #1): the same corpus (seeded ONCE here — do
|
||||
* not re-seed in a sibling file) also gates the two boundary verbs the
|
||||
* protocol doc promises are "zero-LLM, sub-second": context_pack (8 standing
|
||||
* entities → assembleContextPack, the seam the `context_pack` op handler
|
||||
* calls) and delta (a ~150-page changed slice behind a cursor →
|
||||
* assembleDeltaContext). Each gate is p99 < 1000ms × the same multiplier.
|
||||
* No ratio guard on these: a pack is ~8 entity cards end-to-end and the card
|
||||
* itself is already ratio-guarded above — an O(N) regression in the shared
|
||||
* arms trips the card gate first.
|
||||
*
|
||||
* .slow.test.ts suffix keeps it out of the fast loop (`bun run test:slow`).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { buildEntityCard } from '../src/core/verbs/entity-card.ts';
|
||||
import {
|
||||
assembleContextPack,
|
||||
assembleDeltaContext,
|
||||
PACK_DEFAULT_MAX_ENTITIES,
|
||||
DELTA_PAGE_FETCH_LIMIT,
|
||||
} from '../src/core/context/turn-context.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
@@ -43,6 +59,13 @@ const MEASURED = 200;
|
||||
const TARGET_ENTITIES = 50; // pages the measured calls rotate over
|
||||
|
||||
const P99_BUDGET_MS = 100 * (Number(process.env.GBRAIN_PERF_BUDGET_MULTIPLIER) || 1);
|
||||
// v0.45.7 boundary verbs — MEMORY_VERBS_v1.md promises "zero-LLM, sub-second"
|
||||
// for context_pack and delta; same multiplier convention as the entity gate.
|
||||
const BOUNDARY_P99_BUDGET_MS = 1000 * (Number(process.env.GBRAIN_PERF_BUDGET_MULTIPLIER) || 1);
|
||||
const BOUNDARY_WARMUP = 10;
|
||||
const BOUNDARY_MEASURED = 100; // p99 index 98 — second-largest, not the raw max
|
||||
const DELTA_CHANGED_PAGES = 150; // realistic heartbeat slice (spec: ~50-200 changed)
|
||||
const DELTA_CHANGED_FACTS = 100;
|
||||
// entity p99 ≤ 100× max(getPage p50, 1ms) — see the calibration note above.
|
||||
// (100×, not 50×: at the 1ms getPage floor, 50× would cap p99 at 50ms — stricter
|
||||
// than the 100ms absolute budget — and tripped on fast runners where a p99 tail
|
||||
@@ -185,3 +208,114 @@ describe('entity card p99 latency gate', () => {
|
||||
expect(p99 / pageP50).toBeLessThanOrEqual(RATIO_CEILING);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
describe('context_pack p99 latency gate (v0.45.7 ambient recall)', () => {
|
||||
it(`pack p99 < ${BOUNDARY_P99_BUDGET_MS}ms with ${PACK_DEFAULT_MAX_ENTITIES} standing entities on ${PAGES} pages`, async () => {
|
||||
// 8 standing entities per call, rotated across the 50 targets and the
|
||||
// three resolution arms (alias / exact title / exact slug) so no single
|
||||
// card shape dominates the tail.
|
||||
const entitiesAt = (iter: number): string[] => {
|
||||
const out: string[] = [];
|
||||
for (let j = 0; j < PACK_DEFAULT_MAX_ENTITIES; j++) {
|
||||
const t = (iter * PACK_DEFAULT_MAX_ENTITIES + j) % TARGET_ENTITIES;
|
||||
out.push(
|
||||
j % 3 === 0 ? `tp${t}` : j % 3 === 1 ? `Target Person ${t}` : `people/target-person-${t}`,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Fresh session id per call: a pack fires at session START, so the
|
||||
// hot-memory cache (30s TTL, keyed by session) is cold in production —
|
||||
// reusing one id here would measure cache hits, not the promised path.
|
||||
for (let i = 0; i < BOUNDARY_WARMUP; i++) {
|
||||
await assembleContextPack(engine, {
|
||||
sourceId: 'default',
|
||||
entities: entitiesAt(i),
|
||||
sessionId: `pack-warm-${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
const samples: number[] = [];
|
||||
let lastCardCount = 0;
|
||||
for (let i = 0; i < BOUNDARY_MEASURED; i++) {
|
||||
const t0 = performance.now();
|
||||
const res = await assembleContextPack(engine, {
|
||||
sourceId: 'default',
|
||||
entities: entitiesAt(i),
|
||||
sessionId: `pack-${i}`,
|
||||
});
|
||||
samples.push(performance.now() - t0);
|
||||
lastCardCount = res.cards?.length ?? 0;
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
const p50 = percentile(samples, 50);
|
||||
const p99 = percentile(samples, 99);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[context-pack-perf] corpus=${PAGES}p+${LINKS}l+${ALIASES}a+${FACTS}f ` +
|
||||
`entities=${PACK_DEFAULT_MAX_ENTITIES} pack p50=${p50.toFixed(2)}ms p99=${p99.toFixed(2)}ms ` +
|
||||
`| budget=${BOUNDARY_P99_BUDGET_MS}ms`,
|
||||
);
|
||||
|
||||
// The gate must measure real work: every rotated name resolves, so all 8
|
||||
// cards build on every call — an empty pack passing the budget is a bug.
|
||||
expect(lastCardCount).toBe(PACK_DEFAULT_MAX_ENTITIES);
|
||||
expect(p99).toBeLessThan(BOUNDARY_P99_BUDGET_MS);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
describe('delta p99 latency gate (v0.45.7 ambient recall)', () => {
|
||||
it(`delta p99 < ${BOUNDARY_P99_BUDGET_MS}ms over a ${DELTA_CHANGED_PAGES}-page changed slice`, async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = (engine as any).db;
|
||||
// Realistic heartbeat slice: push 150 filler pages (+100 noise facts) one
|
||||
// hour ahead and set the cursor 30 minutes ahead — the changed slice sits
|
||||
// past the cursor, the other ~19,850 pages + ~39,900 facts stay behind it.
|
||||
await db.query(
|
||||
`UPDATE pages SET updated_at = NOW() + interval '1 hour'
|
||||
WHERE id IN (SELECT id FROM pages WHERE slug LIKE 'filler/%' ORDER BY slug LIMIT ${DELTA_CHANGED_PAGES})`,
|
||||
);
|
||||
await db.query(
|
||||
`UPDATE facts SET created_at = NOW() + interval '1 hour'
|
||||
WHERE id IN (SELECT id FROM facts WHERE entity_slug LIKE 'filler/%' ORDER BY id LIMIT ${DELTA_CHANGED_FACTS})`,
|
||||
);
|
||||
const since = new Date(Date.now() + 30 * 60 * 1000).toISOString();
|
||||
|
||||
for (let i = 0; i < BOUNDARY_WARMUP; i++) {
|
||||
await assembleDeltaContext(engine, { sourceId: 'default', since });
|
||||
}
|
||||
|
||||
const samples: number[] = [];
|
||||
let lastPages = 0;
|
||||
let lastOverflow = false;
|
||||
let lastFacts = 0;
|
||||
for (let i = 0; i < BOUNDARY_MEASURED; i++) {
|
||||
const t0 = performance.now();
|
||||
const res = await assembleDeltaContext(engine, { sourceId: 'default', since });
|
||||
samples.push(performance.now() - t0);
|
||||
lastPages = res.deltaPages?.length ?? 0;
|
||||
lastOverflow = res.deltaOverflow === true;
|
||||
lastFacts = res.facts?.length ?? 0;
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
const p50 = percentile(samples, 50);
|
||||
const p99 = percentile(samples, 99);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[delta-perf] corpus=${PAGES}p+${LINKS}l+${ALIASES}a+${FACTS}f ` +
|
||||
`changed=${DELTA_CHANGED_PAGES}p+${DELTA_CHANGED_FACTS}f delta p50=${p50.toFixed(2)}ms p99=${p99.toFixed(2)}ms ` +
|
||||
`| budget=${BOUNDARY_P99_BUDGET_MS}ms`,
|
||||
);
|
||||
|
||||
// Real-work guards: the 150-page slice overflows the 50-page fetch limit
|
||||
// (limit+1 probe → deltaOverflow) and the facts arm delivers the changed
|
||||
// facts — a delta that scanned nothing would pass any latency budget.
|
||||
expect(lastPages).toBe(DELTA_PAGE_FETCH_LIMIT);
|
||||
expect(lastOverflow).toBe(true);
|
||||
expect(lastFacts).toBeGreaterThan(0);
|
||||
expect(p99).toBeLessThan(BOUNDARY_P99_BUDGET_MS);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
+41
@@ -286,5 +286,46 @@
|
||||
"question": "What do we know about conformance {{marker}}?"
|
||||
},
|
||||
"requiresSynthesizeFlag": true
|
||||
},
|
||||
{
|
||||
"name": "context_pack returns a schema-valid bundle for unknown entities (empty, not an error)",
|
||||
"verb": "context_pack",
|
||||
"params": {
|
||||
"entities": "conformance-nonexistent-{{marker}}",
|
||||
"budget_tokens": 500
|
||||
},
|
||||
"validateSchema": true,
|
||||
"expect": [
|
||||
{
|
||||
"path": "protocol_version",
|
||||
"equals": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "delta with an explicit epoch since returns a schema-valid delta",
|
||||
"verb": "delta",
|
||||
"params": {
|
||||
"since": "1970-01-01T00:00:00Z",
|
||||
"budget_tokens": 500
|
||||
},
|
||||
"validateSchema": true,
|
||||
"expect": [
|
||||
{
|
||||
"path": "protocol_version",
|
||||
"equals": 1
|
||||
},
|
||||
{
|
||||
"path": "since",
|
||||
"equals": "1970-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "delta without since or session_id is invalid_params with a suggestion",
|
||||
"verb": "delta",
|
||||
"params": {},
|
||||
"expectErrorCode": "invalid_params",
|
||||
"expectSuggestion": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* MEMORY_VERBS v1 — surface-mode tests (Cathedral 1).
|
||||
*
|
||||
* - 'verbs' filters to EXACTLY the five protocol verbs
|
||||
* - 'verbs' filters to EXACTLY the seven protocol verbs
|
||||
* - 'full' is the identity (existing installs unchanged)
|
||||
* - dispatch-layer allowedOps is FAIL-CLOSED: a hidden op is uncallable
|
||||
* (unknown_tool), not merely unlisted [c2]
|
||||
@@ -44,7 +44,7 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('filterOpsForSurface', () => {
|
||||
it("'verbs' returns exactly the five protocol verbs", () => {
|
||||
it("'verbs' returns exactly the seven protocol verbs", () => {
|
||||
const names = filterOpsForSurface(operations, 'verbs').map(o => o.name).sort();
|
||||
expect(names).toEqual([...VERB_NAMES].sort());
|
||||
});
|
||||
|
||||
@@ -516,7 +516,8 @@ describe('conformance runner — negative self-test [F3]', () => {
|
||||
const honest = await runConformance(lyingClient((_v, b) => b), { marker: 'pos1' });
|
||||
const failures = honest.results.filter(r => r.status === 'fail');
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
}, 20_000); // v0.45.7: two full runConformance passes now exercise 7 verbs;
|
||||
// the default 5s budget flakes under the parallel shard runner (red-team F6).
|
||||
});
|
||||
|
||||
describe('fixture mirror + surface invariants', () => {
|
||||
@@ -525,9 +526,41 @@ describe('fixture mirror + surface invariants', () => {
|
||||
expect(onDisk).toEqual(JSON.parse(JSON.stringify(CONFORMANCE_CASES)));
|
||||
});
|
||||
|
||||
it('exactly five ops carry verb: true and they match VERB_NAMES', async () => {
|
||||
it('every VERB_NAMES entry (7 as of v0.45.7) carries verb: true and nothing else does', async () => {
|
||||
const { operations } = await import('../src/core/operations.ts');
|
||||
const verbs = operations.filter(o => o.verb === true).map(o => o.name).sort();
|
||||
expect(verbs).toEqual([...VERB_NAMES].sort());
|
||||
// An accidental verb addition/removal must be a LOUD, named failure —
|
||||
// the frozen set is 5 core + 2 additive (context_pack, delta).
|
||||
expect(VERB_NAMES.length).toBe(7);
|
||||
});
|
||||
|
||||
it('a pre-v0.45.7 five-verb endpoint still certifies (additive verbs skip, never fail)', async () => {
|
||||
const CORE = ['recall', 'remember', 'entity', 'synthesize', 'forget'];
|
||||
const fiveVerbClient: ConformanceClient = {
|
||||
listTools: async () =>
|
||||
CORE.map((name) => ({
|
||||
name,
|
||||
description: name === 'synthesize' ? '[EXPENSIVE / SLOW] cost-gated' : `MEMORY VERB (v1): ${name}`,
|
||||
})),
|
||||
callTool: async (name, params) => {
|
||||
const res = await dispatchToolCall(engine, name, params, {
|
||||
remote: true,
|
||||
takesHoldersAllowList: ['world'],
|
||||
sourceId: 'default',
|
||||
});
|
||||
return { isError: res.isError, text: res.content[0].text };
|
||||
},
|
||||
};
|
||||
const r = await runConformance(fiveVerbClient, { marker: `five-${Date.now()}` });
|
||||
// The additive verbs must appear ONLY as skips — never executed, never failed.
|
||||
const additive = r.results.filter((x) => x.verb === 'context_pack' || x.verb === 'delta');
|
||||
expect(additive.length).toBeGreaterThan(0);
|
||||
expect(additive.every((x) => x.status === 'skip')).toBe(true);
|
||||
// And no list-level advertising failure for them either.
|
||||
const advertFails = r.results.filter(
|
||||
(x) => x.name.startsWith('tools/list advertises') && x.status === 'fail',
|
||||
);
|
||||
expect(advertFails).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* v0.45.7 ambient recall (issue #1) — migration v126 (session_context_state).
|
||||
*
|
||||
* Pinned contracts:
|
||||
* 1. Migration v126 exists in the MIGRATIONS array with the canonical name
|
||||
* and is flagged idempotent.
|
||||
* 2. Table created cleanly via initSchema() on PGLite, with the exact column
|
||||
* set/types, client_id DEFAULT 'local', jsonb '[]' literal defaults, and
|
||||
* nullable last_wake_at.
|
||||
* 3. Composite PK (source_id, client_id, session_id) — rejects a duplicate
|
||||
* triple; same (source_id, session_id) under a DIFFERENT client_id
|
||||
* coexists (the eng-1B two-harnesses-one-source isolation property).
|
||||
* 4. session_context_state_updated_idx present for the updated_at prune scan.
|
||||
* 5. Upgrade path: drop the table, rewind the version ledger to 125, re-run
|
||||
* runMigrations — v126 alone recreates it, and the migrated shape matches
|
||||
* the fresh-bootstrap shape exactly (drift guard: migrate.ts DDL vs the
|
||||
* schema blob, same contract as test/e2e/schema-drift.test.ts pins for
|
||||
* Postgres).
|
||||
* 6. Re-running migrations afterwards is idempotent (0 applied).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { MIGRATIONS, LATEST_VERSION, runMigrations } from '../src/core/migrate.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* Capture the observable shape of session_context_state: ordered columns
|
||||
* (name/type/nullability/default), ordered PK columns, and index defs.
|
||||
* Used both for direct assertions and the fresh-vs-migrated drift guard.
|
||||
*/
|
||||
async function captureShape() {
|
||||
const columns = await engine.executeRaw<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: string | null;
|
||||
}>(
|
||||
`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'session_context_state'
|
||||
ORDER BY ordinal_position`,
|
||||
);
|
||||
const pk = await engine.executeRaw<{ column_name: string }>(
|
||||
`SELECT kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON kcu.constraint_name = tc.constraint_name
|
||||
AND kcu.table_name = tc.table_name
|
||||
WHERE tc.table_name = 'session_context_state'
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
ORDER BY kcu.ordinal_position`,
|
||||
);
|
||||
const indexes = await engine.executeRaw<{ indexname: string; indexdef: string }>(
|
||||
`SELECT indexname, indexdef FROM pg_indexes
|
||||
WHERE tablename = 'session_context_state'
|
||||
ORDER BY indexname`,
|
||||
);
|
||||
return { columns, pk: pk.map(r => r.column_name), indexes };
|
||||
}
|
||||
|
||||
describe('migration v126 — session_context_state', () => {
|
||||
test('v126 exists in MIGRATIONS with canonical name and idempotent flag', () => {
|
||||
const v126 = MIGRATIONS.find(m => m.version === 126);
|
||||
expect(v126).toBeDefined();
|
||||
expect(v126?.name).toBe('session_context_state');
|
||||
expect(v126?.idempotent).toBe(true);
|
||||
});
|
||||
|
||||
test('LATEST_VERSION >= 126', () => {
|
||||
expect(LATEST_VERSION).toBeGreaterThanOrEqual(126);
|
||||
});
|
||||
|
||||
test('table is created and queryable after initSchema()', async () => {
|
||||
const rows = await engine.executeRaw<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM session_context_state`,
|
||||
);
|
||||
expect(rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
test('table has expected columns with expected types and defaults', async () => {
|
||||
const { columns } = await captureShape();
|
||||
const byName = Object.fromEntries(columns.map(c => [c.column_name, c]));
|
||||
expect(Object.keys(byName).sort()).toEqual([
|
||||
'client_id',
|
||||
'last_wake_at',
|
||||
'session_id',
|
||||
'source_id',
|
||||
'standing_entities',
|
||||
'surfaced_slugs',
|
||||
'updated_at',
|
||||
]);
|
||||
expect(byName.source_id.data_type).toBe('text');
|
||||
expect(byName.client_id.data_type).toBe('text');
|
||||
expect(byName.session_id.data_type).toBe('text');
|
||||
expect(byName.standing_entities.data_type).toBe('jsonb');
|
||||
expect(byName.surfaced_slugs.data_type).toBe('jsonb');
|
||||
expect(byName.last_wake_at.data_type).toBe('timestamp with time zone');
|
||||
expect(byName.updated_at.data_type).toBe('timestamp with time zone');
|
||||
// last_wake_at is the only nullable column (no wake yet).
|
||||
expect(byName.source_id.is_nullable).toBe('NO');
|
||||
expect(byName.client_id.is_nullable).toBe('NO');
|
||||
expect(byName.session_id.is_nullable).toBe('NO');
|
||||
expect(byName.standing_entities.is_nullable).toBe('NO');
|
||||
expect(byName.surfaced_slugs.is_nullable).toBe('NO');
|
||||
expect(byName.last_wake_at.is_nullable).toBe('YES');
|
||||
expect(byName.updated_at.is_nullable).toBe('NO');
|
||||
// client_id defaults to the 'local' sentinel (CLI/hook path); jsonb
|
||||
// columns default to the '[]' DDL literal.
|
||||
expect(byName.client_id.column_default).toContain('local');
|
||||
expect(byName.standing_entities.column_default).toContain('[]');
|
||||
expect(byName.surfaced_slugs.column_default).toContain('[]');
|
||||
expect(byName.updated_at.column_default).toContain('now()');
|
||||
});
|
||||
|
||||
test('composite PK is (source_id, client_id, session_id) in that order', async () => {
|
||||
const { pk } = await captureShape();
|
||||
expect(pk).toEqual(['source_id', 'client_id', 'session_id']);
|
||||
});
|
||||
|
||||
test('PK rejects duplicate (source_id, client_id, session_id) triple', async () => {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO session_context_state (source_id, client_id, session_id)
|
||||
VALUES ('default', 'local', 'sess-dup')`,
|
||||
);
|
||||
let threw = false;
|
||||
try {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO session_context_state (source_id, client_id, session_id)
|
||||
VALUES ('default', 'local', 'sess-dup')`,
|
||||
);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
test('same (source_id, session_id) under a different client_id coexists', async () => {
|
||||
// eng 1B: two harnesses in one source must not stomp each other's cursor —
|
||||
// client_id is part of the key, so both rows insert cleanly.
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO session_context_state (source_id, client_id, session_id)
|
||||
VALUES ('default', 'harness-a', 'sess-shared'),
|
||||
('default', 'harness-b', 'sess-shared')`,
|
||||
);
|
||||
const rows = await engine.executeRaw<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM session_context_state
|
||||
WHERE source_id = 'default' AND session_id = 'sess-shared'`,
|
||||
);
|
||||
expect(rows[0].count).toBe(2);
|
||||
});
|
||||
|
||||
test('defaults fire on insert: client_id=local, jsonb=[], last_wake_at NULL', async () => {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO session_context_state (source_id, session_id)
|
||||
VALUES ('default', 'sess-defaults')`,
|
||||
);
|
||||
const rows = await engine.executeRaw<{
|
||||
client_id: string;
|
||||
standing_entities: unknown;
|
||||
surfaced_slugs: unknown;
|
||||
last_wake_at: string | null;
|
||||
updated_at: string | null;
|
||||
}>(
|
||||
`SELECT client_id, standing_entities, surfaced_slugs, last_wake_at, updated_at
|
||||
FROM session_context_state
|
||||
WHERE source_id = 'default' AND session_id = 'sess-defaults'`,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].client_id).toBe('local');
|
||||
// jsonb defaults round-trip as empty arrays (coerce for driver shape).
|
||||
const standing =
|
||||
typeof rows[0].standing_entities === 'string'
|
||||
? JSON.parse(rows[0].standing_entities)
|
||||
: rows[0].standing_entities;
|
||||
const surfaced =
|
||||
typeof rows[0].surfaced_slugs === 'string'
|
||||
? JSON.parse(rows[0].surfaced_slugs)
|
||||
: rows[0].surfaced_slugs;
|
||||
expect(standing).toEqual([]);
|
||||
expect(surfaced).toEqual([]);
|
||||
expect(rows[0].last_wake_at).toBeNull();
|
||||
expect(rows[0].updated_at).not.toBeNull();
|
||||
});
|
||||
|
||||
test('session_context_state_updated_idx index is created', async () => {
|
||||
const rows = await engine.executeRaw<{ indexname: string }>(
|
||||
`SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'session_context_state'
|
||||
AND indexname = 'session_context_state_updated_idx'`,
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
});
|
||||
|
||||
test('drop + rewind to v125 → runMigrations recreates the fresh-bootstrap shape', async () => {
|
||||
// Drift guard: capture the fresh-bootstrap shape (schema blob), then drop
|
||||
// the table, rewind the version ledger, and re-run migrations so v126's
|
||||
// DDL alone recreates it. The two shapes must match exactly — a divergence
|
||||
// means migrate.ts drifted from src/schema.sql / pglite-schema.ts.
|
||||
const fresh = await captureShape();
|
||||
expect(fresh.columns.length).toBe(7);
|
||||
|
||||
await engine.executeRaw(`DROP TABLE IF EXISTS session_context_state`);
|
||||
await engine.setConfig('version', '125');
|
||||
|
||||
const res = await runMigrations(engine);
|
||||
expect(res.applied).toBeGreaterThanOrEqual(1);
|
||||
expect(await engine.getConfig('version')).toBe(String(LATEST_VERSION));
|
||||
|
||||
const migrated = await captureShape();
|
||||
expect(migrated).toEqual(fresh);
|
||||
}, 30000);
|
||||
|
||||
test('re-running migrations after the rewind round-trip is idempotent (0 applied)', async () => {
|
||||
const res = await runMigrations(engine);
|
||||
expect(res.applied).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
test('source DDL pins the table, key shape, sentinel default, and index', () => {
|
||||
const v126 = MIGRATIONS.find(m => m.version === 126);
|
||||
expect(v126).toBeDefined();
|
||||
// v126 ships one engine-agnostic sql block (no sqlFor split) — both
|
||||
// engines run the same DDL.
|
||||
expect(v126?.sqlFor).toBeUndefined();
|
||||
expect(v126?.sql).toContain('CREATE TABLE IF NOT EXISTS session_context_state');
|
||||
expect(v126?.sql).toContain(`DEFAULT 'local'`);
|
||||
expect(v126?.sql).toContain('PRIMARY KEY (source_id, client_id, session_id)');
|
||||
expect(v126?.sql).toContain('session_context_state_updated_idx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user