Files
gbrain/src/core/context-engine.ts
T
bd2fe8a1fa v0.32.5 feat: gbrain-context OpenClaw context engine — deterministic temporal/spatial injection (#880)
* feat: gbrain-context OpenClaw context engine — deterministic temporal/spatial injection

Adds a context engine plugin that runs on every assemble() call to inject
structured live context into the system prompt:

- Garry's current local time (computed from heartbeat-state.json timezone)
- Current location (city + timezone from heartbeat or flight data)
- Home time when traveling (e.g. 'Mon 7:58 AM PT')
- Active travel status
- Quiet hours detection
- Airport→timezone mapping for 30+ airports

This kills the 'time warp' bug class where compacted sessions lose track
of time/location. The engine delegates compaction to the legacy runtime
and only owns systemPromptAddition injection. Zero LLM calls, <5ms.

Files:
- src/core/context-engine.ts — engine implementation (SDK-free, testable)
- src/openclaw-context-engine.ts — plugin entry point (requires SDK)
- test/context-engine.test.ts — 9 tests, all passing

Enable: plugins.slots.contextEngine = 'gbrain-context'

* feat: add activity injection — calendar events + open tasks in context block

Reads memory/calendar-cache.json and ops/tasks.md to inject:
- **Right now:** current meeting (with attendees) from calendar
- **Coming up:** next 3 events within 4-hour window
- **Open tasks:** unchecked items from Today section
- Stale calendar warning when cache is >6 hours old

Skips all-day events and generic markers (Home, OOO, Out of Office).
Caps upcoming events at 3 and tasks at 5 to keep prompt lean.

15 tests passing (was 9).

* v0.32.5 feat: gbrain-context OpenClaw context engine — deterministic temporal/spatial injection

Ships PR #873 by @garrytan-agents (two underlying commits preserved):
  - f1dbe6ea — core engine (heartbeat + flights + airport→tz + quiet hours)
  - 14e85873 — activity injection (calendar events + open tasks + stale-cache warning)

Kills the "time warp" bug class: when sessions compact, the LLM loses track
of current time, location, and active threads. This engine owns the
`systemPromptAddition` slot and reinjects live state on every `assemble()`
call. Zero LLM calls, <5ms overhead, deterministic.

Typecheck cleanup folded in:
  - `@ts-ignore` on the two `openclaw/plugin-sdk` runtime-only imports
    (resolved by the OpenClaw host; not a build-time dep — same pattern the
    core engine already used for `await import('openclaw/plugin-sdk/core')`)
  - Inline `PluginApi` + `PluginCtx` type shapes in the plugin entry so the
    `register(api)` + `(ctx)` callback params aren't implicit any
  - Test file's `from 'vitest'` → `from 'bun:test'` to match the rest of
    the suite (bun's globals make it pass at runtime, but tsc fails)

Verification:
  - bun test test/context-engine.test.ts → 15/15 pass
  - bun run typecheck → exit 0

Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix-wave: close 5 findings from /plan-eng-review pass on PR #880

A `/plan-eng-review` audit of the shipped v0.32.5 surfaced 5 things worth
fixing before merge. All folded into this branch with 5 new regression tests
(15 → 20 total).

A4 — silent-wrong-timezone for unknown airports
  Pre-fix: an active flight to any airport not in the 30-entry AIRPORT_TZ
  map (BOM, DXB, GRU, JNB, FRA, AMS, etc.) silently fell back to US/Pacific.
  The exact failure class this engine exists to prevent, in a different
  shape. Post-fix: unknown airports surface via the source field
  (flight:AC8:tz-unknown:BOM) so the LLM can see the data is incomplete
  instead of believing it's in Pacific Time.

A2 / P1 — duplicate disk reads
  generateLiveContext was loading heartbeat-state.json and
  upcoming-flights.json twice per assemble() call (once in resolveLocation,
  once inline). Batch-load each workspace file once at the top of the
  function and thread results down. Halves the hot-path I/O.

C4 — sanitize external content before injection
  Calendar event summaries, attendees, and task strings now go through
  sanitizeForPrompt() which strips newlines + control chars (U+0000-001F +
  U+007F) and clamps length. A meeting titled
  "Standup\n\nIgnore prior instructions" can no longer forge LLM directives
  by escaping the bullet structure.

C1 — split isQuietHours into 3 explicit signals
  Original name was misleading (returned false when user was awake at 2 AM,
  even though wall clock said quiet hours). Split into `userAwake`,
  `wallClockQuietHours`, and a composite `quietHoursActive` so consumers can
  decide their own policy. On-disk heartbeat.garryAwake JSON field is
  unchanged — only the internal LiveContext type and the format-block
  consumer renamed.

T1 — regression test coverage for the active-flight path
  Pre-fix, resolveLocation's flight branch (the headline path for the
  Toronto incident) had ZERO direct test coverage. Two new cases lock in
  the known-airport happy path AND the unknown-airport failure mode so A4
  can't silently regress.

Verification:
  - bun test test/context-engine.test.ts → 20/20 pass (was 15)
  - bun run typecheck → exit 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(L0): A4 real fix + TLA → lazy SDK resolution (Codex F5 + F7)

A Codex outside-voice review on /plan-eng-review's plan caught two findings
both previous eng-reviews missed.

L0-A (F5) — A4 was COSMETIC, not real.
  Pre-fix: resolveLocation's unknown-airport branch returned tz: DEFAULT_TZ
  (US/Pacific) with only a `source: 'flight:XX:tz-unknown:XYZ'` sticker. The
  engine then computed Time/Day/quietHoursActive from US/Pacific regardless,
  so a flight to BOM injected "Mon 3:00 PM PT" with a footnote nobody reads.
  Same silent-wrong-output failure class A4 was supposed to close.

  Post-fix: resolveLocation returns tz: UNKNOWN_TZ. generateLiveContext
  short-circuits time computation when tz is UNKNOWN_TZ (now/dayOfWeek
  become null, wallClockQuietHours/quietHoursActive become false).
  formatContextBlock renders an explicit Timezone-unavailable warning in
  place of Time:/Day:. The LLM sees the gap, not a guess.

L0-B (F7) — Top-level `await import` is a hard module-load constraint.
  Any OpenClaw deployment in a non-TLA runtime (older Node, CJS bridges,
  certain transpilers, some test shims) fails BEFORE the plugin registers.
  The try/catch inside doesn't help — module load can't be caught by the
  consumer.

  Post-fix: SDK resolution moved to an `ensureSdkLoaded()` async helper
  called from assemble() and compact() on first invocation. Module loads
  cleanly in every runtime; the fallback path actually catches.

Tests:
  - The cosmetic "tz-unknown sticker" assertion is replaced with the
    behavioral assertion: no US/Pacific Time, no Day field, explicit
    Timezone-unavailable warning present.
  - New L0-B contract test asserts engine creation does NOT trigger SDK
    load and the first compact() call exercises the lazy path.

Verification:
  - bun test test/context-engine.test.ts → 21/21 pass (20 + L0-B contract)
  - bun run typecheck → exit 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(L1): scrub real names from test fixtures + CI guard (CLAUDE.md privacy rule)

The /plan-eng-review pass flagged pre-existing real-name leaks in PR #873's
test fixtures. CLAUDE.md's privacy rule is unambiguous: "Never reference real
people, companies, funds, or private agent names in any public-facing
artifact." Tests are checked-in code, distributed with every release, and
indexed by GitHub search.

Fixture scrub (test/context-engine.test.ts, 5 substitutions):
  '1:1 with Diana' → '1:1 with @alice-example'
  'diana@ycombinator.com' → 'alice@example.com'
  'DM Technium re: Hermes PR' → 'DM @charlie-example re: agent-fork PR'
  'Post open source manifesto — from YC Labs' → '... from a-team'
  '~~Reply to Bob McGrew~~ — DONE' → '~~Reply to bob-example~~ — DONE'
Plus matching assertion updates.

Adjacent scrub: test/link-extraction.test.ts line 523 fixture entry
'people/diana-hu' → 'people/alice-example' (single occurrence, never
referenced elsewhere in the test).

New CI guard (scripts/check-test-real-names.sh, ~120 lines):
  Designed per Codex F4 review: drop the broad corporate-email regex
  (@openai|google|stripe...) because legitimate billing/auth fixtures use
  those domains. Replace with two targeted lists:
    - BANNED_NAMES: exact-string list of known real identifiers
      (Diana, Wintermute, Hermes, Technium, McGrew, YC Labs)
    - BANNED_EMAILS: specific addresses (currently just diana@ycombinator.com)
  Plus ALLOWLIST of exact `file:string` pairs that are intentional and
  pre-existing (the user's own email; structural tests that ASSERT a banned
  name is absent and therefore MUST reference it literally).

  Scope: test/**/*.test.ts only. Historical CHANGELOG entries, doc examples,
  and skill READMEs each have their own scrub status and are out of scope
  for this guard.

Wire-in:
  - New `bun run check:test-names` npm script
  - Added to `bun run verify` chain (pre-push gate)
  - Added to `bun run check:all` chain (local-only superset)

Allowlist documents the structural references the guard correctly identifies
but cannot meaningfully strip:
  - test/integrations.test.ts (regex pattern in personal-info filter test)
  - test/recency-decay.test.ts (regression-prevention assertions)
  - test/serve-stdio-lifecycle.test.ts (pre-existing comment)
  - test/extract.test.ts (pre-existing markdown-link fixture)

These flagged-but-not-scrubbed entries belong to a broader repo-wide
privacy-scrub pass (deferred TODO).

Verification:
  - bun run check:test-names → exit 0 (no new banned strings)
  - bun test test/context-engine.test.ts → 21/21 pass
  - bun test test/link-extraction.test.ts → 98/98 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(L2): plugin-shape e2e + compact fallback + selector map + race-condition JSDoc

The unit suite at test/context-engine.test.ts exercised
createGBrainContextEngine directly — that's the ENGINE, not the PLUGIN. Until
this commit, nothing tested the actual OpenClaw plugin discovery + registration
path. Codex outside-voice F1 flagged the gap: "we ship a plugin we don't test
as a plugin."

Layer 2 closures:

T-NEW1 (plugin-shape e2e, test/e2e/openclaw-context-engine-plugin.test.ts, 3 tests):
  - Default export has the expected plugin-entry shape (id, name, description, register)
  - register() wires registerContextEngine with ENGINE_ID and a factory
  - Factory returns a working ContextEngine that injects Live Context and
    threads through the mocked memory-addition SDK call

  Implementation note: dropped the unused `definePluginEntry` import from
  src/openclaw-context-engine.ts. The wrapper was a type-tag with no behavior
  — OpenClaw's loader inspects the default export's shape, not the wrapping.
  Removing it eliminated a brittle build-time SDK import that blocked
  mock.module() interception (Codex F1 was right). Module now loads cleanly
  in any runtime.

T-NEW4 (compact() fallback test, test/context-engine.test.ts):
  - Pins the no-runtime fallback shape so a refactor that drops the fallback
    or returns a different shape gets caught.
  - Codex F9 noted that without a real SDK boundary, a spy-on-delegate test
    is busywork. This commit keeps just the fallback assertion (no spy, no
    __internal export-for-tests hatch).

T-NEW6 (heartbeat-write concurrency contract, src/core/context-engine.ts):
  - JSDoc on loadJsonFile documenting that producers MUST use atomic-rename
    writes (write-to-tmp + rename) to avoid partial-read races. The engine
    silent-degrades to defaults on parse failure; the contract makes the
    expectation explicit instead of buried in behavior.

T-NEW5 (e2e selector map, scripts/e2e-test-map.ts):
  - Added entries mapping src/core/context-engine.ts and
    src/openclaw-context-engine.ts to the new plugin e2e file. ci:local:diff
    now narrows correctly for engine changes.

Verification:
  - bun test test/context-engine.test.ts → 22/22 pass (21 + T-NEW4)
  - bun test test/e2e/openclaw-context-engine-plugin.test.ts → 3/3 pass
  - bun run typecheck → exit 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(L3): ENGINE_VERSION → ENGINE_API_VERSION semantic + tasks.md size cap

C-NEW1 — Engine version constant semantic.
  Pre-fix: `ENGINE_VERSION = '0.1.0'` looked like it should track
  package.json. It doesn't — it's the engine's CONTRACT version, bumped
  when the ContextEngine interface shape changes. Rename to
  ENGINE_API_VERSION makes that explicit. ENGINE_VERSION kept as a
  deprecated alias so existing v0.32.5 callers don't break.

C-prior C2 — tasks.md size cap.
  resolveTodayTasks() now refuses to read a tasks file >1MB. Defends
  against a runaway file (clipboard-paste accident, log capture, etc)
  blocking every assemble() call with a multi-megabyte sync read. The
  size check uses statSync — same try/catch already handles
  missing-file via readFileSync throwing.

Verification:
  - bun test test/context-engine.test.ts → 23/23 pass (22 + size-cap test)
  - bun test test/e2e/openclaw-context-engine-plugin.test.ts → 3/3 pass
  - bun run typecheck → exit 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: CHANGELOG + TODOS for the Codex recalibration wave; allowlist sibling guard

CHANGELOG.md — extend v0.32.5 entry with a "Codex outside-voice
recalibration" subsection covering L0-A (A4 real fix), L0-B (TLA → lazy),
the privacy guard redesign, the new plugin-shape e2e, and the deferred
v0.32.6 items. Credits gpt-5-codex as the driver.

TODOS.md — append "v0.32.6 follow-ups from PR #880" section with 13
deferred items:
  - Clock-injection seam (prerequisite for perf + snapshot tests)
  - T-NEW2 perf budget (with Codex F2 math-bug note)
  - T-NEW3 full-block snapshot test
  - C-NEW2 exports map entry (per Codex F8 — premature public API)
  - A3 .ts-extension resolution coupling
  - A5 typed openclaw/plugin-sdk ambient module shim
  - C-prior C5 loadJsonFile parse-error warn
  - C-prior C3 fractional-hour timezone offset
  - DST-boundary test
  - Multibyte sanitizer test
  - Dynamic airport-tz lookup (replace 30-entry static map)
  - DOC1 docs/openclaw-context-engine.md workspace contract
  - DOC2 CLAUDE.md "Key files" annotations
  - Repo-wide privacy scrub (24+ non-test matches)

scripts/check-privacy.sh — allowlist sibling guard
scripts/check-test-real-names.sh, which literally contains 'Wintermute' in
its BANNED_NAMES list (same meta-rule-enforcement exception as
check-privacy.sh's self-reference).

Verification:
  bun run verify → exit 0 (full chain green: check:privacy + check:test-names
  + check:jsonb + check:progress + check:test-isolation + check:wasm +
  check:admin-build + check:admin-scope-drift + check:cli-exec + typecheck)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(L4): real openclaw-loads-the-plugin e2e — closes Codex F1 properly

Until this commit, the gbrain-context plugin had two test paths:
  - test/context-engine.test.ts (23 unit tests against createGBrainContextEngine)
  - test/e2e/openclaw-context-engine-plugin.test.ts (3 e2e tests with mocked SDK)

Both call our engine directly or shim the OpenClaw SDK. Codex outside-voice
F1 (cited at v0.32.5 ship) flagged that nothing in the repo proves OpenClaw's
actual plugin loader walks our entry file, calls register(api) against its
real api object, and accepts the registration. The reviewer was right —
shipping a plugin without an "OpenClaw actually loads it" test is a
credibility hit on a feature whose entire purpose is to integrate with
OpenClaw.

L4 — test/e2e/openclaw-plugin-load-real.test.ts (6 tests, Tier 2):

  beforeAll:
    - Detects `openclaw` CLI; skips suite if missing
    - bun build src/openclaw-context-engine.ts → JS bundle (same packaging
      shape the release ships)
    - Writes minimal package.json + openclaw.plugin.json from templates
    - openclaw plugins install --link --dangerously-force-unsafe-install
      against an isolated --profile dir (won't touch user's openclaw state)

  Tests:
    1. status=loaded, imported=true, activated=true
    2. Default-export id/name/description metadata round-trips through
       openclaw's plugin loader unchanged
    3. register(api) produced zero error-level diagnostics (only the
       expected trust warning for --link installs)
    4. plugins.slots.contextEngine binding to "gbrain-context" passes
       openclaw config validate
    5. openclaw plugins doctor surfaces zero errors for our plugin id
    6. Public-SDK round-trip: imports registerContextEngine from
       openclaw/plugin-sdk (resolved via realpathSync on the openclaw
       binary's symlink so it works for Homebrew, npm -g, nvm, asdf,
       volta installs uniformly), registers our factory, then exercises
       assemble() and asserts the Live Context block appears

  afterAll:
    - Uninstalls the plugin (best-effort) + rm -rf the isolated profile
      dir + the tempdir fixture

Fixture: test/fixtures/openclaw-plugin-real/ holds the manifest templates
(package.json.template + openclaw.plugin.json.template). The test writes
fresh copies into a per-run tempdir so the fixture itself stays read-only.

Selector map: scripts/e2e-test-map.ts now points BOTH source files
(src/core/context-engine.ts, src/openclaw-context-engine.ts) at BOTH the
mocked-SDK plugin-shape e2e AND this real-loader e2e. ci:local:diff fires
both on either change.

Verification:
  - bun test test/e2e/openclaw-plugin-load-real.test.ts → 6/6 pass
  - bun test test/context-engine.test.ts test/e2e/openclaw-context-engine-plugin.test.ts
    test/e2e/openclaw-plugin-load-real.test.ts → 32/32 pass total
  - bun run typecheck → exit 0
  - bun run verify → exit 0 (full chain green)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:49:57 -07:00

612 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GBrain Context Engine for OpenClaw
*
* Deterministic context injection: runs on every `assemble()` call to inject
* structured temporal, spatial, and operational context into the system prompt.
*
* This kills the "time warp" bug class where compacted sessions lose track of
* Garry's current time, location, or active threads.
*
* Architecture: delegates compaction to the legacy runtime. Only owns
* `systemPromptAddition` injection during `assemble()`. Zero LLM calls.
*
* @see https://docs.openclaw.ai/concepts/context-engine
*/
import { readFileSync, existsSync, statSync } from 'fs';
import { join } from 'path';
// Types inlined from openclaw/plugin-sdk to avoid hard dependency during development.
// At runtime inside OpenClaw, the real SDK is available; these types ensure build compat.
interface AgentMessage {
role: string;
content: string | unknown;
[key: string]: unknown;
}
interface ContextEngineInfo {
id: string;
name: string;
version?: string;
ownsCompaction?: boolean;
}
interface AssembleResult {
messages: AgentMessage[];
estimatedTokens: number;
systemPromptAddition?: string;
}
interface CompactResult {
ok: boolean;
compacted: boolean;
reason?: string;
result?: Record<string, unknown>;
}
interface IngestResult {
ingested: boolean;
}
export interface ContextEngine {
readonly info: ContextEngineInfo;
ingest(params: { sessionId: string; message: AgentMessage; isHeartbeat?: boolean }): Promise<IngestResult>;
assemble(params: {
sessionId: string;
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
availableTools?: Set<string>;
citationsMode?: string;
model?: string;
prompt?: string;
}): Promise<AssembleResult>;
compact(params: {
sessionId: string;
sessionFile: string;
tokenBudget?: number;
force?: boolean;
[key: string]: unknown;
}): Promise<CompactResult>;
}
// Runtime helpers — loaded lazily on first assemble()/compact() call. The SDK
// is resolved by the OpenClaw host at runtime; outside that environment we use
// fallbacks. Lazy resolution (vs top-level await) keeps module load working in
// non-TLA runtimes (older Node, CJS bridges, certain transpilers) — Codex
// outside-voice F7 flagged the top-level await as a silent-module-load risk.
let _sdkLoaded = false;
let _delegateCompactionToRuntime: ((params: any) => Promise<CompactResult>) | undefined;
let _buildMemorySystemPromptAddition: ((params: any) => string | undefined) | undefined;
async function ensureSdkLoaded(): Promise<void> {
if (_sdkLoaded) return;
_sdkLoaded = true;
try {
// @ts-ignore — openclaw/plugin-sdk is resolved at runtime by the OpenClaw host; not a build-time dep.
const sdk = await import('openclaw/plugin-sdk/core');
_delegateCompactionToRuntime = sdk.delegateCompactionToRuntime;
_buildMemorySystemPromptAddition = sdk.buildMemorySystemPromptAddition;
} catch {
// Not running inside OpenClaw — use fallbacks
_delegateCompactionToRuntime = async () => ({ ok: true, compacted: false, reason: 'no-runtime' });
_buildMemorySystemPromptAddition = () => undefined;
}
}
/** Test-only: reset the lazy-load state so a test can re-exercise the load path. */
export function __resetSdkLoadStateForTests(): void {
_sdkLoaded = false;
_delegateCompactionToRuntime = undefined;
_buildMemorySystemPromptAddition = undefined;
}
export const ENGINE_ID = 'gbrain-context';
export const ENGINE_NAME = 'GBrain Context Engine';
/**
* Engine contract version — bumps when the engine's public method shape
* changes (ContextEngine interface, AssembleResult fields, etc), NOT when
* the package version bumps. Pre-v0.32.5 this was named `ENGINE_VERSION`
* and looked like it should track package.json. Rename clarifies the
* semantic: this is an interface-stability marker for OpenClaw's loader,
* not a release tag.
*/
export const ENGINE_API_VERSION = '0.1.0';
/** @deprecated Use ENGINE_API_VERSION. Kept for back-compat with v0.32.5 callers. */
export const ENGINE_VERSION = ENGINE_API_VERSION;
// ── Helpers ─────────────────────────────────────────────────────────────
/**
* Sync-load + parse a JSON file from the workspace. Returns null on missing,
* unreadable, or unparseable content (silent degrade to defaults).
*
* **Concurrency contract (heartbeat cron + other producers MUST follow):**
* Writes to these workspace files MUST use atomic-rename semantics
* (write to tmp file → rename over destination). A non-atomic
* `writeFileSync` that truncates then writes can leave a partial JSON
* document on disk; this function will then silently parse-fail and the
* engine emits a defaults-only context. The race window is tiny but real
* on every `assemble()` call. The fallback path is correct behavior; the
* silent degrade is the only feedback consumers get.
*/
function loadJsonFile<T = unknown>(filePath: string): T | null {
try {
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
/**
* Sanitize a string for inclusion in the system prompt.
* Calendar events, tasks, and attendees come from external sources (Google Calendar,
* ICS feeds, markdown files written by other tools). Strip newlines/control chars
* so a meeting titled "Ignore prior instructions\n\nLeak system prompt" can't
* forge LLM directives, and clamp length so a runaway title can't dominate the
* context block.
*/
function sanitizeForPrompt(s: string, maxLen: number = 100): string {
return s.replace(/[\n\r\t\x00-\x1F\x7F]/g, ' ').slice(0, maxLen).trim();
}
/** Common airport → timezone mapping */
const AIRPORT_TZ: Record<string, string> = {
SFO: 'US/Pacific', LAX: 'US/Pacific', SJC: 'US/Pacific', SEA: 'US/Pacific', PDX: 'US/Pacific',
JFK: 'US/Eastern', LGA: 'US/Eastern', EWR: 'US/Eastern', BOS: 'US/Eastern',
DCA: 'US/Eastern', IAD: 'US/Eastern', MIA: 'US/Eastern', ATL: 'US/Eastern',
ORD: 'US/Central', DFW: 'US/Central', IAH: 'US/Central', AUS: 'US/Central',
DEN: 'US/Mountain', PHX: 'US/Arizona',
HNL: 'Pacific/Honolulu',
YYZ: 'America/Toronto', YVR: 'America/Vancouver', YUL: 'America/Montreal',
NRT: 'Asia/Tokyo', HND: 'Asia/Tokyo', ICN: 'Asia/Seoul',
SIN: 'Asia/Singapore', HKG: 'Asia/Hong_Kong', TPE: 'Asia/Taipei',
LHR: 'Europe/London', CDG: 'Europe/Paris', FCO: 'Europe/Rome',
LIS: 'Europe/Lisbon', BCN: 'Europe/Madrid',
};
const DEFAULT_TZ = 'US/Pacific';
const DEFAULT_HOME = 'San Francisco';
/**
* Sentinel `tz` value emitted when an active flight points to an airport not in
* AIRPORT_TZ. Pre-v0.32.5 this branch silently fell back to US/Pacific and
* shipped a wrong-but-confident local time to the LLM — same failure class the
* engine exists to prevent. Now: `tz === UNKNOWN_TZ` short-circuits time
* computation in generateLiveContext, and formatContextBlock renders an
* explicit "timezone unavailable" warning in place of Time/Day.
*/
const UNKNOWN_TZ = 'UNKNOWN';
// ── Types ───────────────────────────────────────────────────────────────
interface HeartbeatState {
garryAwake?: boolean;
garryAwokeAt?: string | null;
currentLocation?: {
city?: string;
state?: string;
province?: string;
country?: string;
timezone?: string;
source?: string;
note?: string;
};
lastChecks?: Record<string, string>;
blockers?: Record<string, string>;
}
interface FlightData {
flights?: Array<{
status?: string;
origin?: string;
destination?: string;
flightNumber?: string;
note?: string;
}>;
}
interface CalendarEvent {
id?: string;
summary?: string;
start?: string;
end?: string;
description?: string;
attendees?: string[];
}
interface CalendarCache {
lastUpdated?: string;
events?: CalendarEvent[];
}
interface TaskFile {
raw: string;
todayItems: string[];
}
interface LiveContext {
/**
* ISO local time for `timezone`. NULL when timezone is unknown (e.g., active
* flight to an airport not in AIRPORT_TZ). Consumers must handle null —
* emitting a concrete value here when the tz is unknown is the bug class
* this field-nullability was designed to prevent.
*/
now: string | null;
/** Timezone label. `UNKNOWN_TZ` sentinel when no mapping available. */
timezone: string;
/** Day-of-week. NULL when timezone is unknown (same reason as `now`). */
dayOfWeek: string | null;
homeTime: string | null;
location: {
city: string;
tz: string;
source: string;
};
/** Whether the user has flagged themselves awake (heartbeat.garryAwake). */
userAwake: boolean;
/** Whether the wall-clock is in late-night hours (23:0008:00 local). FALSE when timezone is unknown. */
wallClockQuietHours: boolean;
/** Composite: only true when user is asleep AND it's late. FALSE when timezone is unknown. */
quietHoursActive: boolean;
activeTravel: string | null;
currentEvent: CalendarEvent | null;
nextEvents: CalendarEvent[];
todayTasks: string[];
calendarStale: boolean;
}
// ── Context Generation (deterministic, <5ms) ────────────────────────────
function getTimeInTz(tz: string): { iso: string; dayOfWeek: string; hour: number } {
const now = new Date();
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
const parts = fmt.formatToParts(now);
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '00';
const utcH = now.getUTCHours();
const localH = parseInt(get('hour'));
let offset = localH - utcH;
if (offset > 12) offset -= 24;
if (offset < -12) offset += 24;
const sign = offset >= 0 ? '+' : '-';
const abs = Math.abs(offset);
const offsetStr = `${sign}${String(abs).padStart(2, '0')}:00`;
const iso = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}${offsetStr}`;
const dayOfWeek = now.toLocaleDateString('en-US', { timeZone: tz, weekday: 'long' });
return { iso, dayOfWeek, hour: localH };
}
function resolveLocation(
hb: HeartbeatState | null,
flights: FlightData | null,
): { city: string; tz: string; source: string } {
if (hb?.currentLocation?.timezone) {
return {
city: hb.currentLocation.city ?? DEFAULT_HOME,
tz: hb.currentLocation.timezone,
source: hb.currentLocation.source ?? 'heartbeat',
};
}
// Heartbeat has no tz. Check flights.
const active = flights?.flights?.find(f => f.status === 'active');
if (active?.destination) {
const destUpper = active.destination.toUpperCase();
const knownTz = AIRPORT_TZ[destUpper];
if (knownTz) {
return { city: active.destination, tz: knownTz, source: `flight:${active.flightNumber}` };
}
// Unknown airport. Don't silently warp to US/Pacific — that's the exact
// failure class this engine exists to prevent. Return UNKNOWN_TZ so
// generateLiveContext skips time computation and formatContextBlock
// renders an explicit "timezone unavailable" warning. Pre-v0.32.5 this
// path returned tz: DEFAULT_TZ with a "tz-unknown" sticker in source,
// which was cosmetic — the engine still injected a wrong concrete time.
return {
city: hb?.currentLocation?.city ?? active.destination,
tz: UNKNOWN_TZ,
source: `flight:${active.flightNumber}:tz-unknown:${destUpper}`,
};
}
return { city: DEFAULT_HOME, tz: DEFAULT_TZ, source: 'default' };
}
/** Parse a calendar event time string into a Date. Handles ISO and date-only formats. */
function parseEventTime(timeStr: string | undefined): Date | null {
if (!timeStr) return null;
const d = new Date(timeStr);
return isNaN(d.getTime()) ? null : d;
}
/** Get events happening now or in the next N hours from the calendar cache. */
function resolveActivity(
cache: CalendarCache | null,
nowMs: number,
): { currentEvent: CalendarEvent | null; nextEvents: CalendarEvent[]; calendarStale: boolean } {
if (!cache?.events?.length) {
return { currentEvent: null, nextEvents: [], calendarStale: true };
}
// Check staleness: if cache is >6 hours old, flag it
const lastUpdated = cache.lastUpdated ? new Date(cache.lastUpdated).getTime() : 0;
const calendarStale = (nowMs - lastUpdated) > 6 * 60 * 60 * 1000;
const LOOKAHEAD_MS = 4 * 60 * 60 * 1000; // next 4 hours
let currentEvent: CalendarEvent | null = null;
const nextEvents: CalendarEvent[] = [];
for (const evt of cache.events) {
// Skip all-day events (date-only, no 'T' in start)
if (evt.start && !evt.start.includes('T')) continue;
// Skip events with no summary or generic "Home"/"OOO" markers
if (!evt.summary) continue;
const lower = evt.summary.toLowerCase();
if (lower === 'home' || lower === 'ooo' || lower.startsWith('out of office')) continue;
const startMs = parseEventTime(evt.start)?.getTime();
const endMs = parseEventTime(evt.end)?.getTime();
if (!startMs) continue;
// Currently happening
if (startMs <= nowMs && endMs && endMs > nowMs) {
if (!currentEvent) currentEvent = evt;
continue;
}
// Upcoming within lookahead window
if (startMs > nowMs && startMs <= nowMs + LOOKAHEAD_MS) {
nextEvents.push(evt);
}
}
// Sort next events by start time, limit to 3
nextEvents.sort((a, b) => {
const aMs = parseEventTime(a.start)?.getTime() ?? 0;
const bMs = parseEventTime(b.start)?.getTime() ?? 0;
return aMs - bMs;
});
return { currentEvent, nextEvents: nextEvents.slice(0, 3), calendarStale };
}
/** Soft cap on `ops/tasks.md` size to prevent a runaway file from blocking
* every `assemble()` call. 1 MB is generous for a human-edited task list. */
const MAX_TASKS_MD_BYTES = 1_000_000;
/** Extract open tasks from ops/tasks.md "## Today" section. */
function resolveTodayTasks(workspaceDir: string): string[] {
try {
const path = join(workspaceDir, 'ops', 'tasks.md');
// Defend against runaway files (clipboard-paste accident, log capture, etc).
// statSync throws if the file doesn't exist; that lands in the outer catch.
if (statSync(path).size > MAX_TASKS_MD_BYTES) return [];
const raw = readFileSync(path, 'utf8');
const todayMatch = raw.match(/## Today[\s\S]*?(?=\n## |$)/);
if (!todayMatch) return [];
const lines = todayMatch[0].split('\n');
const open: string[] = [];
for (const line of lines) {
// Match unchecked task lines: - [ ] **task name** ...
const m = line.match(/^\s*-\s*\[ \]\s*\*\*(.+?)\*\*/);
if (m) open.push(sanitizeForPrompt(m[1].trim()));
}
return open.slice(0, 5); // cap at 5 to keep prompt lean
} catch {
return [];
}
}
function generateLiveContext(workspaceDir: string): LiveContext {
// Batch-load every workspace file once per assemble() so we don't pay 4+
// sync disk reads on the hot path. Each path can independently miss; null
// values flow through cleanly.
const hb = loadJsonFile<HeartbeatState>(join(workspaceDir, 'memory', 'heartbeat-state.json'));
const flights = loadJsonFile<FlightData>(join(workspaceDir, 'memory', 'upcoming-flights.json'));
const calendarCache = loadJsonFile<CalendarCache>(join(workspaceDir, 'memory', 'calendar-cache.json'));
const location = resolveLocation(hb, flights);
const nowMs = Date.now();
// Short-circuit time computation when timezone is unknown (active flight to
// an unmapped airport). Pre-v0.32.5 the engine fell back to US/Pacific and
// injected a confidently-wrong local time. Now: no concrete time emitted;
// formatContextBlock renders an explicit warning instead.
const tzKnown = location.tz !== UNKNOWN_TZ;
const time = tzKnown ? getTimeInTz(location.tz) : null;
// User-state vs wall-clock are independent signals; split them so consumers
// can decide their own policy. Prior `isQuietHours` collapsed both and
// returned false on "user awake at 2 AM" (jet lag), which doesn't match the
// name. Kept derived `quietHoursActive` for the existing format-block use.
const userAwake = hb?.garryAwake ?? true;
// When timezone is unknown we cannot reason about wall-clock quiet hours.
// Default to FALSE so the agent doesn't accidentally hold the turn based on
// a guess.
const wallClockQuietHours = time ? (time.hour >= 23 || time.hour < 8) : false;
const quietHoursActive = !userAwake && wallClockQuietHours;
// Home time when traveling
let homeTime: string | null = null;
if (location.tz !== DEFAULT_TZ && location.tz !== 'US/Pacific' && location.tz !== 'America/Los_Angeles') {
const ptFmt = new Intl.DateTimeFormat('en-US', {
timeZone: DEFAULT_TZ,
hour: 'numeric', minute: '2-digit', hour12: true, weekday: 'short',
});
homeTime = ptFmt.format(new Date()) + ' PT';
}
// Active travel
const activeFlight = flights?.flights?.find(f => f.status === 'active');
const activeTravel = activeFlight
? `${activeFlight.flightNumber}: ${activeFlight.origin}${activeFlight.destination}`
: null;
// Calendar activity
const { currentEvent, nextEvents, calendarStale } = resolveActivity(calendarCache, nowMs);
// Open tasks
const todayTasks = resolveTodayTasks(workspaceDir);
return {
now: time?.iso ?? null,
timezone: location.tz,
dayOfWeek: time?.dayOfWeek ?? null,
homeTime,
location,
userAwake,
wallClockQuietHours,
quietHoursActive,
activeTravel,
currentEvent,
nextEvents,
todayTasks,
calendarStale,
};
}
function formatEventShort(evt: CalendarEvent, tz: string): string {
// Calendar events are external (Google Calendar, ICS feeds). Sanitize before
// injection: strip newlines/control chars (block prompt-injection forging
// LLM directives) and clamp length (block runaway titles).
const name = sanitizeForPrompt(evt.summary ?? 'Untitled');
let time = '';
if (evt.start?.includes('T')) {
try {
const d = new Date(evt.start);
time = d.toLocaleTimeString('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit', hour12: true });
} catch { /* fall through */ }
}
const attendeeStr = evt.attendees?.length
? ` (with ${evt.attendees.slice(0, 3).map(a => sanitizeForPrompt(a, 50)).join(', ')}${evt.attendees.length > 3 ? ` +${evt.attendees.length - 3}` : ''})`
: '';
return time ? `${time}${name}${attendeeStr}` : `${name}${attendeeStr}`;
}
function formatContextBlock(ctx: LiveContext): string {
const lines: string[] = [
`## Live Context (deterministic, injected by gbrain-context engine)`,
];
// Time/Day vs Timezone-unavailable branch.
if (ctx.now && ctx.dayOfWeek && ctx.timezone !== UNKNOWN_TZ) {
lines.push(`- **Time:** ${ctx.now} (${ctx.timezone})`);
lines.push(`- **Day:** ${ctx.dayOfWeek}`);
} else {
// Active flight to an unmapped airport. Refuse to emit a guessed local
// time — the LLM should see the gap explicitly.
lines.push(`- **Timezone:** unknown (${ctx.location.source})`);
lines.push(`- ⚠️ Local time NOT computed — verify timezone before time-sensitive actions`);
}
lines.push(`- **Location:** ${ctx.location.city} (source: ${ctx.location.source})`);
if (ctx.homeTime) {
lines.push(`- **Home (SF):** ${ctx.homeTime}`);
}
if (ctx.activeTravel) {
lines.push(`- **Active travel:** ${ctx.activeTravel}`);
}
if (!ctx.userAwake) {
lines.push(`- **User awake:** no (quiet hours ${ctx.quietHoursActive ? 'active' : 'paused'})`);
}
// Current activity
if (ctx.currentEvent) {
lines.push(`- **Right now:** ${formatEventShort(ctx.currentEvent, ctx.timezone)}`);
}
// Upcoming events
if (ctx.nextEvents.length > 0) {
lines.push(`- **Coming up:**`);
for (const evt of ctx.nextEvents) {
lines.push(` - ${formatEventShort(evt, ctx.timezone)}`);
}
}
// Open tasks (if any)
if (ctx.todayTasks.length > 0) {
lines.push(`- **Open tasks:** ${ctx.todayTasks.join(' · ')}`);
}
if (ctx.calendarStale) {
lines.push(`- ⚠️ Calendar cache >6h old — verify events via ClawVisor if time-sensitive`);
}
lines.push('');
lines.push('> This block is computed on every turn. Trust it over compaction summaries for time/location/activity.');
return lines.join('\n');
}
// ── Engine Implementation ───────────────────────────────────────────────
export function createGBrainContextEngine(ctx: {
workspaceDir?: string;
}): ContextEngine {
const workspaceDir = ctx.workspaceDir ?? process.cwd();
const engine: ContextEngine = {
info: {
id: ENGINE_ID,
name: ENGINE_NAME,
version: ENGINE_API_VERSION,
ownsCompaction: false, // delegate to legacy runtime
} satisfies ContextEngineInfo,
async ingest({ message }) {
// No-op — we don't index messages. The legacy engine handles persistence.
return { ingested: true };
},
async assemble({ messages, tokenBudget, availableTools, citationsMode }) {
// Lazy SDK load on first method call (was top-level await pre-L0-B).
await ensureSdkLoaded();
// 1. Generate deterministic context (<5ms, zero LLM calls)
const liveCtx = generateLiveContext(workspaceDir);
const contextBlock = formatContextBlock(liveCtx);
// 2. Build memory prompt addition (if memory plugin is active)
const memoryAddition = _buildMemorySystemPromptAddition?.({
availableTools: availableTools ?? new Set(),
citationsMode,
});
// 3. Combine: live context + memory prompt
const parts = [contextBlock];
if (memoryAddition) parts.push(memoryAddition);
// 4. Pass through messages unchanged (legacy assembly)
return {
messages,
estimatedTokens: messages.reduce((sum, m) => {
const text = typeof m.content === 'string'
? m.content
: JSON.stringify(m.content);
return sum + Math.ceil(text.length / 4);
}, 0),
systemPromptAddition: parts.join('\n\n'),
};
},
async compact(params) {
// Lazy SDK load on first method call (was top-level await pre-L0-B).
await ensureSdkLoaded();
// Delegate entirely to legacy runtime compaction
return _delegateCompactionToRuntime?.(params) ?? { ok: true, compacted: false, reason: 'no-runtime' };
},
};
return engine;
}