ListArtifacts() / createArtifact() in .md file to compare - side-by-side diff against artifacts
artifacts
description: "Triggers on prompt mention of 'artifacts'."
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.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
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.
state/skills/artifacts/SKILL.md
present
state/lib/artifacts.ts
present
state/bin/artifacts/
not present
state/skills/artifacts/AGENTS.md
present
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.
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.
State/lint/library-shape.ts (the lib must export the two This skill doesn't fix its own gaps yet.
state/log/evals.ndjson - Atomic write or it never happened. createArtifact must temp+rename.
- state/log/ is gitignored. Artifacts are LOCAL cache. Do not assume
- list returns desc by created_at. Don't change the sort without
- Pure read for listArtifacts. Never write inside it. Even if a
- Refresh / delete are NOT in this version. When they land, separate
- name is required. createArtifact throws on empty name. Cockpit
- +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.
- Loading feedback rows…
how the work flows- who makes it, who checks it
import { listArtifacts, createArtifact } from "./state/lib/artifacts.ts"
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
listArtifacts(): Promise<Artifact[]>- read every*.jsonunder
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.
createArtifact({ name, description?, pinned_chat_id?, source_connectors? })
- mint a
randomUUID(), build theArtifactrow, write
state/log/artifacts/<id>.json atomically (temp + rename), return the materialized object. name is required; everything else is optional.
- 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).
| Outcome | Score |
|---|---|
| Lib exports correct, atomic write holds, HTTP endpoints respond | 1.0 |
| Lib exports correct but endpoint returns malformed shape | 0.5 |
| Lib import fails or list crashes on a corrupt row | 0.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.
nameis 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- ownsGET /artifactsand
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.
createArtifactmust 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.
nameis required.createArtifactthrows on empty name. Cockpit
should enforce client-side too.
createArtifactvalidates input via ArtifactSpec whenSNAPPY_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- returnsArtifact[], sorted desc bycreated_at.
CORS *.
POST /artifacts- body{ name, description? }→ returns the new
Artifact. CORS *. 400 on missing/empty name.
Working primitives
listArtifacts()- read every*.jsonunderstate/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:
- [ ] Use atomic write (temp + rename) in any new write path
- [ ] Treat
state/log/as per-machine ephemeral storage - [ ] Reject empty
nameat the lib level (don't push validation to
the HTTP layer)
- [ ] 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
no recent runs logged - the eval contract is declared but nothing has been graded yet