This repository is PUBLIC on GitHub. Every commit is visible to the world.
- NEVER commit personal data — no names, emails, addresses, phone numbers, account IDs, or user identifiers
- NEVER commit API keys, tokens, or secrets — even in comments or examples
- NEVER commit memory content — the
facts/,entities/,corrections/,questions/,state/directories contain user memories and must NEVER be committed - NEVER commit IDENTITY.md or profile.md — these contain personal behavioral profiles
- NEVER commit
.envfiles or any file containing credentials - NEVER reference specific users, their preferences, or their data in code comments or commit messages
- Config examples must use placeholders —
${OPENAI_API_KEY}, not actual keys - Test data must be synthetic — never use real conversation data in tests
- Source code (
src/,scripts/) - Package manifests (
package.json,tsconfig.json,tsup.config.ts) - Plugin manifest (
openclaw.plugin.json) - Documentation (
README.md) - Build configuration
.gitignore- This
CLAUDE.mdfile
Ships enabled by default since issue #567 PR 4/5 (previously default-off). Operators who want to opt out must set procedural.enabled: false (nested procedural object). Agents should read docs/procedural-memory.md and the README Configuration table for the full threshold defaults and the opt-out path.
git diff --cachedcontains NO personal information- No hardcoded API keys, URLs with tokens, or credentials
- No references to specific users or their data
packages/remnic-core/src/
│
│ ── Core lifecycle ──────────────────────────────────────
├── index.ts # Plugin entry point, hook registration
├── config.ts # Config parsing with defaults
├── types.ts # TypeScript interfaces
├── logger.ts # Logging wrapper
├── orchestrator.ts # Core memory coordination
├── storage.ts # File I/O for memories
├── buffer.ts # Smart turn buffering
├── lifecycle.ts # Session and service lifecycle management
├── bootstrap.ts # Plugin bootstrap / init sequence
│
│ ── Extraction & scoring ────────────────────────────────
├── extraction.ts # GPT-5.2 extraction engine
├── extraction-judge.ts # LLM-as-judge fact-worthiness gate
├── importance.ts # Importance scoring
├── calibration.ts # Score calibration helpers
├── topics.ts # Topic extraction
│
│ ── Chunking & storage format ───────────────────────────
├── chunking.ts # Recursive large-content chunking
├── semantic-chunking.ts # Topic-boundary chunking (embedding-based)
├── page-versioning.ts # Snapshot-based version history for memory files
├── citations.ts # OAI-mem-citation block generation
│
│ ── Recall & retrieval ──────────────────────────────────
├── qmd.ts # QMD search client
├── qmd-recall-cache.ts # Recall result caching
├── retrieval.ts # Primary retrieval orchestration
├── recall-audit.ts # Recall audit trail
├── recall-mmr.ts # Maximal marginal relevance diversification
├── recall-qos.ts # Recall quality-of-service enforcement
├── recall-query-policy.ts # Query rewriting / policy
├── recall-state.ts # Recall state tracking
├── rerank.ts # Result reranking
├── source-attribution.ts # Source attribution for recalled facts
│
│ ── Dedup & consolidation ───────────────────────────────
├── dedup/ # Semantic deduplication pipeline
├── semantic-consolidation.ts # Embedding-aware memory merging
├── summarizer.ts # Summary generation
├── summary-snapshot.ts # Point-in-time summary snapshots
│
│ ── Taxonomy & classification ───────────────────────────
├── taxonomy/ # MECE taxonomy resolver, loader, defaults
├── entity-retrieval.ts # Entity-aware retrieval
├── entity-schema.ts # Entity type definitions
│
│ ── Extensions & publishers ─────────────────────────────
├── memory-extension/ # Third-party extension discovery + publishers
├── memory-extension-host/ # Host-side extension rendering + discovery
│
│ ── Enrichment ──────────────────────────────────────────
├── enrichment/ # External enrichment pipeline, provider registry
│
│ ── Binary lifecycle ────────────────────────────────────
├── binary-lifecycle/ # Mirror/redirect/clean pipeline for binary files
│
│ ── Wearables ───────────────────────────────────────────
├── wearables/ # Wearable transcript ingestion: connector registry, cleanup, redaction, corrections, speaker registry, day store, trust-gated memory gen
│
│ ── Access surfaces ─────────────────────────────────────
├── cli.ts # CLI commands
├── access-mcp.ts # MCP server surface
├── access-http.ts # HTTP API surface
├── access-cli.ts # CLI access helpers
├── surfaces/ # Heartbeat, dreams, and other surface integrations
│
│ ── Maintenance & governance ────────────────────────────
├── maintenance/ # Governance crons, archive, backup, observation ledger
├── hygiene.ts # Memory hygiene checks
├── memory-cache.ts # Multi-layer memory cache
│
│ ── Compatibility & migration ───────────────────────────
├── compat/ # Provider compatibility checks (Codex, etc.)
├── migrate/ # Legacy data migration utilities
├── sdk-compat.ts # SDK compatibility shims
│
│ ── Session & threading ─────────────────────────────────
├── threading.ts # Conversation threading
├── session-integrity.ts # Session identity validation
├── session-toggles.ts # Per-session feature toggles
├── namespaces/ # Multi-tenant namespace resolution
│
│ ── Supporting subsystems ───────────────────────────────
├── routing/ # Tier and model routing
├── sync/ # Cross-device sync
├── network/ # Network transport helpers
├── profiling.ts # Runtime profiling
├── intent.ts # User intent classification
├── tokens.ts # Token counting utilities
└── utils/ # Shared utility functions
- Three-phase flow — recall (before), buffer (after), extract (periodic)
- Smart buffer — decides when to flush based on content signals
- GPT-5.2 for extraction — uses OpenAI Responses API (NOT Chat Completions)
- QMD for search — hybrid BM25 + vector + reranking
- Markdown + YAML frontmatter — human-readable storage format
- Consolidation — periodic merging, cleaning, and summarization
- Extraction judge — optional LLM-as-judge post-filter evaluates fact durability before writes
- Semantic chunking — sentence-embedding-based topic boundary detection alternative to recursive chunking
- Page versioning — every memory file overwrite saves a numbered snapshot; list/diff/revert via CLI
- Citation blocks — recall responses emit
<oai-mem-citation>blocks for Codex-compatible attribution - Publisher contract — pluggable
MemoryExtensionPublisherinterface for host-specific extension installation - MECE taxonomy — deterministic categorization via mutually exclusive, collectively exhaustive directory
- Enrichment pipeline — importance-tiered external enrichment with provider registry and audit trail
- Binary lifecycle — three-stage mirror/redirect/clean pipeline for binary files in memory directory
- Wearable connectors — à-la-carte
@remnic/connector-limitless|bee|omipackages feed the sharedsrc/wearables/pipeline (pull → cleanup → redaction → corrections → speaker labels → day store → trust-gated memory gen). Day transcripts live at<memoryDir>/wearables/<source>/<date>.md— QMD-searchable but outside the memory scan roots. Memory creation defaults tomemoryMode: "review"(pending_review). See docs/wearables.md
api.on("gateway_start")— initialize orchestratorapi.on("before_agent_start")— inject memory contextapi.on("agent_end")— buffer turn for extractionapi.registerTool()— memory search, stats, etc.api.registerCommand()— CLI interfaceapi.registerService()— service lifecycle
# Build
npm run build
# Full restart (gateway_start hook needs this)
launchctl kickstart -k gui/501/ai.openclaw.gateway
# Or for hot reload (but gateway_start won't fire)
kill -USR1 $(pgrep openclaw-gateway)
# Trigger a conversation to test
# View logs
grep "\[engram\]" ~/.openclaw/logs/gateway.log- OpenAI must use Responses API — never Chat Completions (per CLAUDE.md guidelines)
- Zod optional fields — must use
.optional().nullable(), not just.optional() - Gateway launchd env isolated — API keys must be in plist EnvironmentVariables
- Config schema strict — new properties MUST be added to
openclaw.plugin.jsonconfigSchema - SIGUSR1 doesn't fire gateway_start — use
launchctl kickstart -kfor full restart - profile.md injected everywhere — keep under 600 lines or consolidation triggers
- QMD
queryis intentional — DO NOT change the default fromquerytosearchorvsearch. Thequerycommand provides LLM expansion + reranking that Remnic relies on. Remnic's own reranking was disabled becauseqmd queryhandles it. Likewise, the daemon'squeryMCP call intentionally runs alex+vec+hydeplan (full hybrid recall), not BM25-only. Both are by design, not bugs — a slower daemon path doing more inference is expected on CPU-only models, NOT 70x "overhead" (issue #1335). If you need a faster BM25-only path, it is exposed as opt-in config, never as a default change:qmdSubprocessStrategy: "search"(CLI fallback) andqmdSearchStrategy: "lex"/"lex-vec"(daemon plan). Defaults stayquery/hybrid. Seedocs/search-backends.md→ "Tuning daemon latency on CPU-only models". - QMD version gates — Remnic targets
@tobilu/qmd2.5.3, probesqmd --version, and must keep older QMD installs working by omitting unsupported flags. Use--format jsonfor QMD 2.5.3+ query/search subprocess calls; keep legacy--jsonfor older versions. - Legacy env var fallback chains — always try
REMNIC_*first, then fall back toENGRAM_*. This applies to config parsing, hook scripts, and daemon label lookups. - Never interpolate unsanitized values into shell scripts — pass host/port/config values via environment variables, never via string interpolation into script command strings.
- Scope globals per plugin ID — runtime orchestrator mirrors, CLI dedupe guards, and capability caches must be keyed by
serviceIdwhen multiple instances can coexist. - Write rollback data before success markers — if a migration writes
.migrated-from-engram, the.rollback.jsonmust be written first so failures don't leave a false success marker. - Wrap external service calls in try-catch — token generation, daemon health probes, and filesystem writes must not crash the primary install/remove/config flow. Fail gracefully and surface a user-facing note instead.
- Validate CLI flag arguments exist —
--format,--focus,--sincewithout a following value must throw an error, not silently default. - Sync lock files after dependency changes — changing
workspace:*specifiers or adding/removing packages requirespnpm installto updatepnpm-lock.yamland any nestedpackage-lock.jsonfiles. - Clean up ALL test globals in teardown — include unkeyed globals like
__openclawEngramOrchestratorinresetGlobals()helpers, not just the keyed ones. - Expand
~in all user-facing path inputs — Node.jsfsdoes NOT expand~. UseexpandTilde()consistently, never ad-hoc regex. This applies tomemoryDir,--config, env var paths, and--memory-dir. - Validate JSON parse result type —
JSON.parse('null')succeeds butnullis not a valid config. Always checktypeof result === 'object' && result !== nullafter parsing before property access. - Sort comparators must return 0 for equal items — a comparator that returns
1for bothcompare(a,b)andcompare(b,a)violates the contract and produces non-deterministic ordering. Use a stable secondary key. - Search ALL code when changing function signatures — when changing
addTurn(role, content)toaddTurn(sessionId, turn), searchevals/,tests/, andpackages/*/— not justsrc/. Missed call sites in adapters/evals were a recurring source of post-merge fixes. - Interactive prompts must gate actual mutations — if a migration prompt asks "migrate legacy config?" and the user says "no", the code must skip the actual config mutations, not just print different console messages while still writing the new config.
- Config resolution must be deduplicated — the slot → PLUGIN_ID → LEGACY_PLUGIN_ID resolution was independently implemented in 5+ locations with divergent edge-case handling. Always import from the shared utility rather than reimplementing.
- Hash operations must use consistent content form — if writes hash
rawContent, reads and dedup checks must also hashrawContent, not the timestampedcitedContent. Mixing forms silently breaks dedup. - Reject file paths used as directory arguments —
existsSyncreturns true for files. UsestatSync().isDirectory()when a directory is expected. Accepting a file asmemoryDirproduces a broken install that only fails later. - Don't destroy old state before confirming new state succeeds — rotate tokens AFTER config write succeeds, clean up old profiles AFTER new profile is confirmed. PR #400 had 20+ review rounds on this pattern alone.
- Import via package name, not relative cross-package paths —
import { X } from "@remnic/core"notimport { X } from "../../../remnic-core/src/foo.js". Directory renames silently break relative imports with no package-dependency signal. - Guard
slice(-n)againstn === 0—entries.slice(-0)equalsslice(0)and returns ALL entries. Always checkif (n <= 0)before negating for slice. The-0 === 0footgun is a JavaScript-specific trap. - Coerce CLI values to expected types at input boundaries —
--config port=5555produces"5555"(string).typeof saved === "number"rejects it on reinstall. AlwaysNumber(port)+ validate at the boundary, store as the expected type. - Force-flush must bypass dedupe — explicit flush surfaces (session flush, before_reset) must pass
skipDedupeCheck: true. Stale dedup fingerprints from failed extractions suppress legitimate retries. - New filters/transforms must have configuration gates — every new recall filter, config transformation, or behavioral override needs an
enabledcheck or escape hatch. Unconditional changes remove user control and break feature-flag symmetry. - Core package files must never have host-specific prefixes —
openclaw-recall-audit.tsin@remnic/coreviolates the architecture boundary. Generic modules in core should use generic names (recall-audit.ts). Host adapters wrap core, not the other way around. - Line parsers must track position during iteration, not use indexOf —
content.indexOf(line)returns the first occurrence, not the current parsing position. When parsing structured text with potential duplicate lines, maintain a running offset variable. - Test mock function signatures must match production interfaces — if production declares
getLastRecall(sessionKey: string), the mock must accept and usesessionKey, not define a zero-argument function. Mismatched mocks make tests pass vacuously. - Distinguish empty results from backend failures —
search()returning[]for both "index is empty" and "endpoint returned 5xx" prevents callers from short-circuiting on genuine failures. Use distinct result shapes:{ok: true, results: []}vs{ok: false, error: "backend_unavailable"}. - Time-range filters must use exclusive upper bounds —
ts <= toMscauses double-counting at midnight boundaries. Usets < toMsconsistently for half-open[start, end)interval semantics. Test with exact-boundary timestamps. - String
"false"is truthy in JavaScript —--config installExtension=falseproduces"false"(string), which!== falseevaluates astrue. Coerce boolean-like strings ("false","0","no","off") at config-read boundaries using a shared helper. - Cache invalidation must clear ALL cache layers — if
invalidateAllMemoriesCache()only clears the hot cache but notcoldMemoriesCache, stale data persists. When adding a cache layer, grep all invalidation functions and update them. - Sort object keys before hashing/serializing —
Object.entries({city, country})vsObject.entries({country, city})produce different strings, breaking deduplication. Sort keys before serializing for any hash/content-dedup operation. - Feature gates must be identical across all code paths — if
temporalSupersessionEnabledgates the QMD path but not the recent-scan fallback path, behavioral divergence depends on which recall path is exercised. Enumerate every path when adding a feature gate. - Serialized promise chains must recover from rejection —
writeChain = writeChain.then(fn)without.catch()recovery permanently poisons the chain after the first I/O error. All subsequent.then()callbacks never execute. Use aqueueWrite()wrapper that recovers the chain after rejection while still surfacing the error to the caller. - Match loop iterator method to the data you need —
for (const v of map.values())when you also need the key means referencing an undefined or outer-scope variable. Use.entries()to destructure both key and value. TypeScript strict mode should catch this, but verifynoImplicitAnyis enabled. - Read and write paths must resolve through the same namespace layer — if search uses namespace-aware resolution, get/delete must too. Un-namespaced search in multi-principal deployments exposes cross-tenant data. Constrain search scope via session-derived namespace resolution.
- Direct-write paths must trigger reindex — bypassing the normal extraction→persist→index pipeline (e.g., heartbeat import writing directly to storage) leaves data undiscoverable until unrelated maintenance. After direct writes, explicitly call the reindex step.
- Don't index content that failed to persist — if a dedup check, importance gate, or other filter rejects content before it's written to storage, do NOT add it to
contentHashIndex. Phantom index entries cause subsequent extractions with similar content to be silently dedup-suppressed against non-existent stored facts. PR #399. - Config schema minimums must honor documented disable values — if docs say "set to 0 to disable", both the JSON schema
minimumAND the code path must accept 0.Math.max(1, value)withminimum: 1in the schema silently overrides the user's documented disable intent. PR #399. - Escape literal template parts before building regex — when constructing regex from user-provided templates, always
escapeRegex()on the prefix/suffix. Empty prefix+suffix produces a match-everything regex. Special$in replacement strings corrupts output — use a replacement function or escape$→$$. PR #401. - Shared mutable objects must not leak across connections/sessions — a single mutable
clientInfoobject shared across MCP connections lets one session's adapter metadata bleed into another. In multi-tenant deployments this is a cross-tenant data leak. Use per-connection instances or deep-copy. PR #347. - Enum defaults must be least-privileged — when a decision/status enum is missing or
undefined, defaulting to"approved"or"enabled"is a security vulnerability. Always default to"rejected","pending","disabled", or"none". PR #344, #345. - Deduplicate batch operation inputs before executing — duplicate rollout slugs in a batch rename cause ENOENT crash when the second rename tries to move an already-moved file. Check for duplicates before processing, or verify source exists before each move. PR #392.
- CI must never silence test/type failures —
|| trueonpytest,mypy,tsc, or equivalent in CI makes broken code pass. Each quality gate must be a separate CI step that fails the build on error. PR #349. - Reject invalid user input instead of silently defaulting — invalid
--format,--since,--focus, MCP parameters, or briefing window tokens must throw errors listing valid options. Silently falling back to defaults hides configuration mistakes. Applies to ALL input surfaces: CLI, MCP tools, API endpoints, and config parsing. PR #396 (10+ instances). - Validation allow-lists must exactly match handled values — if
BRIEFING_FORMAT_ALLOWEDincludes"text"but downstream code only handles"markdown"and"json", the validator accepts what the code can't process. Dead switch cases after name normalization (e.g.,case "remnic.briefing":after converting toengram.*) must be removed. PR #396. - Status filters must enumerate ALL non-active states — filtering only
supersededandarchivedbut notquarantined,rejected, orpending_reviewcauses stale data in user-facing outputs. Define an explicitACTIVE_STATUSESset rather than an ad-hoc exclusion list. When adding a new status, grep ALL filters. PR #396. - Never delete before write in file replace operations —
rmSync(target)thenrenameSync(tmp, target)loses data permanently if rename fails. Write to temp first, then rename atomically. Verify rename success before cleanup.renameSynccan fail on cross-device moves. PR #394. - Documented behavior must have a corresponding implementation and test — if docs say "timeout is applied to all daemon calls", the provider must forward the timeout parameter AND a test must verify it. CI publish workflows must validate
github.ref == 'refs/heads/main'on the job, not just the trigger. Config properties defined in schema must be wired end-to-end. PR #397, #398. - Never merge before AI reviewers post —
cursor[bot]andchatgpt-codex-connector[bot]take 2-5 minutes to review a PR. Merging immediately after PR creation races past them, leaving comments unaddressed on merged code. Runscripts/pre-merge-check.sh <PR#>before everygh pr merge. The script verifies: (1) both AI reviewers have posted, (2) zero unresolved threads remain. PRs #429-#439 had 5 comments missed due to this race. - À-la-carte packages must stay optional at every install layer — users who only need memory features should not have to install benchmark, weclone, or plugin code. Optional workspace packages (
@remnic/bench,@remnic/export-weclone,@remnic/import-weclone, etc.) MUST be loaded via computed-specifier dynamic imports (await import("@remnic/" + "bench")) and MUST NOT appear in any base install surface's runtimedependenciesornoExternalbundler list. Declare them aspeerDependenciesMeta.*.optional = trueand surface a user-facing install hint when the dynamic import fails. Seepackages/remnic-cli/src/optional-bench.tsandoptional-weclone-export.tsfor the canonical pattern. See also the "À-la-carte packaging" section below.
Remnic ships as a family of packages that compose. Every install surface must respect this contract:
- Core always works alone.
@remnic/coreis the only install most users need. - Optional packages never piggyback on the base install.
@remnic/bench,@remnic/export-weclone,@remnic/import-weclone,@remnic/plugin-openclaw, etc. must be separatelynpm install-able and must never be bundled, noExternal'd, or declared as a runtimedependenciesentry on a base package. - Load optional packages lazily. Use a computed-specifier dynamic import (
await import("@remnic/" + "bench")) so bundlers cannot statically resolve the module. Wrap in a loader helper that throws a user-facing install hint on miss. Canonical implementations:packages/remnic-cli/src/optional-bench.ts,packages/remnic-cli/src/optional-weclone-export.ts,packages/remnic-core/src/cli.ts:ensureBuiltInBulkImportAdapters. - Declare as optional peer deps. In the consuming package's
package.json, list optional companions underpeerDependenciesand mark each as optional viapeerDependenciesMeta.<name>.optional = true. Do not list them underdependencies. - Never add to
noExternal. In tsup configs, optional packages must beexternal(or simply omitted fromnoExternal). Adding them tonoExternalbundles them into the base install and breaks à-la-carte. - Publish everything. Any package that end users are expected to install (even as an extension) must be published to npm. If it's
"private": trueand you recommend it, that's a bug — ship it or remove the recommendation. The publish order in.github/workflows/release-and-publish.ymlis the source of truth; keep it topologically sorted.
When you touch any of these files — tsup configs, CLI/plugin package.json dependencies, or dynamic-import loaders — re-verify the contract end to end: does npm install @remnic/cli still work without the optional packages present? Does the CLI throw a clean install hint instead of a MODULE_NOT_FOUND?
Default workflow going forward:
-
Keep each PR narrow.
- Prefer one subsystem group per PR.
- Split mixed work into separate PRs for schema/surface, storage/cache, and retrieval/planner behavior when possible.
-
Sync
mainbefore review.- Rebase or merge
mainbefore the first serious AI review cycle. - Avoid mid-review base refreshes unless a conflict forces it.
- Rebase or merge
-
Batch fixes.
- Group unresolved comments by subsystem, fix the full group, verify once, then push once.
- Do not use review feedback as a micro-push loop.
-
Run local review gates first.
npm run preflight:quicknpm run test:entity-hardeningwhen touchingsrc/orpackages/remnic-core/src/orchestrator.ts,storage.ts,intent.ts,memory-cache.ts,entity-retrieval.ts, orconfig.tsnpm run review:cursorwhen the local Cursor CLI is available
-
Treat AI review freshness as a merge criterion.
- A stale positive verdict on an older head does not count.
- Merge-ready means green checks, zero unresolved review threads, and a fresh positive AI verdict on the current head.
-
Run pre-merge check before every merge.
scripts/pre-merge-check.sh <PR#>— blocks if reviewers haven't posted or threads are unresolved.- Wait at least 3 minutes after PR creation before attempting to merge.
- Never merge a PR in the same tool call that created it.
Reference:
docs/ops/pr-review-hardening-playbook.md
When a PR touches session identity, retrieval routing, compaction, cache, or other lifecycle-heavy behavior, repeated review rounds usually mean the change was fixed too locally instead of being hardened as a whole subsystem.
The common failure mode:
- A fix is made for the reported bug only.
- A reviewer then exercises an adjacent path:
- sparse metadata
- remembered binding reuse
- provider rebinding
- restart recovery
before_resetsession_end- compaction
- Another follow-up commit is required.
Required prevention workflow:
- Build the scenario matrix before coding.
- Define the invariants for every entrypoint the subsystem owns.
- Add tests for the entire failure class, not only the reported example.
- Apply one cohesive subsystem patch.
- Run the hardening gate before requesting AI review again.
If the work is stateful and you are responding one review comment at a time, stop and widen the fix before pushing.
Two adjacent surfaces with similar names — both shipped on main. Do not conflate them:
-
recall/explain(graph-path, shipped) —POST /engram/v1/recall/explain/engram.recall_explainMCP tool /EngramAccessService.recallExplain(). Returns a graph-path explanation document ("why these memories?" for the graph subsystem). Markdown formatting delegates to the sharedrecall-explain-renderer.tsso CLI / HTTP / MCP stay in sync. -
Recall xray / tier explain (#570, shipped) —
GET /engram/v1/recall/xray/engram.recall_xrayMCP tool /remnic xrayCLI /EngramAccessService.recallXray(). Returns a structured per-result annotation of which retrieval tier served the query (direct-answer,hybrid, etc.). Attached toLastRecallSnapshot.tierExplainonly whenrecallDirectAnswerEnabled: true.
On-disk modules (all shipped):
packages/remnic-core/src/direct-answer.ts— pure eligibility function over caller-resolvedDirectAnswerCandidates.packages/remnic-core/src/direct-answer-wiring.ts— source-agnostictryDirectAnswer(...)binding invoked by the orchestrator.packages/remnic-core/src/recall-xray.ts,recall-xray-renderer.ts,recall-xray-cli.ts— tier-explain core, shared renderer, and CLI surface.packages/remnic-core/src/recall-explain-renderer.ts— shared markdown renderer for the legacy graph-path/recall/explainsurface.packages/remnic-core/src/types.ts—RecallTierExplaininterface, attached toLastRecallSnapshotviarecall-state.ts.
Rule 22 applies: never fork formatting — extend the renderers. If a
shared abort-error.ts module is later introduced, migrate the
private throwIfAborted(signal) helper in direct-answer-wiring.ts
rather than re-implementing it per call site.