Gateway Module — `psychic-cache/`
1. Purpose
The operational-memory tier — the short-lived, mission-scoped working store LISA
and the CyberShinobi agents write to during a dispatch. Nine context_types
(decision, directive, intel, state_change, output, report,
escalation, shadow_clone, thread_checkpoint) each carry a structured Zod
contract enforced at the write boundary. Entries are TTL-bounded (default 24h),
per-mission token-budget-capped (10k) and entry-capped (200), and hybrid-searched
(Voyage vector + FTS5 BM25 fused via RRF). The module also carries two
correctness-load-bearing side channels: a dispatch heartbeat tick on every
attributed write, and the thread_checkpoint compaction-survival store.
2. File Inventory
| File | Lines | Responsibility |
|---|---|---|
repository.ts | 679 | createPsychicCacheRepository: write (budget/entry eviction, metadata JSON build, embed→psychic_vec, heartbeat tick, SSE emit), hybrid query (vector+FTS+RRF+filters+budget truncation), clear, stats, TTL cleanup, list, hasOutputForDispatch, getLatestCheckpoint |
types.ts | 388 | PSYCHIC_CACHE_CONFIG, ContextType, CacheMetadata (flat union of all branch fields), WriteCacheInputSchema (9-branch z.discriminatedUnion with .strict()), QueryCacheInputSchema, ClearCacheInputSchema, CacheEntryListItem, repo interface |
routes.ts | 157 | createPsychicCacheRouter: /write, /query, /checkpoint/:session_id, /all/entries, /:mission/entries, /:mission (DELETE), /:mission/stats |
cleanup.ts | 43 | createTTLCleanup — setInterval (60-min) sweep of expired entries, unref'd |
index.ts | 29 | createPsychicCacheLayer — assembles repo + router + ttlCleanup |
| Total | 1,296 |
3. Public API Surface
REST Endpoints
Mounted at /api/psychic-cache. Auth per AK-415: GETs → Bearer (write OR
read-only); writes → Bearer (write only). No route is auth-exempt.
| Method | Path | Auth | Body Schema | Response | Side Effects |
|---|---|---|---|---|---|
| POST | /write | Bearer | WriteCacheInputSchema (9-branch union, strict) | 201 {data:{id, token_count, cache_total_tokens, evicted_count}} / 400 | Insert psychic_cache; FTS via AFTER-INSERT trigger; embed → psychic_vec; budget/entry eviction; heartbeat tick on agent_dispatch if dispatch_id; onWrite callback (dispatch GC sweep); SSE cache_write emit |
| POST | /query | Bearer | QueryCacheInputSchema | {data:{context_block, entries[], total_tokens, cache_size}} / 400 | Reads only; logs cache_query activity |
| GET | /checkpoint/:session_id | Bearer | — | {data: CacheEntryListItem} / 404 | Exact-match latest thread_checkpoint for session |
| GET | /all/entries | Bearer | — | {data: CacheEntryListItem[]} (recent 100) | none |
| GET | /:mission/entries | Bearer | — | {data: CacheEntryListItem[]} | none |
| DELETE | /:mission | Bearer | ?reason / {reason} (read but not enforced) | {data:{deleted_count, freed_tokens}} | Delete all mission rows + FTS + vec |
| GET | /:mission/stats | Bearer | — | {data: CacheStats} | none |
MCP Tools
| Tool Name | Raw payload? | Endpoint | Notes |
|---|---|---|---|
write_psychic_cache | No (permissive-flat) | POST /api/psychic-cache/write | Legacy; MCP schema exposes every branch field as flat optional |
write_psychic_cache_raw | Yes ({payload}) | POST /api/psychic-cache/write | Canonical write path (D26) — staleness-immune one-field schema |
clear_psychic_cache | No | DELETE /api/psychic-cache/:mission | URL path + query only, no JSON body |
Per-branch required-field enforcement +
.strict()unknown-field rejection is performed server-side only (the MCP face is the permissive union of all branches). MCP handlers forward the full params viaJSON.stringify(args).
4. Internal API
createPsychicCacheLayer(db, embeddingProvider, activityRepo?, onWrite?): {repo, router, ttlCleanup}.PsychicCacheRepositorymethods consumed cross-module:query()— wrapped by the context assemblypsychicSearchadapter.hasOutputForDispatch(missionId, dispatchId)— called bydispatch/routes.tscomplete handler to setoutput_missing.getLatestCheckpoint(sessionId)— backs the checkpoint GET route.
PSYCHIC_CACHE_CONFIG,WriteCacheInputSchema,ContextType— imported by dashboard-api, curator, tests.
5. Background Services
- TTL cleanup (
cleanup.ts) —setIntervaleveryCLEANUP_INTERVAL_MS(60 min) callingrepo.cleanupExpired()(deletes rows whereexpires_at < datetime('now')+ FTS/vec).unref'd so it never keeps the process alive. Started fromserver/index.ts. - Token-budget eviction (on write) — when
currentTokens + new > 10,000, evicts oldest-first until freed; also entry-count eviction at ≥ 200. - Dispatch heartbeat tick (D1175/AK-345) — on every write carrying
dispatch_id, PK-boundUPDATE agent_dispatch SET last_heartbeat(status-filtered to live rows). Placed before the embedding await for latency. No-op for pre-dispatch LISA writes. - SSE timeline emit —
setImmediatetimelineBus.emitTimelineEvent({kind:'cache_write', …})after commit.
6. Data Contracts
WriteCacheInputSchema — z.discriminatedUnion('context_type', [...]), each
branch BaseWrite.extend({...}).strict().
BaseWrite = {mission_id≥1, content≥1, agent_name?, ttl_hours?>0, tags?[], source_turn?:int, dispatch_id?:int>0}.
Per-branch required fields:
decision→decision_rationale; optdecision_alternatives[]directive→directive_target; optdirective_priorityenumintel→intel_source; optintel_confidenceenumstate_change→state_from,state_to; optstate_triggeroutput→agent_name,output_url,dispatch_id(REQUIRED here only, AK-439/D1929); optoutput_summaryreport→report_dispatch_id>0,report_outcomeenumescalation→escalation_target,escalation_reasonshadow_clone→subtasks[](min 2); optclone_count(1–3)thread_checkpoint→session_id,turn_count≥0,active_dispatches[],open_decisions[],pending_directives[],current_mission_namespaces[](min 1),working_state_prose(1–2000 chars)
QueryCacheInputSchema = {mission_id, query, token_budget?, context_types?[], tags?[]}.
ClearCacheInputSchema = {mission_id, reason:enum} (defined but route reads reason without enforcing).
Owned tables (declared in memory/db.ts): psychic_cache (+2 indexes),
psychic_vec (vec0, 512-dim), psychic_fts (fts5), and the 3 sidecar triggers
psychic_cache_ai/ad/au_sidecar (FTS is trigger-maintained; service writes only vec).
7. Dependencies
- Gateway modules consumed:
memory/types(EmbeddingProvider),shared/search-utils(estimateTokens,buildFts5Query,reciprocalRankFusion),shared/zod-error(formatZodError),activity/repository(ActivityRepository),dispatch/eventBus(timelineBus). Writes toagent_dispatch(dispatch module's table) for heartbeat. - External libraries:
better-sqlite3,sqlite-vec,express,zod. - Environment variables: none directly (TTL/budgets are
PSYCHIC_CACHE_CONFIGconstants; DB + embedding provider injected by the factory).
8. Test Coverage
| Layer | File | Tests |
|---|---|---|
| Repository (CRUD, eviction, hybrid query, checkpoint, hasOutputForDispatch) | test/psychic-cache-repository.test.ts | 35 |
| Routes (7 endpoints, Zod negatives, auth boundary) | test/psychic-cache-routes.test.ts | 42 |
| Type/schema contracts (9-branch union, strict, per-field messages) | test/psychic-cache-types.test.ts | 76 |
Strong coverage (153 tests) — the discriminated-union contract is the most heavily-tested surface in the shard.
9. Known Limitations
ClearCacheInputSchema.reasonis validated nowhere — the DELETE route readsreasonfrom query/body but does not runClearCacheInputSchema, so an invalid/absent reason silently clears the mission. Cosmetic, low risk.'*'mission sentinel returns empty — when the context psychic adapter passes no namespace it falls back tomission_id='*', which matches zero rows (documented D57). Harmless in practice (callers always pass a namespace) but the "search all missions" comment is misleading.- Live column-default drift — older DBs still default
created_atto space-separateddatetime('now'); mitigated by explicit ISO-Z binding on every insert (D52). Do not remove the explicit binding until the column default is formally migrated. - Recency owned by context funnel —
query()returns pure RRF-ranked entries (D23 removed the internal recency boost); the direct/queryREST consumer loses local recency bias.
10. Change History
| Date | Dispatch | Summary |
|---|---|---|
| 2026-07-04 | 2295 | Initial per-module spec filed (audit campaign Phase 4) |
| (historical) | D26 | write_psychic_cache_raw canonical write path (staleness fix) |
| (historical) | D62 | thread_checkpoint + getLatestCheckpoint + checkpoint GET (Compaction Survival) |
| (historical) | D71 | Structured metadata keys + flat CacheEntryListItem surfacing; full discriminated union |
| (historical) | D77 | Universal dispatch_id attribution (base field) |
| (historical) | D52 | Root-cause hasOutputForDispatch rewrite (metadata.dispatch_id key); NULL agent_name preservation |
| (historical) | D1175 (AK-345) | Dispatch heartbeat tick on attributed write |
| (historical) | AK-439/D1929 | dispatch_id REQUIRED on output branch (close D52 asymmetry) |