zettcode 0.1.3__tar.gz → 0.1.4__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 (160) hide show
  1. {zettcode-0.1.3 → zettcode-0.1.4}/.gitignore +1 -0
  2. {zettcode-0.1.3 → zettcode-0.1.4}/PKG-INFO +6 -3
  3. {zettcode-0.1.3 → zettcode-0.1.4}/README.md +2 -1
  4. {zettcode-0.1.3 → zettcode-0.1.4}/README.zh-CN.md +1 -1
  5. {zettcode-0.1.3 → zettcode-0.1.4}/pyproject.toml +17 -7
  6. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/__init__.py +1 -1
  7. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/__init__.py +3 -2
  8. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/agent.py +143 -18
  9. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/approval.py +20 -3
  10. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/capabilities.py +2 -2
  11. zettcode-0.1.4/src/zettcode/app/agent/context.py +532 -0
  12. zettcode-0.1.4/src/zettcode/app/agent/entries.py +218 -0
  13. zettcode-0.1.4/src/zettcode/app/agent/mentions.py +303 -0
  14. zettcode-0.1.4/src/zettcode/app/agent/permission.py +146 -0
  15. zettcode-0.1.4/src/zettcode/app/agent/presenters.py +615 -0
  16. zettcode-0.1.4/src/zettcode/app/agent/readonly.py +435 -0
  17. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/rendering.py +60 -4
  18. zettcode-0.1.4/src/zettcode/app/agent/replay.py +156 -0
  19. zettcode-0.1.4/src/zettcode/app/agent/rows.py +388 -0
  20. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/runtime.py +207 -74
  21. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/storage/__init__.py +2 -1
  22. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/storage/records.py +120 -15
  23. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/storage/store.py +135 -2
  24. zettcode-0.1.4/src/zettcode/app/agent/transcript/__init__.py +73 -0
  25. zettcode-0.1.4/src/zettcode/app/agent/transcript/fold.py +56 -0
  26. zettcode-0.1.4/src/zettcode/app/agent/transcript/log.py +182 -0
  27. zettcode-0.1.4/src/zettcode/app/agent/transcript/oneoff.py +54 -0
  28. zettcode-0.1.4/src/zettcode/app/agent/transcript/stream.py +165 -0
  29. zettcode-0.1.4/src/zettcode/app/agent/transcript/turn.py +164 -0
  30. zettcode-0.1.4/src/zettcode/app/pipeline.py +191 -0
  31. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/app.py +208 -15
  32. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/boot.py +20 -10
  33. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/commands.py +57 -2
  34. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/demo.py +18 -18
  35. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/keys.py +38 -2
  36. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/labels.py +44 -5
  37. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/notices.py +30 -8
  38. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/rows.py +59 -3
  39. zettcode-0.1.4/src/zettcode/app/ui/sessions.py +266 -0
  40. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/settings.py +23 -0
  41. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/shell.py +46 -2
  42. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/approval_page.py +38 -8
  43. zettcode-0.1.4/src/zettcode/app/ui/widgets/context_page.py +114 -0
  44. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/model_page.py +17 -9
  45. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/root.py +8 -1
  46. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/transcript.py +26 -15
  47. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/welcome.py +1 -1
  48. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/cli.py +152 -30
  49. zettcode-0.1.4/src/zettcode/collector.py +44 -0
  50. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/config.py +131 -37
  51. zettcode-0.1.4/src/zettcode/paths.py +60 -0
  52. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/plugins/builtins.py +18 -3
  53. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/plugins/state.py +5 -0
  54. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/app.py +15 -2
  55. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/scheduler.py +1 -1
  56. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/input/decoder.py +13 -1
  57. zettcode-0.1.4/src/zettcode/tui/input/keys.py +259 -0
  58. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/__init__.py +2 -0
  59. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/style.py +23 -4
  60. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/text.py +50 -3
  61. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/runner.py +19 -0
  62. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/terminal.py +8 -0
  63. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/list_page.py +17 -4
  64. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/markdown.py +61 -3
  65. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/text.py +7 -5
  66. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/textarea.py +9 -9
  67. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/update.py +18 -3
  68. zettcode-0.1.3/src/zettcode/app/agent/blocks.py +0 -367
  69. zettcode-0.1.3/src/zettcode/app/agent/context.py +0 -293
  70. zettcode-0.1.3/src/zettcode/app/agent/entries.py +0 -191
  71. zettcode-0.1.3/src/zettcode/app/agent/mentions.py +0 -126
  72. zettcode-0.1.3/src/zettcode/app/agent/replay.py +0 -84
  73. zettcode-0.1.3/src/zettcode/app/agent/rows.py +0 -215
  74. zettcode-0.1.3/src/zettcode/app/agent/transcript.py +0 -531
  75. zettcode-0.1.3/src/zettcode/app/ui/sessions.py +0 -89
  76. zettcode-0.1.3/src/zettcode/app/ui/widgets/context_page.py +0 -70
  77. zettcode-0.1.3/src/zettcode/paths.py +0 -29
  78. zettcode-0.1.3/src/zettcode/tui/input/keys.py +0 -60
  79. {zettcode-0.1.3 → zettcode-0.1.4}/LICENSE +0 -0
  80. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/_compat.py +0 -0
  81. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/__init__.py +0 -0
  82. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/ask.py +0 -0
  83. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/compaction.py +0 -0
  84. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/export.py +0 -0
  85. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/mcp.py +0 -0
  86. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/projection.py +0 -0
  87. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/side.py +0 -0
  88. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/storage/metadata.py +0 -0
  89. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/subagents.py +0 -0
  90. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/title.py +0 -0
  91. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/trace/script.js +0 -0
  92. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/trace/style.css +0 -0
  93. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/trace/template.html +0 -0
  94. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/agent/usage.py +0 -0
  95. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/brand.py +0 -0
  96. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/commands.py +0 -0
  97. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/registry.py +0 -0
  98. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/__init__.py +0 -0
  99. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/clipboard.py +0 -0
  100. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/updating.py +0 -0
  101. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/__init__.py +0 -0
  102. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/ask_page.py +0 -0
  103. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/completer.py +0 -0
  104. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/composer.py +0 -0
  105. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/panel.py +0 -0
  106. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/sessions_page.py +0 -0
  107. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/steering.py +0 -0
  108. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/app/ui/widgets/update_page.py +0 -0
  109. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/plugins/__init__.py +0 -0
  110. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/plugins/container.py +0 -0
  111. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/plugins/extension.py +0 -0
  112. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/plugins/loader.py +0 -0
  113. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/plugins/mixins.py +0 -0
  114. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/plugins/plugin.py +0 -0
  115. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/__init__.py +0 -0
  116. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/capabilities.py +0 -0
  117. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/console.py +0 -0
  118. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/__init__.py +0 -0
  119. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/events.py +0 -0
  120. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/focus.py +0 -0
  121. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/geometry.py +0 -0
  122. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/host.py +0 -0
  123. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/keymap.py +0 -0
  124. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/screen.py +0 -0
  125. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/theme.py +0 -0
  126. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/core/widget.py +0 -0
  127. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/gallery.py +0 -0
  128. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/glyphs.py +0 -0
  129. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/input/__init__.py +0 -0
  130. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/input/bridge.py +0 -0
  131. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/input/events.py +0 -0
  132. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/input/reader.py +0 -0
  133. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/layout/__init__.py +0 -0
  134. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/layout/box.py +0 -0
  135. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/layout/overlay.py +0 -0
  136. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/layout/scroll.py +0 -0
  137. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/layout/solver.py +0 -0
  138. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/layout/spacing.py +0 -0
  139. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/canvas.py +0 -0
  140. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/code.py +0 -0
  141. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/color.py +0 -0
  142. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/renderer.py +0 -0
  143. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/rich_text.py +0 -0
  144. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/render/sweep.py +0 -0
  145. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/testing/__init__.py +0 -0
  146. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/testing/harness.py +0 -0
  147. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/testing/snapshot.py +0 -0
  148. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/theme_file.py +0 -0
  149. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/__init__.py +0 -0
  150. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/collapsible.py +0 -0
  151. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/completion.py +0 -0
  152. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/dialog.py +0 -0
  153. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/diff.py +0 -0
  154. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/list.py +0 -0
  155. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/progress.py +0 -0
  156. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/rich_text.py +0 -0
  157. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/status.py +0 -0
  158. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/table.py +0 -0
  159. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/tasks.py +0 -0
  160. {zettcode-0.1.3 → zettcode-0.1.4}/src/zettcode/tui/widgets/toast.py +0 -0
