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

  1. Manifest construction — build a manifest dict with per-file SHA-256 checksums, roles, and byte sizes.

  2. Zip export — bundle a save folder into a compressed zip archive.

  3. Zip import — validate and extract an uploaded zip, verifying manifest checksums for scientific integrity.

  4. Markdown text extraction — strip provenance headers from output.md, baseline.md, and system_prompt.md to 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.json is a special case: it cannot hash itself (the hash would change the content, which would change the hash — an infinite loop). Its entry uses sha256: null and size_bytes: 0 as 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 including metadata.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) and files

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_ROLES are 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 every

valid 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:

tuple[dict[str, bytes], list[str]]

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.md or baseline.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.md or baseline.md file.)

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.md file.)

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.md file 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.md file.)

Returns:

list[dict]channel, ooc_message, ic_text. When the 9-column format is detected, also includes status, 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”),