Agent Attachments and Image Previews¶
Overview¶
SASE treats files produced by agents as first-class completion artifacts. When a
successful agent adds or modifies a supported image or video file, the completion path
records the media in done.json and appends it to the notification file list after the
standard chat and diff artifacts, and after any generated Markdown PDFs. When a
successful agent adds or modifies up to 10 Markdown files, core SASE renders PDF
artifacts and attaches those PDFs to the same completion notification. Explicit
artifacts saved with sase artifact create are appended after generated media when the
agent completion notification is sent. Notification plugins can then deliver those files
from Notification.files without re-scanning the workspace.
sase's TUI is SASE's terminal UI. It has two image surfaces: lightweight in-panel
previews for notification and file-panel attachments, and the separate a artifact
viewer for opening completed agent artifacts.
sase's TUI can also surface media files referenced in saved prompt artifacts
(raw_macros.md and *_prompt.md) even when the media itself was not part of the
agent's git diff. For current successful runs, those prompt-referenced media files are
persisted alongside the other default generated-media artifacts — as byte copies or as
byte-free version-control references, per
VCS-Backed Artifact Files — so the Agents-tab artifact
picker can still open them after a workspace is cleaned up. Legacy runs without
persisted default artifacts fall back to prompt-file discovery at view time.
Prompt-referenced media are not notification delivery attachments unless they also
appear in done.json.image_paths, appear in done.json.video_paths, or were saved
explicitly with sase artifact create.
Supported image extensions are:
.png.jpg.jpeg.webp.gif
Supported video extensions are:
.mp4.m4v.mov.webm
Generated Media Attachment Contract¶
Generated-image discovery runs when an agent finalizes successfully. This contract
covers images added to done.json.image_paths and completion notifications.
Generated-video discovery uses the same algorithm and writes videos to
done.json.video_paths. The collector checks candidate paths in stable order:
- tracked files changed relative to
HEAD - untracked files in the agent workspace
- files named by the saved proposal or commit diff
- files touched by the latest commit when the agent committed or opened a PR
Only existing files with supported media extensions are kept. Paths are resolved to absolute paths so outbound notification processes can attach them even when they run outside the agent workspace. Duplicates are removed while preserving order. Generated images are appended after any already-attached chat, diff, or generated PDF files; generated videos are appended after generated images.
The same lists are persisted as image_paths and video_paths in the agent's
done.json. Agent metadata consumers should read those fields instead of trying to
infer generated media from arbitrary notification files. GIFs remain in image_paths
for compatibility with image-preview consumers; downstream notification plugins can
still choose an animation-specific transport based on the .gif suffix.
Source: src/sase/axe/image_attachments.py
Prompt-Referenced Media Default Artifacts¶
Default artifact persistence also scans the saved prompt files in the agent artifacts directory:
raw_macros.md- every sibling
*_prompt.mdfile
Any path-like token ending in a common image suffix or supported video suffix is
resolved as an absolute, home-relative, or workspace-relative path. Existing files are
added after done.json.image_paths and done.json.video_paths, duplicates are removed,
and the file does not need to appear in the agent's git diff. These candidates carry
origin mentioned into the capture policy, so a mentioned repo file the run neither
authored nor can reproduce from version control gets no row at all. Images and GIFs are
added as image artifacts. Prompt-referenced videos are added as ordinary file
artifacts; sase's TUI detects the video suffix at view time and opens them with the
video preview path.
This is useful when a prompt asks an agent to inspect or transform an existing screenshot, mockup, reference image, or reference video and the resulting run should keep that source media one keypress away in sase's TUI.
Prompt-referenced media are artifact-list entries in sase's TUI, not notification
delivery attachments. Current runs persist them to the global artifact index during
finalization; legacy runs can still synthesize them from prompt artifacts when sase's
TUI loads the row. Downstream notification plugins should continue to use
done.json.image_paths and done.json.video_paths for the generated-media notification
contract.
Source: src/sase/core/artifact_file_defaults.py
Prompt Artifact Staging and Archive¶
Launch-time prompt preprocessing also stages every resolvable prompt reference so a later commit can publish a durable prompt document. This staging is workspace-local and lives under:
<workspace>/.sase/artifacts/
The directory has three responsibilities:
home/holds readable working copies for home-directory@pathreferences. This replaces the old.sase/home/location.pool/<sha12>-<basename>holds immutable content-addressed copies of external file bytes. Thesha12prefix is the first twelve hexadecimal characters of the file's SHA-256 digest, and the basename is sanitized for a single path component. Captured@file:<path>references use the fixed pool stemfile-refand mirror the source suffix aspool/<sha12>-file-ref<.ext>, while staged@pathand artifact references keeppool/<sha12>-<basename>. Published prompt archives copy these pooled bytes to the agents sidecar's digest-addressedfiles/objects/sha256/<hex-prefix>/<sha256>object store, which remains extensionless.prompt-artifacts.jsonlrecords one manifest row per staged reference;prompt-artifacts.lockserializes concurrent writers.
Clean tracked files inside a known repository are recorded as VCS-backed rows instead of
copied into pool/. External files are hashed and pooled unless they exceed
artifacts.capture.max_file_size_bytes; oversized files are still hashed and recorded
with a skip reason, but their bytes are not copied. Locator-only references such as
@agent:, @patch:, and @stitch: get manifest rows without file bytes.
When sase stitch create publishes the canonical prompt archive, it reads the manifest
rows for that run, copies pooled files to the agents sidecar's content-addressed object
store, and writes the prompt to prompts/<YYYYMM>/<name>.md. The body is the prompt
text selected for publication. The prompt's ARTIFACTS header section lists exactly the
@... references made clickable in the body. VCS-backed rows link to hosted source
blobs at the recorded revision, while copied external files link to
../../files/objects/sha256/<hex-prefix>/<sha256>.
The local pool is a cache for publication, not the permanent archive.
artifacts.capture.pool_max_bytes controls when SASE opportunistically garbage-collects
pool files whose manifest rows all belong to terminal runs that have already published
their prompt archive. Manifest rows are retained so validation can still explain what
happened.
If an older workspace still has .sase/home/, leave it in place until no live agent can
be using it. sase doctor reports it as workspace.legacy_artifact_home and tells you
the exact directory to remove after that check.
Sources:
src/sase/core/prompt_artifact_staging.pysrc/sase/agents_sync/prompt_archive/publish.pysrc/sase/agents_sync/prompt_archive/validation.pysrc/sase/doctor/checks_workspace.py
VCS-Backed Artifact Files¶
Automatic capture at agent finalization means authorship, and it never copies what
version control already stores. Every candidate discovered by generated-media and
prompt-referenced-media discovery is classified by
src/sase/core/artifact_capture_policy.py before anything is written.
The decision matrix¶
origin distinguishes candidates the run changed (done.json.image_paths /
video_paths, which come from git diff HEAD, untracked files, and the run's own
commit) from candidates merely mentioned in a saved prompt file. The first matching
rule wins:
| # | Condition | Outcome | Reason slug |
|---|---|---|---|
| 1 | The exact content is reproducible from a durable commit | reference — byte-free row with vcs_repo/vcs_sha/vcs_relpath |
vcs_reproducible |
| 2 | Authored by this run: inside the agent's artifacts directory, origin == changed, or mtime at or after the run start |
store — copy bytes |
artifacts_dir, changed, run_window |
| 3 | Mentioned only, and outside every known repo working tree | store — the user-supplied input case |
mentioned_external |
| 4 | Otherwise (mentioned only, inside a known repo, not reproducible) | skip — no row is written |
mentioned_repo |
Three invariants make this safe:
- No silent substitution. A
referencerow is written only after the candidate's bytes have been reproduced from<vcs_sha>:<vcs_relpath>and the reproduction's SHA-256 verified equal to the candidate's. A tracked file with uncommitted edits therefore fails rule 1 and is stored instead. - Durability.
vcs_shais reachable from a remote-tracking ref at capture time. An unpushed local commit does not qualify, because the numbered workspace holding it is reset on next open. - Fail-safe. Any git failure, timeout, unknown repo, or ambiguity downgrades a
would-be
referencetostore(reasonvcs_probe_failed). Capture can never lose bytes.
Explicit artifacts created with sase artifact create never enter this matrix and
always store their own bytes.
Finalization prints one summary line beside the other [artifacts] output:
[artifacts] default capture: stored=3 referenced=12 skipped=1 declared=2 cap_fired=false
cap_fired reports whether artifacts.capture.max_stored_per_agent was reached; once
it is, remaining store candidates become skip with reason capture_cap. Reference
rows cost no bytes, are not counted, and are never capped. declared counts
auto-discovered candidates omitted because the agent already registered their source
with sase artifact create. See configuration.md for
the artifacts.capture block.
The record fields¶
A VCS-backed row carries path: null plus three provenance fields:
| Field | Meaning |
|---|---|
vcs_repo |
Repo inventory name (record.name) the content belongs to. |
vcs_sha |
A durable commit that held the exact content at capture time. |
vcs_relpath |
Path of the file relative to that repo's toplevel. |
sha256, size_bytes, and mime_type are recorded exactly as they are for byte-backed
rows, so a byte-free row loses no integrity metadata. Index rows are written at schema
version 2; the reader accepts versions 1 and 2.
Materialization and the vcs-cache directory¶
Reference resolution stays pure — it never shells out to git — so read-only callers
(sase lsp, @-completion, TUI hover) stay cheap. A VCS-backed row resolves with
status vcs_backed and locator <vcs_repo>@<vcs_sha>:<vcs_relpath> and no
resolved_path. Callers that need bytes materialize explicitly through
src/sase/core/artifact_file_vcs.py's materialize_artifact_file(), which is the
single Python entry point:
sase artifact pathprints the materialized cache path, andsase artifact opencontinues into the usual viewer.@file:references in a prompt expand to prose naming the materialized path (the <path> file); a failure fails the launch loudly rather than handing an agent a dangling path.- sase's TUI Files pane renders a
PROVENANCEsection instead of a stored path and materializes off the UI thread. sase artifact showandsase artifact listneed no materialization;showreportsstored_path_status: vcs-backed (<locator>).
Materialized content lands in a content-keyed cache:
~/.sase/artifacts/vcs-cache/<sha256[:2]>/<sha256><suffix>
Lookup order is cache hit (re-hashed before it is trusted), then
git cat-file blob <vcs_sha>:<vcs_relpath> in each known checkout of the selected
repository, then a bounded history scan in each checkout. The scan visits at most
artifacts.capture.max_history_scan commits per checkout touching the path and
reachable from origin remote-tracking refs. Every recovered blob must match the
recorded SHA-256, including blobs found at the recorded commit. This can recover content
after a squash rewrite or loss of that commit if identical bytes survive in the scanned
history.
The repository is chosen owner-first: when the row names a project, vcs_repo is
matched by name or alias against that project's repositories for the row's workspace.
Only if no repository matches does lookup use the caller's current-directory inventory.
A content miss in the selected repository does not retry a same-named repository in the
caller's project. Resolvers are scoped to a call or batch, so a long-lived TUI sees
clones that appear between requests. The cache is transient: deleting it requires
re-materialization.
Diagnosing an unresolvable reference¶
If sase artifact path on a VCS-backed row exits 1, the content could not be reproduced
from any known checkout. Work through it in this order:
sase artifact show <ref>— read the locator to get the repo, commit, and relpath.sase repo list --project <project>— confirm thevcs_reponame or alias exists in the row's owning project and has at least one existing clone. An unknown repo name leaves the resolver with no checkout to try unless the caller's inventory supplies a match. If the row names no project, usesase repo listfor the current directory's inventory.- In a checkout of that repo,
git fetchand retry. Resolution measures reachability from remote-tracking refs, so a stale clone is the common cause. git cat-file blob <vcs_sha>:<vcs_relpath>— if the object is gone (a rewritten or pruned commit), the bounded history walk is the fallback; raiseartifacts.capture.max_history_scanif the content is deeper in history than the current bound.sase artifact doctor -v— audits every VCS-backed row and lists the unresolvable ones underUnresolvable VCS references, so a systemic problem shows up as a bucket rather than one failing command.
A failure always names the repo, commit, path, and digest. SASE never substitutes different bytes and never returns an empty file.
Sources:
src/sase/core/artifact_capture_policy.pysrc/sase/core/artifact_file_vcs.pysrc/sase/core/artifact_file_defaults.py
Consumption Ledger¶
When a launch prompt expands @ artifact references in rewrite mode, SASE records the
references that the agent was actually handed. The ledger is append-only JSONL at:
~/.sase/artifacts/consumption.jsonl
Writes use a sibling lock file:
~/.sase/artifacts/consumption.lock
Each line is a schema-versioned envelope:
{
"schema_version": 1,
"consumption": {
"id": "3f0a91c2d4e5",
"timestamp": "2026-07-30T14:02:11.481293+00:00",
"ref": "file:default:52895d68931185056fd0e49f",
"ref_kind": "file",
"fragment": null,
"role": "image",
"artifact_id": "default:52895d68931185056fd0e49f",
"resolved_path": "/home/user/.sase/artifacts/agents/sase/20260730134501/image.png",
"resolution_status": "exact",
"agent_name": "sase-b8.2",
"agent_source": "SASE_AGENT_NAME",
"artifacts_dir": "/home/user/.sase/projects/gh_sase-org__sase/artifacts/ace-run/202607/30/20260730134501",
"project": "gh_sase-org__sase"
}
}
ref is the fragment-free canonical reference and is the join key used by
sase artifact show and sase artifact list --unused. A prompt reference such as
@file:default:<digest>#L1-L5 records ref: file:default:<digest> and stores the
discarded anchor as fragment: L1-L5, so all fragments of the same artifact aggregate
together. artifact_id is populated only for file: references; non-file references
such as plan:, bead:, agent:, patch:, stitch:, and research: leave it null
but are still recorded and summarized by show.
The v1 role vocabulary is deliberately small:
| Role | Derivation |
|---|---|
report |
Document and historical chat: references; file: references whose resolved path is Markdown, plain text, or PDF |
image |
Visual media by suffix, including images and videos such as PNG, JPEG, GIF, SVG, WebP, MP4, MOV, and WebM |
source |
Code, data, entity/revision references (including historical aliases), unknown suffixes, and other non-report, non-visual references |
test-result |
Reserved for future writers; no v1 code emits it |
Grouping videos under image is intentional: in this ledger the role means visual
media, and keeping the v1 vocabulary to four values leaves later lineage work additive.
SASE logs every reference that successfully expands in rewrite mode. It does not log
validation-only checks, failed expansion passes, sase artifact open,
sase artifact path, sase's TUI browsing, or LSP completion. Within one expansion pass
duplicate references collapse to one event by canonical ref; later launches, retries,
and workflow steps append new events because they are separate consumptions.
sase artifact show <reference> reads the ledger for any resolvable reference and adds
consumption_count, consumed_by_agents, consuming_agents, and last_consumed_at to
the pretty report. JSON output adds an additive consumption object with the full
summary, or null when the reference has never been consumed.
sase artifact list --unused filters artifact files to rows with no recorded
file:<id> consumption; the filter is applied before --limit, so -u -l 50 asks for
50 unused artifacts, not for unused rows among the newest 50.
Every canonical, fragment-free file: key in the ledger is a hard lifecycle protection.
The shared protection collector unions those consumed IDs with IDs found in persistent
ProjectSpec, plan, bead, and research references before sase artifact stats projects
the default policy, sase artifact prune removes rows, sase artifact reclaim converts
stored rows to VCS-backed identities, or opt-in automatic retention runs after agent
finalization. Overlap is deduplicated, while stats reports referenced, consumed,
overlap, and total counts separately. A missing ledger is an empty optional source; if a
present ledger cannot be queried, reporting surfaces show it as unavailable and every
destructive apply or automatic enforcement pass refuses to change artifacts.
Sources:
src/sase/core/artifact_consumption.pysrc/sase/core/artifact_consumption_query.pysrc/sase/artifact_ref_prompt.pysrc/sase/artifact_cli/show.pysrc/sase/artifact_cli/listing.pysrc/sase/core/artifact_file_protection.pysrc/sase/artifact_cli/stats.pysrc/sase/artifact_cli/prune.pysrc/sase/artifact_cli/reclaim.pysrc/sase/axe/run_agent_exec_finalize.py
Store Lifecycle¶
The artifact-file store is measured, drained, and bounded through one deliberately staged progression: report → dry run → opt-in retention. Nothing in it removes anything by surprise, and nothing it removes is gone immediately.
| Stage | Command | Writes |
|---|---|---|
| Report | sase artifact stats |
Never |
| Reclaim (lossless) | sase artifact reclaim |
Only with -a/--apply |
| Prune (lossy) | sase artifact prune |
Only with -a/--apply |
| Undo / finalize | sase artifact trash {list,restore,purge} |
restore and purge write; purge is the only hard delete |
| Ongoing | artifacts.retention |
Only when explicitly enabled |
The protection contract¶
These rules hold on every surface above — manual and automatic, dry run and apply:
- Declaration is permanent. A row created by
sase artifact create(explicit=true) is never removed, converted, or rewritten by any lifecycle command. - Referenced artifacts are protected. An artifact ID appearing in a ProjectSpec,
plan, bead, bead page, or research document is excluded, in both its bare
default:/explicit:andfile:-prefixed forms. - Consumed artifacts are protected. Any canonical, fragment-free
file:ID in the consumption ledger is excluded. - The newest generation always survives. Whatever the predicates say, the newest
capture of every
(project, label)pair is never selected. A label's history can shrink to one; it cannot vanish. - Unreadable protection sources block removal, never permit it. If a required source
cannot be read,
--applyrefuses with the source named and the automatic pass skips entirely. The dry run still renders and reports the gap. - Nothing hard-deletes except
trash purge. Every other removal is restorable.
Report first: sase artifact stats¶
stats is read-only. It panels totals split explicit / automatic / VCS-backed, the
observed window and growth rate, per-kind and per-project and top-agent groups (with the
truncated remainder shown rather than hidden), duplicate-digest redundancy, per-label
generation projections, an upper bound on reclaimable rows, protection counts
(referenced, consumed, overlap, total, plus any unavailable source as a warning), trash
occupancy, and — last — exactly what the configured default policy would select.
-j/--json emits the same content as one envelope.
Read the reclaimable figure as an upper bound, not a result: it counts automatic
byte-backed rows whose source_path lives inside their workspace_dir. Reproducibility
is only ever established by the digest verification reclaim does.
Reclaim before pruning¶
sase artifact reclaim is lossless, so run it first: it keeps every row, label, and
provenance field and deletes only a copy of content version control already stores. For
each eligible automatic row it resolves the owning repo and relative path from
workspace_dir + source_path (mapping a sase/repos/... prefix to the matching
sidecar, linked, or external repo), walks that path's durable remote-tracking history
within -d/--max-history-scan commits (default 100), and converts the row only when a
blob's SHA-256 equals the row's recorded digest. Because the dry run compares digests
rather than materializing content, it touches nothing at all — not even vcs-cache/.
Reclaim changes a row's ID. A reference row's ID derives from its VCS identity
rather than its stored path, so the converted row gets a new ID; the old row is removed
and the new one written. Both IDs appear in the plan's OLD REF / NEW REF columns and in
each Reclaimed file:<old> -> file:<new> apply line, so anyone holding the old ID can
follow it. Preserving the old ID is not an option — it would collide with the ID a
re-capture of the same content computes, and idempotent capture depends on that. This is
also why reclaim honors the reference and consumption protections: changing an ID that
something already names would break the reference.
Rows reclaim cannot verify are never touched. They are reported in the Rows Left Untouched panel, grouped by a stable reason:
| Reason | What to do |
|---|---|
explicit, referenced |
Working as intended; the row is protected. |
already_vcs_backed |
Nothing to do; the row is already byte-free. |
missing_sha256, missing_size |
Run sase artifact doctor -f to backfill enrichment fields, then retry. |
missing_workspace_dir, missing_source_path |
The row predates provenance capture; only pruning can address it. |
source_outside_workspace |
The file was never inside a repo checkout, so no relpath is derivable. |
unknown_project, unknown_repo, inventory_unavailable |
Check sase repo list; the repo the row names is not in the inventory. |
missing_checkout |
No live clone of that repo exists right now. Open one and retry. |
vcs_probe_failed |
A git call failed or timed out; the row is left alone by design. |
digest_not_found |
The exact content is not in durable history within the scan bound. Raise -d, or accept that the bytes are genuinely unique and let pruning decide. |
Prune what remains¶
sase artifact prune is the lossy step, and the only one that removes information. It
plans from the same protection set with -g/--keep-generations (defaulting to
artifacts.retention.keep_per_label), -b/--before, -k/--kind, -m/--min-size,
-p/--project, and -l/--limit, renders the identical table in both modes, and changes
nothing without -a/--apply. Selecting a byte-free row is legitimate and reclaims no
bytes — it removes index noise — so the plan counts byte-backed and byte-free selections
separately and only ever sums byte-backed rows into reclaimable bytes.
The trash¶
Every removal — from prune, from reclaim, and from automatic retention — moves the
stored bytes and the complete index row into ~/.sase/artifacts/trash/. One entry is
one directory containing entry.json (schema version, entry ID, artifact ID,
trashed_at, reason, size_bytes, stored filename, and the verbatim original index
row) plus the moved payload file when the row had bytes. Bytes are moved before the
index row is dropped, so an interrupted batch leaves a restorable entry rather than an
orphaned row.
sase artifact trash # newest-first listing, grace-period flag per entry
sase artifact trash restore <entry-or-ref> # payload back in place, index row re-inserted
sase artifact trash purge # permanent, and only past the grace period
sase artifact trash purge -a # permanent, ignoring the grace period
artifacts.retention.trash_grace_days (default 14) is the cutoff purge honors and the
one trash list marks entries against. Until a purge runs, trashed bytes still occupy
disk: a full reclaim pass moves its recovered bytes into the trash, so du does not
drop at apply time. reclaim --apply says this outright in its summary.
Ongoing enforcement¶
artifacts.retention is disabled by default and removes nothing until enabled. Once
enabled, one bounded pass runs after automatic capture at each agent finalization: it
plans with the configured policy plus the protection scan, trashes what it selects, and
purges trash entries past the grace period, printing one [artifacts] retention: line
with rows trashed, bytes reclaimed, and entries purged. The pass is wrapped defensively
and never fails a run; when a protection source is unavailable it prints
[artifacts] retention skipped: and touches nothing. See
configuration for the fields.
Sources:
src/sase/artifact_cli/stats.pysrc/sase/artifact_cli/prune.pysrc/sase/artifact_cli/reclaim.pysrc/sase/artifact_cli/trash.pysrc/sase/core/artifact_file_retention.pysrc/sase/core/artifact_file_reclaim.pysrc/sase/core/artifact_file_trash.pysrc/sase/core/artifact_file_protection.pysrc/sase/axe/run_agent_exec_finalize.py
Markdown PDF Attachment Contract¶
Markdown discovery runs on successful agent finalization with the same candidate
ordering as image discovery. Supported source extensions are .md and .markdown.
Sources are resolved to existing workspace files, generated run artifacts are excluded,
and duplicates are removed before rendering. If more than 10 Markdown sources remain
after filtering, SASE skips PDF rendering for that run and adds a
completion-notification note instead of rendering a large attachment set.
Core SASE renders discovered Markdown sources into the current agent artifacts directory:
<artifacts_dir>/markdown_pdfs/<sanitized-relative-source-path>.pdf
<artifacts_dir>/markdown_pdfs/index.json
Rendering is best-effort. Missing Pandoc/PDF-engine tools or conversion errors do not
fail the agent run; failed sources are omitted. Successful PDF paths are persisted as
markdown_pdf_paths in done.json, and index.json records source_path to
pdf_path mappings for diagnostics. When the 10-source limit is exceeded,
done.json.markdown_pdf_paths is empty and the source count is carried through
completion handling for the user-facing skip note.
While PDFs are being prepared, the runner writes workflow_state.json.pdf_status plus a
compact activity label. sase's TUI loads the activity during refresh and shows
messages such as Preparing PDFs from Markdown..., PDF 2/4 <path>, or
PDFs done 3/4 (1 skipped) only in the prompt/detail header's labeled Activity:
field. This status is transient finalization state; the durable output remains
done.json.markdown_pdf_paths and markdown_pdfs/index.json.
Markdown PDFs use a built-in small-screen layout by default: a narrow portrait page,
small margins, larger readable body text, and wrapping-friendly CSS for code blocks,
tables, links, and other long content. The preferred wkhtmltopdf path receives both
the default stylesheet and explicit page/margin options; LaTeX fallbacks receive the
same page size, margin, font size, and line-height defaults through Pandoc variables.
When a discovered Markdown source starts with usable, non-empty YAML frontmatter, the
rendered PDF replaces the raw metadata block with a styled Properties card; the
original Markdown file is not changed. Labels and property ordering use the same helpers
as sase's TUI plan-detail presentation, while the PDF renders nested mappings and
sequences as indented lines. HTML-sensitive property text is escaped. On the preferred
wkhtmltopdf path, Pandoc's document-title metadata uses the frontmatter title value
converted to text, or the source filename stem when title is absent. Empty, malformed,
or absent frontmatter leaves the render input unchanged, and a preprocessing failure
falls back to rendering the original Markdown. Dedicated launch-preview PDFs opt out of
this transformation in both the highlighted and generic-fallback passes, preserving
prompt frontmatter.
Completion notifications attach generated Markdown PDFs after the saved chat and diff
files, before image attachments. The Agents tab file panel also loads
markdown_pdf_paths alongside plan and image files for completed agents.
Sources:
src/sase/attachments/markdown_pdf.pysrc/sase/axe/run_agent_exec.py
Explicit Artifact Contract¶
Agents can save a generated file explicitly with:
sase artifact create [-k <kind>] [-l <label>] [-m] -p <path>
sase artifact-file create remains a compatibility alias for the same command.
-k/--kind is one of chat, plan, image, markdown, pdf, or file, and
defaults to a kind inferred from the file extension. -l/--label sets the display label
and defaults to the source file name. -m/--move removes the source after storing it
instead of retaining the default workspace copy; it is intended for scratch files, and
using it on a tracked file leaves a deletion in the working tree. -p/--path is
required.
On success the command prints five lines (bead: only with --bead):
id: explicit:<hash>
source: /absolute/path/to/report.md
path: /home/<user>/.sase/artifacts/agents/<project>/<timestamp>/report-<digest>.md
ref: file:explicit:<hash>
var: artifacts[0]
bead: <id>
The source: line records where the artifact came from, path: names the stored
snapshot, and ref: is the copyable name to hand to a user or another agent. var:
names the entry index in the agent's artifacts output variable. By default the source
remains in place. Later source edits do not propagate to the stored snapshot; run
create again to register a fresh one.
artifacts output variable¶
Every successful create also records the artifact in the agent's SASE-managed
artifacts output variable (a list of maps in registration order):
| Field | Present | Meaning |
|---|---|---|
ref |
always | Canonical file:explicit:<hash> identity. |
label |
always | The -l/--label value (default: source file name). |
kind |
always | markdown, image, pdf, plan, chat, or file. |
path |
always | Absolute path of the immutable stored snapshot. |
source_path |
only when the source was kept | Absolute path of the living source file. |
bead |
only when --bead attached |
The bead id the artifact was attached to. |
Re-registering the same ref, or the same label and source_path, replaces that
entry in place with the newer snapshot; registering 101 artifacts keeps the newest 100.
An agent-owned artifacts value is left alone and recording is best-effort (it never
changes create's exit status). Render with
{{ agents["research.final"].artifacts[0].ref }} or select with
sase var get 'research.final.artifacts[0]["ref"]' --format raw.
Every new index row also records sha256 (the full digest of the stored file),
size_bytes, and mime_type. All three are optional at index schema version 1, so rows
written before they existed simply carry null; sase artifact doctor reports those
gaps, sase artifact doctor -f backfills them for every row whose stored file is still
present, and sase artifact doctor -v re-hashes live stored files to verify the
recorded digests. The reader accepts index schema versions 1 and 2 and the writer
preserves rows with any other schema version verbatim, so a mixed-age fleet cannot lose
rows on rewrite.
The CLI command is intended for agent processes: it requires SASE_AGENT=1 and
SASE_ARTIFACTS_DIR so SASE knows which run owns the artifact, and it exits non-zero
with an explanatory message when either is missing or the source path is not a file. It
copies the source file into persistent SASE artifact storage, records an association
with the current agent, and lets sase's TUI show the artifact even after the agent is
dismissed and later revived. During completion notification delivery, SASE appends
existing explicit artifact files after chat, diff, generated Markdown PDFs, generated
image attachments, and generated video attachments. Duplicate stored paths and artifacts
whose source is already attached are ignored, missing files are skipped, and
explicit-artifact index failures do not fail the completion path.
Sources:
src/sase/artifact_cli/create.pysrc/sase/core/artifact_file_explicit.py
Notification Delivery¶
Core SASE stores generated PDFs, generated images, generated videos, and explicit
artifact attachments in the existing Notification.files list. There is no separate
notification schema field for typed attachments yet. This keeps the contract compatible
with existing notification storage and lets downstream plugins decide how to render each
file:
- Telegram integrations can send static images as photos, GIFs as animations, videos as videos, and keep markdown/diff files as documents.
- Google Chat integrations can upload image files directly into the completion thread.
- sase's TUI notification modal can still open attached files in
$EDITORwitheand cycle them withCtrl+N/Ctrl+P.
See notifications.md for the notification model and modal
keybindings.
sase's TUI Artifact Viewer¶
The Agents tab exposes completed agent artifacts through the a key. When artifacts
exist, sase's TUI opens the artifact panel for selection. Chat transcripts, plan files,
generated Markdown PDFs, generated images, generated videos, prompt-referenced media,
and explicit artifacts created with
sase artifact create [-k <kind>] [-l <label>] [-m] -p <path> all use the same list.
Generated videos are stored as ordinary file artifacts, but the picker labels
supported video suffixes as [video] and the viewer opens them with terminal video
playback. The panel is shown even for a single artifact so users can confirm the
artifact label, kind, and path before opening it.
The selected agent's prompt/detail header also includes non-chat artifacts in the
plan-adjacent SASE CONTEXT ARTIFACTS lane. The complete lane order is PLAN,
BEAD, ARTIFACTS, MEMORY, GLOSSARY, SKILLS, then WORKSPACES; within
ARTIFACTS, Reads, Commits, Deltas, and Files remain in that order when
present. Reads shows audited sase artifact read invocations (newest first, with
reasons and session attribution), not prompt citations or silent artifact inspection.
Paths are shown relative to the agent workspace when possible, home-relative when
appropriate, and with hint numbers when hint mode is active. A read without a recorded
resolved path still renders but does not consume a hint.
The panel supports one-key selectors, j/k navigation, m to mark rows, Enter to
open the marked set or highlighted row, y to copy highlighted Markdown contents, Y
to copy the highlighted artifact path, and A to open every artifact in list order.
Copied paths are workspace-relative when possible and fall back to home-relative paths.
When multiple artifacts are opened together, the terminal viewer adds n/p navigation
between artifacts in addition to page navigation.
When sase's TUI is running inside tmux, the artifact viewer launches in a right-side
tmux pane and the Agents list collapses while the pane is live. Press l from the
Agents tab to focus the tracked artifact pane, or press a again to close it.
Row-changing navigation is guarded while the pane is open so the TUI does not drift to a
different agent than the viewer. Outside tmux, sase's TUI suspends and opens the viewer
in the current terminal pane. The viewer chooses its mode from the artifact kind and
file extension: supported images are displayed directly, supported videos play with mpv,
PDFs are converted to PNG pages, and Markdown is rendered to PDF before paging. The page
loop uses j/k to move between pages, wrapping at the first and last page, n/p to
move between artifacts in a sequence, r to refresh or replay the current artifact, and
q to close the viewer.
Only one plan artifact is listed for each agent. If run metadata contains both an archived plan path and an SDD tale path, committed plans prefer the SDD path; uncommitted plans prefer the archived path unless only the SDD path is available.
Viewer dependencies are intentionally outside the agent completion path. kitten is
required for image/PDF/Markdown terminal display, mpv is required for terminal video
playback, pdftoppm is required for PDF/Markdown paging, and Markdown rendering also
needs pandoc plus one supported PDF engine. If a dependency is missing, sase's TUI
shows a warning instead of failing the TUI or changing the stored artifact list.
Source: src/sase/ace/tui/graphics/viewer.py
Video Preview¶
sase's TUI plays .mp4, .m4v, .mov, and .webm artifacts in the same artifact
viewer used for images and PDFs. Inside tmux, selecting a video opens the tracked
right-side artifact pane; outside tmux, sase's TUI suspends and plays in the current
terminal. Playback uses mpv --vo=kitty by default, bounded to the same cell area used
for image artifacts.
While mpv is running, mpv owns playback keys: space pauses or resumes, arrow keys
seek, m toggles mute, and q stops playback. After playback exits, the artifact
viewer footer returns with the usual navigation keys: r replays, n/p move through
a multi-artifact sequence, z toggles tmux zoom when available, <tab> focuses the
SASE TUI from a tmux artifact pane, and q closes the viewer.
Videos are muted by default because SASE often runs on a remote host or inside tmux
where the server audio device is not useful. Configure playback under
ace.artifact_file_viewer.video:
ace:
artifact_file_viewer:
video:
audio: false
loop: false
vo: "kitty"
extra_mpv_args: []
Set audio: true to start unmuted, loop: true to pass --loop-file=inf, vo to use
another mpv video output such as tct, and extra_mpv_args to append additional mpv
flags after SASE defaults. SASE launches mpv with --no-config so user mpv profiles
cannot break the curated terminal preview; put viewer-specific customization in the SASE
config instead.
sase's TUI Image Preview Foundation¶
The notification modal and Agents tab file panel route supported image extensions through the preview layer before attempting text decoding.
The internal preview layer renders PNG, JPEG, WebP, and GIF attachments as a portable Rich cell preview. It uses Pillow to decode the first image frame, apply EXIF orientation, fit it inside the visible panel bounds, composite transparency onto a dark background, and apply a mild preview-only sharpen pass after resizing. Each terminal cell samples a 2 x 2 pixel block and chooses the closest Unicode block mask with foreground and background colors, which preserves more edges, diagonals, UI details, and text-like shapes than a fixed half-block sampler.
No Kitty, iTerm2, Sixel, or other terminal image protocol support is required. The renderer only emits colored Unicode text through Rich/Textual, so it works the same way in ordinary terminals, multiplexed sessions, SSH sessions, and environments with no image protocol support. Preview quality depends on the visible pane size and terminal color depth: larger panes provide more sampled cells, and truecolor terminals preserve colors better than 256-color terminals.
sase's TUI checks only terminal color depth from the environment. When
COLORTERM=truecolor, COLORTERM=24bit, or a truecolor marker in TERM is present,
previews use 24-bit color; otherwise they use 256-color approximations. Missing files,
unsupported extensions, decode errors, missing Pillow, and images above the renderer
guardrails show a concise text fallback with the file path, byte size when available,
and the relevant editor or artifact action. Use e in notifications, E on the Agents
tab, or the a artifact viewer whenever full-fidelity viewing is needed.
Source: src/sase/ace/tui/graphics/