export * as SessionContextEntry from "./context-entry" import { and, asc, eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry" import { Database } from "../database/database" import { makeLocationNode } from "../effect/app-node" import { SystemContext } from "../system-context/index" import { SessionSchema } from "./schema" import { SessionContextEntryTable } from "./sql" export const Key = SessionContextEntry.Key export type Key = typeof Key.Type export const Info = SessionContextEntry.Info export type Info = typeof Info.Type export interface Interface { readonly list: (sessionID: SessionSchema.ID) => Effect.Effect> readonly put: (input: { readonly sessionID: SessionSchema.ID readonly key: Key readonly value: Schema.Json }) => Effect.Effect readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect /** Produces one SystemContext source per stored entry, keyed `api/`. */ readonly load: (sessionID: SessionSchema.ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionContextEntry") {} const renderValue = (value: Schema.Json) => (typeof value === "string" ? value : JSON.stringify(value, null, 2)) const renderBlock = (key: Key, value: Schema.Json) => [``, renderValue(value), ""].join("\n") // Rendering stays mechanism-neutral: the model sees session context, not how // it was attached. Only chronological updates and removals carry narration. const source = (entry: Info) => SystemContext.make({ key: SystemContext.Key.make(`api/${entry.key}`), codec: Schema.toCodecJson(Schema.Json), load: Effect.succeed(entry.value), baseline: (value) => renderBlock(entry.key, value), update: (_previous, value) => [ `The context under "${entry.key}" changed and supersedes the previous value:`, renderBlock(entry.key, value), ].join("\n"), removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`, }) const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service const list = Effect.fn("SessionContextEntry.list")(function* (sessionID: SessionSchema.ID) { const rows = yield* db .select() .from(SessionContextEntryTable) .where(eq(SessionContextEntryTable.session_id, sessionID)) .orderBy(asc(SessionContextEntryTable.key)) .all() .pipe(Effect.orDie) return rows.map((row) => ({ key: row.key, value: row.value })) }) const put = Effect.fn("SessionContextEntry.put")(function* (input: { readonly sessionID: SessionSchema.ID readonly key: Key readonly value: Schema.Json }) { yield* db .insert(SessionContextEntryTable) .values({ session_id: input.sessionID, key: input.key, value: input.value }) .onConflictDoUpdate({ target: [SessionContextEntryTable.session_id, SessionContextEntryTable.key], set: { value: input.value, time_updated: Date.now() }, }) .run() .pipe(Effect.orDie) }) const remove = Effect.fn("SessionContextEntry.remove")(function* (input: { readonly sessionID: SessionSchema.ID readonly key: Key }) { yield* db .delete(SessionContextEntryTable) .where( and(eq(SessionContextEntryTable.session_id, input.sessionID), eq(SessionContextEntryTable.key, input.key)), ) .run() .pipe(Effect.orDie) }) const load = Effect.fn("SessionContextEntry.load")(function* (sessionID: SessionSchema.ID) { const entries = yield* list(sessionID) return SystemContext.combine(entries.map(source)) }) return Service.of({ list, put, remove, load }) }), ) export const node = makeLocationNode({ service: Service, layer, deps: [Database.node] })