/** * v0.4 helpers for the pi NATS channel — extracted from `nats-channel.ts` * so they can be unit-tested without spinning up a NATS connection / pi * extension host. * * Most of this file is pure — `resolveV04Coord`, `v2EnvelopeText`, * `v2Scope`. The one stateful export is `V2Stores`, a lazy KV+OS opener * per (scopeKind, scopeId). Slice 6b adds it so the v2 handler can write * durable Messages (and, later, Artifacts) instead of only firing stream * events. The helper mirrors omp's `V2Stores` (see * `omp-nats-channel/extensions/nats-channel-v2.ts`) — close enough to * the same shape that the two could be lifted into a shared SDK helper * once the third adapter (grok/gemini) lands the same wiring. * * Bucket naming uses the SDK's `v04.scope.{messagesBucket,artifactsBucket, * objectsBucket}` helpers (PR #23) — Slice 6 deferred this layer with an * inline TODO ("SDK doesn't ship a `scope.bucketName()` helper yet"); * that gap is now closed. */ import { hostname } from "node:os"; import { jetstream } from "@nats-io/jetstream"; import { Kvm, type KV } from "@nats-io/kv"; import { Objm, type ObjectStore } from "@nats-io/obj"; import type { NatsConnection } from "@nats-io/transport-node"; import { sanitizeSubjectToken, v04, type Coord, type Message as V2Message, type ScopeRef, } from "@agent-ops/sesh-channels-sdk"; // Re-export the canonical subject-token sanitizer so existing importers of // this module (e.g. tests) keep their entry point. The single source of // truth is `@agent-ops/sesh-channels-sdk` (`sdk/src/sanitize.ts`). export { sanitizeSubjectToken }; /** * Resolve the v0.4 coordinate (machine/project/session) used by * `agents.prompt.*` and friends. * * Each token is sanitized; if sanitization yields an empty string, a stable * fallback is applied so we never publish an empty subject token. * * Precedence per token: * machine: $SESH_MACHINE > os.hostname() > "local" * project: $SESH_PROJECT_ID (40-hex pin, canonical) > $SESH_PROJECT > owner * session: opts.sessionName (already resolved upstream) * * The 40-hex SESH_PROJECT_ID pin is the canonical token (sesh ≥ * v0.6.0); the SESH_PROJECT slug is the no-pin fallback. A present-but- * malformed pin throws loudly — see SDK resolveProjectToken (#65). * * The coord is session-scoped (5-token). Role/class identity is no longer a * subject token — it lives in NATS micro service metadata instead. * * `env` is injectable so tests don't have to mutate process.env. */ export function resolveV04Coord(opts: { owner: string; sessionName: string; env?: NodeJS.ProcessEnv; hostFn?: () => string; }): Coord { const env = opts.env ?? process.env; const host = (opts.hostFn ?? hostname)(); const machineRaw = (env.SESH_MACHINE ?? "").trim() || host || "local"; const machine = sanitizeSubjectToken(machineRaw) || "local"; const project = v04.subjects.resolveProjectToken({ pin: env.SESH_PROJECT_ID, slug: (env.SESH_PROJECT ?? "").trim() || opts.owner, fallback: "default", }); const session = sanitizeSubjectToken(opts.sessionName) || "default"; return { machine, project, session }; } /** * Concatenate the text content of all `text` parts in a v2 envelope. Non-text * parts are skipped (file / data / url parts are handled separately by the * v2 prompt handler — text-only is the slice-6 minimum). */ export function v2EnvelopeText(env: V2Message): string { const out: string[] = []; for (const p of env.parts) { if (typeof p.text === "string") out.push(p.text); } return out.join("\n"); } /** * Derive the per-task scope ref from a v2 envelope. The envelope's * `metadata` MAY carry `sesh.scope_kind` / `sesh.scope_id`; if absent we * default to a task-scoped ref where the scope id matches the task id. * * Keeping this pure (no NATS, no I/O) makes it trivially testable. */ export function v2Scope(env: V2Message, taskId: string): ScopeRef { const md = env.metadata ?? {}; const kind = typeof md["sesh.scope_kind"] === "string" ? (md["sesh.scope_kind"] as string) : undefined; const id = typeof md["sesh.scope_id"] === "string" ? (md["sesh.scope_id"] as string) : undefined; const validKinds = new Set([ "task", "session", "machine", ]); const scopeKind: ScopeRef["scopeKind"] = validKinds.has( kind as ScopeRef["scopeKind"], ) ? (kind as ScopeRef["scopeKind"]) : "task"; const scopeId = id && id.length > 0 ? id : taskId; return { scopeKind, scopeId }; } /** * Lazy KV + Object Store opener for a (scopeKind, scopeId) pair. Created * on demand from the v2 handler so JetStream-disabled brokers don't * block the prompt path — first failure disables the durable surface for * the rest of the session and surfaces one warning; the stream-event * path stays live regardless. * * Buckets: * messages → `sesh_messages__` (v04.scope.messagesBucket) * artifacts → `sesh_artifacts__` (v04.scope.artifactsBucket) * objects → `sesh_objects__` (v04.scope.objectsBucket) * * Pre-warming the artifacts bucket on first open keeps the first * `putArtifact` call off the stream-creation critical path; messages * and artifacts are split so GC policies can diverge later. */ export class V2Stores { private kvHandle: KV | null = null; private osHandle: ObjectStore | null = null; private artifactsKvHandle: KV | null = null; private tasksKvHandle: KV | null = null; private disabled = false; private openPromise: Promise | null = null; constructor( private readonly nc: NatsConnection, private readonly scope: ScopeRef, private readonly onWarn: (msg: string) => void, ) {} /** * Returns the (messages KV, objects OS) pair, opening them on first * call. Both are `null` once a previous open attempt failed (the * handler treats `null` as "skip durable writes" — stream events * continue regardless). */ async ensure(): Promise<{ kv: KV | null; os: ObjectStore | null }> { if (this.disabled) return { kv: null, os: null }; if (this.kvHandle && this.osHandle) { return { kv: this.kvHandle, os: this.osHandle }; } if (!this.openPromise) { this.openPromise = this.open(); } await this.openPromise; return { kv: this.kvHandle, os: this.osHandle }; } /** Artifacts KV handle (lazy). `null` once `ensure()` has failed. */ async artifactsKv(): Promise { if (this.disabled) return null; if (this.artifactsKvHandle) return this.artifactsKvHandle; await this.ensure(); return this.artifactsKvHandle; } /** * Open the durable A2A Task record for `taskId`: ensures the task bucket * exists (create-if-missing) and mints the initial SUBMITTED record if no * writer (shim) did, via the shared SDK helper. The mint makes the adapter * self-sufficient on a shim-less mesh (#66). `null` once `ensure()` failed. * * Replaces the old taskId-agnostic `tasksKv()` getter — the record mint is * the whole point of #66, so task-open is the natural granularity. The * underlying bucket handle is still cached by `ensure()`. */ async openTask( taskId: string, opts: { contextId?: string } = {}, ): Promise { if (this.disabled) return null; await this.ensure(); if (!this.tasksKvHandle) return null; try { const js = jetstream(this.nc); const kvm = new Kvm(js); const { kv } = await v04.task.openTask(kvm, this.scope, taskId, opts); return kv; } catch (e) { this.onWarn(`v0.4 task open failed for ${taskId}: ${(e as Error).message}`); return null; } } private async open(): Promise { try { const js = jetstream(this.nc); const kvm = new Kvm(js); const objm = new Objm(js); this.kvHandle = await kvm.create(v04.scope.messagesBucket(this.scope)); this.osHandle = await objm.create(v04.scope.objectsBucket(this.scope)); this.artifactsKvHandle = await kvm.create( v04.scope.artifactsBucket(this.scope), ); this.tasksKvHandle = await kvm.create(v04.scope.tasksBucket(this.scope)); } catch (e) { this.disabled = true; this.kvHandle = null; this.osHandle = null; this.artifactsKvHandle = null; this.tasksKvHandle = null; this.onWarn( `v0.4 KV/OS unavailable (${(e as Error).message}) — v2 stream chunks will fire ` + "but no durable Messages/Artifacts will be written this session", ); } } }