venice-cli 0.83.44__tar.gz → 0.83.45__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. {venice_cli-0.83.44 → venice_cli-0.83.45}/PKG-INFO +25 -6
  2. {venice_cli-0.83.44 → venice_cli-0.83.45}/README.md +24 -5
  3. venice_cli-0.83.45/src/venice/__init__.py +1 -0
  4. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_agent.py +8 -0
  5. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_compact.py +60 -11
  6. venice_cli-0.83.45/src/venice/commands/_context_archive.py +283 -0
  7. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_repl.py +124 -13
  8. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_session.py +35 -5
  9. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/chat.py +16 -2
  10. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/code.py +28 -7
  11. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/userconfig.py +8 -0
  12. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_chat.py +1 -0
  13. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_code_command.py +1 -0
  14. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_compact.py +70 -1
  15. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_config.py +20 -4
  16. venice_cli-0.83.45/tests/test_context_archive.py +88 -0
  17. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_drive_cli.py +15 -3
  18. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_planner.py +1 -0
  19. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_repl.py +83 -0
  20. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_review.py +1 -0
  21. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_sessions.py +46 -0
  22. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_spawn.py +2 -1
  23. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_web_search.py +1 -0
  24. venice_cli-0.83.44/src/venice/__init__.py +0 -1
  25. {venice_cli-0.83.44 → venice_cli-0.83.45}/.dockerignore +0 -0
  26. {venice_cli-0.83.44 → venice_cli-0.83.45}/.gitignore +0 -0
  27. {venice_cli-0.83.44 → venice_cli-0.83.45}/CONTRIBUTING.md +0 -0
  28. {venice_cli-0.83.44 → venice_cli-0.83.45}/Dockerfile +0 -0
  29. {venice_cli-0.83.44 → venice_cli-0.83.45}/LICENSE +0 -0
  30. {venice_cli-0.83.44 → venice_cli-0.83.45}/Makefile +0 -0
  31. {venice_cli-0.83.44 → venice_cli-0.83.45}/SECURITY.md +0 -0
  32. {venice_cli-0.83.44 → venice_cli-0.83.45}/bin/venice +0 -0
  33. {venice_cli-0.83.44 → venice_cli-0.83.45}/contracts/openapi/implementation.json +0 -0
  34. {venice_cli-0.83.44 → venice_cli-0.83.45}/contracts/openapi/venice.lock.json +0 -0
  35. {venice_cli-0.83.44 → venice_cli-0.83.45}/deploy/kubernetes/remote-mcp.yaml +0 -0
  36. {venice_cli-0.83.44 → venice_cli-0.83.45}/install.sh +0 -0
  37. {venice_cli-0.83.44 → venice_cli-0.83.45}/pyproject.toml +0 -0
  38. {venice_cli-0.83.44 → venice_cli-0.83.45}/scripts/openapi-requirements.txt +0 -0
  39. {venice_cli-0.83.44 → venice_cli-0.83.45}/scripts/openapi_contract.py +0 -0
  40. {venice_cli-0.83.44 → venice_cli-0.83.45}/scripts/verify_release.py +0 -0
  41. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/__main__.py +0 -0
  42. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/_egress.py +0 -0
  43. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/_numeric.py +0 -0
  44. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/audio_player.py +0 -0
  45. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/audio_post.py +0 -0
  46. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/auth.py +0 -0
  47. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/billing.py +0 -0
  48. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/cli.py +0 -0
  49. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/client.py +0 -0
  50. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/__init__.py +0 -0
  51. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_audio.py +0 -0
  52. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_browser.py +0 -0
  53. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_browser_proxy.py +0 -0
  54. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_code.py +0 -0
  55. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_exec.py +0 -0
  56. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_index.py +0 -0
  57. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_mailbox.py +0 -0
  58. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_mcp.py +0 -0
  59. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_mcp_client.py +0 -0
  60. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_memory.py +0 -0
  61. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_models.py +0 -0
  62. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_openai.py +0 -0
  63. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_persona.py +0 -0
  64. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_queue.py +0 -0
  65. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_review.py +0 -0
  66. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_shared.py +0 -0
  67. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_steer.py +0 -0
  68. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/_video_jobs.py +0 -0
  69. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/balance.py +0 -0
  70. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/bg_remove.py +0 -0
  71. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/cache_probe.py +0 -0
  72. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/completion.py +0 -0
  73. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/config.py +0 -0
  74. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/contact_sheet.py +0 -0
  75. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/embed.py +0 -0
  76. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/image.py +0 -0
  77. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/image_edit.py +0 -0
  78. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/index.py +0 -0
  79. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/login.py +0 -0
  80. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/master.py +0 -0
  81. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/mcp_serve.py +0 -0
  82. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/memory.py +0 -0
  83. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/models.py +0 -0
  84. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/music.py +0 -0
  85. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/review.py +0 -0
  86. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/search.py +0 -0
  87. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/secret.py +0 -0
  88. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/sessions.py +0 -0
  89. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/sfx.py +0 -0
  90. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/tts.py +0 -0
  91. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/upscale.py +0 -0
  92. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/commands/video.py +0 -0
  93. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/config.py +0 -0
  94. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/image_montage.py +0 -0
  95. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/mcp_server.py +0 -0
  96. {venice_cli-0.83.44 → venice_cli-0.83.45}/src/venice/remote_media.py +0 -0
  97. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/__init__.py +0 -0
  98. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/_drive.py +0 -0
  99. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/_hygiene.py +0 -0
  100. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/_mcp_fake_server.py +0 -0
  101. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/_venice_fake_server.py +0 -0
  102. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_agent.py +0 -0
  103. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_auth.py +0 -0
  104. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_balance.py +0 -0
  105. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_bg_remove.py +0 -0
  106. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_browser.py +0 -0
  107. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_browser_integration.py +0 -0
  108. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_cache_probe.py +0 -0
  109. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_cli.py +0 -0
  110. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_client.py +0 -0
  111. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_code.py +0 -0
  112. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_completion.py +0 -0
  113. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_contact_sheet.py +0 -0
  114. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_egress.py +0 -0
  115. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_embed.py +0 -0
  116. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_exec.py +0 -0
  117. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_fake_server.py +0 -0
  118. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_image.py +0 -0
  119. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_image_edit.py +0 -0
  120. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_index.py +0 -0
  121. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_install_scripts.py +0 -0
  122. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_login.py +0 -0
  123. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_mailbox.py +0 -0
  124. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_master.py +0 -0
  125. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_mcp_client.py +0 -0
  126. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_mcp_http.py +0 -0
  127. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_mcp_serve.py +0 -0
  128. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_mcp_tools.py +0 -0
  129. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_memory.py +0 -0
  130. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_models.py +0 -0
  131. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_music.py +0 -0
  132. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_openapi_contract.py +0 -0
  133. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_persona.py +0 -0
  134. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_profiles.py +0 -0
  135. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_release_gate.py +0 -0
  136. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_remote_media.py +0 -0
  137. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_search.py +0 -0
  138. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_secret.py +0 -0
  139. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_sfx.py +0 -0
  140. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_shared.py +0 -0
  141. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_shared_openai.py +0 -0
  142. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_source_hygiene.py +0 -0
  143. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_steer.py +0 -0
  144. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_tts.py +0 -0
  145. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_upscale.py +0 -0
  146. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_video.py +0 -0
  147. {venice_cli-0.83.44 → venice_cli-0.83.45}/tests/test_video_jobs.py +0 -0
  148. {venice_cli-0.83.44 → venice_cli-0.83.45}/uninstall.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: venice-cli
