@zenodinh/pi-render 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/index.ts +284 -0
  4. package/package.json +70 -5
  5. package/src/commands/canvas.ts +57 -0
  6. package/src/core/code-theme.ts +275 -0
  7. package/src/core/log.ts +57 -0
  8. package/src/core/paint.ts +64 -0
  9. package/src/core/registry.ts +135 -0
  10. package/src/core/settings.ts +119 -0
  11. package/src/core/types/code-theme.ts +24 -0
  12. package/src/core/types/host.ts +169 -0
  13. package/src/core/types/log.ts +28 -0
  14. package/src/core/types/paint.ts +54 -0
  15. package/src/core/types/registry.ts +44 -0
  16. package/src/core/types/settings.ts +31 -0
  17. package/src/core/types.ts +34 -0
  18. package/src/renderers/content/artifacts/cache.ts +234 -0
  19. package/src/renderers/content/artifacts/cards.ts +136 -0
  20. package/src/renderers/content/artifacts/engines.ts +396 -0
  21. package/src/renderers/content/artifacts/local-binary.ts +80 -0
  22. package/src/renderers/content/artifacts/prereqs.ts +128 -0
  23. package/src/renderers/content/artifacts/server.ts +181 -0
  24. package/src/renderers/content/code-panel.ts +161 -0
  25. package/src/renderers/content/image-card.ts +252 -0
  26. package/src/renderers/content/index.ts +79 -0
  27. package/src/renderers/content/json-panel.ts +116 -0
  28. package/src/renderers/content/table.ts +174 -0
  29. package/src/renderers/content/types.ts +20 -0
  30. package/src/renderers/tool/index.ts +113 -0
  31. package/src/renderers/tool/runtime.ts +267 -0
  32. package/src/renderers/tool/specs/bash.ts +168 -0
  33. package/src/renderers/tool/specs/codemode.ts +248 -0
  34. package/src/renderers/tool/specs/edit.ts +213 -0
  35. package/src/renderers/tool/specs/ls.ts +136 -0
  36. package/src/renderers/tool/specs/read.ts +296 -0
  37. package/src/renderers/tool/specs/search.ts +325 -0
  38. package/src/renderers/tool/specs/write.ts +142 -0
  39. package/src/renderers/tool/types.ts +45 -0
  40. package/themes/dracula-soft.json +81 -0
  41. package/themes/one-dark.json +80 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * host.ts — the host shapes pi-render reads, composes against, and registers through.