@@ -4,6 +4,7 @@
4
4
  *.egg-info/
5
5
  build/
6
6
  dist/
7
+ .smoke/
7
8
  .coverage
8
9
  **/.pytest_cache/
9
10
  **/.ruff_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: A focused terminal coding agent powered by zett-agent
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Project-URL: Issues, https://github.com/Chang-LeHung/zettcode/issues
@@ -21,12 +21,14 @@ Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Typing :: Typed
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: exceptiongroup<2,>=1.2; python_version < '3.11'
24
+ Requires-Dist: pydantic<3,>=2
24
25
  Requires-Dist: pyperclip<2,>=1.9
25
26
  Requires-Dist: setproctitle<2,>=1.3.7; os_name == 'posix'
27
+ Requires-Dist: tiktoken<1,>=0.12
26
28
  Requires-Dist: tomli<3,>=2; python_version < '3.11'
27
29
  Requires-Dist: truststore<1,>=0.10
28
30
  Requires-Dist: wcwidth<1,>=0.2
29
- Requires-Dist: zett-agent<0.2,>=0.1.11
31
+ Requires-Dist: zett-agent<0.2,>=0.1.14
30
32
  Description-Content-Type: text/markdown
31
33
 
32
34
  <p align="center">
@@ -49,7 +51,8 @@ Description-Content-Type: text/markdown
49
51
 