3
- Version: 0.83.44
3
+ Version: 0.83.45
4
4
  Summary: A stdlib-only Python CLI for the Venice.ai API: audio, video, images, chat, and embeddings.
5
5
  Project-URL: Homepage, https://github.com/gobha-me/venice-cli
6
6
  Project-URL: Repository, https://github.com/gobha-me/venice-cli
@@ -679,6 +679,8 @@ catalog), `/models` (list the available models, marking the current and the
679
679
  default), `/auto` and `/manual` (toggle auto-accepting paid/side-effecting tool
680
680
  calls for following turns), `/compact [N]` (summarize older history into one
681
681
  message, keeping the last `N` turns verbatim),
682
+ `/context list [CURSOR]` (list archived evidence metadata) and
683
+ `/context read ID [OFFSET]` (read up to 32 KiB of an exact archived message),
682
684
  `/cost` (this session's estimated spend so far, with the active model's
683
685
  current-run cache hit rate on the same line; `--session-max-spend` adds a
684
686
  cap), `/usage` (a token + cost breakdown for the session, keeping the
@@ -717,9 +719,19 @@ older prefix into one synthetic message once the prompt crosses
717
719
  the last `--compact-keep-turns` turns (default 10) verbatim. The trigger uses
718
720
  the server-reported `usage.prompt_tokens` when a turn provides it (falling
719
721
  back to a chars-per-token estimate otherwise), so it fires on the real prompt
720
- size rather than a guess. Compaction is best-effort — a failed summarization
721
- call leaves the history untouched — and never orphans a tool result from its
722
- assistant turn. It's off by default because it costs a summarization call.
722
+ size rather than a guess. `--compact-loss-policy aggressive|evidence` controls
723
+ what happens to the summarized prefix. Chat defaults to `aggressive`, preserving
724
+ the original behavior. Code defaults to `evidence`: every removed user message,
725
+ assistant tool call (including arguments), and tool result is copied exactly into
726
+ the private session envelope before the live history is replaced. The model gets
727
+ a bounded index plus the read-only `venice_context_archive` tool; operators can use
728
+ the `/context` commands above even in a plain REPL. The archive is capped at 512
729
+ entries and 8 MiB. If the next compaction would cross either cap, it is refused
730
+ before the summary API call and both history and archive remain unchanged. Reads
731
+ are paged at 32 KiB and list pages at 50 entries. Compaction is otherwise
732
+ best-effort — a failed or empty summarization leaves both stores untouched — and
733
+ never orphans a tool result from its assistant turn. It's off by default because
734
+ it costs a summarization call.
723
735
 
724
736
  #### Sessions
725
737
 
@@ -728,7 +740,11 @@ each turn to `~/.config/venice/sessions/<id>.json` (mode 0600;
728
740
  `$VENICE_SESSIONS_DIR` overrides the location). Unlike a bare `/save` transcript,
729
741
  a session travels with its **settings** — model, system prompt, generation
730
742
  parameters, `max-tool-calls`, the `venice code` sandbox root, and the running
731
- token/cost usage — so resuming restores the whole context, not just the messages.
743
+ token/cost usage — plus any bounded exact context archive — so resuming restores
744
+ the whole context, not just the messages. Session envelope v2 adds
745
+ `context_archive` plus the structural count of authoritative leading system
746
+ messages; v1 envelopes remain readable, conservatively preserve their complete
747
+ leading system prefix, and resume with an empty archive.
732
748
  The API key is never written to a session.
733
749
 
734
750
  `venice code` also assigns each session an opaque `prompt_cache_key`, sent as an
@@ -1598,6 +1614,7 @@ jq '.usage.billed_total' ~/.config/venice/sessions/<id>.json
1598
1614
  | `venice_scout` | delegate a read-only investigation to a disposable subagent with a fresh context; returns a structured report so exploration doesn't pollute the main context — **opt-in with `--scout`** (see [Scout subagent](#scout-subagent---scout)) | no |
1599
1615
  | `venice_web_search` | search the web to **discover** documentation you don't have a URL for; returns a short answer + cited URLs (billed, but bounded by the tool-call budget) — **opt-in with `--web-search`** (see [Web search](#web-search---web-search)) | no |
1600
1616
  | `venice_review` | hand the current diff to a **cold-context reviewer** — a disposable subagent with a fresh context, read-only tools and (where the catalog allows) a different model — and get back defects with `file:line` + a repro. Findings only: it approves and certifies nothing — **opt-in with `--review`** (see [Reviewer rail](#reviewer-rail---review)) | no |
1617
+ | `venice_context_archive` | list bounded metadata or page through exact messages removed by evidence-preserving compaction; current session only, with no filesystem, network, or process access | no |
1601
1618
 
1602
1619
  **Safety.** Every filesystem path is resolved and confined to the **writable roots** —
1603
1620
  the startup root (default: cwd, or `--root` / `$VENICE_CODE_ROOT`) plus any added with
@@ -1659,6 +1676,7 @@ external diff helpers. Use the confirmed `run` tool for other Git forms.
1659
1676
  | `--review-model MODEL` | model for `--review` (default: a function-calling model from a **different family** than the coding model; falls back to the coding model with a warning); config `defaults.code.review_model` |
1660
1677
  | `--review-rounds N` | passes `venice_review` makes over the same diff (default **1**, max 3); config `defaults.code.review_rounds` |
1661
1678
  | `--auto-compact` | summarize older history once the prompt crosses `--compact-threshold` tokens (default 100 000), keeping the last `--compact-keep-turns` turns (default 10); long runs stay in-context |
1679
+ | `--compact-loss-policy aggressive\|evidence` | discard summarized messages or retain their exact JSON in the bounded private session archive; default **evidence** for code and **aggressive** for chat; config `defaults.code.compact_loss_policy` / `defaults.chat.compact_loss_policy` |
1662
1680
  | `--cache-guard off\|warn\|stop` | react when a cache-priced model explicitly reports zero cached tokens after the cold first API call and a 2,000-token prompt; default **warn**, config `defaults.code.cache_guard` |
1663
1681
  | `-i`, `--json`, `--model`, `--system` | interactive REPL · JSON envelope · model · extra system instructions |
1664
1682
  | `--persona NAME` | load `~/.config/venice/personas/NAME.md` as the system prompt at launch (`/persona` in the REPL) |
@@ -1677,7 +1695,8 @@ model and a sane `--max-tool-calls` when running unattended.
1677
1695
 
1678
1696
  Per-flag config defaults live under `defaults.code.*` (e.g. `model`, `root`, `auto`,
1679
1697
  `assets`, `scout`, `spawn`, `spawn_max_spend`, `subagent_max_tokens`, `planner`,
1680
- `max_tool_calls`, `review`, `review_model`, `review_rounds`, `cache_guard`).
1698
+ `max_tool_calls`, `review`, `review_model`, `review_rounds`, `cache_guard`,
1699
+ `compact_loss_policy`).
1681
1700
 
1682
1701
  #### Scout subagent (`--scout`)
1683
1702
 
@@ -638,6 +638,8 @@ catalog), `/models` (list the available models, marking the current and the
638
638
  default), `/auto` and `/manual` (toggle auto-accepting paid/side-effecting tool
639
639
  calls for following turns), `/compact [N]` (summarize older history into one
640
640
  message, keeping the last `N` turns verbatim),
641
+ `/context list [CURSOR]` (list archived evidence metadata) and
642
+ `/context read ID [OFFSET]` (read up to 32 KiB of an exact archived message),
641
643
  `/cost` (this session's estimated spend so far, with the active model's
642
644
  current-run cache hit rate on the same line; `--session-max-spend` adds a
643
645
  cap), `/usage` (a token + cost breakdown for the session, keeping the
@@ -676,9 +678,19 @@ older prefix into one synthetic message once the prompt crosses
676
678
  the last `--compact-keep-turns` turns (default 10) verbatim. The trigger uses
677
679
  the server-reported `usage.prompt_tokens` when a turn provides it (falling
678
680
  back to a chars-per-token estimate otherwise), so it fires on the real prompt
679
- size rather than a guess. Compaction is best-effort — a failed summarization
680
- call leaves the history untouched — and never orphans a tool result from its
681
- assistant turn. It's off by default because it costs a summarization call.
681
+ size rather than a guess. `--compact-loss-policy aggressive|evidence` controls
682
+ what happens to the summarized prefix. Chat defaults to `aggressive`, preserving
683
+ the original behavior. Code defaults to `evidence`: every removed user message,
684
+ assistant tool call (including arguments), and tool result is copied exactly into
685
+ the private session envelope before the live history is replaced. The model gets
686
+ a bounded index plus the read-only `venice_context_archive` tool; operators can use
687
+ the `/context` commands above even in a plain REPL. The archive is capped at 512
688
+ entries and 8 MiB. If the next compaction would cross either cap, it is refused
689
+ before the summary API call and both history and archive remain unchanged. Reads
690
+ are paged at 32 KiB and list pages at 50 entries. Compaction is otherwise
691
+ best-effort — a failed or empty summarization leaves both stores untouched — and
692
+ never orphans a tool result from its assistant turn. It's off by default because
693
+ it costs a summarization call.
682
694
 
683
695
  #### Sessions
684
696
 
@@ -687,7 +699,11 @@ each turn to `~/.config/venice/sessions/<id>.json` (mode 0600;
687
699
  `$VENICE_SESSIONS_DIR` overrides the location). Unlike a bare `/save` transcript,
688
700
  a session travels with its **settings** — model, system prompt, generation
689
701
  parameters, `max-tool-calls`, the `venice code` sandbox root, and the running
690
- token/cost usage — so resuming restores the whole context, not just the messages.
702
+ token/cost usage — plus any bounded exact context archive — so resuming restores
703
+ the whole context, not just the messages. Session envelope v2 adds
704
+ `context_archive` plus the structural count of authoritative leading system
705
+ messages; v1 envelopes remain readable, conservatively preserve their complete
706
+ leading system prefix, and resume with an empty archive.
691
707
  The API key is never written to a session.
692
708
 
693
709
  `venice code` also assigns each session an opaque `prompt_cache_key`, sent as an
@@ -1557,6 +1573,7 @@ jq '.usage.billed_total' ~/.config/venice/sessions/<id>.json
1557
1573
  | `venice_scout` | delegate a read-only investigation to a disposable subagent with a fresh context; returns a structured report so exploration doesn't pollute the main context — **opt-in with `--scout`** (see [Scout subagent](#scout-subagent---scout)) | no |
1558
1574
  | `venice_web_search` | search the web to **discover** documentation you don't have a URL for; returns a short answer + cited URLs (billed, but bounded by the tool-call budget) — **opt-in with `--web-search`** (see [Web search](#web-search---web-search)) | no |
1559
1575
  | `venice_review` | hand the current diff to a **cold-context reviewer** — a disposable subagent with a fresh context, read-only tools and (where the catalog allows) a different model — and get back defects with `file:line` + a repro. Findings only: it approves and certifies nothing — **opt-in with `--review`** (see [Reviewer rail](#reviewer-rail---review)) | no |
1576
+ | `venice_context_archive` | list bounded metadata or page through exact messages removed by evidence-preserving compaction; current session only, with no filesystem, network, or process access | no |
1560
1577
 
1561
1578
  **Safety.** Every filesystem path is resolved and confined to the **writable roots** —
1562
1579
  the startup root (default: cwd, or `--root` / `$VENICE_CODE_ROOT`) plus any added with
@@ -1618,6 +1635,7 @@ external diff helpers. Use the confirmed `run` tool for other Git forms.
1618
1635
  | `--review-model MODEL` | model for `--review` (default: a function-calling model from a **different family** than the coding model; falls back to the coding model with a warning); config `defaults.code.review_model` |
1619
1636
  | `--review-rounds N` | passes `venice_review` makes over the same diff (default **1**, max 3); config `defaults.code.review_rounds` |
1620
1637
  | `--auto-compact` | summarize older history once the prompt crosses `--compact-threshold` tokens (default 100 000), keeping the last `--compact-keep-turns` turns (default 10); long runs stay in-context |
1638
+ | `--compact-loss-policy aggressive\|evidence` | discard summarized messages or retain their exact JSON in the bounded private session archive; default **evidence** for code and **aggressive** for chat; config `defaults.code.compact_loss_policy` / `defaults.chat.compact_loss_policy` |
1621
1639
  | `--cache-guard off\|warn\|stop` | react when a cache-priced model explicitly reports zero cached tokens after the cold first API call and a 2,000-token prompt; default **warn**, config `defaults.code.cache_guard` |
1622
1640
  | `-i`, `--json`, `--model`, `--system` | interactive REPL · JSON envelope · model · extra system instructions |
1623
1641
  | `--persona NAME` | load `~/.config/venice/personas/NAME.md` as the system prompt at launch (`/persona` in the REPL) |
@@ -1636,7 +1654,8 @@ model and a sane `--max-tool-calls` when running unattended.
1636
1654
 
1637
1655
  Per-flag config defaults live under `defaults.code.*` (e.g. `model`, `root`, `auto`,
1638
1656
  `assets`, `scout`, `spawn`, `spawn_max_spend`, `subagent_max_tokens`, `planner`,
1639
- `max_tool_calls`, `review`, `review_model`, `review_rounds`, `cache_guard`).
1657
+ `max_tool_calls`, `review`, `review_model`, `review_rounds`, `cache_guard`,
1658
+ `compact_loss_policy`).
1640
1659
 
1641
1660
  #### Scout subagent (`--scout`)
1642
1661
 
@@ -0,0 +1 @@
1
+ __version__ = "0.83.45"
@@ -3779,6 +3779,8 @@ def run_loop(
3779
3779
  oai_tools = to_openai_tools(tools)
3780
3780
  dispatch = dispatch_map(tools)
3781
3781
  runtime = _ToolRuntime(model=model, models=models)
3782
+ if budget is not None and budget.protected_system_messages is None:
3783
+ budget.protected_system_messages = _compact.leading_system_count(messages)
3782
3784
  calls_made = 0
3783
3785
  gate = {"auto": bool(yes)} # mutable so an `a`/`all` confirm flips the run to auto
3784
3786
  # #82: the per-tool timing sink, bound ONCE so both dispatch paths -- and every pool
@@ -3860,6 +3862,9 @@ def run_loop(
3860
3862
  on_compact=lambda b, a: _progress(
3861
3863
  f"(auto-compacted history: {b} -> {a} messages)", enabled=show,
3862
3864
  ),
3865
+ on_blocked=lambda reason: print(
3866
+ f"auto-compaction refused: {reason}", file=sys.stderr,
3867
+ ),
3863
3868
  ledger=ledger, # #99: log the event; #101: and bill the summary call
3864
3869
  )
3865
3870
  _t0 = time.monotonic()
@@ -3960,6 +3965,9 @@ def run_loop(
3960
3965
  on_compact=lambda b, a: _progress(
3961
3966
  f"(auto-compacted history: {b} -> {a} messages)", enabled=show,
3962
3967
  ),
3968
+ on_blocked=lambda reason: print(
3969
+ f"auto-compaction refused: {reason}", file=sys.stderr,
3970
+ ),
3963
3971
  ledger=ledger, # #99
3964
3972
  )
3965
3973
  return _force_final(
@@ -50,7 +50,7 @@ import time
50
50
  from dataclasses import dataclass
51
51
  from typing import List, Optional, Tuple
52
52
 
53
- from . import _openai
53
+ from . import _context_archive, _openai
54
54
 
55
55
  # Rough chars-per-token for English/code text. Deliberately conservative
56
56
  # (overestimate tokens) so the fallback triggers compaction a little early
@@ -64,6 +64,7 @@ SUMMARY_MAX_TOKENS = 1024
64
64
 
65
65
  DEFAULT_THRESHOLD_TOKENS = 100_000
66
66
  DEFAULT_KEEP_TURNS = 10
67
+ LOSS_POLICY_CHOICES = ("aggressive", "evidence")
67
68
 
68
69
  _SUMMARY_PREFIX = "[Summary of earlier conversation]"
69
70
  _INSTRUCT = (
@@ -124,6 +125,9 @@ class Budget:
124
125
  threshold_tokens: int = DEFAULT_THRESHOLD_TOKENS
125
126
  keep_turns: int = DEFAULT_KEEP_TURNS
126
127
  last_prompt_tokens: Optional[int] = None
128
+ loss_policy: str = "aggressive"
129
+ archive: Optional[_context_archive.ContextArchive] = None
130
+ protected_system_messages: Optional[int] = None
127
131
 
128
132
  def observe(self, usage) -> None:
129
133
  """Record prompt tokens from a response's `usage` (dict or SDK obj)."""
@@ -150,7 +154,7 @@ class Budget:
150
154
  return estimate_tokens(messages) >= self.threshold_tokens
151
155
 
152
156
 
153
- def budget_from_args(args) -> Optional["Budget"]:
157
+ def budget_from_args(args, archive=None) -> Optional["Budget"]:
154
158
  """The auto-compact Budget for a parsed-args namespace, or None when it
155
159
  isn't opted into (#48).
156
160
 
@@ -168,6 +172,8 @@ def budget_from_args(args) -> Optional["Budget"]:
168
172
  keep_turns=(
169
173
  getattr(args, "compact_keep_turns", None) or DEFAULT_KEEP_TURNS
170
174
  ),
175
+ loss_policy=getattr(args, "compact_loss_policy", None) or "aggressive",
176
+ archive=archive,
171
177
  )
172
178
 
173
179
 
@@ -212,8 +218,16 @@ def _groups(messages: List[dict]) -> List[List[dict]]:
212
218
  return groups
213
219
 
214
220
 
221
+ def leading_system_count(messages: List[dict]) -> int:
222
+ """Count the contiguous authoritative/generated system prefix."""
223
+ count = 0
224
+ while count < len(messages) and messages[count].get("role") == "system":
225
+ count += 1
226
+ return count
227
+
228
+
215
229
  def split_for_compaction(
216
- messages: List[dict], keep_turns: int
230
+ messages: List[dict], keep_turns: int, protected_system_messages: Optional[int] = None
217
231
  ) -> Optional[Tuple[List[dict], List[dict]]]:
218
232
  """Split history into (prefix to summarize, tail to keep verbatim).
219
233
 
@@ -224,14 +238,21 @@ def split_for_compaction(
224
238
  """
225
239
  if keep_turns < 1:
226
240
  keep_turns = 1
227
- sys_end = 0
228
- while sys_end < len(messages) and messages[sys_end].get("role") == "system":
229
- sys_end += 1
230
- tail_groups = _groups(messages[sys_end:])
241
+ leading_systems = leading_system_count(messages)
242
+ # None is the conservative compatibility path: every leading system
243
+ # message is authoritative. Session-aware callers persist the exact count,
244
+ # which lets later generated summary/index messages be replaced without
245
+ # classifying authority from forgeable content text.
246
+ if protected_system_messages is None:
247
+ sys_end = leading_systems
248
+ else:
249
+ sys_end = min(max(0, protected_system_messages), leading_systems)
250
+ generated_end = leading_systems
251
+ tail_groups = _groups(messages[generated_end:])
231
252
  if len(tail_groups) <= keep_turns:
232
253
  return None
233
254
  cut = len(tail_groups) - keep_turns
234
- prefix: List[dict] = []
255
+ prefix: List[dict] = list(messages[sys_end:generated_end])
235
256
  for g in tail_groups[:cut]:
236
257
  prefix.extend(g)
237
258
  tail: List[dict] = []
@@ -283,6 +304,9 @@ def compact_messages(
283
304
  ledger=None,
284
305
  budget: Optional[Budget] = None,
285
306
  trigger: str = "auto",
307
+ loss_policy: str = "aggressive",
308
+ archive: Optional[_context_archive.ContextArchive] = None,
309
+ protected_system_messages: Optional[int] = None,
286
310
  ) -> bool:
287
311
  """Summarize the older prefix in place; keep system + last `keep_turns`.
288
312
 
@@ -311,12 +335,28 @@ def compact_messages(
311
335
  history it now describes -- i.e. re-firing compaction immediately, a sawtooth #99's
312
336
  trace would show and nothing would prevent.
313
337
  """
314
- split = split_for_compaction(messages, keep_turns)
338
+ split = split_for_compaction(
339
+ messages, keep_turns, protected_system_messages=protected_system_messages
340
+ )
315
341
  if split is None:
316
342
  return False
317
343
  prefix, tail = split
318
344
  sys_msgs = messages[: len(messages) - len(prefix) - len(tail)]
319
345
 
346
+ # Evidence mode refuses before spending a summarization call if the exact
347
+ # removed messages cannot fit. Staging is side-effect-free; commit happens
348
+ # only after a usable summary exists, alongside the live-history rewrite.
349
+ staged = []
350
+ if loss_policy == "evidence":
351
+ if archive is None:
352
+ return False
353
+ archive.last_error = None
354
+ try:
355
+ staged = archive.stage(prefix)
356
+ except _context_archive.ArchiveError as e:
357
+ archive.last_error = str(e)
358
+ return False
359
+
320
360
  # #128: compaction is a deliberately fresh summarization conversation. Reusing
321
361
  # the parent session's routing identity would mix unrelated prefixes on one cache
322
362
  # affinity key; preserve all other generation/Venice parameters while stripping it.
@@ -366,7 +406,11 @@ def compact_messages(
366
406
  # lie in the artifact that exists to stop one.
367
407
  est_before = estimate_tokens(messages)
368
408
  msgs_before = len(messages)
369
- messages[:] = sys_msgs + [synthetic_message(summary)] + tail
409
+ replacement = sys_msgs + [synthetic_message(summary)]
410
+ if loss_policy == "evidence":
411
+ archive.commit(staged)
412
+ replacement.append(archive.live_index_message())
413
+ messages[:] = replacement + tail
370
414
  if ledger is not None:
371
415
  ledger.record_compaction({
372
416
  "trigger": trigger,
@@ -411,7 +455,7 @@ def compact_messages(
411
455
 
412
456
  def maybe_compact(oai, model: str, messages: List[dict],
413
457
  budget: Optional[Budget], base_kwargs: Optional[dict] = None,
414
- on_compact=None, ledger=None) -> bool:
458
+ on_compact=None, on_blocked=None, ledger=None) -> bool:
415
459
  """Compact `messages` in place when `budget` says they're over budget.
416
460
 
417
461
  The shared gate for every compaction site (`run_loop`'s per-turn check, its
@@ -433,7 +477,12 @@ def maybe_compact(oai, model: str, messages: List[dict],
433
477
  oai, model, messages,
434
478
  keep_turns=budget.keep_turns, base_kwargs=base_kwargs,
435
479
  ledger=ledger, budget=budget, trigger="auto",
480
+ loss_policy=budget.loss_policy, archive=budget.archive,
481
+ protected_system_messages=budget.protected_system_messages,
436
482
  ):
483
+ if (on_blocked is not None and budget.archive is not None
484
+ and budget.archive.last_error):
485
+ on_blocked(budget.archive.last_error)
437
486
  return False
438
487
  if on_compact is not None:
439
488
  on_compact(before, len(messages))
@@ -0,0 +1,283 @@
1
+ """Bounded, lossless local archive for evidence-preserving compaction (#74)."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from typing import List, Optional
9
+
10
+
11
+ MAX_ARCHIVE_BYTES = 8 * 1024 * 1024
12
+ MAX_ARCHIVE_ENTRIES = 512
13
+ MAX_LIST_ENTRIES = 50
14
+ MAX_READ_BYTES = 32 * 1024
15
+ LIVE_INDEX_ENTRIES = 32
16
+ EXCERPT_CHARS = 160
17
+
18
+
19
+ class ArchiveError(ValueError):
20
+ """An archive envelope is malformed or a capacity boundary was crossed."""
21
+
22
+
23
+ def _canonical(message: dict) -> str:
24
+ try:
25
+ return json.dumps(
26
+ message, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
27
+ allow_nan=False,
28
+ )
29
+ except (TypeError, ValueError) as e:
30
+ raise ArchiveError(f"archived message is not finite JSON: {e}") from None
31
+
32
+
33
+ def _digest(text: str) -> str:
34
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
35
+
36
+
37
+ def _excerpt(text: str, *, tail: bool = False) -> str:
38
+ value = text[-EXCERPT_CHARS:] if tail else text[:EXCERPT_CHARS]
39
+ return value.replace("\n", "\\n")
40
+
41
+
42
+ def _metadata(entry: dict) -> dict:
43
+ result = {
44
+ "id": entry["id"],
45
+ "role": entry["role"],
46
+ "chars": entry["chars"],
47
+ "sha256": entry["sha256"],
48
+ "head": entry["head"],
49
+ "tail": entry["tail"],
50
+ }
51
+ if entry.get("name"):
52
+ result["name"] = entry["name"]
53
+ return result
54
+
55
+
56
+ def _bounded_utf8_slice(text: str, offset: int) -> str:
57
+ """Return the longest char-aligned slice no larger than MAX_READ_BYTES."""
58
+ lo, hi = offset, len(text)
59
+ while lo < hi:
60
+ mid = (lo + hi + 1) // 2
61
+ if len(text[offset:mid].encode("utf-8")) <= MAX_READ_BYTES:
62
+ lo = mid
63
+ else:
64
+ hi = mid - 1
65
+ return text[offset:lo]
66
+
67
+
68
+ @dataclass
69
+ class ContextArchive:
70
+ """A bounded collection of exact, canonicalized archived messages."""
71
+
72
+ entries: List[dict] = field(default_factory=list)
73
+ last_error: Optional[str] = None
74
+
75
+ @classmethod
76
+ def from_envelope(cls, raw) -> "ContextArchive":
77
+ if raw is None:
78
+ return cls()
79
+ if not isinstance(raw, list):
80
+ raise ArchiveError("context_archive must be a JSON list")
81
+ if len(raw) > MAX_ARCHIVE_ENTRIES:
82
+ raise ArchiveError(
83
+ f"context_archive exceeds {MAX_ARCHIVE_ENTRIES} entries"
84
+ )
85
+ validated = []
86
+ seen = set()
87
+ for index, item in enumerate(raw, 1):
88
+ if not isinstance(item, dict) or not isinstance(item.get("message"), dict):
89
+ raise ArchiveError(f"context_archive entry {index} is malformed")
90
+ message = item["message"]
91
+ if not isinstance(message.get("role"), str):
92
+ raise ArchiveError(f"context_archive entry {index} has no role")
93
+ text = _canonical(message)
94
+ entry_id = item.get("id")
95
+ expected_id = f"ctx-{index:06d}"
96
+ if entry_id != expected_id:
97
+ raise ArchiveError(f"context_archive entry {index} has an invalid id")
98
+ if entry_id in seen:
99
+ raise ArchiveError(f"context_archive entry {index} repeats id {entry_id}")
100
+ seen.add(entry_id)
101
+ expected = cls._entry(entry_id, message, text)
102
+ for key in ("sha256", "chars", "role", "head", "tail"):
103
+ if item.get(key) != expected[key]:
104
+ raise ArchiveError(
105
+ f"context_archive entry {entry_id} has invalid {key}"
106
+ )
107
+ if item.get("name") != expected.get("name"):
108
+ raise ArchiveError(
109
+ f"context_archive entry {entry_id} has invalid name"
110
+ )
111
+ validated.append(expected)
112
+ total = len(_canonical(validated).encode("utf-8"))
113
+ if total > MAX_ARCHIVE_BYTES:
114
+ raise ArchiveError(f"context_archive exceeds {MAX_ARCHIVE_BYTES} bytes")
115
+ return cls(validated)
116
+
117
+ @staticmethod
118
+ def _entry(entry_id: str, message: dict, text: Optional[str] = None) -> dict:
119
+ text = _canonical(message) if text is None else text
120
+ entry = {
121
+ "id": entry_id,
122
+ "role": message["role"],
123
+ "chars": len(text),
124
+ "sha256": _digest(text),
125
+ "head": _excerpt(text),
126
+ "tail": _excerpt(text, tail=True),
127
+ "message": copy.deepcopy(message),
128
+ }
129
+ name = message.get("name")
130
+ if isinstance(name, str) and name:
131
+ entry["name"] = name
132
+ elif message.get("role") == "assistant":
133
+ calls = message.get("tool_calls")
134
+ if isinstance(calls, list):
135
+ names = []
136
+ for call in calls:
137
+ fn = call.get("function") if isinstance(call, dict) else None
138
+ if isinstance(fn, dict) and isinstance(fn.get("name"), str):
139
+ names.append(fn["name"])
140
+ if names:
141
+ entry["name"] = ",".join(names)
142
+ return entry
143
+
144
+ @property
145
+ def bytes_used(self) -> int:
146
+ return len(_canonical(self.entries).encode("utf-8"))
147
+
148
+ def stage(self, messages: List[dict]) -> List[dict]:
149
+ """Build entries and enforce both caps without changing the archive."""
150
+ staged = []
151
+ for message in messages:
152
+ if not isinstance(message, dict) or not isinstance(message.get("role"), str):
153
+ raise ArchiveError("message selected for archival is malformed")
154
+ text = _canonical(message)
155
+ entry_id = f"ctx-{len(self.entries) + len(staged) + 1:06d}"
156
+ staged.append(self._entry(entry_id, message, text))
157
+ count = len(self.entries) + len(staged)
158
+ if count > MAX_ARCHIVE_ENTRIES:
159
+ raise ArchiveError(
160
+ f"evidence archive full: compaction needs {count} entries "
161
+ f"(limit {MAX_ARCHIVE_ENTRIES})"
162
+ )
163
+ used = len(_canonical(self.entries + staged).encode("utf-8"))
164
+ if used > MAX_ARCHIVE_BYTES:
165
+ raise ArchiveError(
166
+ f"evidence archive full: compaction needs {used} bytes "
167
+ f"(limit {MAX_ARCHIVE_BYTES})"
168
+ )
169
+ return staged
170
+
171
+ def commit(self, staged: List[dict]) -> None:
172
+ self.entries.extend(staged)
173
+ self.last_error = None
174
+
175
+ def clear(self) -> None:
176
+ self.entries.clear()
177
+ self.last_error = None
178
+
179
+ def to_envelope(self) -> list:
180
+ return copy.deepcopy(self.entries)
181
+
182
+ def list_page(self, cursor: int = 0, limit: int = MAX_LIST_ENTRIES) -> dict:
183
+ if not isinstance(cursor, int) or isinstance(cursor, bool) or cursor < 0:
184
+ raise ArchiveError("cursor must be a non-negative integer")
185
+ if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= MAX_LIST_ENTRIES:
186
+ raise ArchiveError(f"limit must be between 1 and {MAX_LIST_ENTRIES}")
187
+ page = self.entries[cursor:cursor + limit]
188
+ end = cursor + len(page)
189
+ return {
190
+ "entries": [_metadata(e) for e in page],
191
+ "cursor": cursor,
192
+ "next_cursor": end if end < len(self.entries) else None,
193
+ "total": len(self.entries),
194
+ "bytes": self.bytes_used,
195
+ }
196
+
197
+ def read(self, entry_id: str, offset: int = 0) -> dict:
198
+ if not isinstance(entry_id, str):
199
+ raise ArchiveError("entry_id must be a string")
200
+ if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
201
+ raise ArchiveError("offset must be a non-negative integer")
202
+ entry = next((e for e in self.entries if e["id"] == entry_id), None)
203
+ if entry is None:
204
+ raise ArchiveError(f"unknown archive entry {entry_id!r}")
205
+ text = _canonical(entry["message"])
206
+ if offset > len(text):
207
+ raise ArchiveError(f"offset {offset} exceeds entry length {len(text)}")
208
+ content = _bounded_utf8_slice(text, offset)
209
+ end = offset + len(content)
210
+ return {
211
+ "id": entry_id,
212
+ "sha256": entry["sha256"],
213
+ "offset": offset,
214
+ "content": content,
215
+ "next_offset": end if end < len(text) else None,
216
+ "complete": end >= len(text),
217
+ "chars": len(text),
218
+ }
219
+
220
+ def live_index_message(self) -> dict:
221
+ newest = self.entries[-LIVE_INDEX_ENTRIES:]
222
+ lines = [
223
+ "[Archived context evidence index]",
224
+ f"{len(self.entries)} exact message(s), {self.bytes_used} byte(s). "
225
+ "Use venice_context_archive to list metadata or read exact content.",
226
+ ]
227
+ older = len(self.entries) - len(newest)
228
+ if older:
229
+ lines.append(f"{older} older entry/entries are discoverable with list pagination.")
230
+ for entry in newest:
231
+ label = entry["role"]
232
+ if entry.get("name"):
233
+ label += f"/{entry['name']}"
234
+ lines.append(
235
+ f"{entry['id']} {label} {entry['chars']} chars "
236
+ f"sha256={entry['sha256']} head={entry['head']!r} tail={entry['tail']!r}"
237
+ )
238
+ return {"role": "system", "content": "\n".join(lines)}
239
+
240
+
241
+ def archive_tool(archive: ContextArchive):
242
+ """Build the current-session, read-only archive lookup tool."""
243
+ from . import _agent
244
+
245
+ def invoke(args, *, confirm=False):
246
+ del confirm
247
+ try:
248
+ action = args.get("action")
249
+ if action == "list":
250
+ return {"status": "ok", **archive.list_page(
251
+ args.get("cursor", 0), args.get("limit", MAX_LIST_ENTRIES)
252
+ )}
253
+ if action == "read":
254
+ return {"status": "ok", **archive.read(
255
+ args.get("entry_id"), args.get("offset", 0)
256
+ )}
257
+ return {"status": "error", "message": "action must be list or read"}
258
+ except ArchiveError as e:
259
+ return {"status": "error", "message": str(e)}
260
+
261
+ return _agent.Tool(
262
+ name="venice_context_archive",
263
+ description=(
264
+ "Read evidence archived from earlier context compactions in this session. "
265
+ "List bounded metadata pages, then read exact canonical message JSON by id."
266
+ ),
267
+ parameters={
268
+ "type": "object",
269
+ "properties": {
270
+ "action": {"type": "string", "enum": ["list", "read"]},
271
+ "entry_id": {"type": "string"},
272
+ "cursor": {"type": "integer", "minimum": 0},
273
+ "offset": {"type": "integer", "minimum": 0},
274
+ "limit": {"type": "integer", "minimum": 1, "maximum": MAX_LIST_ENTRIES},
275
+ },
276
+ "required": ["action"],
277
+ "additionalProperties": False,
278
+ },
279
+ invoke=invoke,
280
+ paid=False,
281
+ category="context",
282
+ tags=("read",),
283
+ )