OR Key
drop another .md file to compare - side-by-side diff against artifacts

artifacts

Keeps your saved charts, tables, and briefs handy so you can reopen them anytime.
description: "Triggers on prompt mention of 'artifacts'."
personal 2 files

What it does for you

Keeps your saved charts, tables, and briefs handy so you can reopen them anytime.

What it produces

A recent result, so you can see the kind of work it returns.

loading…

How to get it

These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.

Work with me
For developers how this skill is built, graded, and how it runs

at a glance- the short version

actorListArtifacts() / createArtifact() in
auditorState/lint/library-shape.ts (the lib must export the two
eval modeshape
stages2

what's inside - the parts that make up a skill 3/4 present

A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.

The skill
state/skills/artifacts/SKILL.md present
the skill itself, in plain text
The main file. It says what the skill is and lays out the steps in plain English.
Code
state/lib/artifacts.ts present
code the skill can run
Reusable code this skill can call when it needs to.
Scripts
state/bin/artifacts/ not present
helper scripts
Optional. Added when a skill has a few commands to run.
Loader
state/skills/artifacts/AGENTS.md present
what the AI loads on the fly
Loaded automatically the moment this skill is needed. Kept short on purpose.

how it's graded - what counts as a good run 5 criteria · 5 deterministic

Each row is one thing a good run has to get right. deterministic means a quick check decides, pass or fail. judge means the AI reads the result and rates it. Grading each piece on its own (instead of one overall score) shows exactly where a run fell short, so the fix is obvious.

name
kind
check
primary_path_responds
deterministic
GET /artifacts succeeds (HTTP 200) and returns a valid JSON array (even if empty: []).
create_persists_artifact
deterministic
POST /artifacts with {name: '...'} returns {id, name, created_at, status, ...}; artifact file created in state/log/artifacts/; subsequent GET shows it.
if_endpoint_down_fallback_capped
deterministic
If HTTP endpoints are unavailable (500 errors, connection refused), eval verb is marked 'fallback' and score is capped at 0.5 max.
atomic_write_no_corruption
deterministic
No `.tmp` files appear in state/log/artifacts/ during concurrent access. All writes are temp+rename. Corrupt files are silently skipped by listArtifacts (does not crash).
no_partial_credit_for_fallback
deterministic
If endpoints are down AND a local cache fallback is used, score cannot exceed 0.5 regardless of cache accuracy.

how it runs - the shared frame every skill uses 4/5 present

Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.

makes the work The worker
present
ListArtifacts() / createArtifact() in the worker
Does the actual work. Whatever it produces is what gets checked next.
checks the work The reviewer
present
State/lint/library-shape.ts (the lib must export the two the checker
A separate checker grades the work, so the part that made it can't approve its own work.
frame
learns Self-correction
not present

This skill doesn't fix its own gaps yet.

tidies up Background fixes
present
queued for rewrite runs in the background
Bigger fixes that can't be made on the spot get queued and rewritten in the background later.
remembers Run history
present
state/log/evals.ndjson unknown runs
Every run is written down here, so the next time this skill is used it already knows how the last runs went.
Critical rules the things this skill must not get wrong
  1. Atomic write or it never happened. createArtifact must temp+rename.
  2. state/log/ is gitignored. Artifacts are LOCAL cache. Do not assume
  3. list returns desc by created_at. Don't change the sort without
  4. Pure read for listArtifacts. Never write inside it. Even if a
  5. Refresh / delete are NOT in this version. When they land, separate
  6. name is required. createArtifact throws on empty name. Cockpit
  7. +1 more in AGENTS.md →

what it has learned - fixes written back in over time sample

When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.

  1. Loading feedback rows…

how the work flows- who makes it, who checks it

actor ListArtifacts() / createArtifact() in
1 generator
invoke
actor = ListArtifacts() / createArtifact() in
import { listArtifacts, createArtifact } from "./state/lib/artifacts.ts"
auditor State/lint/library-shape.ts (the lib must export the two
2 data
eval log
`state/log/evals.ndjson` (skill: "artifacts")

SKILL.md- the skill, written out in plain English

artifacts

The cockpit-is-renderer principle says every cockpit affordance must map to a snappy-os skill. The Live artifacts sidebar destination (cd-22 / cd-23 in dogfood-loop/refs/) is no exception - this skill is the producer.

An artifact is a named, refreshable piece of structured output a user has opted to keep around: a daily revenue chart, a top-pipeline-deals table, a weekly catchup brief. Each is one JSON file in state/log/artifacts/, keyed by uuid. The cockpit lists them via GET /artifacts and creates new ones via POST /artifacts.

The lib at state/lib/artifacts.ts is the action scope. Refresh + delete are deliberately deferred until the cockpit's interaction surface lands - list + create is the minimum viable producer that lets the empty-state vignette ("Create your first artifact") become non-empty.

Pure read for listArtifacts. createArtifact writes one new file atomically (temp + rename) so partial writes are never visible to the listing.

Steps

  1. listArtifacts(): Promise<Artifact[]> - read every *.json under

state/log/artifacts/, parse, validate shape, drop bad rows, return sorted by created_at desc. Creates the directory if missing so the first call on a fresh checkout returns [] cleanly.

  1. createArtifact({ name, description?, pinned_chat_id?, source_connectors? })
  • mint a randomUUID(), build the Artifact row, write

state/log/artifacts/<id>.json atomically (temp + rename), return the materialized object. name is required; everything else is optional.

  1. The HTTP endpoints are wired in state/bin/head-screen/server.ts:
  • GET /artifacts → returns the array directly.
  • POST /artifacts (body { name, description? }) → returns the new

artifact. Both endpoints emit CORS * so the WKWebView (file:// origin) can hit them.

Library API

state/lib/artifacts.ts exports two functions and the Artifact type. Importable from any TS agent code; also runnable as a CLI smoke.

export type ArtifactStatus = "fresh" | "stale" | "error";

export interface Artifact {
  id: string;
  name: string;
  description?: string;
  created_at: string;
  last_refreshed_at?: string;
  status: ArtifactStatus;
  pinned_chat_id?: string;
  source_connectors?: string[];
  last_output_preview?: string;
}

export async function listArtifacts(): Promise<Artifact[]>;
export async function createArtifact(opts: {
  name: string;
  description?: string;
  pinned_chat_id?: string;
  source_connectors?: string[];
}): Promise<Artifact>;

CLI:

npx tsx state/lib/artifacts.ts                       # list (JSON to stdout)
npx tsx state/lib/artifacts.ts create "Revenue brief" "Daily auto-refresh"

Storage shape

state/log/artifacts/
  <uuid>.json   # one per artifact

state/log/ is gitignored (per-machine ephemeral) - artifacts are local state, not synced across machines. If a future axis needs cross-machine artifacts, add a sync hook here, don't move the storage.

Per-row JSON schema example:

{
  "id": "8e1c4a08-…",
  "name": "Daily revenue brief",
  "description": "Pulled from FreshBooks each morning",
  "created_at": "2026-04-29T05:30:00.000Z",
  "status": "fresh",
  "source_connectors": ["freshbooks"]
}

Eval

Actor: listArtifacts() / createArtifact() in state/lib/artifacts.ts. Auditor: state/lint/library-shape.ts (the lib must export the two functions with the documented signatures) plus the cockpit dogfood loop that proves the sidebar list renders (not error fallback).

Eval kind: shape. Mechanical: import the lib, assert listArtifacts and createArtifact exist as functions, type-check passes, atomic write contract holds (no .tmp files visible to a concurrent list during create).

OutcomeScore
Lib exports correct, atomic write holds, HTTP endpoints respond1.0
Lib exports correct but endpoint returns malformed shape0.5
Lib import fails or list crashes on a corrupt row0.0

Pitfalls

  • state/log/ is gitignored. Don't expect artifacts to follow the

agent across machines - they're local cache. If Robert needs portable artifacts, that's a separate axis (sync hook, not storage move).

  • Atomic write is non-negotiable. A partial JSON file in the directory

means listArtifacts parse-fails the row and silently drops it - the cockpit will never see it. Always temp+rename.

  • name is required, everything else is optional. The cockpit's

"New artifact" sheet should enforce this client-side; the lib also throws if name is empty.

  • Refresh / delete are NOT in this version. When they land, they're

separate endpoints (POST /artifacts/<id>/refresh, DELETE /artifacts/<id>) and separate skills. Don't bolt them on here.

Files

  • state/lib/artifacts.ts - the API (importable + CLI).
  • state/bin/head-screen/server.ts - owns GET /artifacts and

POST /artifacts.

  • state/log/artifacts/ - per-row JSON storage (gitignored).

Rubric

criteria:
  - name: primary_path_responds
    kind: deterministic
    check: "GET /artifacts succeeds (HTTP 200) and returns a valid JSON array (even if empty: [])."
  - name: create_persists_artifact
    kind: deterministic
    check: "POST /artifacts with {name: '...'} returns {id, name, created_at, status, ...}; artifact file created in state/log/artifacts/; subsequent GET shows it."
  - name: if_endpoint_down_fallback_capped
    kind: deterministic
    check: "If HTTP endpoints are unavailable (500 errors, connection refused), eval verb is marked 'fallback' and score is capped at 0.5 max."
  - name: atomic_write_no_corruption
    kind: deterministic
    check: "No `.tmp` files appear in state/log/artifacts/ during concurrent access. All writes are temp+rename. Corrupt files are silently skipped by listArtifacts (does not crash)."
  - name: no_partial_credit_for_fallback
    kind: deterministic
    check: "If endpoints are down AND a local cache fallback is used, score cannot exceed 0.5 regardless of cache accuracy."

AGENTS.md- what the AI loads when this skill comes up

artifacts - loader

Per-turn rules for the artifacts skill. Full reference: state/skills/artifacts/SKILL.md. Storage: state/log/artifacts/<uuid>.json (gitignored, per-machine).

Critical Rules

  • Atomic write or it never happened. createArtifact must temp+rename.

A partial JSON file in state/log/artifacts/ makes listArtifacts silently drop the row and the cockpit never sees it. Always atomic.

  • state/log/ is gitignored. Artifacts are LOCAL cache. Do not assume

they sync across machines. If Robert needs portable artifacts, that's a separate axis.

  • list returns desc by created_at. Don't change the sort without

updating the cockpit's expectations.

  • Pure read for listArtifacts. Never write inside it. Even if a

corrupt row is found, skip silently - don't try to repair on read.

  • Refresh / delete are NOT in this version. When they land, separate

endpoints + separate skills. Don't bolt them on here.

  • name is required. createArtifact throws on empty name. Cockpit

should enforce client-side too.

  • createArtifact validates input via ArtifactSpec when SNAPPY_ARTIFACT_SPEC_STRICT=1. Invalid specs return { ok: false, errors } before disk write; atomic-write contract preserved. Flag defaults OFF so existing callers continue working. Phase 3 migrates callers and flips the flag.

Commands

| ui model | live composition via compose_inline, persisted as artifact lang_body, reopened with OpenArtifact | |invoke: import { listArtifacts, createArtifact } from "./state/lib/artifacts.ts" |cli list: npx tsx state/lib/artifacts.ts |cli create: npx tsx state/lib/artifacts.ts create "<name>" "<optional desc>" |http list: curl -s http://127.0.0.1:3147/artifacts |http create: curl -s -XPOST http://127.0.0.1:3147/artifacts -H 'content-type: application/json' -d '{"name":"Daily brief"}' |storage: state/log/artifacts/<uuid>.json |eval log: state/log/evals.ndjson (skill: "artifacts")

Endpoints (head-screen owns these)

  • GET /artifacts - returns Artifact[], sorted desc by created_at.

CORS *.

  • POST /artifacts - body { name, description? } → returns the new

Artifact. CORS *. 400 on missing/empty name.

Working primitives

  • listArtifacts() - read every *.json under state/log/artifacts/,

parse, validate, drop bad rows, sort desc by created_at. Creates the directory if missing so the first call on a fresh checkout returns [].

  • createArtifact({ name, description?, pinned_chat_id?, source_connectors? })
  • mint uuid, atomic write, return the materialized row.

Self-Test

An agent reading this should correctly:

  1. [ ] Use atomic write (temp + rename) in any new write path
  2. [ ] Treat state/log/ as per-machine ephemeral storage
  3. [ ] Reject empty name at the lib level (don't push validation to

the HTTP layer)

  1. [ ] Skip corrupt rows silently in listArtifacts - never throw

Found a gap? Edit this file. <!-- footer-injection-point -->

api.ts- the code it can call

#!/usr/bin/env npx tsx
/**
 * state/lib/artifacts.ts -- Live artifacts producer for the snappy-chat
 * cockpit's artifacts sidebar. An artifact is a named, refreshable piece of
 * structured output a user kept around (chart, table, brief), one JSON file
 * in state/log/artifacts/ keyed by uuid. Pure read for listArtifacts;
 * createArtifact writes one file atomically (temp+rename) so a partial write
 * can never be observed.
 */

import {
  existsSync,
  mkdirSync,
  readFileSync,
  readdirSync,
  renameSync,
  writeFileSync,
} from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { randomUUID } from "crypto";
import { tmpdir } from "os";
import { boundHandleArtifactFields } from "./lang-bound-handles.ts";
import { classifyArtifact } from "./object-model.ts";
import { recordTurnArtifact } from "./turn-artifact-registry.ts";
import { safeParseArtifactSpec } from "./artifact-spec.ts";
import { stateLogPath } from "./state-root.ts";
import { artifactLangInvalidity, assertFullArtifactLangBody, assertUpdatableArtifactLangBody, assertArtifactOutcomeHonest } from "./artifact-lang-contract.ts";
export { artifactLangInvalidity } from "./artifact-lang-contract.ts";
// S1541: binding provenance lives in its own module; re-exported so artifacts.ts import sites stay unchanged.
import { bindingReceiptFrom, type DataBindingReceipt, type ConnectorReceipt } from "./databinding-receipt.ts";
export { bindingReceiptFrom, type DataBindingReceipt, type ConnectorReceipt };
// Saved-app fuzzy-match scorers live in their own module; re-exported so import sites stay unchanged.
import {
  appMatchTokens,
  scoreAppIntentPhrase,
  scoreAppTitleAndDescription,
  type LiveAppMatch,
} from "./artifact-app-match.ts";
export { appMatchTokens, scoreAppIntentPhrase, scoreAppTitleAndDescription, type LiveAppMatch };
// Lite artifact header reader lives in its own module; re-exported so import sites stay unchanged.
import {
  parseJsonString,
  skipJsonWhitespace,
  readFilePrefix,
  readTopLevelStringFields,
  artifactLiteFromObject,
  readArtifactLiteFile,
  type ArtifactLite,
} from "./artifact-lite.ts";
export {
  parseJsonString,
  skipJsonWhitespace,
  readFilePrefix,
  readTopLevelStringFields,
  artifactLiteFromObject,
  readArtifactLiteFile,
  type ArtifactLite,
};
// Artifact schema (types + enums + normalizer + MAX_VERSIONS) lives in its own
// module; re-exported so artifacts.ts import sites stay unchanged.
import {
  LIVE_APP_ICON_KEYS,
  MAX_VERSIONS,
  normalizeArtifactDisplayMode,
  type ArtifactStatus,
  type LiveAppIconKey,
  type LiveAppMeta,
  type ArtifactVersion,
  type ArtifactDisplayMode,
  type ArtifactKind,
  type Artifact,
  type CreateArtifactOpts,
  type UpdateArtifactOpts,
  type ArtifactVersionMeta,
} from "./artifact-types.ts";
export {
  LIVE_APP_ICON_KEYS,
  MAX_VERSIONS,
  normalizeArtifactDisplayMode,
  type ArtifactStatus,
  type LiveAppIconKey,
  type LiveAppMeta,
  type ArtifactVersion,
  type ArtifactDisplayMode,
  type ArtifactKind,
  type Artifact,
  type CreateArtifactOpts,
  type UpdateArtifactOpts,
  type ArtifactVersionMeta,
};
// HTML-field normalizer + version snapshot helpers live in their own module;
// re-exported so artifacts.ts import sites stay unchanged.
import {
  normalizeHtmlArtifactFields,
  snapshotSize,
  buildVersionEntry,
  pushVersion,
} from "./artifact-version-helpers.ts";
export { normalizeHtmlArtifactFields, snapshotSize, buildVersionEntry, pushVersion };
import {
  artifactLangBodyHash,
  artifactTurnMessageKey,
  artifactTurnThreadKey,
} from "./artifact-turn-dedupe.ts";
import { isArtifact, isPrimaryArtifact } from "./artifact-guards.ts";
import { NAME_DEDUPE_PRODUCERS } from "./artifact-dedupe-producers.ts";
export { isPrimaryArtifact } from "./artifact-guards.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
// A7b: artifacts are mutable user data - state-root resolved. Under any test
// runner, or with SNAPPY_ARTIFACTS_DIR set, writes go to a temp dir so node:test
// runs never create REAL product artifacts in state/log/artifacts (CU audit).
const _underTestRunner = Boolean(process.env.NODE_TEST_CONTEXT || process.env.VITEST);
export const ARTIFACTS_DIR = process.env.SNAPPY_ARTIFACTS_DIR
  ? process.env.SNAPPY_ARTIFACTS_DIR
  : _underTestRunner
    ? join(tmpdir(), `snappy-artifacts-test-${process.pid}`)
    : stateLogPath(ROOT, "artifacts");

function ensureDir(): void {
  if (!existsSync(ARTIFACTS_DIR)) {
    mkdirSync(ARTIFACTS_DIR, { recursive: true });
  }
}

/** List every artifact, newest first; unparseable rows are skipped. */
export async function listArtifacts(): Promise<Artifact[]> {
  ensureDir();
  const entries = readdirSync(ARTIFACTS_DIR);
  const out: Artifact[] = [];
  for (const f of entries) {
    if (!f.endsWith(".json")) continue;
    const fp = join(ARTIFACTS_DIR, f);
    try {
      const raw = readFileSync(fp, "utf-8");
      const parsed = JSON.parse(raw);
      if (isPrimaryArtifact(parsed)) {
        const displayMode = normalizeArtifactDisplayMode(parsed.displayMode);
        const cls = classifyArtifact(parsed);
        out.push({
          ...parsed,
          object_kind: cls.object_kind,
          gallery_ok: cls.gallery,
          live_app_ok: cls.live_app,
          surface_class: cls.surface_class,
          home: cls.home,
          ...(displayMode ? { displayMode } : {}),
        });
      }
    } catch {
      // skip — corrupt rows must not crash the listing
    }
  }
  out.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0));
  return out;
}

/**
 * List every artifact in state/log/artifacts/ with lightweight metadata only.
 * Returns only {id, pinned_chat_id, created_at} per row — no heavy bodies
 * (lang_body, html_body, text_body, versions, etc.). Sorted by created_at desc.
 * Files that fail to parse or fail the shape check are skipped silently.
 * Used by listProjectsSummaryRoute to avoid unbounded reads when counting
 * artifacts per project.
 */
export async function listArtifactsLite(): Promise<ArtifactLite[]> {
  ensureDir();
  const entries = readdirSync(ARTIFACTS_DIR);
  const out: ArtifactLite[] = [];
  for (const f of entries) {
    if (!f.endsWith(".json")) continue;
    const fp = join(ARTIFACTS_DIR, f);
    try {
      const lite = readArtifactLiteFile(fp, isArtifact);
      if (lite) out.push(lite);
    } catch {
      // skip — corrupt rows must not crash the listing
    }
  }
  out.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0));
  return out;
}

