# Scriptorium Cutover Implementation Plan This plan implements the target state in [scriptorium.md](scriptorium.md): a hard cutover from Notarius' local OpenAI-compatible adapter, local LLM profile schema, and local system/user prompt renderer to Scriptorium-backed prompt execution. The target implementer is an LLM coding agent. Complete each stage in order. Do not preserve backward compatibility for removed local LLM-profile or prompt-rendering behavior unless a stage explicitly says to keep a temporary test seam. ## Global Constraints - Keep Scriptorium types out of chunk, extract, and normalize module contracts. Module-facing requests should use Notarius-owned contract types. - Keep provider plumbing behind `internal/framework/llm` and CLI construction. - Keep module-owned prompt intent, prompt IDs, response schemas, validators, and source-reference validation with the relevant modules. - Keep raw input material, raw references, raw prompts, raw schemas, and API keys out of manifests and default diagnostics. - Treat the original source input and large references as prompt input materials. They may be passed to Scriptorium as inline/file inputs, but should not be copied into durable provenance. - Use Scriptorium profile configuration directly. Do not keep Notarius' `llm_profiles` schema as a second profile system. - Use Scriptorium structured-output execution and validation behavior for production calls. Notarius should still unmarshal successful structured JSON into module-owned response structs and run module-owned deterministic validation afterward. ## Stage 1: Dependency And API Grounding Goal: add the Scriptorium dependency and establish the exact public API surface used by the rest of the implementation. Work: - Add `gitea.maximumdirect.net/eric/scriptorium` to `go.mod` with `go get`. - Inspect the installed package with `go doc` or source reads before coding the adapter. Confirm names and fields for: - `NewEngine`; - `Config`; - `WithPromptFS`, `WithPromptFile`, or equivalent prompt source options; - `WithProfileFile`, `WithProfileFS`, profile directory config, and built-in profile behavior; - `WithSchemaFS` or equivalent schema source options; - `RunRequest`, including prompt ID, prompt version, profile ID, inputs, variables, metadata, direct API key, and validation override fields; - `RunResult`, including raw output, output artifact, validation status, prompt/profile/model metadata, usage, and timings; - public sentinel errors. - Add a short internal implementation note as a code comment only where needed; do not add user-facing docs in this stage except if a package-level test helper needs explanation. Acceptance checks: - `go test ./internal/framework/llm` - `go test ./internal/cli` - `go test ./...` ## Stage 2: Prompt Input Materials And Session IDs Goal: make raw source input and references available to module prompt execution without storing large content in manifests or default diagnostics. Work: - Add Notarius-owned contract types under `internal/framework/contracts`: - `LLMInputMaterial` with at least `Name`, `MediaType`, `Content`, `Digest`, `OriginURI`, and `SizeBytes`; - a helper-friendly collection type, such as `LLMInputSet`, if useful. - Add prompt-execution fields to `StructuredCompletionRequest`: - `PromptID`; - `PromptVersion`; - `ProfileID` or reuse the request `LLMProfile` value when called from stage requests; - `SessionID`; - `Inputs map[string]LLMInputMaterial`; - `Vars map[string]any`. - Keep or remove the old `Messages`, `Model`, `ResponseSchemaName`, and `ResponseSchema` fields according to what makes the cutover cleanest. The final production path must not require modules to pass rendered messages or raw schema JSON directly to the provider adapter. - Add `SourceInput contracts.LLMInputMaterial` and `SessionID string` to `ChunkRequest`, `ExtractionRequest`, and `NormalizeRequest`. - Add `SessionID string` to `pipeline.RunInput`. - In `pipeline.Run`, build a source input material from `RunInput.RawInput` and `RunInput.Path`: - preserve the bytes exactly; - infer media type from the input path extension, using `application/json` for `.json` and a deterministic fallback for unknown extensions; - compute a SHA-256 digest; - use a file URI or path-derived origin URI; - do not put the bytes in manifest metadata. - Pass the same source input material and session ID to chunk, extract, and normalize requests. - Reuse existing materialized `ReferenceSet` content for reference prompt inputs. Do not introduce a second file-reading path for references. - Add CLI support for `--session-id ` on `notarius run`. - Empty means use a deterministic default derived from the parsed source document ID. - The deterministic default must be stable across runs over the same parsed source document. - The explicit value should be trimmed and rejected if empty after trimming. - Because the default depends on the parsed source document, compute and attach the final session ID inside the runner after input parsing, or return enough information from parsing for the CLI to resolve it before LLM calls. - Record the non-secret session ID in run metadata or manifest metadata, but never record raw prompt content. Acceptance tests: - Contract tests proving `LLMInputMaterial.Content` is defensively copied where relevant and omitted from JSON. - Pipeline tests proving chunk, extract, and normalize requests receive the same source input bytes and session ID. - CLI tests for `--session-id`, including explicit value, missing value, trimming, and default behavior. - Redaction/diagnostics tests proving raw source bytes and reference content do not appear in manifests or default diagnostics. Focused checks: - `go test ./internal/framework/contracts` - `go test ./internal/framework/pipeline` - `go test ./internal/cli` ## Stage 3: Configuration Hard Cutover To Scriptorium Profiles Goal: replace Notarius' local LLM profile schema with Scriptorium profile selection and profile sources. Work: - Bump the Notarius file config version because this is an incompatible config schema change. - Remove the top-level `llm_profiles` file-config schema and the local `config.LLMProfile` model. - Add a top-level Scriptorium config block. Use this shape unless Scriptorium's installed API requires a small naming adjustment: ```yaml scriptorium: profile_dir: ./profiles profile_file: ./profiles.yml ``` - Treat `profile_dir` and `profile_file` as mutually exclusive in Notarius config validation. Built-in Scriptorium profiles remain available when neither is set. - Keep existing module binding field name `llm_profile`; it now names a Scriptorium profile ID. - Stop forcing empty module `llm_profile` bindings to `pipeline.DefaultLLMProfile`. Empty means "use the Scriptorium prompt's `default_profile`." - Keep `--llm-profile` as a run-level operational override. It should set the same explicit Scriptorium profile ID for every LLM-eligible selected target. - Remove `OpenAICompatibleClientConfig` construction from `internal/core/config`. - Remove local OpenAI-compatible provider validation from config validation. - Add config validation for: - config version; - mutually exclusive `scriptorium.profile_dir` and `scriptorium.profile_file`; - non-empty profile source paths when fields are present; - non-empty explicit `llm_profile` strings after trimming. - Add CLI/config validation that uses Scriptorium profile loading to reject unknown explicit profile IDs when possible. Prompt-default profile failures may surface during Scriptorium prepare/run if Scriptorium owns that lookup. - Remove or replace environment override behavior tied to `NOTARIUS_LLM_DEFAULT_*`. Secrets should come from Scriptorium profile `api_key_env` values or direct request-scoped Scriptorium behavior. - Update redacted effective config behavior for the new Scriptorium config shape. Acceptance tests: - Config parsing accepts `scriptorium.profile_dir`. - Config parsing accepts `scriptorium.profile_file`. - Config validation rejects both fields set at once. - Config validation rejects stale `llm_profiles`. - Existing pipeline bindings with explicit `llm_profile` resolve to trimmed Scriptorium profile IDs. - Empty `llm_profile` remains empty through resolution unless `--llm-profile` is supplied. - CLI config validation fails cleanly for an unknown explicit profile ID. - Redacted config diagnostics do not contain raw API keys. Focused checks: - `go test ./internal/core/config` - `go test ./internal/cli` ## Stage 4: Scriptorium Prompt And Schema Assets Goal: move production LLM prompts and schemas to Scriptorium-compatible assets while preserving module ownership. Work: - Create a prompt/schema asset registration mechanism that does not put D&D-specific prompt content in framework packages. - Recommended shape: add a small prompt-asset registry in `internal/framework/llm` or a sibling framework package that can collect `fs.FS` roots for Scriptorium prompt and schema sources. - Production module packages should register their own Scriptorium prompt and schema assets through production catalog/registry wiring in `internal/cli`. - Shared D&D prompt assets may live in a D&D-specific module package such as `internal/modules/dnd/promptassets`; they must not live in `internal/core` or source-agnostic framework packages. - Add shared D&D prompt assets: - stable shared system message, if needed; - stable cacheable transcript user message: ```text A transcript of a Dungeons & Dragons gameplay session is provided below. {{ input "transcript" }} ``` - stable cacheable reference/context message templates for roster, glossary, previous recap, or other large references used by current D&D modules. - Convert `dnd/scenes` to a Scriptorium prompt definition: - prompt ID: `dnd.scenes`; - prompt version: current module prompt version; - input `transcript`, required, `application/json`; - messages ordered for cache reuse: shared system, shared transcript user message with cache control, scene task, scene instructions; - output JSON schema path pointing at the existing scene schema asset; - schema IDs/names/versions remain module-owned and manifest-safe. - Convert `dnd/spells` to a Scriptorium prompt definition: - prompt ID: `dnd.spells`; - prompt version: current module prompt version; - input `transcript`, required, `application/json`; - optional inputs for `roster` and `glossary`; - messages ordered for cache reuse: shared system, shared transcript user message with cache control, optional/reference context message with cache control, spell task, spell instructions; - output JSON schema path pointing at the existing spell schema asset. - For optional references, pass empty inline input material when the slot is unbound unless Scriptorium's template/input semantics support missing optional inputs cleanly. Do not let optional missing references make prompt rendering fail. - For multiple reference items in one slot, concatenate deterministically with stable headings that include only non-secret provenance, then pass the result as that prompt input. Existing single-item slots should keep their current behavior. - Replace current prompt hash metadata with hashes derived from the Scriptorium prompt definition plus message assets, or with Scriptorium prepared-run prompt metadata if it is available without raw prompt content. - Preserve existing manifest metadata keys where practical: - `prompt_id`; - `prompt_version`; - `prompt_sha256`; - `response_schema_key`; - `response_schema_id`; - `response_schema_name`; - `response_schema_version`; - `response_schema_sha256`. Acceptance tests: - Prompt asset loading fails fast for missing prompt files or schemas. - `dnd/scenes` prepared prompt contains separate transcript and task messages. - `dnd/spells` prepared prompt contains separate transcript, reference, and task messages. - The transcript message body is byte-identical to the expected shared template plus original Seriatim JSON bytes. - Reference prompt input rendering is deterministic. - Prompt/schema diagnostics omit raw prompt text, raw transcript bytes, raw reference content, and raw schema JSON. Focused checks: - `go test ./internal/framework/llm` - `go test ./internal/modules/chunk/dnd/scenes` - `go test ./internal/modules/extract/dnd/spells` ## Stage 5: Scriptorium-Backed LLM Runtime Goal: implement the production `StructuredLLMClient` using Scriptorium. Work: - Add a Scriptorium-backed client under `internal/framework/llm`. - Its constructor should accept: - Scriptorium profile source settings from effective Notarius config; - registered prompt/schema assets; - request timeout or HTTP client settings only if still owned by Notarius after the profile cutover; - optional Scriptorium engine options for tests. - Implement `CompleteStructured(ctx, req, out)` by: - validating `out` is a non-nil pointer; - validating `req.PromptID` is non-empty; - converting Notarius `LLMInputMaterial` values to Scriptorium artifact refs; - adding `session_id` to Scriptorium request vars when non-empty; - passing explicit profile ID only when the module binding or CLI override supplied one; - passing no raw API key unless a deliberate request-scoped secret path is implemented; - calling Scriptorium `Run`; - converting final validation failure into a Notarius error; - unmarshaling successful structured JSON into `out`; - returning `StructuredCompletionResponse` with raw JSON content, provider, model, profile ID when available, token usage, and non-secret metadata. - Add a profile recorder or response accumulator so `RunManifest.LLMProfiles` records the actual Scriptorium profile/provider/model values used during the run. - Do not rely on one precomputed profile ID before pipeline execution. - Deduplicate profile manifest entries deterministically. - Keep the existing `Scheduler` and scheduled client wrapper unless Scriptorium provides an equivalent Notarius-approved concurrency mechanism. - Ensure all Scriptorium errors are wrapped with context and converted to concise CLI-facing errors. Preserve `errors.Is` checks internally when practical. - Apply Notarius secret redaction to errors before writing diagnostics. - Remove production construction of `OpenAICompatibleClient`. Acceptance tests: - Scriptorium adapter maps Notarius prompt request fields into the expected Scriptorium run request using an injected fake Scriptorium LLM client. - Successful structured output unmarshals into the caller target. - Scriptorium validation failure returns an error. - Provider/runtime failure returns an error with operation context. - Context cancellation is respected. - Token usage maps into `StructuredCompletionResponse`. - Used Scriptorium profile/provider/model metadata appears in the run manifest without secrets. - API keys or bearer tokens in synthetic errors are redacted. - Scheduler still bounds concurrent Scriptorium-backed calls. Focused checks: - `go test ./internal/framework/llm` - `go test ./internal/framework/pipeline` - `go test ./internal/cli` ## Stage 6: Module Cutover And Legacy Runtime Removal Goal: update production modules to call the prompt-based contract and remove obsolete local prompt/runtime code. Work: - Update `dnd/scenes`: - stop rendering local system/user prompt strings; - call `CompleteStructured` with `PromptID`, `PromptVersion`, `SessionID`, `ProfileID` or request LLM profile, `transcript` input material, and any required vars; - keep existing response validation, chunk canonicalization, caveat handling, and manifest metadata policy. - Update `dnd/spells`: - stop rendering local system/user prompt strings; - call `CompleteStructured` with `transcript`, optional `roster`, optional `glossary`, session ID, profile ID, and vars; - keep existing spell response validation, source-reference validation, and manifest metadata policy. - Update any LLM-backed normalize modules if present. If only noop normalize is present, ensure the contract and tests prove normalizers can receive the same Scriptorium-capable client and prompt inputs. - Remove the old `internal/framework/prompt` renderer if no remaining code uses it. If generic tests still need prompt rendering, replace them with Scriptorium prompt asset tests or delete obsolete tests. - Remove `internal/framework/llm/openai_compatible_client.go` and its tests after the Scriptorium adapter tests cover replacement behavior. - Remove local OpenAI-compatible integration docs after current-behavior docs are updated in Stage 7. - Remove stale schema registry helpers only if they are no longer needed for module-owned schema metadata. Keep lightweight schema hashing/loading helpers if modules still use them for manifest metadata. Acceptance tests: - `dnd/scenes` fake-client tests assert the module sends prompt ID, transcript input, session ID, and schema/prompt metadata rather than rendered message text. - `dnd/spells` fake-client tests assert roster/glossary inputs are passed as inputs and not interpolated locally. - Existing malformed LLM response tests still fail as malformed structured output. - Existing source-reference validation tests still pass. - No production code imports the old prompt renderer or local OpenAI-compatible client. Focused checks: - `go test ./internal/modules/chunk/dnd/scenes` - `go test ./internal/modules/extract/dnd/spells` - If `internal/framework/prompt` is deleted, do not run a package-specific test for it; instead verify with `rg -n "internal/framework/prompt|RenderUserSystem" internal` that no production code still depends on it. ## Stage 7: CLI, Examples, Docs, And Full Validation Goal: finish user-facing behavior, examples, and canonical docs for the new runtime. Work: - Update `examples/dnd-spells.config.yml` and any maintained test configs to the new config version and Scriptorium profile source behavior. - Update `docs/config.md`: - new config version; - `scriptorium.profile_dir` and `scriptorium.profile_file`; - `llm_profile` now means Scriptorium profile ID; - empty `llm_profile` behavior; - removed `llm_profiles`; - removed `NOTARIUS_LLM_DEFAULT_*` env behavior if removed; - secret-handling policy through Scriptorium profile `api_key_env`. - Update `docs/cli.md`: - add `--session-id`; - update `--llm-profile` wording to Scriptorium profile override; - update run examples if needed. - Update `docs/internal/llm.md`: - Scriptorium-backed runtime; - prompt asset loading; - input material handling; - scheduling; - structured-output validation; - profile manifest recording; - secret redaction. - Update `docs/internal/modules.md`: - module prompt ownership through Scriptorium prompt definitions; - chunk/extract/normalize request input materials and session ID; - D&D scenes/spells prompt metadata. - Update `docs/internal/pipeline.md`: - raw input material lifecycle; - reference material lifecycle into prompt inputs; - session ID lifecycle; - actual LLM profile provenance. - Update `docs/integrations/json-output.md` for any manifest changes. - Remove or rewrite `docs/integrations/openai-compatible.md`. If no local OpenAI-compatible adapter remains, do not document it as current Notarius behavior. - Update `docs/troubleshooting.md` for: - Scriptorium profile-not-found; - prompt-not-found; - schema/validation failures; - missing API key env values; - session ID usage if relevant. - Update `docs/operations.md` if diagnostics or retention behavior changes. - Remove the Scriptorium migration item from `docs/roadmap/future.md` once the feature is implemented. - Replace `docs/roadmap/implementation.md` with a completed note or remove it after implementation is complete, according to the repository's current roadmap cleanup pattern. Acceptance tests and inspections: - `rg -n "llm_profiles|OpenAICompatibleClient|openai-compatible|RenderUserSystem|NOTARIUS_LLM_DEFAULT" internal docs examples` should return only intentional historical/deferred references, if any. - `rg -n "scriptorium|session-id|profile_dir|profile_file" docs examples` should show current-behavior docs and examples are updated. - `go test ./...` - `go vet ./...` - `go build ./cmd/notarius` ## Cross-Stage Review Checklist Before considering the implementation complete, verify: - No Scriptorium public types appear in chunk, extract, or normalize contracts. - No raw source input, reference content, prompt text, schema JSON, API key, or bearer token appears in manifests or default diagnostics. - The shared transcript message uses original Seriatim JSON bytes exactly. - Large reference content can be supplied as Scriptorium prompt inputs without changing module-facing reference slot contracts. - `--session-id` is easy for an external orchestrator to pass. - Empty `llm_profile` lets prompt defaults work; explicit `llm_profile` and `--llm-profile` select Scriptorium profile IDs. - Production Notarius LLM execution goes through Scriptorium. - Current-behavior documentation describes only implemented behavior.