Save Package¶
Manifest builder, zip archive export/import for session save packages.
app/save_package.py¶
Save package utilities for the Axis Descriptor Lab.
Why a dedicated module?¶
The save system writes multiple files into a timestamped folder. Making these
packages self-describing (manifest with checksums), portable (zip export), and
re-importable (zip upload → state restoration) requires logic that is
independent of HTTP routing and Pydantic schemas. Keeping it here avoids
bloating main.py and follows the one-module-per-responsibility pattern
established by hashing.py, signal_isolation.py, and
transformation_map.py.
Sections¶
Manifest construction — build a
manifestdict with per-file SHA-256 checksums, roles, and byte sizes.Zip export — bundle a save folder into a compressed zip archive.
Zip import — validate and extract an uploaded zip, verifying manifest checksums for scientific integrity.
Markdown text extraction — strip provenance headers from
output.md,baseline.md, andsystem_prompt.mdto recover plain text for frontend state restoration.
- app.save_package.build_manifest(save_dir, files_written)[source]¶
Build a manifest dict describing every file in a save package.
The manifest provides scientific integrity metadata: each file gets a SHA-256 checksum, a role classification, and a byte size. This allows downstream consumers (import, audit tools) to verify that no file has been tampered with or corrupted.
metadata.jsonis a special case: it cannot hash itself (the hash would change the content, which would change the hash — an infinite loop). Its entry usessha256: nullandsize_bytes: 0as sentinel values.- Parameters:
save_dir (Path to the save folder containing the written files.)
files_written (Ordered list of filenames already written to
save_dir) – (not includingmetadata.json, which is written after the manifest is built).
- Returns:
dict – (dict mapping filename → {sha256, role, size_bytes}).
- Return type:
Manifest dict with
manifest_version(int) andfiles
- app.save_package.create_zip_archive(save_dir)[source]¶
Bundle a save folder into a compressed zip archive.
Only files whose names appear in
_FILE_ROLESare included — any unexpected files (e.g. OS metadata like.DS_Store) are silently skipped. Files are stored with flat names (no directory nesting) so the zip extracts cleanly into a single folder.- Parameters:
save_dir (Path to the save folder to archive.)
- Returns:
bytes – to disk.
- Return type:
Raw zip file bytes, ready to stream to the client or write
- app.save_package.validate_and_extract_zip(zip_bytes)[source]¶
Parse, validate, and extract a save-package zip archive.
Performs security validation first (size limits, path traversal, known filenames), then optionally validates manifest checksums if a manifest is present in
metadata.json.- Parameters:
zip_bytes (Raw bytes of the uploaded zip file.)
- Returns:
A 2-tuple of: -
files: dict mapping filename → raw file bytes for everyvalid entry extracted from the zip.
warnings: list of non-fatal warning strings (e.g. “No manifest found in metadata.json — checksums not verified”).
- Return type:
- Raises:
ValueError – If the input is not a valid zip, exceeds size limits, contains path-traversal entries, or has manifest checksum mismatches.
- app.save_package.extract_body_text(content)[source]¶
Strip the Markdown heading and HTML comment header from
output.mdorbaseline.md, returning only the body paragraph.The save system writes these files with a structure like:
# Output <!-- Axis Descriptor Lab – generated output --> <!-- saved: 2026-02-19T09:29:22 --> <!-- model: gemma2:2b | temp: 0.2 | ... --> The actual generated text starts here...
This function walks the lines, skipping the heading (
# ...), blank lines, and HTML comments (<!-- ... -->) until it reaches the first line of body text. Everything from that point onward is returned.- Parameters:
content (The full text of an
output.mdorbaseline.mdfile.)- Returns:
str – body text is found (e.g. the file is all headers), the original content is returned as a fallback.
- Return type:
The body text with leading/trailing whitespace stripped. If no
- app.save_package.extract_fenced_code(content)[source]¶
Extract text from a fenced code block in
system_prompt.md.The save system writes the system prompt wrapped in a Markdown fenced code block:
# System Prompt <!-- Axis Descriptor Lab – system prompt for save ... --> ```text You are a descriptive layer inside a deterministic system. ... ```
This function finds the opening fence (a line starting with ``
`text `` or just `` `) and the closing fence (a line that is exactly `` ```), and returns everything between them.- Parameters:
content (The full text of a
system_prompt.mdfile.)- Returns:
str – If no fenced code block is found, falls back to
extract_body_text()to strip headers and return the body.- Return type:
The extracted prompt text with leading/trailing whitespace stripped.
- app.save_package.parse_game_log_md(content)[source]¶
Parse a
game_log.mdfile back into a list of entry dicts.Reads the Markdown table produced by
app.save_formatting.build_game_log_md()and reconstructs each log row as a plain dict. Supports two table formats:5-column (legacy):
| # | Char | OOC | Channel | IC Text |
9-column (current):
| # | Char | OOC | Channel | IC Text | Status | Duration | Sent | Gap |
Pipe characters inside OOC and IC text cells were escaped as
\|when the file was written; this function reverses that escaping.- Parameters:
content (The full text of a
game_log.mdfile.)- Returns:
list[dict] –
channel,ooc_message,ic_text. When the 9-column format is detected, also includesstatus,duration_ms,sent_at. Header and separator rows are skipped. Returns an empty list if no data rows are found.- Return type:
One dict per data row with keys
ch(lowercase “a”/”b”),