/** * Muscle memory for tool outputs — the persistence half of runtime shape * inference (see shape-inference.ts for the algorithm). * * Observed shapes live in the already-migrated `plugin_storage` table under a * reserved system plugin id: owner-scoped, tenant-partitioned, and untouched * by tool-catalog refresh (which deletes and recreates `tool` rows, so the * tool row itself is not a viable home). An in-memory read-through cache * keeps the hot path off the database: within one executor instance a tool's * shape is loaded at most once, and a write happens only when a new * observation actually changes the merged shape — after a few calls a stable * API stops producing writes entirely. * * `observe` never fails and is intended to be forked off the dispatch path; * `recall` degrades to "no memory" on any storage failure. */ import { Clock, Effect } from "effect"; import type { Owner } from "./ids"; import type { PluginStorageFacade } from "./plugin-storage"; import { observeShape, type ObservedShape } from "./shape-inference"; /** Reserved system namespace inside `plugin_storage`; not a real plugin. */ export const SHAPE_MEMORY_PLUGIN_ID = "executor.shape-memory"; const COLLECTION = "observed-output-shapes"; export type ShapeMemory = { /** * Fold one successful tool payload into the tool's remembered shape. * Structure only — values never leave this call. Never fails. */ readonly observe: (address: string, owner: Owner, value: unknown) => Effect.Effect; /** The remembered shape for an address, or null when nothing is known. */ readonly recall: (address: string, owner: Owner) => Effect.Effect; }; export const makeShapeMemory = (storage: PluginStorageFacade): ShapeMemory => { const cache = new Map(); const persisted = new Map(); const cacheKey = (owner: Owner, address: string) => `${owner}:${address}`; const load = (address: string, owner: Owner): Effect.Effect => Effect.gen(function* () { const key = cacheKey(owner, address); const hit = cache.get(key); if (hit !== undefined) return hit; const entry = yield* storage .getForOwner({ owner, collection: COLLECTION, key: address }) .pipe(Effect.catch(() => Effect.succeed(null))); const record = entry?.data ?? null; cache.set(key, record); if (record !== null) persisted.set(key, JSON.stringify(record.schema)); return record; }); const observe = (address: string, owner: Owner, value: unknown): Effect.Effect => Effect.gen(function* () { const key = cacheKey(owner, address); const prior = yield* load(address, owner); const now = yield* Clock.currentTimeMillis; const next = observeShape(prior, value, now); cache.set(key, next); // Write only when the merged shape actually changed — observation // counters alone are bookkeeping, not worth a row write per call. const schemaJson = JSON.stringify(next.schema); if (persisted.get(key) === schemaJson) return; yield* storage .put({ owner, collection: COLLECTION, key: address, data: next }) .pipe(Effect.catch(() => Effect.succeed(null))); persisted.set(key, schemaJson); }).pipe(Effect.catchCause(() => Effect.void)); return { observe, recall: (address, owner) => load(address, owner).pipe(Effect.catchCause(() => Effect.succeed(null))), }; }; /** * Render a remembered shape as the JSON Schema served in place of a missing * declared output schema. The description marks provenance so a reader (and * the schema view) can tell an observed shape from an author-declared one. */ export const observedShapeToJsonSchema = (record: ObservedShape): unknown => ({ ...record.schema, description: `Observed from ${record.observations} live response${record.observations === 1 ? "" : "s"}; fields may be incomplete.`, });