3
+ *
4
+ * Boundary: these are STRUCTURAL declarations of shapes the pi host also declares. No host
5
+ * package is imported here, so a plain object literal satisfies them and a unit test needs no pi
6
+ * runtime; the composition root (index.ts) passes the real host in as a parameter.
7
+ *
8
+ * shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
9
+ *
10
+ * Ported from pi-pretty-tui/src/types.ts — survives because: host-verified structural shapes keep
11
+ * every renderer decoupled from a host import, so unit tests need no pi runtime. The predecessor's
12
+ * `*Like` suffix meant "structural declaration of the host shape"; that meaning lives in these docs.
13
+ */
14
+
15
+ /** The host theme slice paint reads. Structural host shape: any object with these two members qualifies. */
16
+ export interface HostTheme {
17
+ /** Maps a theme token and text to escape-wrapped text, e.g. fg("accent", "ok"). Required. */
18
+ fg(key: string, text: string): string;
19
+ /** Wraps text in the theme's bold escape sequence. Required. */
20
+ bold(text: string): string;
21
+ }
22
+
23
+ /** Live markdown-theme closures the content painter wraps. Structural host shape — no host import. */
24
+ export interface MarkdownTheme {
25
+ /** Horizontal-rule escape wrapper. Optional: a host without this token degrades to plain text. */
26
+ hr?(text: string): string;
27
+ /** Blockquote body wrapper. Optional. */
28
+ quote?(text: string): string;
29
+ /** Blockquote left-border wrapper. Optional. */
30
+ quoteBorder?(text: string): string;
31
+ /** Inline-code wrapper. Optional. */
32
+ code?(text: string): string;
33
+ /** Fenced-code-block border wrapper. Optional. */
34
+ codeBlockBorder?(text: string): string;
35
+ }
36
+
37
+ /** The pi-tui component slice renderers return and receive. Structural host shape — no host import. */
38
+ export interface UiComponent {
39
+ /** Draws the current frame as lines; `width` is the terminal width in columns. Required. */
40
+ render(width: number): string[];
41
+ /** Marks the cached frame stale so the next render() redraws. Required. */
42
+ invalidate(): void;
43
+ /** Replaces the component's text. Optional: base components the runtime delegates to do not have it. */
44
+ setText?(v: string): void;
45
+ }
46
+
47
+ /** The text-component slice the runtime reads back. Structural host shape — no host import. */
48
+ export interface TextComponent {
49
+ /** Replaces the displayed text. Required. */
50
+ setText(v: string): void;
51
+ /** Reads the displayed text back. Optional: the host's own component may not expose it. */
52
+ getText?(): string;
53
+ }
54
+
55
+ /** What renderCall / renderResult receive. Structural host shape — no host import; a plain object is accepted. */
56
+ export interface RenderContext {
57
+ /** ONE state bag per row, shared across that row's invocations; the parse-once memo lives here. Required. */
58
+ state: Record<string, unknown>;
59
+ /** Tool arguments as the host passes them; unreadable until argsComplete is true. Optional. */
60
+ args?: Record<string, unknown>;
61
+ /** Stable id of the tool call this row draws, used as the logOnce key. Optional. */
62
+ toolCallId?: string;
63
+ /** Asks the host to redraw this row, e.g. after an async decorate() resolved. Optional. */
64
+ invalidate?: () => void;
65
+ /** The component returned on the previous invocation of the same row. Optional. */
66
+ lastComponent?: UiComponent;
67
+ /** Working directory the tool ran in, when the host reports one. Optional. */
68
+ cwd?: string;
69
+ /** True once the host has delivered the full argument set — the gate for argument-dependent output. Optional. */
70
+ argsComplete?: boolean;
71
+ /** True while the row is drawn expanded (ctrl+o or click). Optional. */
72
+ expanded?: boolean;
73
+ /** True when the tool call failed; the row collapses to an error summary. Optional. */
74
+ isError?: boolean;
75
+ /** True while the result is still streaming; the row must stay cheap. Optional. */
76
+ isPartial?: boolean;
77
+ }
78
+
79
+ /** One result content block; only text blocks are rendered. Structural host shape. */
80
+ export interface ToolResultContent {
81
+ /** Block kind as the host labels it, e.g. "text" or "image". Required. */
82
+ type: string;
83
+ /** The block's text; absent for non-text blocks. Optional. */
84
+ text?: string;
85
+ }
86
+
87
+ /** The result slice renderers read. Structural host shape — the shape is narrowed, never assumed. */
88
+ export interface ToolResult {
89
+ /** Result blocks in host order. Optional: a result may carry details only. */
90
+ content?: ToolResultContent[];
91
+ /** Host-supplied structured payload; opaque here and never rendered directly. Optional. */
92
+ details?: unknown;
93
+ /** True when the tool call failed. Optional. */
94
+ isError?: boolean;
95
+ }
96
+
97
+ /** Renderer options exactly as the host passes them — one bag per render call, no defaults invented. */
98
+ export interface RenderResultOptions {
99
+ /** True while the row is drawn expanded. Required. */
100
+ expanded: boolean;
101
+ /** True while the result is still streaming. Required. */
102
+ isPartial: boolean;
103
+ }
104
+
105
+ /** The renderer triple the resolver returns or delegates to; renderShell stays host-owned. */
106
+ export interface ToolRenderers {
107
+ /** Asks the host for its own shell frame instead of drawing one. Optional: exactly "default" or "self". */
108
+ renderShell?: "default" | "self";
109
+ /** Draws the call line. Optional: omitting it delegates this row to the next resolver. */
110
+ renderCall?(args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent;
111
+ /** Draws the result. Optional: omitting it delegates this row to the next resolver. */
112
+ renderResult?(result: ToolResult, options: RenderResultOptions, theme: HostTheme, ctx: RenderContext): UiComponent;
113
+ }
114
+
115
+ /** The host resolver chain signature. Structural host shape — the host calls it, pi-render returns it. */
116
+ export type ToolRendererResolver = (
117
+ /** Name of the tool being rendered, exactly as the host spells it, e.g. "bash". Required. */
118
+ toolName: string,
119
+ /** Returns what the remaining chain would draw, or undefined when the chain is exhausted. Required. */
120
+ next: () => ToolRenderers | undefined,
121
+ ) => ToolRenderers | undefined;
122
+
123
+ /** The host markdown-transform context slice surfaces read. Structural host shape — no host import. */
124
+ export interface TransformContext {
125
+ /** Which transcript stream the text belongs to. Required: "user" | "assistant" | "assistant-thinking". */
126
+ messageType: "user" | "assistant" | "assistant-thinking";
127
+ /** True while the message is still streaming in; a streaming message is not rewritten. Required. */
128
+ isStreaming: boolean;
129
+ /** Columns available to the message, in the host's character-cell units. Required; > 0. */
130
+ availableWidth: number;
131
+ }
132
+
133
+ /** The command-handler context slice /canvas reads. Structural host shape — no host import. */
134
+ export interface CommandContext {
135
+ /** Host output mode. Required: "tui" | "rpc" | "json" | "print". */
136
+ mode: "tui" | "rpc" | "json" | "print";
137
+ /** Host notification seam for user-facing feedback. Required. */
138
+ ui: {
139
+ /** Shows one message to the user, e.g. notify("Canvas closed"). Required. */
140
+ notify(message: string): void;
141
+ };
142
+ }
143
+
144
+ /**
145
+ * The host surface the extension registers through. Structural host shape: the host arrives as the
146
+ * composition root's parameter, never as a module import, so this file stays pi-free.
147
+ */
148
+ export interface ExtensionApi {
149
+ /** Adds one resolver to the tool-render chain. Required; called exactly once, in index.ts. */
150
+ registerToolRenderer(resolver: ToolRendererResolver): void;
151
+ /** Adds one markdown rewrite stage. Required; called exactly once, in index.ts. */
152
+ registerMarkdownTransformer(transformer: (markdown: string, ctx: TransformContext) => string): void;
153
+ /** Registers one slash command, e.g. name "canvas". Required; one call per command. */
154
+ registerCommand(
155
+ name: string,
156
+ options: { description?: string; handler: (args: string, ctx: CommandContext) => Promise<void> },
157
+ ): void;
158
+ /** Subscribes to a host event, e.g. "session_start". Required; the per-event ctx carries ui. */
159
+ on(event: string, handler: (event: unknown, ctx: EventContext) => void): void;
160
+ }
161
+
162
+ /** The context a host event handler receives. Structural host shape. */
163
+ export interface EventContext {
164
+ /** The host's UI surface for this event — present in interactive mode, a no-op stub otherwise. Optional. */
165
+ ui?: {
166
+ /** Collapses (false) / expands (true) every tool row. The collapse-first default rides this. Optional. */
167
+ setToolsExpanded?(expanded: boolean): void;
168
+ };
169
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * log.ts — the diagnostics contract core/log (T-06) implements.
3
+ *
4
+ * Boundary: renderers call these from a render path, so the contract forbids throwing and forbids
5
+ * writing to stdout/stderr — pi owns both for the TUI.
6
+ *
7
+ * shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
8
+ */
9
+
10
+ /** One captured diagnostic entry: the memory record a test or the /render panel reads back. */
11
+ export interface LogEntry {
12
+ /** Emitting module scope, e.g. "tool.bash". Required. */
13
+ scope: string;
14
+ /** One-line diagnostic text, never multi-line. Required. */
15
+ message: string;
16
+ /** Capture time in epoch milliseconds (Date.now()). Required. */
17
+ time: number;
18
+ }
19
+
20
+ /** Keyed in-memory diagnostics. Structural core shape: any object with these three methods qualifies. */
21
+ export interface Logger {
22
+ /** Appends one entry. Required; repeats freely, so keep it off per-frame paths. */
23
+ logLine(scope: string, message: string): void;
24
+ /** Appends at most one entry per key, e.g. key "tool:call-42". Required — the per-frame default. */
25
+ logOnce(key: string, scope: string, message: string): void;
26
+ /** Returns every captured entry and empties the buffer. Required; tests and the panel consume this. */
27
+ drain(): LogEntry[];
28
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * paint.ts — the painting contract: the two role sets every renderer draws through.
3
+ *
4
+ * Boundary: region 1 is the tool row, region 2 is markdown content. Renderers call role methods
5
+ * and never emit an escape sequence themselves (AGENTS: escapes originate in core/paint.ts).
6
+ *
7
+ * shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
8
+ */
9
+
10
+ import type { HostTheme } from "./host.ts";
11
+
12
+ /** Region 1 — the tool-row roles. Text in, theme-derived escape-wrapped text out. */
13
+ export interface RowPaint {
14
+ /** The tool name or call title, e.g. title("bash"). Required. */
15
+ title(text: string): string;
16
+ /** Tool output / result text. Required. */
17
+ output(text: string): string;
18
+ /** De-emphasized metadata such as durations and counts. Required. */
19
+ muted(text: string): string;
20
+ /** Accent for the active or highlighted element. Required. */
21
+ accent(text: string): string;
22
+ /** Error text on a failed call. Required. */
23
+ error(text: string): string;
24
+ /** Warning text for a degraded or partial result. Required. */
25
+ warning(text: string): string;
26
+ /** The row's left gutter marker. Required. */
27
+ gutter(text: string): string;
28
+ /** A matched substring highlighted inside a result line. Required. */
29
+ match(text: string): string;
30
+ /** Diff line added (+), from the host toolDiffAdded token. Required. */
31
+ diffAdded(text: string): string;
32
+ /** Diff line removed (−), from the host toolDiffRemoved token. Required. */
33
+ diffRemoved(text: string): string;
34
+ /** Diff context line, from the host toolDiffContext token. Required. */
35
+ diffContext(text: string): string;
36
+ }
37
+
38
+ /** Region 1 factory: the theme arrives per render call and is read live, so a theme switch repaints. */
39
+ export type RowPaintFactory = (
40
+ /** The host theme as delivered on this render call. Required. */
41
+ theme: HostTheme,
42
+ ) => RowPaint;
43
+
44
+ /** Region 2 — the markdown-content roles. */
45
+ export interface ContentPaint {
46
+ /** Horizontal rule; falls back hr → quoteBorder → plain rather than throw. Required. */
47
+ rule(text: string): string;
48
+ /** Blockquote body. Required. */
49
+ quote(text: string): string;
50
+ /** Inline code. Required. */
51
+ code(text: string): string;
52
+ /** Fenced-code-block border. Required. */
53
+ codeBlockBorder(text: string): string;
54
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * registry.ts — the module-registry contract core/registry (T-09) implements.
3
+ *
4
+ * Boundary: index.ts is the only registration site; renderers read the registry per invocation and
5
+ * never write it.
6
+ *
7
+ * shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
8
+ */
9
+
10
+ /** One declared setting: the /render panel renders an editor from this spec. */
11
+ export interface SettingSpec {
12
+ /** Setting name inside the module's slice, e.g. "collapseAt". Required; unique per module. */
13
+ name: string;
14
+ /** Editor kind. Required: "enum" | "toggle" | "number". */
15
+ kind: "enum" | "toggle" | "number";
16
+ /** Allowed values — required when kind is "enum", ignored otherwise. Optional. */
17
+ values?: string[];
18
+ /** Value used when the document omits the key. Required; must read as `unknown` before use. */
19
+ default: unknown;
20
+ }
21
+
22
+ /** One pluggable module's registration. Duplicate keys keep the first and log once. */
23
+ export interface ModuleDescriptor {
24
+ /** Registry key, unique module id, e.g. "row.bash". Required. */
25
+ key: string;
26
+ /** Human-readable name shown in the /render panel, e.g. "Bash row". Required. */
27
+ name: string;
28
+ /** Initial enabled state before any settings write. Required. */
29
+ defaultEnabled: boolean;
30
+ /** Settings this module exposes. Required; use [] when it exposes none. */
31
+ settings: SettingSpec[];
32
+ }
33
+
34
+ /** Consumers poll per invocation; a settings write lands on the next read and pushes no redraw. */
35
+ export interface Registry {
36
+ /** Declares one module. Required; a repeat key keeps the first definition. */
37
+ defineModule(d: ModuleDescriptor): void;
38
+ /** Reads a module's enabled flag by key, e.g. "row.bash". Required; an unknown key reads false. */
39
+ isEnabled(key: string): boolean;
40
+ /** Reads a module's effective settings (defaults merged with stored values). Required; unknown key = {}. */
41
+ getSettings(key: string): Record<string, unknown>;
42
+ /** Merges a patch into a module's stored settings. Required; takes effect on the next read, not now. */
43
+ setSettings(key: string, patch: object): void;
44
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * settings.ts — the persisted-settings contract core/settings (T-07) implements.
3
+ *
4
+ * Boundary: only the store touches the disk; a render path reads the cached document it returns.
5
+ *
6
+ * shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
7
+ */
8
+
9
+ /** One module's persisted slice, e.g. { enabled: false, settings: { collapseAt: 3 } }. */
10
+ export interface ModuleSettings {
11
+ /** Whether the module's row/surface is active. Required; a missing module key reads as enabled. */
12
+ enabled: boolean;
13
+ /** The module's own key/value bag, validated by the module that owns it. Optional; default {}. */
14
+ settings?: Record<string, unknown>;
15
+ }
16
+
17
+ /** The persisted document: one file for every module. A malformed value is replaced by all defaults. */
18
+ export interface SettingsDoc {
19
+ /** Document schema revision, currently 1 — bumped by a migration, never by a module. Required. */
20
+ version: number;
21
+ /** Module slices keyed by registry key, e.g. "row.bash". Required; absent key = that module's defaults. */
22
+ modules: Record<string, ModuleSettings>;
23
+ }
24
+
25
+ /** Cached load plus atomic save. A render path never touches the disk after the first load. */
26
+ export interface SettingsStore {
27
+ /** Returns the cached document, reading the file only on first call. Required. */
28
+ load(): SettingsDoc;
29
+ /** Writes the document atomically (temp file + rename). Required; a failure degrades to in-memory. */
30
+ save(doc: SettingsDoc): void;
31
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * types.ts — the seam catalog barrel: every cross-lane contract, in one import path.
3
+ *
4
+ * Boundary: cross-lane contracts are declared per scope under `src/core/types/` and per lane under
5
+ * `src/renderers/{tool,content}/types.ts`; this file re-exports them so `core/types.ts` stays the
6
+ * stable import path every module and ticket names. Add a type to its scope file, never here.
7
+ * AC-2 scope: "a later ticket finds a missing interface" means a missing cross-lane interface in
8
+ * one of those files, never any interface a ticket happens to mention.
9
+ *
10
+ * shape: none — re-export barrel (no runtime unit), so no DSG-1 shape trigger applies.
11
+ */
12
+
13
+ export type { Surface, SurfaceKey } from "../renderers/content/types.ts";
14
+ export type { CallModel, RowSpec, RowView } from "../renderers/tool/types.ts";
15
+ export type { CodeTheme, HighlightEngine } from "./types/code-theme.ts";
16
+ export type {
17
+ CommandContext,
18
+ ExtensionApi,
19
+ HostTheme,
20
+ MarkdownTheme,
21
+ RenderContext,
22
+ RenderResultOptions,
23
+ TextComponent,
24
+ ToolRendererResolver,
25
+ ToolRenderers,
26
+ ToolResult,
27
+ ToolResultContent,
28
+ TransformContext,
29
+ UiComponent,
30
+ } from "./types/host.ts";
31
+ export type { LogEntry, Logger } from "./types/log.ts";
32
+ export type { ContentPaint, RowPaint, RowPaintFactory } from "./types/paint.ts";
33
+ export type { ModuleDescriptor, Registry, SettingSpec } from "./types/registry.ts";
34
+ export type { ModuleSettings, SettingsDoc, SettingsStore } from "./types/settings.ts";
@@ -0,0 +1,234 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/cache.ts — survives because: the hash-keyed atomic pair
2
+ // is what makes a repeated fence free, and the two caps keep that promise bounded.
3
+
4
+ /**
5
+ * Disk cache for rendered diagrams: hash-keyed artifact + meta pairs under
6
+ * `~/.pi/agent/pi-render/cache`, bounded so repeat renders stay free without the store growing without
7
+ * limit.
8
+ *
9
+ * Why pairs and why atomic: the artifact IS the render result and the meta is its
10
+ * provenance (source form, mode, dimensions, creation time) — eviction orders
11
+ * by meta.createdAt. Every write lands via tmp+rename so a concurrent reader
12
+ * sees the old or the new complete file, never a partial one.
13
+ *
14
+ * shape: closure returning an object literal — trigger #4, one bound directory plus the store methods,
15
+ * so no class, subclassing, or instanceof.
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import type { Logger } from "../../../core/types/log.ts";
22
+
23
+ export interface CacheMeta {
24
+ source: string;
25
+ mode: string;
26
+ mime: string;
27
+ rows: number;
28
+ widthCells: number;
29
+ createdAt: string;
30
+ }
31
+
32
+ export interface CacheOptions {
33
+ cacheDir?: string;
34
+ /** Diagnostics sink; absent means eviction failures are swallowed unwritten. */
35
+ log?: Logger;
36
+ }
37
+
38
+ const CACHE_DIR_NAME = "cache";
39
+ const SCOPE = "content.artifacts";
40
+ const META_SUFFIX = ".meta.json";
41
+ const PNG_SUFFIX = ".png";
42
+ const VECTOR_SUFFIX = ".svg";
43
+ const SOURCE_INFIX = ".src.";
44
+ const MAX_BYTES = 256 * 1024 * 1024;
45
+ const MAX_ENTRIES = 2048;
46
+
47
+ /**
48
+ * shape: none — one env lookup beside the cache dir name; `PI_CODING_AGENT_DIR` mirrors the host's
49
+ * agent-directory override, and the tree is the one `settings.json` already lives in.
50
+ */
51
+ function resolveCacheDir(opts?: CacheOptions): string | undefined {
52
+ if (opts?.cacheDir) return opts.cacheDir;
53
+ const agentDir = process.env.PI_CODING_AGENT_DIR || (process.env.HOME ? join(process.env.HOME, ".pi/agent") : "");
54
+ return agentDir ? join(agentDir, "pi-render", CACHE_DIR_NAME) : undefined;
55
+ }
56
+
57
+ function writeAtomic(path: string, data: Buffer): void {
58
+ const temporary = `${path}.tmp`;
59
+ writeFileSync(temporary, data);
60
+ renameSync(temporary, path);
61
+ }
62
+
63
+ /** shape: none — one hash over a fixed tuple; the key format is the whole contract. */
64
+ export function renderCacheKey(form: string, source: string, mode: string, widthCells: number): string {
65
+ return createHash("sha256").update(`${form}\0${source}\0${mode}\0${widthCells}`).digest("hex");
66
+ }
67
+
68
+ /** The directory the render cache lives in; exported so callers can point a viewer at it. */
69
+ export function resolveRenderedCacheDir(opts?: CacheOptions): string | undefined {
70
+ return resolveCacheDir(opts);
71
+ }
72
+
73
+ /** shape: none — the vector is the artifact the card opens; existence is the hit. */
74
+ export function putRenderedVector(key: string, svg: string, meta: CacheMeta, opts: CacheOptions = {}): void {
75
+ const dir = resolveCacheDir(opts);
76
+ if (!dir) return;
77
+ mkdirSync(dir, { recursive: true });
78
+ writeAtomic(join(dir, `${key}${VECTOR_SUFFIX}`), Buffer.from(svg));
79
+ writeAtomic(join(dir, `${key}${META_SUFFIX}`), Buffer.from(`${JSON.stringify(meta, null, 2)}\n`));
80
+ }
81
+
82
+ /** shape: none — one existence check; the path is what the card opens. */
83
+ export function getRenderedVectorPath(key: string, opts: CacheOptions = {}): string | undefined {
84
+ const dir = resolveCacheDir(opts);
85
+ if (!dir) return undefined;
86
+ const path = join(dir, `${key}${VECTOR_SUFFIX}`);
87
+ return existsSync(path) ? path : undefined;
88
+ }
89
+
90
+ /** shape: none — one atomic write; the source is kept for copy/reuse, never read back. */
91
+ export function putRenderedSource(
92
+ key: string,
93
+ source: string,
94
+ extension: string,
95
+ opts: CacheOptions = {},
96
+ ): string | undefined {
97
+ const dir = resolveCacheDir(opts);
98
+ if (!dir) return undefined;
99
+ mkdirSync(dir, { recursive: true });
100
+ const path = join(dir, `${key}${SOURCE_INFIX}${extension}`);
101
+ writeAtomic(path, Buffer.from(source));
102
+ return path;
103
+ }
104
+
105
+ /** boundary: a persisted meta file is untrusted JSON and narrows here before any field is read. */
106
+ function isCacheMeta(value: unknown): value is CacheMeta {
107
+ if (typeof value !== "object" || value === null) return false;
108
+ if (
109
+ !(
110
+ "source" in value &&
111
+ "mode" in value &&
112
+ "mime" in value &&
113
+ "rows" in value &&
114
+ "widthCells" in value &&
115
+ "createdAt" in value
116
+ )
117
+ ) {
118
+ return false;
119
+ }
120
+ return (
121
+ typeof value.source === "string" &&
122
+ typeof value.mode === "string" &&
123
+ typeof value.mime === "string" &&
124
+ typeof value.rows === "number" &&
125
+ typeof value.widthCells === "number" &&
126
+ typeof value.createdAt === "string"
127
+ );
128
+ }
129
+
130
+ /** shape: none — one atomic pair write; Command does not apply (not queued or replayable). */
131
+ export function putRenderedEntry(key: string, png: Buffer, meta: CacheMeta, opts: CacheOptions = {}): void {
132
+ const dir = resolveCacheDir(opts);
133
+ if (!dir) return;
134
+ mkdirSync(dir, { recursive: true });
135
+ writeAtomic(join(dir, `${key}${PNG_SUFFIX}`), png);
136
+ writeAtomic(join(dir, `${key}${META_SUFFIX}`), Buffer.from(`${JSON.stringify(meta, null, 2)}\n`));
137
+ }
138
+
139
+ /** shape: none — one guarded read pair; returns undefined on any miss. */
140
+ export function getRenderedEntry(key: string, opts: CacheOptions = {}): { png: Buffer; meta: CacheMeta } | undefined {
141
+ const dir = resolveCacheDir(opts);
142
+ if (!dir) return undefined;
143
+ try {
144
+ const raw: unknown = JSON.parse(readFileSync(join(dir, `${key}${META_SUFFIX}`), "utf8"));
145
+ if (!isCacheMeta(raw)) return undefined;
146
+ return { png: readFileSync(join(dir, `${key}${PNG_SUFFIX}`)), meta: raw };
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * shape: none — a bounded sweep; eviction is cleanup, never a render outcome.
154
+ * Two caps (bytes and entry count) both order by meta.createdAt ascending; an
155
+ * unreadable meta sorts first so the least trustworthy entry leaves earliest.
156
+ * Every failure is logged and swallowed — a full disk must not fail a render.
157
+ */
158
+ export function evictIfNeeded(opts: CacheOptions = {}): void {
159
+ const dir = resolveCacheDir(opts);
160
+ if (!dir || !existsSync(dir)) return;
161
+ try {
162
+ const entries = new Map<string, { bytes: number; createdAt: string; files: string[] }>();
163
+ for (const name of readdirSync(dir)) {
164
+ const base = name.endsWith(META_SUFFIX)
165
+ ? name.slice(0, -META_SUFFIX.length)
166
+ : name.endsWith(PNG_SUFFIX)
167
+ ? name.slice(0, -PNG_SUFFIX.length)
168
+ : name.endsWith(VECTOR_SUFFIX)
169
+ ? name.slice(0, -VECTOR_SUFFIX.length)
170
+ : name.includes(SOURCE_INFIX)
171
+ ? name.slice(0, name.indexOf(SOURCE_INFIX))
172
+ : undefined;
173
+ if (base === undefined) continue;
174
+ const entry = entries.get(base) ?? { bytes: 0, createdAt: "", files: [] };
175
+ entry.files.push(name);
176
+ entry.bytes += statSync(join(dir, name)).size;
177
+ entries.set(base, entry);
178
+ }
179
+ for (const [base, entry] of entries) {
180
+ try {
181
+ const raw: unknown = JSON.parse(readFileSync(join(dir, `${base}${META_SUFFIX}`), "utf8"));
182
+ if (isCacheMeta(raw)) entry.createdAt = raw.createdAt;
183
+ } catch {
184
+ // An unreadable meta keeps the empty sort key: evicted first.
185
+ }
186
+ }
187
+
188
+ let totalBytes = 0;
189
+ for (const entry of entries.values()) totalBytes += entry.bytes;
190
+ let count = entries.size;
191
+ if (totalBytes <= MAX_BYTES && count <= MAX_ENTRIES) return;
192
+
193
+ const oldestFirst = [...entries.entries()].sort((a, b) => (a[1].createdAt < b[1].createdAt ? -1 : 1));
194
+ for (const [, entry] of oldestFirst) {
195
+ if (totalBytes <= MAX_BYTES && count <= MAX_ENTRIES) break;
196
+ for (const file of entry.files) rmSync(join(dir, file), { force: true });
197
+ totalBytes -= entry.bytes;
198
+ count -= 1;
199
+ }
200
+ } catch (error) {
201
+ opts.log?.logLine(SCOPE, `cache eviction failed: ${error instanceof Error ? error.message : String(error)}`);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The artifacts-internal cache surface: content-hash keyed through `renderCacheKey`, one instance bound
207
+ * to a directory. T-26 reads artifact paths from it; T-28 injects the one production instance.
208
+ */
209
+ export interface ArtifactCache {
210
+ /** The cached raster pair, or undefined on any miss. */
211
+ get(key: string): { png: Buffer; meta: CacheMeta } | undefined;
212
+ /** One atomic raster pair write. */
213
+ put(key: string, png: Buffer, meta: CacheMeta): void;
214
+ /** One atomic vector write — the artifact a vector card opens. */
215
+ putVector(key: string, svg: string, meta: CacheMeta): void;
216
+ /** Absolute vector path, or undefined when absent. */
217
+ vectorPath(key: string): string | undefined;
218
+ /** Saves the fence source beside its artifact; returns the absolute path. */
219
+ putSource(key: string, source: string, extension: string): string | undefined;
220
+ /** Bounded sweep; never throws. */
221
+ evict(): void;
222
+ }
223
+
224
+ /** shape: none — one delegating literal over the ported free functions; no state of its own. */
225
+ export function createArtifactCache(opts: CacheOptions = {}): ArtifactCache {
226
+ return {
227
+ get: (key) => getRenderedEntry(key, opts),
228
+ put: (key, png, meta) => putRenderedEntry(key, png, meta, opts),
229
+ putVector: (key, svg, meta) => putRenderedVector(key, svg, meta, opts),
230
+ vectorPath: (key) => getRenderedVectorPath(key, opts),
231
+ putSource: (key, source, extension) => putRenderedSource(key, source, extension, opts),
232
+ evict: () => evictIfNeeded(opts),
233
+ };
234
+ }