/**
 * When SNAPPY_ARTIFACT_SPEC_STRICT=1, createArtifact validates the input
 * against ArtifactSpec before disk write. Validation failure returns a
 * structured error result instead of writing.
 *
 * Default: OFF (0). Flip to ON after all callers are migrated to strict
 * shape (Task #56 phase 3).
 */
const SPEC_STRICT = process.env.SNAPPY_ARTIFACT_SPEC_STRICT === "1";

export type CreateArtifactResult =
  | Artifact
  | { ok: false; errors: string[] };

/**
 * Create a new artifact. Atomic write (temp + rename) so partial files
 * are never visible to listArtifacts.
 *
 * When SNAPPY_ARTIFACT_SPEC_STRICT=1, the input is validated against
 * ArtifactSpec first. Validation failure returns { ok: false, errors }
 * without writing to disk. Callers that don't pass a full ArtifactSpec
 * should leave the flag OFF until they are migrated (phase 3).
 */
export async function createArtifact(opts: CreateArtifactOpts): Promise<Artifact>;
export async function createArtifact(opts: Record<string, unknown>): Promise<CreateArtifactResult>;
export async function createArtifact(opts: CreateArtifactOpts | Record<string, unknown>): Promise<CreateArtifactResult> {
  // --- Strict ArtifactSpec gate (opt-in via env flag) ---
  if (SPEC_STRICT) {
    const result = safeParseArtifactSpec(opts);
    if (!result.ok) {
      return { ok: false, errors: (result as { ok: false; errors: string[] }).errors };
    }
  }

  // After the SPEC_STRICT gate (or in default-off mode) the opts shape is
  // either a validated ArtifactSpec or the historic CreateArtifactOpts shape.
  // Narrow once so the rest of the body has the typed view.
  const o = opts as CreateArtifactOpts;
  const name = (o.name ?? "").trim();
  if (!name) {
    throw new Error("createArtifact: name is required");
  }
  // Blank-card gate: a kind=lang artifact with an empty or whitespace-only
  // lang_body renders as a blank card in the Live Apps gallery. Reject at
  // the persistence boundary so callers can never produce an unrenderable
  // lang artifact. Non-lang kinds (html, text, image, etc.) are unaffected.
  if (
    (o.kind === "lang" || (typeof o.lang_body === "string" && o.lang_body.trim().length > 0)) &&
    o.kind === "lang" &&
    (typeof o.lang_body !== "string" || o.lang_body.trim().length === 0)
  ) {
    throw new Error("createArtifact: kind=lang requires a non-empty lang_body");
  }
  // Renderable-or-nothing gate (2026-06-10): nine blank "apps" named
  // Test/Whitespace/Standalone shipped to the Live Apps gallery because a
  // probe persisted kind=lang rows with empty bodies. A lang artifact with
  // no renderable lang_body can never render anything - refuse at the door.
  if (o.kind === "lang") {
    const _langBody = typeof (o as { lang_body?: unknown }).lang_body === "string"
      ? ((o as { lang_body: string }).lang_body).trim()
      : "";
    if (!_langBody) {
      throw new Error(`createArtifact: kind=lang requires a non-empty lang_body ("${name}" would render blank)`);
    }
  }
  // Full-body vs patch contract: a persisted lang_body must be a complete
  // renderable program (root statement + parser-renderable). Rootless
  // statement patches are mergeStatements input, not artifact bodies.
  if (typeof o.lang_body === "string" && o.lang_body.trim().length > 0) {
    assertFullArtifactLangBody("createArtifact", o.lang_body, name);
  }
  const htmlFields = normalizeHtmlArtifactFields({
    name,
    kind: o.kind,
    html_body: o.html_body,
    template_id: o.template_id,
    slot_manifest: o.slot_manifest,
    share_mode: o.share_mode,
    slot_values: o.slot_values,
  });
  ensureDir();
  // Dedupe only when the caller provides an explicit stable identity:
  //   1. app_slug is the same saved app.
  //   2. template_id is the same HTML template.
  //   3. name equality is allowed only for known retry-prone cron producers.
  //   4. Command-equality dedupe over 5 min for cli_* producers (Track G
  //      auto-persist landed in 8e54bd93 created 50+ duplicate
  //      "cli_github rate-limit - 01:06" rows because the name carries
  //      HH:MM and never repeats minute-to-minute, defeating the name
  //      check above). Key is (producer_slug, shape_args.command). On
  //      hit, update the existing row's last_output_preview and
  //      last_refreshed_at instead of writing a new file. This collapses
  //      the wall-of-identical-rows on the Live artifacts page back to
  //      one row per command per 5-minute window.
  const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
  const CLI_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
  const cutoff = Date.now() - DEDUPE_WINDOW_MS;
  const cliCutoff = Date.now() - CLI_DEDUPE_WINDOW_MS;
  const incomingAppSlug = typeof o.app_slug === "string" && o.app_slug.trim()
    ? o.app_slug.trim()
    : "";
  const incomingDisplayMode = normalizeArtifactDisplayMode(o.displayMode);
  const incomingCommand = (() => {
    const sa = o.shape_args as { command?: unknown } | undefined;
    return typeof sa?.command === "string" && sa.command.trim() ? sa.command.trim() : "";
  })();
  const incomingTurnThread = artifactTurnThreadKey(o);
  const incomingTurnMessage = artifactTurnMessageKey(o);
  const incomingLangHash = artifactLangBodyHash(o.lang_body);
  try {
    const existing = await listArtifacts();
    // Duplicate-paint guard: one user message can legitimately fetch data and
    // render once, but any second artifact with the same thread/message/lang
    // body is the same paint entering through another adapter. Return the
    // first persisted record instead of minting a gallery/live-app twin.
    if (incomingTurnThread && incomingTurnMessage && incomingLangHash) {
      const dupByTurnLang = existing.find((a) =>
        artifactTurnThreadKey(a) === incomingTurnThread &&
        artifactTurnMessageKey(a) === incomingTurnMessage &&
        artifactLangBodyHash(a.lang_body) === incomingLangHash
      );
      if (dupByTurnLang) return dupByTurnLang;
    }
    // (0) Stable app identity. `app_slug` is the user's reusable app key:
    // repeated saves/compositions for the same app update the existing row
    // and append version history instead of minting another Live Apps tile.
    // This is intentionally stronger than time-window dedupe; it is the
    // app/document identity contract.
    if (incomingAppSlug) {
      const dupByAppSlug = existing.find((a) => a.app_slug === incomingAppSlug);
      if (dupByAppSlug) {
        const refreshed = await updateArtifact(dupByAppSlug.id, {
          name,
          ...(typeof o.description === "string" ? { description: o.description } : {}),
          ...(o.shape_args !== undefined ? { shape_args: o.shape_args } : {}),
          ...(typeof o.last_output_preview === "string" ? { last_output_preview: o.last_output_preview } : {}),
          ...(typeof o.lang_body === "string" ? { lang_body: o.lang_body } : {}),
          ...(typeof o.text_body === "string" ? { text_body: o.text_body } : {}),
          ...(o.kind !== undefined ? { kind: o.kind } : {}),
          ...(o.isLiveApp === true ? { isLiveApp: true } : {}),
          ...(o.liveAppMeta ? { liveAppMeta: o.liveAppMeta } : {}),
          // 2026-05-26 wave-3 (P-046): HTML artifacts identified by
          // app_slug must also propagate html_body / template_id /
          // slot_manifest / share_mode / slot_values into the update so
          // the body actually rewrites. Without this spread, an
          // HTMLPreview follow-up with the same app_slug would hit
          // this branch, find the existing tile, but leave the body
          // unchanged — the user would see a "Reload" with no diff.
          ...htmlFields,
          app_slug: incomingAppSlug,
          ...(incomingDisplayMode ? { displayMode: incomingDisplayMode } : {}),
          ...(o.refreshCli !== undefined ? { refreshCli: o.refreshCli } : {}),
          ...(Array.isArray(o.argSlots) ? { argSlots: o.argSlots } : {}),
          ...(Array.isArray(o.intents) && o.intents.length > 0 ? { intents: o.intents } : {}),
          version_source: o.producer_slug === "compose_inline" ? "compose_inline" : "edit",
        });
        return refreshed ?? dupByAppSlug;
      }
    }
    if (htmlFields.template_id && htmlFields.kind === "html-template") {
      const dupByTemplateId = existing.find((a) =>
        a.id &&
        a.template_id === htmlFields.template_id &&
        a.kind === "html-template"
      );
      if (dupByTemplateId) {
        const refreshed = await updateArtifact(dupByTemplateId.id, {
          name,
          ...(typeof o.description === "string" ? { description: o.description } : {}),
          ...(typeof o.last_output_preview === "string" ? { last_output_preview: o.last_output_preview } : {}),
          ...htmlFields,
          version_source: "edit",
        });
        return refreshed ?? dupByTemplateId;
      }
    }
    // (1) Name-equality dedupe (24h window).
    const dupByName = existing.find((a) =>
      NAME_DEDUPE_PRODUCERS.has(String(o.producer_slug ?? "")) &&
      a.name === name &&
      a.producer_slug === o.producer_slug &&
      Date.parse(a.created_at) > cutoff
    );
    if (dupByName) {
      // 2026-05-26 wave-2 (P-045): HTML producers must actually
      // overwrite the existing artifact body on dedupe hit. Without
      // this, every follow-up "build me a live app" call returned the
      // STALE original body and the user saw no update. Brain-digest
      // pre-existing semantics (silent return) preserved for that one
      // producer; everything else routes through updateArtifact and
      // gets a version snapshot.
      if (o.producer_slug === "html-preview") {
        const refreshed = await updateArtifact(dupByName.id, {
          name,
          ...(typeof o.description === "string" ? { description: o.description } : {}),
          ...(typeof o.last_output_preview === "string" ? { last_output_preview: o.last_output_preview } : {}),
          ...htmlFields,
          // 2026-05-26 wave-5 (P-047): when the name-dedupe branch fires
          // for an HTML producer and the new request carries an
          // app_slug the existing artifact does not, stamp the slug on
          // the existing row so future calls can dedupe by slug instead
          // of falling back to name. Without this, the wave-3 app_slug
          // identity contract is bypassed whenever a generic "HTML
          // Preview" tile already exists from a pre-slug session.
          ...(incomingAppSlug ? { app_slug: incomingAppSlug } : {}),
          version_source: "edit",
        });
        return refreshed ?? dupByName;
      }
      return dupByName;
    }
    // (2) Command-equality dedupe (5 min window) — cli_* auto-persist path.
    if (incomingCommand && typeof o.producer_slug === "string" && o.producer_slug.startsWith("cli:")) {
      const dupByCommand = existing.find((a) => {
        if (a.producer_slug !== o.producer_slug) return false;
        if (Date.parse(a.created_at) < cliCutoff) return false;
        const sa = a.shape_args as { command?: unknown } | null | undefined;
        const existingCommand = typeof sa?.command === "string" ? sa.command.trim() : "";
        return existingCommand === incomingCommand;
      });
      if (dupByCommand) {
        // Refresh the existing row's preview + shape_args.response so the
        // drawer reflects the latest output, without minting a new id.
        const refreshed = await updateArtifact(dupByCommand.id, {
          shape_args: o.shape_args,
          last_output_preview: o.last_output_preview,
        });
        return refreshed ?? dupByCommand;
      }
    }
  } catch { /* fall through to fresh create */ }

  const id = randomUUID();
  const artifact: Artifact = {
    id,
    name,
    description: o.description?.trim() || undefined,
    created_at: new Date().toISOString(),
    status: (typeof o.lang_body === "string" && /\b(?:render_status|save_status)["':=,\s]+"?failed/i.test(o.lang_body)) ? "error" : "fresh",
    pinned_chat_id: o.pinned_chat_id,
    ...(Array.isArray(o.source_connectors) && o.source_connectors.length
      ? { source_connectors: o.source_connectors, bound_query_handles: o.bound_query_handles }
      : boundHandleArtifactFields(o.lang_body)),
    thread_id: o.thread_id,
    message_id: o.message_id,
    binding_receipt: o.binding_receipt,
    producer_slug: o.producer_slug,
    shape_name: o.shape_name,
    shape_args: o.shape_args,
    intent: o.intent,
    refresh_url: o.refresh_url,
    refresh_interval: o.refresh_interval,
    kind: htmlFields.kind ?? o.kind,
    lang_body: o.lang_body,
    html_body: htmlFields.html_body,
    template_id: htmlFields.template_id,
    slot_manifest: htmlFields.slot_manifest,
    share_mode: htmlFields.share_mode,
    slot_values: htmlFields.slot_values,
    text_body: o.text_body,
    source: o.source,
    last_output_preview: o.last_output_preview,
    versions: [],
    // Phase L.1 (2026-05-11): curated Live App flag + metadata.
    ...(o.isLiveApp === true ? { isLiveApp: true } : {}),
    ...(o.liveAppMeta ? { liveAppMeta: o.liveAppMeta } : {}),
    // Wave A (2026-05-11): live-app lifecycle template fields.
    ...(typeof o.app_slug === "string" && o.app_slug.trim() ? { app_slug: o.app_slug.trim() } : {}),
    ...(incomingDisplayMode ? { displayMode: incomingDisplayMode } : {}),
    ...(o.refreshCli !== undefined ? { refreshCli: o.refreshCli } : {}),
    ...(Array.isArray(o.argSlots) ? { argSlots: o.argSlots } : {}),
    ...(Array.isArray(o.intents) && o.intents.length > 0 ? { intents: o.intents.map((s) => String(s).trim()).filter((s) => s.length > 0) } : {}),
    // File artifacts (render_output / generators): bytes on disk + serve route.
    ...(typeof o.cachedPath === "string" ? { cachedPath: o.cachedPath } : {}),
    ...(typeof o.url === "string" ? { url: o.url } : {}),
    ...(typeof o.originUrl === "string" ? { originUrl: o.originUrl } : {}),
    ...(typeof o.model === "string" ? { model: o.model } : {}),
    ...(typeof o.mimeType === "string" ? { mimeType: o.mimeType } : {}),
    ...(typeof o.fileExtension === "string" ? { fileExtension: o.fileExtension } : {}),
  };
  pushVersion(artifact, buildVersionEntry("create", {
    lang_body: artifact.lang_body,
    shape_args: artifact.shape_args,
    last_output_preview: artifact.last_output_preview,
  }));
  const finalPath = join(ARTIFACTS_DIR, `${id}.json`);
  const tmpPath = join(ARTIFACTS_DIR, `.${id}.tmp`);
  writeFileSync(tmpPath, JSON.stringify(artifact, null, 2) + "\n", "utf-8");
  renameSync(tmpPath, finalPath);
  recordTurnArtifact(artifact.message_id, id);
  // Saved-term cache invalidation: when this artifact has an app_slug, it
  // is callable as an OpenUI Lang term and the cached saved-terms list
  // must refresh so the next compose_inline call sees it.
  if (typeof artifact.app_slug === "string" && artifact.app_slug.length > 0) {
    try {
      const mod = await import("./saved-terms.ts");
      mod.flushSavedTermsCache();
    } catch { /* best-effort */ }
  }
  return artifact;
}

/**
 * Get a single artifact by id. Returns null when not found.
 */
export async function getArtifact(id: string): Promise<Artifact | null> {
  ensureDir();
  const fp = join(ARTIFACTS_DIR, `${id}.json`);
  try {
    const raw = readFileSync(fp, "utf-8");
    const parsed = JSON.parse(raw);
    if (isPrimaryArtifact(parsed)) {
      const cls = classifyArtifact(parsed);
      return { ...parsed, object_kind: cls.object_kind, gallery_ok: cls.gallery, live_app_ok: cls.live_app, surface_class: cls.surface_class, home: cls.home };
    }
    return null;
  } catch {
    return null;
  }
}

/**
 * Update a subset of artifact fields. Bumps updatedAt. Atomic write.
 * Returns the updated artifact, or null when not found. Pushes a pre-edit
 * version row when the render payload changes.
 */
export async function updateArtifact(id: string, opts: UpdateArtifactOpts): Promise<Artifact | null> {
  ensureDir();
  const fp = join(ARTIFACTS_DIR, `${id}.json`);
  try {
    const raw = readFileSync(fp, "utf-8");
    const parsed = JSON.parse(raw);
    if (!isArtifact(parsed)) return null;
    const langBodyChanging = typeof opts.lang_body === "string" && opts.lang_body !== parsed.lang_body;
    const htmlBodyChanging = typeof opts.html_body === "string" && opts.html_body !== parsed.html_body;
    const shapeArgsChanging = opts.shape_args !== undefined
      && JSON.stringify(opts.shape_args) !== JSON.stringify(parsed.shape_args);
    const previewChanging = typeof opts.last_output_preview === "string"
      && opts.last_output_preview !== parsed.last_output_preview;
    const payloadChanging = langBodyChanging || htmlBodyChanging || shapeArgsChanging || previewChanging;

    const versions: ArtifactVersion[] = Array.isArray(parsed.versions) ? [...parsed.versions] : [];
    if (versions.length === 0) {
      const backfill = buildVersionEntry("create", {
        lang_body: parsed.lang_body,
        shape_args: parsed.shape_args,
        last_output_preview: parsed.last_output_preview,
        html_body: parsed.html_body,
        template_id: parsed.template_id,
        slot_manifest: parsed.slot_manifest,
      });
      backfill.ts = parsed.created_at;
      versions.push(backfill);
    }
    if (payloadChanging) {
      const preEdit = buildVersionEntry(opts.version_source ?? "edit", {
        lang_body: parsed.lang_body,
        shape_args: parsed.shape_args,
        last_output_preview: parsed.last_output_preview,
        html_body: parsed.html_body,
        template_id: parsed.template_id,
        slot_manifest: parsed.slot_manifest,
      });
      preEdit.ts = parsed.last_refreshed_at ?? parsed.created_at;
      versions.push(preEdit);
      while (versions.length > MAX_VERSIONS) versions.shift();
    }

    const incomingDisplayMode = normalizeArtifactDisplayMode(opts.displayMode);
    const htmlFields = normalizeHtmlArtifactFields({
      name: opts.name ?? parsed.name,
      kind: opts.kind,
      html_body: opts.html_body,
      template_id: opts.template_id,
      slot_manifest: opts.slot_manifest,
      share_mode: opts.share_mode,
      slot_values: opts.slot_values,
    });
    if (htmlFields.template_id && htmlFields.kind === "html-template") {
      const existing = await listArtifacts();
      const dup = existing.find((a) =>
        a.id !== id &&
        a.kind === "html-template" &&
        a.template_id === htmlFields.template_id
      );
      if (dup) throw new Error(`template_id already exists: ${htmlFields.template_id}`);
    }
    // Full-body vs patch contract: when a caller replaces lang_body, the new
    // body must be a complete renderable program. Rootless statement patches
    // are not valid full artifact bodies -- they belong in mergeStatements
    // input.
    const resultingKind = htmlFields.kind ?? opts.kind ?? parsed.kind;
    if ((resultingKind === "lang" || typeof opts.lang_body === "string") && typeof opts.lang_body === "string") {
      assertUpdatableArtifactLangBody("updateArtifact", opts.lang_body, parsed);
    }
    assertArtifactOutcomeHonest("updateArtifact", opts.status ?? parsed.status, opts.genui_outcome ?? parsed.genui_outcome);

    const updated: Artifact = {
      ...parsed,
      ...(typeof opts.name === "string" && opts.name.trim() ? { name: opts.name.trim() } : {}),
      ...(typeof opts.description === "string" ? { description: opts.description.trim() || undefined } : {}),
      ...(opts.shape_args !== undefined ? { shape_args: opts.shape_args } : {}),
      ...(typeof opts.last_output_preview === "string" ? { last_output_preview: opts.last_output_preview } : {}),
      ...(opts.status !== undefined ? { status: opts.status } : {}),
      ...(opts.genui_outcome !== undefined ? { genui_outcome: opts.genui_outcome } : {}),
      ...(typeof opts.lang_body === "string" ? { lang_body: opts.lang_body } : {}),
      ...(typeof opts.text_body === "string" ? { text_body: opts.text_body } : {}),
      ...(typeof opts.pinned_chat_id === "string" && opts.pinned_chat_id ? { pinned_chat_id: opts.pinned_chat_id } : {}),
      ...(htmlFields.kind !== undefined ? { kind: htmlFields.kind } : opts.kind !== undefined ? { kind: opts.kind } : {}),
      ...(typeof htmlFields.html_body === "string" ? { html_body: htmlFields.html_body } : {}),
      ...(typeof htmlFields.template_id === "string" ? { template_id: htmlFields.template_id } : {}),
      ...(Array.isArray(htmlFields.slot_manifest) ? { slot_manifest: htmlFields.slot_manifest } : {}),
      ...(htmlFields.share_mode !== undefined ? { share_mode: htmlFields.share_mode } : {}),
      ...(htmlFields.slot_values !== undefined ? { slot_values: htmlFields.slot_values } : {}),
      // Wave A (2026-05-11): live-app lifecycle template promotion.
      ...(opts.isLiveApp === true ? { isLiveApp: true } : {}),
      ...(opts.liveAppMeta ? { liveAppMeta: opts.liveAppMeta } : {}),
      ...(typeof opts.app_slug === "string" && opts.app_slug.trim() ? { app_slug: opts.app_slug.trim() } : {}),
      ...(incomingDisplayMode ? { displayMode: incomingDisplayMode } : {}),
      ...(opts.refreshCli !== undefined ? { refreshCli: opts.refreshCli } : {}),
      ...(Array.isArray(opts.argSlots) ? { argSlots: opts.argSlots } : {}),
      ...(Array.isArray(opts.intents) && opts.intents.length > 0 ? { intents: opts.intents.map((s) => String(s).trim()).filter((s) => s.length > 0) } : {}),
      ...(opts.state_snapshot !== undefined ? { state_snapshot: opts.state_snapshot } : {}),
      // Lane D (2026-05-21): publishing fields.
      ...(opts.published !== undefined ? { published: opts.published } : {}),
      ...(opts.share_token !== undefined ? { share_token: opts.share_token } : {}),
      ...(opts.published_at !== undefined ? { published_at: opts.published_at } : {}),
      ...(opts.unpublished_at !== undefined ? { unpublished_at: opts.unpublished_at } : {}),
      // Phase 3 Lane A: paint readback timestamp. Only stamped when caller provides the field.
      ...(typeof opts.last_painted_at === "string" ? { last_painted_at: opts.last_painted_at } : {}),
      ...(typeof opts.cachedPath === "string" ? { cachedPath: opts.cachedPath } : {}),
      ...(typeof opts.url === "string" ? { url: opts.url } : {}),
      ...(typeof opts.originUrl === "string" ? { originUrl: opts.originUrl } : {}),
      ...(typeof opts.model === "string" ? { model: opts.model } : {}),
      ...(typeof opts.mimeType === "string" ? { mimeType: opts.mimeType } : {}),
      ...(typeof opts.fileExtension === "string" ? { fileExtension: opts.fileExtension } : {}),
      ...(opts.binding_receipt !== undefined ? { binding_receipt: opts.binding_receipt } : {}), // S1541
      ...(Array.isArray(opts.source_connectors) && opts.source_connectors.length ? { source_connectors: opts.source_connectors } : boundHandleArtifactFields(typeof opts.lang_body === "string" ? opts.lang_body : parsed.lang_body)), // OAI gate 8 + C181
      ...(Array.isArray(opts.bound_query_handles) && opts.bound_query_handles.length ? { bound_query_handles: opts.bound_query_handles } : {}),
      ...(opts.connector_receipt ? { connector_receipt: opts.connector_receipt } : {}), // OAI gate 9
      ...(typeof opts.thread_id === "string" ? { thread_id: opts.thread_id } : {}),
      ...(typeof opts.message_id === "string" ? { message_id: opts.message_id } : {}),
      versions,
      last_refreshed_at: new Date().toISOString(),
    };
    const tmpPath = join(ARTIFACTS_DIR, `.${id}.tmp`);
    writeFileSync(tmpPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
    renameSync(tmpPath, fp);
    // Saved-term cache invalidation on update too — body might have
    // changed, so the callable expansion needs to refresh.
    if (langBodyChanging || htmlBodyChanging) {
      try {
        const mod = await import("./saved-terms.ts");
        mod.flushSavedTermsCache();
      } catch { /* best-effort */ }
    }
    return updated;
  } catch (e) {
    // A template_id collision is a real conflict, not "artifact not found".
    // Re-throw it so the route surfaces the actual reason (the handler's
    // .catch returns it) instead of collapsing to a misleading 404.
    // Same for the lang_body full-body contract: a rootless / unrenderable
    // lang_body is a caller bug, not a missing artifact.
    if (e instanceof Error && (
      e.message.startsWith("template_id already exists") ||
      e.message.startsWith("updateArtifact: lang_body")
    )) throw e;
    return null;
  }
}


/**
 * Version history for an artifact, newest first. Includes a synthetic
 * "__current__" row so the chip strip always shows the live version even
 * before any edits. Returns [] when missing or corrupt.
 */
export async function listArtifactVersions(id: string): Promise<ArtifactVersionMeta[]> {
  const a = await getArtifact(id);
  if (!a) return [];
  const versions = Array.isArray(a.versions) ? a.versions : [];
  const currentSize = snapshotSize({
    lang_body: a.lang_body,
    shape_args: a.shape_args,
    last_output_preview: a.last_output_preview,
    html_body: a.html_body,
    template_id: a.template_id,
    slot_manifest: a.slot_manifest,
  });
  return [
    {
      id: "__current__",
      ts: a.last_refreshed_at ?? a.created_at,
      source: "edit",
      snapshot_size_bytes: currentSize,
      is_current: true,
    },
    ...versions.slice().reverse().map((v) => ({
      id: v.id,
      ts: v.ts,
      source: v.source,
      snapshot_size_bytes: v.snapshot_size_bytes,
    })),
  ];
}

/** Fetch a single version's full snapshot, or null. */
export async function getArtifactVersion(id: string, versionId: string): Promise<ArtifactVersion | null> {
  const a = await getArtifact(id);
  if (!a) return null;
  const versions = Array.isArray(a.versions) ? a.versions : [];
  return versions.find((v) => v.id === versionId) ?? null;
}

/**
 * Restore a prior version's snapshot to top-level fields. Captures the
 * current state as a `source: "restore"` version so the restore itself
 * is reversible. Atomic write.
 */
export async function restoreArtifactVersion(id: string, versionId: string): Promise<Artifact | null> {
  ensureDir();
  const fp = join(ARTIFACTS_DIR, `${id}.json`);
  try {
    const raw = readFileSync(fp, "utf-8");
    const parsed = JSON.parse(raw);
    if (!isArtifact(parsed)) return null;
    const versions: ArtifactVersion[] = Array.isArray(parsed.versions) ? [...parsed.versions] : [];
    const target = versions.find((v) => v.id === versionId);
    if (!target) return null;
    const displaced = buildVersionEntry("restore", {
      lang_body: parsed.lang_body,
      shape_args: parsed.shape_args,
      last_output_preview: parsed.last_output_preview,
    });
    displaced.ts = parsed.last_refreshed_at ?? parsed.created_at;
    versions.push(displaced);
    while (versions.length > MAX_VERSIONS) versions.shift();
    const updated: Artifact = {
      ...parsed,
      lang_body: target.lang_body,
      shape_args: target.shape_args,
      last_output_preview: target.last_output_preview,
      html_body: target.html_body,
      template_id: target.template_id,
      slot_manifest: target.slot_manifest,
      versions,
      last_refreshed_at: new Date().toISOString(),
    };
    const tmpPath = join(ARTIFACTS_DIR, `.${id}.tmp`);
    writeFileSync(tmpPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
    renameSync(tmpPath, fp);
    return updated;
  } catch {
    return null;
  }
}

/**
 * Delete an artifact by id. No-op when not found. Returns true when
 * a file was actually removed.
 */
export async function deleteArtifact(id: string): Promise<boolean> {
  ensureDir();
  const fp = join(ARTIFACTS_DIR, `${id}.json`);
  try {
    const { unlinkSync } = await import("fs");
    if (!existsSync(fp)) return false;
    unlinkSync(fp);
    return true;
  } catch {
    return false;
  }
}

/**
 * Phase L.1 (2026-05-11): list every curated Live App. A Live App is an
 * artifact with `isLiveApp === true` (the SaveArtifact harness tool is
 * the only writer; ephemeral artifacts never carry this flag). Sorted
 * by created_at desc to mirror the cockpit's Live Apps sidebar order.
 *
 * Phase L.2 will use this list as the seed for FindSavedApp's registry
 * query; exporting now so the call site is stable when L.2 lands.
 */
export async function listLiveApps(): Promise<Artifact[]> {
  const all = await listArtifacts();
  return all.filter((a) => a.isLiveApp === true && a.liveAppMeta != null);
}

/**
 * Wave A (2026-05-11): list every saved app template. A saved app is a
 * Live App that has been promoted via SaveAsTemplate -- carries `app_slug`
 * plus an explicit `intents[]` array. This list is for model-visible
 * saved-app lookup and library UX, not for a server-side pre-LLM shortcut.
 */
export async function listSavedApps(): Promise<Artifact[]> {
  const live = await listLiveApps();
  return live.filter((a) => typeof a.app_slug === "string" && a.app_slug.length > 0);
}

/**
 * Phase L.1 export (used by Phase L.2's FindSavedApp). Returns Live Apps
 * whose `liveAppMeta.intents[]` fuzzy-matches the query string, scored
 * by token overlap. Empty query returns the full list ordered by recency.
 * Top-N caller-capped via the `limit` parameter (default 3 per spec).
 *
 * Match strategy: lowercase tokenize both sides, score = matched-tokens
 * count + bonus if the full query string appears as a substring of any
 * intent phrase. Two-tier sort: score desc, then created_at desc for
 * ties. Returns no matches under threshold 1 so callers can branch on
 * "did anything match" without an extra predicate.
 */
export async function findLiveAppByIntent(
  query: string,
  limit: number = 3,
): Promise<LiveAppMatch[]> {
  const apps = await listLiveApps();
  const trimmed = typeof query === "string" ? query.trim() : "";
  if (trimmed.length === 0) {
    return apps.slice(0, Math.max(1, Math.min(50, limit))).map((a) => ({ artifact: a, score: 0 }));
  }
  const qLower = trimmed.toLowerCase();
  const qTokens = qLower.split(/\s+/).filter((t) => t.length >= 2);
  const scored: LiveAppMatch[] = [];
  for (const a of apps) {
    const meta = a.liveAppMeta;
    if (!meta) continue;
    let score = 0;
    // Wave A (2026-05-11): top-level `intents[]` (set by SaveAsTemplate) takes
    // precedence over `liveAppMeta.intents`. Falls back to liveAppMeta for
    // pre-Wave-A artifacts.
    const intents = Array.isArray(a.intents) && a.intents.length > 0
      ? a.intents
      : Array.isArray(meta.intents) ? meta.intents : [];
    for (const intent of intents) {
      score += scoreAppIntentPhrase(qLower, qTokens, intent);
    }
    // Title + description carry minor weight so a Live App named "GitHub
    // Status" still surfaces on "github" even when its intents miss.
    score += scoreAppTitleAndDescription(qLower, qTokens, meta.title, meta.description);
    if (score >= 1) scored.push({ artifact: a, score });
  }
  scored.sort((a, b) => {
    if (a.score !== b.score) return b.score - a.score;
    const at = Date.parse(a.artifact.created_at);
    const bt = Date.parse(b.artifact.created_at);
    return (Number.isFinite(bt) ? bt : 0) - (Number.isFinite(at) ? at : 0);
  });
  return scored.slice(0, Math.max(1, Math.min(20, limit)));
}

/**
 * Model-visible saved-app lookup. Limits to saved-app templates (Live Apps
 * with explicit `app_slug`) instead of every Live App, and ranks by the
 * same fuzzy scorer used in findLiveAppByIntent. Chat dispatch must not
 * call this before the model; the harness/tool loop owns reopening or
 * refining saved apps.
 */
export async function findSavedAppByIntent(
  query: string,
  limit: number = 3,
): Promise<LiveAppMatch[]> {
  const apps = await listSavedApps();
  const trimmed = typeof query === "string" ? query.trim() : "";
  if (trimmed.length === 0) {
    return apps.slice(0, Math.max(1, Math.min(50, limit))).map((a) => ({ artifact: a, score: 0 }));
  }
  const qLower = trimmed.toLowerCase();
  const qTokens = qLower.split(/\s+/).filter((t) => t.length >= 2);
  const scored: LiveAppMatch[] = [];
  for (const a of apps) {
    const meta = a.liveAppMeta;
    let score = 0;
    const intents = Array.isArray(a.intents) && a.intents.length > 0
      ? a.intents
      : Array.isArray(meta?.intents) ? meta!.intents : [];
    for (const intent of intents) {
      score += scoreAppIntentPhrase(qLower, qTokens, intent);
    }
    score += scoreAppTitleAndDescription(qLower, qTokens, meta?.title ?? a.name, meta?.description ?? a.description);
    if (score >= 1) scored.push({ artifact: a, score });
  }
  scored.sort((a, b) => {
    if (a.score !== b.score) return b.score - a.score;
    const at = Date.parse(a.artifact.created_at);
    const bt = Date.parse(b.artifact.created_at);
    return (Number.isFinite(bt) ? bt : 0) - (Number.isFinite(at) ? at : 0);
  });
  return scored.slice(0, Math.max(1, Math.min(20, limit)));
}

// CLI smoke: `npx tsx state/lib/artifacts.ts` lists; `... create "<name>"` creates.
if (import.meta.url === `file://${process.argv[1]}`) {
  (async () => {
    const cmd = process.argv[2] ?? "list";
    if (cmd === "list") {
      const items = await listArtifacts();
      console.log(JSON.stringify(items, null, 2));
      return;
    }
    if (cmd === "create") {
      const name = process.argv[3];
      if (!name) {
        console.error("usage: artifacts.ts create <name> [description]");
        process.exit(2);
      }
      const description = process.argv[4];
      const a = await createArtifact({ name, description });
      console.log(JSON.stringify(a, null, 2));
      return;
    }
    console.error("usage: artifacts.ts [list|create <name> [desc]]");
    process.exit(2);
  })();
}

scripts- helper scripts it can run

prose-only skill - 5 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).

how we check it- the checks, plus the last 10 runs

rubric shape schema-shape check (no inline rubric)
recent no runs actor/auditor: unverifiable
deps none declared

no recent runs logged - the eval contract is declared but nothing has been graded yet