50
52
  ## Get running
51
53
 
52
- You need **Python 3.10+**, a UTF-8 terminal, and an **OpenAI-compatible API**.
54
+ You need **Python 3.10+**, a UTF-8 terminal, and an **OpenAI-compatible API**
55
+ (or Anthropic's **Messages API**).
53
56
  Works on macOS, Linux, and Windows (use Windows Terminal).
54
57
 
55
58
  ### 1. Install
@@ -18,7 +18,8 @@
18
18
 
19
19
  ## Get running
20
20
 
21
- You need **Python 3.10+**, a UTF-8 terminal, and an **OpenAI-compatible API**.
21
+ You need **Python 3.10+**, a UTF-8 terminal, and an **OpenAI-compatible API**
22
+ (or Anthropic's **Messages API**).
22
23
  Works on macOS, Linux, and Windows (use Windows Terminal).
23
24
 
24
25
  ### 1. Install
@@ -18,7 +18,7 @@
18
18
 
19
19
  ## 先跑起来
20
20
 
21
- 需要 **Python 3.10+**、支持 UTF-8 的终端和 **OpenAI 兼容接口**。
21
+ 需要 **Python 3.10+**、支持 UTF-8 的终端和 **OpenAI 兼容接口**(或 Anthropic 的 **Messages API**)。
22
22
  支持 macOS、Linux 和 Windows(请使用 Windows Terminal)。
23
23
 
24
24
  ### 1. 安装
@@ -24,19 +24,29 @@ classifiers = [
24
24
  dependencies = [
25
25
  # The stdlib gained both of these in 3.11; older runtimes take the backports.
26
26
  "exceptiongroup>=1.2,<2; python_version < '3.11'",
27
+ # Imported directly by the session store's record and metadata codecs, so it
28
+ # is declared here rather than borrowed from the runtime that brings it today.
29
+ "pydantic>=2,<3",
27
30
  "pyperclip>=1.9,<2",
28
31
  "setproctitle>=1.3.7,<2; os_name == 'posix'",
32
+ # The counter behind ``/context`` measures a request with it, and imports it
33
+ # itself: the floor matches the runtime's, so neither resolver has to fight.
34
+ "tiktoken>=0.12,<1",
29
35
  "tomli>=2,<3; python_version < '3.11'",
30
36
  # The system trust store, so the release check works behind a proxy.
31
37
  "truststore>=0.10,<1",
32
38
  "wcwidth>=0.2,<1",
33
- # A range, not a pin: 0.1.11 is the oldest runtime with the APIs this client
34
- # uses (``AgentsMdExtension`` among them) and the first whose subagent
35
- # extension imports its SQLite store lazily, so warming a runtime no longer
36
- # loads SQLAlchemy. An exact pin would make zettcode fight every other tool
37
- # that shares the dependency — including an upgrade of zett-agent itself,
38
- # which pip would downgrade to satisfy it.
39
- "zett-agent>=0.1.11,<0.2",
39
+ # A range, not a pin: 0.1.13 is the oldest runtime whose tool argument schemas
40
+ # are self-contained — Pydantic's ``$defs``/``$ref`` are expanded when the
41
+ # schema is built — and a gateway that refuses to resolve a pointer otherwise
42
+ # answers 400 to every request on its Messages route. It also keeps the two
43
+ # the floor below it was raised for: the shell tool called ``bash``, which this
44
+ # client's phrasing, colours, and approval path are keyed on, and SQLAlchemy,
45
+ # tiktoken, and httpx behind their first use, so warming a runtime costs less
46
+ # than it did. An exact pin would make zettcode fight every other tool that
47
+ # shares the dependency — including an upgrade of zett-agent itself, which pip
48
+ # would downgrade to satisfy it.
49
+ "zett-agent>=0.1.14,<0.2",
40
50
  ]
41
51
 
42
52
  [project.scripts]
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
7
7
 
8
8
  #: The one place the version is written. ``pyproject.toml`` reads it from here
9
9
  #: and the release workflow checks the tag against it, so a bump is one edit.
10
- __version__ = "0.1.3"
10
+ __version__ = "0.1.4"
11
11
 
12
12
  #: Where each public name lives; read by :func:`__getattr__` on first use.
13
13
  _EXPORTS = {
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
7
7
 
8
8
  #: Where each public name lives; read by :func:`__getattr__` on first use.
9
9
  _EXPORTS = {
10
- "Entry": ".transcript",
10
+ "Entry": ".entries",
11
11
  "Transcript": ".transcript",
12
12
  "TranscriptProjector": ".projection",
13
13
  "ZettCodeAgent": ".agent",
@@ -17,9 +17,10 @@ _EXPORTS = {
17
17
 
18
18
  if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
19
19
  from .agent import ZettCodeAgent
20
+ from .entries import Entry
20
21
  from .projection import TranscriptProjector
21
22
  from .runtime import ZettCodeRuntime
22
- from .transcript import Entry, Transcript
23
+ from .transcript import Transcript
23
24
 
24
25
 
25
26
  def __getattr__(name: str) -> object:
@@ -33,7 +33,8 @@ from .ask import ASK_USER_RESPONSE_EVENT_NAME, AskUserQuestion, answer_payload,
33
33
  from .context import ContextReport
34
34
  from .export import build_trace, render_html, write_export
35
35
  from .mentions import MentionProvider, MentionRegistry
36
- from .replay import replay
36
+ from .permission import PERMISSION_LEVELS, Permission
37
+ from .replay import Replayed, replay
37
38
  from .runtime import ZettCodeRuntime, build_system_prompt
38
39
  from .side import SIDE, question
39
40
  from .storage import Session, SessionInfo
@@ -58,6 +59,24 @@ class MentionPart:
58
59
  PromptPart: TypeAlias = str | MentionPart | tuple[bytes, str]
59
60
 
60
61
 
62
+ @dataclass(frozen=True, slots=True)
63
+ class StoredSession:
64
+ """A session read from the store and rebuilt into rows, not yet made active.
65
+
66
+ Attributes:
67
+ session_id: The session that was read.
68
+ session: Its parsed file.
69
+ replayed: The rows, totals, and branch it replays into.
70
+ title: Its stored display title, if it has one: read here because folding
71
+ the metadata log is the other thing resuming used to do on the loop.
72
+ """
73
+
74
+ session_id: str
75
+ session: Session
76
+ replayed: Replayed
77
+ title: str | None
78
+
79
+
61
80
  def plain_text_part(part: PromptPart) -> str:
62
81
  """Return the text one part contributes; an image contributes nothing."""
63
82
  if isinstance(part, str):
@@ -152,6 +171,15 @@ class ZettCodeAgent:
152
171
  """
153
172
  return self.runtime.persistence.session_title(self.session_id)
154
173
 
174
+ def session_path(self) -> Path:
175
+ """Return the file this session is stored in, under the store root.
176
+
177
+ The whole conversation, on disk: what ``/context`` shows beside the token
178
+ breakdown, and what a reader opens when they want the raw records rather
179
+ than a rendered transcript.
180
+ """
181
+ return self.runtime.persistence.session_path(self.session_id)
182
+
155
183
  @property
156
184
  def session_name(self) -> str:
157
185
  """Return the name to show for the active session.
@@ -175,16 +203,36 @@ class ZettCodeAgent:
175
203
  async def context_report(self) -> ContextReport | None:
176
204
  """Return what the next request carries by source, or None before the first call.
177
205
 
178
- The tokenizer is prepared first: it prefers tiktoken and falls back to a
179
- character estimate when that vocabulary cannot be opened, which the
180
- report names so the numbers are not mistaken for exact ones.
206
+ The shares come from the tokenizer, which is prepared first: it prefers
207
+ tiktoken and falls back to a character estimate when that vocabulary
208
+ cannot be opened, which the report names so the numbers are not mistaken
209
+ for exact ones. The total comes from the provider when it counted this
210
+ very request — it counts what it bills — and from the tokenizer when the
211
+ request was primed from the store, whose stored figure belongs to a call
212
+ the dialogue may have outgrown.
181
213
  """
182
214
  tokenizer = self.runtime.tokenizer
183
215
  await tokenizer.prepare()
216
+ window = self.runtime.active_model.context_window
217
+ counted = self.runtime.usage.snapshot(self.session_id).context_tokens
218
+ # The provider's own count of this conversation is the total: what it
219
+ # billed for the request this process assembled, or — for a session
220
+ # rebuilt from the store, which has no call of its own yet — what it
221
+ # billed for the last call that session made. Locally counting a rebuilt
222
+ # request is not a substitute: on this machine's own session the rebuilt
223
+ # list measured 1.65 M tokens against a provider's 326 k, because a
224
+ # rebuilt list holds messages the live request no longer carries.
225
+ #
226
+ # A count that reaches the window is refused instead: a request cannot
227
+ # carry more than the window it is measured against, so a figure that does
228
+ # is about something else, and a share pinned at a hundred is worse than
229
+ # the measurement.
230
+ reported = counted if 0 < counted < window else None
184
231
  return self.runtime.context.report(
185
232
  self.session_id,
186
- window=self.runtime.active_model.context_window,
233
+ window=window,
187
234
  tokenizer=tokenizer,
235
+ reported=reported,
188
236
  )
189
237
 
190
238
  @property
@@ -344,27 +392,81 @@ class ZettCodeAgent:
344
392
  """Switch future turns to a session."""
345
393
  self.runtime.use_session(session_id)
346
394
 
347
- def restore_session(self, session_id: str, transcript: Transcript) -> None:
348
- """Load the active stored branch into the UI before switching sessions."""
349
- session = self.runtime.persistence.read(session_id)
395
+ def fork_session(self) -> str:
396
+ """Copy this conversation's context into a new session, and return its id.
397
+
398
+ What is carried over is what a request would send — the newest checkpoint's
399
+ summary and the messages after it — not the whole stored history: the fork
400
+ starts from the conversation the model is actually working with. The shell
401
+ installs the result the way a resume does, so the reader lands in the fork
402
+ with the same conversation in front of them.
403
+
404
+ Synchronous file work on purpose, so the shell can run it on a thread.
405
+ """
406
+ return self.runtime.persistence.fork(self.session_id)
407
+
408
+ def load_session(self, session_id: str, transcript: Transcript) -> StoredSession:
409
+ """Read a stored session and rebuild what it will show, changing nothing.
410
+
411
+ This is the expensive half of resuming — parsing the file and replaying the
412
+ newest part of it into rows — and it touches no state the shell is reading:
413
+ the rows are built into a transcript of their own, and the result is a value.
414
+ That is what lets the shell run it on a worker thread while the interface
415
+ keeps answering keys, and hand the result to :meth:`install_session` on the
416
+ loop. The ``transcript`` is only read, for the renderers, presenters, and cap
417
+ the rebuilt one has to match.
418
+
419
+ Args:
420
+ session_id: The session to read.
421
+ transcript: The transcript the result will be installed into.
422
+
423
+ Raises:
424
+ ValueError: The store holds no such session, or its file is corrupt.
425
+ """
426
+ persistence = self.runtime.persistence
427
+ session = persistence.read(session_id)
350
428
  if session.header is None:
351
429
  raise ValueError(f"Unknown session: {session_id}")
352
430
  replayed = replay(
353
431
  session,
354
432
  renderers=transcript.renderers,
355
- processors=transcript.processors,
433
+ presenters=transcript.presenters,
356
434
  frame=transcript.frame,
435
+ max_entries=transcript.max_entries,
436
+ )
437
+ return StoredSession(
438
+ session_id=session_id,
439
+ session=session,
440
+ replayed=replayed,
441
+ title=persistence.session_title(session_id),
357
442
  )
443
+
444
+ def install_session(self, stored: StoredSession, transcript: Transcript) -> None:
445
+ """Make a loaded session the active one: its rows, its totals, its id.
446
+
447
+ The cheap half, and the one that changes state, so it runs where the rest of
448
+ the shell does. Everything it does is assignment.
449
+ """
450
+ session_id, session, replayed = stored.session_id, stored.session, stored.replayed
358
451
  transcript.replace(replayed.transcript.entries)
359
452
  self.runtime.usage.seed(session_id, replayed.usage)
360
- # The instructions come from a request this process already assembled, or
361
- # from the prompt the runtime would build; the environment and tool notes
362
- # only exist inside a request, which the report admits.
363
- instructions = self.runtime.context.instructions or (
364
- SystemMessage(content=build_system_prompt(self.runtime.config)),
365
- )
366
- self.runtime.context.remember(session_id, [*instructions, *replayed.history])
367
453
  self.use_session(session_id)
454
+ # Prime the report with what the next turn would send, not with what the
455
+ # transcript shows: the display keeps every message a compaction folded
456
+ # away, and measuring those claims a request several times the size of the
457
+ # real one — 2.9 M tokens against a context of 290 k, which is a share
458
+ # pinned at a hundred. `_context_request` is the one definition of that
459
+ # request; the environment and tool notes only exist inside a real one,
460
+ # which the report admits.
461
+ #
462
+ # It is handed over as a function, not built here: the request holds every
463
+ # message since the last checkpoint, decoding them is most of what a long
464
+ # resume would cost, and nothing reads the list until `/context` does.
465
+ self.runtime.context.remember(session_id, lambda: self._stored_request(session))
466
+
467
+ def restore_session(self, session_id: str, transcript: Transcript) -> None:
468
+ """Load the active stored branch into the UI before switching sessions."""
469
+ self.install_session(self.load_session(session_id, transcript), transcript)
368
470
 
369
471
  async def list_sessions(self, *, limit: int = 20) -> list[SessionInfo]:
370
472
  """List persisted session metadata by recent activity."""
@@ -426,6 +528,20 @@ class ZettCodeAgent:
426
528
  """Approve every shell command for the rest of this run, without asking."""
427
529
  self.runtime.approve_all_shell_commands()
428
530
 
531
+ @property
532
+ def permission(self) -> Permission:
533
+ """Return how much of the shell may run without asking."""
534
+ return self.runtime.permission
535
+
536
+ @property
537
+ def permissions(self) -> tuple[str, ...]:
538
+ """Return the permission levels, strictest first."""
539
+ return tuple(level.value for level in PERMISSION_LEVELS)
540
+
541
+ def use_permission(self, name: str | Permission) -> Permission:
542
+ """Set how much of the shell may run without asking, for the rest of this run."""
543
+ return self.runtime.use_permission(name)
544
+
429
545
  async def compact(self) -> CompactionEvent | None:
430
546
  """Summarize the conversation now, without adding a turn to it.
431
547
 
@@ -477,7 +593,7 @@ class ZettCodeAgent:
477
593
  async def export_session(self, path: str | Path | None = None) -> Path:
478
594
  """Write the active session, and the context it now carries, to HTML.
479
595
 
480
- The conversation is rendered with the same processors the shell paints,
596
+ The conversation is rendered with the same presenters the shell paints,
481
597
  and the request the next turn would send is included beside it, so a
482
598
  reader sees both the history and what the model is down to.
483
599
 
@@ -522,13 +638,22 @@ class ZettCodeAgent:
522
638
  if assembled is not None:
523
639
  messages, tools = assembled
524
640
  return tuple(messages), tuple(tools)
641
+ return self._stored_request(session), self.runtime.context.tools
642
+
643
+ def _stored_request(self, session: Session) -> tuple[AnyMessage, ...]:
644
+ """Return the messages a request for ``session`` would carry, from what is stored.
645
+
646
+ This depends on the session it is given and on nothing that changes with the
647
+ active one, so it can be run later — a restore hands it over unrun, and the
648
+ reader may have moved to another session by the time it is wanted.
649
+ """
525
650
  instructions = self.runtime.context.instructions or (
526
651
  SystemMessage(content=build_system_prompt(self.runtime.config)),
527
652
  )
528
653
  checkpoint, tail = session.active()
529
654
  stored: list[AnyMessage] = [checkpoint.message[0]] if checkpoint is not None else []
530
655
  stored.extend(line.message[0] for line in tail if line.message[0].include_in_messages)
531
- return (*(instructions or ()), *stored), self.runtime.context.tools
656
+ return (*instructions, *stored)
532
657
 
533
658
  def _export_meta(self, session: Session) -> tuple[tuple[str, str], ...]:
534
659
  """Return the label/value pairs the exported page's sidebar carries."""
@@ -14,6 +14,8 @@ from __future__ import annotations
14
14
 
15
15
  from zett_agent.extensions.shell_approval import ShellApprovalMode
16
16
 
17
+ from .permission import ShellPolicy
18
+
17
19
 
18
20
  class ShellApprovalMemory:
19
21
  """Approval memory for one process: a policy per session, exact commands.
@@ -21,12 +23,25 @@ class ShellApprovalMemory:
21
23
  This satisfies ``zett-agent``'s ``ShellApprovalStorage``, which the shell
22
24
  extension uses to look up a session's mode and to check and record one
23
25
  approved command.
26
+
27
+ The shell extension asks :meth:`is_allowed` before it puts a command to the
28
+ reader, which makes it the one place a permission level can say "do not ask":
29
+ ``safe`` answers yes for a command that cannot change anything and ``auto``
30
+ answers yes for every command, while ``ask`` falls through to the commands the
31
+ reader approved by hand. ``deny`` is not decided here, because refusing is not
32
+ a thing this question can express.
24
33
  """
25
34
 
26
- def __init__(self) -> None:
27
- """Start with no session policy and nothing approved."""
35
+ def __init__(self, policy: ShellPolicy | None = None) -> None:
36
+ """Start with no session policy and nothing approved.
37
+
38
+ Args:
39
+ policy: The permission level to consult; without one every command
40
+ is put to the reader, which is what ``ask`` does.
41
+ """
28
42
  self._modes: dict[str, ShellApprovalMode] = {}
29
43
  self._allowed: set[str] = set()
44
+ self._policy = policy
30
45
 
31
46
  async def get_session_mode(self, session_id: str) -> ShellApprovalMode:
32
47
  """Return a session's policy, which is review until one is set."""
@@ -41,7 +56,9 @@ class ShellApprovalMemory:
41
56
  self._modes.pop(session_id, None)
42
57
 
43
58
  async def is_allowed(self, session_id: str, command: str) -> bool:
44
- """Return whether this run already approved one exact command."""
59
+ """Return whether a command may run without asking: by level, or approved by hand."""
60
+ if self._policy is not None and self._policy.allows_unasked(command):
61
+ return True
45
62
  return command in self._allowed
46
63
 
47
64
  async def allow_command(self, command: str) -> None:
@@ -6,7 +6,7 @@ from collections.abc import Mapping
6
6
 
7
7
  from zett_agent.agent import AgentRunContext
8
8
  from zett_agent.extensions.base import AgentExtension
9
- from zett_agent.providers.openai import OpenAIProvider
9
+ from zett_agent.model import AgentModel
10
10
  from zett_agent.tools.images import view_image
11
11
 
12
12
  from ...config import ModelConfig
@@ -29,7 +29,7 @@ class ModelCapabilities(AgentExtension):
29
29
  #: After the coding bundle, which is what registers the tool being filtered.
30
30
  priority = 110
31
31
 
32
- def __init__(self, models: Mapping[ModelConfig, OpenAIProvider]) -> None:
32
+ def __init__(self, models: Mapping[ModelConfig, AgentModel]) -> None:
33
33
  """Remember the runtime's providers, keyed by the model each one serves.
34
34
 
35
35
  The mapping is the runtime's own and grows as models are selected, so