zai-cli 0.1.8__tar.gz → 0.1.9__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 (113) hide show
  1. {zai_cli-0.1.8 → zai_cli-0.1.9}/CHANGELOG.md +20 -0
  2. {zai_cli-0.1.8/zai_cli.egg-info → zai_cli-0.1.9}/PKG-INFO +22 -4
  3. {zai_cli-0.1.8 → zai_cli-0.1.9}/README.md +21 -3
  4. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_interactive.py +54 -1
  5. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_settings_cli.py +38 -0
  6. zai_cli-0.1.9/tests/test_tokens.py +57 -0
  7. zai_cli-0.1.9/zai/__init__.py +1 -0
  8. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/interactive.py +40 -4
  9. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/settings.py +34 -2
  10. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/status.py +45 -1
  11. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/config.py +3 -0
  12. zai_cli-0.1.9/zai/core/compaction.py +83 -0
  13. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/context.py +16 -14
  14. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/session.py +24 -0
  15. zai_cli-0.1.9/zai/core/tokens.py +158 -0
  16. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/main.py +7 -2
  17. {zai_cli-0.1.8 → zai_cli-0.1.9/zai_cli.egg-info}/PKG-INFO +22 -4
  18. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/SOURCES.txt +3 -0
  19. zai_cli-0.1.8/zai/__init__.py +0 -1
  20. {zai_cli-0.1.8 → zai_cli-0.1.9}/LICENSE +0 -0
  21. {zai_cli-0.1.8 → zai_cli-0.1.9}/MANIFEST.in +0 -0
  22. {zai_cli-0.1.8 → zai_cli-0.1.9}/pyproject.toml +0 -0
  23. {zai_cli-0.1.8 → zai_cli-0.1.9}/scripts/release_preflight.py +0 -0
  24. {zai_cli-0.1.8 → zai_cli-0.1.9}/setup.cfg +0 -0
  25. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_agent.py +0 -0
  26. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_auth.py +0 -0
  27. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_browser.py +0 -0
  28. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_code_runner.py +0 -0
  29. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_config_main.py +0 -0
  30. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_core.py +0 -0
  31. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_errors.py +0 -0
  32. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_hooks_skills_session.py +0 -0
  33. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_input.py +0 -0
  34. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_integrations_cli.py +0 -0
  35. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_mcp.py +0 -0
  36. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_plugins.py +0 -0
  37. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_process.py +0 -0
  38. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_project_memory.py +0 -0
  39. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_providers.py +0 -0
  40. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_release_preflight.py +0 -0
  41. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_search.py +0 -0
  42. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_security.py +0 -0
  43. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_storage.py +0 -0
  44. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_streaming.py +0 -0
  45. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_tool_schema.py +0 -0
  46. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_tools.py +0 -0
  47. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_undo.py +0 -0
  48. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_utilities.py +0 -0
  49. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_vision.py +0 -0
  50. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_watch.py +0 -0
  51. {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_workflows.py +0 -0
  52. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/__main__.py +0 -0
  53. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/__init__.py +0 -0
  54. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/common.py +0 -0
  55. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/integrations.py +0 -0
  56. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/setup_wizard.py +0 -0
  57. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/utilities.py +0 -0
  58. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/workflows.py +0 -0
  59. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/commit.md +0 -0
  60. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/explain.md +0 -0
  61. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/feature.md +0 -0
  62. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/fix.md +0 -0
  63. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/review.md +0 -0
  64. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/__init__.py +0 -0
  65. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/agent.py +0 -0
  66. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/auth.py +0 -0
  67. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/cancellation.py +0 -0
  68. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/commands.py +0 -0
  69. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/errors.py +0 -0
  70. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/fallback.py +0 -0
  71. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/hooks.py +0 -0
  72. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/memory.py +0 -0
  73. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/process.py +0 -0
  74. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/project_memory.py +0 -0
  75. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/repomap.py +0 -0
  76. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/runtime.py +0 -0
  77. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/security.py +0 -0
  78. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/storage.py +0 -0
  79. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/streaming.py +0 -0
  80. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/tool_schema.py +0 -0
  81. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/undo.py +0 -0
  82. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/watch.py +0 -0
  83. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/mcp/__init__.py +0 -0
  84. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/mcp/client.py +0 -0
  85. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/mcp/manager.py +0 -0
  86. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/plugins/__init__.py +0 -0
  87. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/plugins/base.py +0 -0
  88. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/plugins/loader.py +0 -0
  89. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/__init__.py +0 -0
  90. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/anthropic.py +0 -0
  91. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/base.py +0 -0
  92. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/cerebras.py +0 -0
  93. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/gemini.py +0 -0
  94. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/groq.py +0 -0
  95. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/ollama.py +0 -0
  96. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/openai.py +0 -0
  97. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/openrouter.py +0 -0
  98. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/qwen.py +0 -0
  99. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/skills/__init__.py +0 -0
  100. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/skills/registry.py +0 -0
  101. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/__init__.py +0 -0
  102. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/browser.py +0 -0
  103. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/code_runner.py +0 -0
  104. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/files.py +0 -0
  105. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/git.py +0 -0
  106. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/search.py +0 -0
  107. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/vision.py +0 -0
  108. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/ui/__init__.py +0 -0
  109. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/ui/input.py +0 -0
  110. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/dependency_links.txt +0 -0
  111. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/entry_points.txt +0 -0
  112. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/requires.txt +0 -0
  113. {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/top_level.txt +0 -0
@@ -5,6 +5,26 @@ All notable changes to this project are documented in this file.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and the project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.1.9] - 2026-06-30
9
+
10
+ ### Added
11
+
12
+ - Added provider-neutral token estimation helpers and context usage reporting.
13
+ - Added `zai context tokens`, `zai context summary`, interactive
14
+ `/context tokens`, and interactive `/context summary`.
15
+ - Added interactive `/compact` to summarize older session history.
16
+ - Added `zai session compact <name-or-id>` for saved sessions.
17
+ - Added compacted-summary metadata to saved session payloads.
18
+
19
+ ### Changed
20
+
21
+ - Automatic context compaction now uses configurable thresholds and prints a
22
+ transparent message when older history is summarized.
23
+ - Context status now shows estimated token usage, estimated context limit,
24
+ remaining tokens, and compacted-summary availability.
25
+ - Long-session summaries redact obvious API keys, bearer tokens, passwords, and
26
+ secret values before storage/display.
27
+
8
28
  ## [0.1.8] - 2026-06-29
9
29
 
10
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zai-cli
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Your personal AI CLI — free, fast, and smart
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/HumaizaNaz/zai_cli
@@ -506,6 +506,20 @@ Use `zai context` or `/context` to inspect the current folder, whether project
506
506
  memory exists, whether a repo-map cache is available, and how many conversation
507
507
  messages are loaded in the current interactive session.
508
508
 
509
+ For token/context usage:
510
+
511
+ ```text
512
+ zai context tokens
513
+ zai context summary
514
+ /context tokens
515
+ /context summary
516
+ /compact
517
+ ```
518
+
519
+ `zai context tokens` and `/context tokens` show an approximate provider-neutral
520
+ token estimate, the configured context limit, remaining tokens, and whether the
521
+ session is approaching the compaction threshold.
522
+
509
523
  Project memory is separate from current model context. It is a small
510
524
  `.zai/project_memory.json` summary created with `zai memory refresh` or
511
525
  `/memory refresh`.
@@ -514,7 +528,10 @@ Repo maps are summaries/indexes used on demand for repository questions. They
514
528
  are not full source files loaded into every model call. If a file has not been
515
529
  read or indexed for the current task, treat it as not loaded yet.
516
530
 
517
- Token tracking and automatic long-session compaction are planned for `0.1.9`.
531
+ For long interactive sessions, `/compact` summarizes older conversation and
532
+ tool history so the active session can continue with less context pressure. The
533
+ summary preserves the goal, decisions, recent work, commands/results where
534
+ visible, and pending context in a compact form.
518
535
 
519
536
  All chat, workflow, utility, and agent requests use a model-aware context
520
537
  pipeline:
@@ -528,8 +545,9 @@ pipeline:
528
545
  - context usage uses a conservative shared token estimate, while provider
529
546
  response usage is retained where the SDK reports it.
530
547
 
531
- This keeps requests bounded, but it is not a replacement for future
532
- long-session token tracking and automatic compaction.
548
+ This keeps requests bounded. Compaction is still approximate, so save important
549
+ stable project facts explicitly with `/remember <fact>` or `zai memory remember
550
+ <fact>`.
533
551
 
534
552
  ## Providers and fallback
535
553
 
@@ -449,6 +449,20 @@ Use `zai context` or `/context` to inspect the current folder, whether project
449
449
  memory exists, whether a repo-map cache is available, and how many conversation
450
450
  messages are loaded in the current interactive session.
451
451
 
452
+ For token/context usage:
453
+
454
+ ```text
455
+ zai context tokens
456
+ zai context summary
457
+ /context tokens
458
+ /context summary
459
+ /compact
460
+ ```
461
+
462
+ `zai context tokens` and `/context tokens` show an approximate provider-neutral
463
+ token estimate, the configured context limit, remaining tokens, and whether the
464
+ session is approaching the compaction threshold.
465
+
452
466
  Project memory is separate from current model context. It is a small
453
467
  `.zai/project_memory.json` summary created with `zai memory refresh` or
454
468
  `/memory refresh`.
@@ -457,7 +471,10 @@ Repo maps are summaries/indexes used on demand for repository questions. They
457
471
  are not full source files loaded into every model call. If a file has not been
458
472
  read or indexed for the current task, treat it as not loaded yet.
459
473
 
460
- Token tracking and automatic long-session compaction are planned for `0.1.9`.
474
+ For long interactive sessions, `/compact` summarizes older conversation and
475
+ tool history so the active session can continue with less context pressure. The
476
+ summary preserves the goal, decisions, recent work, commands/results where
477
+ visible, and pending context in a compact form.
461
478
 
462
479
  All chat, workflow, utility, and agent requests use a model-aware context
463
480
  pipeline:
@@ -471,8 +488,9 @@ pipeline:
471
488
  - context usage uses a conservative shared token estimate, while provider
472
489
  response usage is retained where the SDK reports it.
473
490
 
474
- This keeps requests bounded, but it is not a replacement for future
475
- long-session token tracking and automatic compaction.
491
+ This keeps requests bounded. Compaction is still approximate, so save important
492
+ stable project facts explicitly with `/remember <fact>` or `zai memory remember
493
+ <fact>`.
476
494
 
477
495
  ## Providers and fallback
478
496
 
@@ -249,7 +249,13 @@ def test_interactive_context_and_providers_do_not_call_ai(tmp_path, monkeypatch,
249
249
  with patch("zai.cli.interactive._connect_mcp_servers"):
250
250
  with patch(
251
251
  "zai.cli.interactive.InteractiveInput.prompt",
252
- side_effect=["/providers", "/context", EOFError()],
252
+ side_effect=[
253
+ "/providers",
254
+ "/context",
255
+ "/context tokens",
256
+ "/context summary",
257
+ EOFError(),
258
+ ],
253
259
  ):
254
260
  with patch("zai.cli.interactive.run_agent") as run_agent:
255
261
  run_interactive("groq")
@@ -258,10 +264,57 @@ def test_interactive_context_and_providers_do_not_call_ai(tmp_path, monkeypatch,
258
264
  assert "Provider status" in output
259
265
  assert "Project context" in output
260
266
  assert "unread files are not loaded" in output
267
+ assert "Estimated tokens used" in output
268
+ assert "No compacted summary" in output
261
269
  assert "app.py is loaded" not in output
262
270
  run_agent.assert_not_called()
263
271
 
264
272
 
273
+ def test_interactive_manual_compact_summarizes_history(tmp_path, monkeypatch, capsys):
274
+ monkeypatch.chdir(tmp_path)
275
+ prompts = [
276
+ "task one",
277
+ "task two",
278
+ "task three",
279
+ "task four",
280
+ "task five",
281
+ "/compact",
282
+ "/context summary",
283
+ EOFError(),
284
+ ]
285
+ with patch("zai.cli.interactive.has_available_provider", return_value=True):
286
+ with patch("zai.cli.interactive.fire_hook", return_value=True):
287
+ with patch("zai.cli.interactive.plugin_loader.load_all", return_value={}):
288
+ with patch("zai.cli.interactive.plugin_loader.get_errors", return_value={}):
289
+ with patch("zai.cli.interactive._connect_mcp_servers"):
290
+ with patch(
291
+ "zai.cli.interactive.InteractiveInput.prompt",
292
+ side_effect=prompts,
293
+ ):
294
+ with (
295
+ patch(
296
+ "zai.cli.interactive.run_agent",
297
+ side_effect=[
298
+ "answer one",
299
+ "answer two",
300
+ "answer three",
301
+ "answer four",
302
+ "answer five",
303
+ ],
304
+ ),
305
+ patch(
306
+ "zai.cli.interactive.Confirm.ask",
307
+ return_value=False,
308
+ ),
309
+ ):
310
+ run_interactive("groq")
311
+
312
+ output = capsys.readouterr().out
313
+ assert "Context compacted" in output
314
+ assert "Current compacted summary" in output
315
+ assert "task one" in output
316
+
317
+
265
318
  def test_interactive_offers_project_memory_after_agent_task(tmp_path, monkeypatch, capsys):
266
319
  from zai.core.project_memory import load_project_memory
267
320
 
@@ -149,9 +149,26 @@ def test_context_command_is_honest_about_unloaded_files(tmp_path, monkeypatch):
149
149
  assert "Project memory: missing" in result.output
150
150
  assert "Repo map/index: not loaded yet" in result.output
151
151
  assert "unread files are not loaded" in result.output
152
+ assert "Estimated tokens used" in result.output
152
153
  assert "app.py is loaded" not in result.output
153
154
 
154
155
 
156
+ def test_context_tokens_and_summary_commands(tmp_path, monkeypatch):
157
+ app = settings_app()
158
+ monkeypatch.chdir(tmp_path)
159
+
160
+ tokens = CliRunner().invoke(app, ["context", "tokens"])
161
+ summary = CliRunner().invoke(app, ["context", "summary"])
162
+ bad = CliRunner().invoke(app, ["context", "bad"])
163
+
164
+ assert tokens.exit_code == 0
165
+ assert "Estimated tokens used" in tokens.output
166
+ assert "Compacted summary" in tokens.output
167
+ assert summary.exit_code == 0
168
+ assert "No compacted summary" in summary.output
169
+ assert "Usage: zai context" in bad.output
170
+
171
+
155
172
  def test_model_check_reports_valid_and_invalid_configuration():
156
173
  app = settings_app()
157
174
  runner = CliRunner()
@@ -408,6 +425,27 @@ def test_session_rename_delete_and_export():
408
425
  assert "Export failed" in export_failed.output
409
426
 
410
427
 
428
+ def test_session_compact_routes_history():
429
+ from zai.providers.base import Message
430
+
431
+ app = settings_app()
432
+ history = [
433
+ Message(role="user", content=f"message {index}")
434
+ for index in range(12)
435
+ ]
436
+ info = {"name": "auth", "project_path": "project"}
437
+ with (
438
+ patch("zai.cli.settings.load_history", return_value=history),
439
+ patch("zai.cli.settings.get_session_info", return_value=info),
440
+ patch("zai.cli.settings.save_history") as save,
441
+ ):
442
+ result = CliRunner().invoke(app, ["session", "compact", "auth"])
443
+
444
+ assert result.exit_code == 0
445
+ assert "Session compacted" in result.output
446
+ save.assert_called_once()
447
+
448
+
411
449
  def test_undo_list_empty_and_populated():
412
450
  app = settings_app()
413
451
  runner = CliRunner()
@@ -0,0 +1,57 @@
1
+ from zai.core.compaction import manual_compact_messages
2
+ from zai.core.tokens import (
3
+ compacted_summaries,
4
+ context_usage,
5
+ estimate_messages_tokens,
6
+ estimate_tokens,
7
+ redact_secrets,
8
+ )
9
+ from zai.providers.base import Message
10
+
11
+
12
+ def test_estimate_tokens_is_stable_and_nonzero():
13
+ assert estimate_tokens("") == 0
14
+ assert estimate_tokens("hello world") >= 2
15
+ assert estimate_tokens("x" * 120) >= 40
16
+
17
+
18
+ def test_context_usage_reports_thresholds():
19
+ messages = [Message(role="user", content="x" * 900)]
20
+ usage = context_usage(messages, context_limit=100)
21
+
22
+ assert usage.tokens_used == estimate_messages_tokens(messages)
23
+ assert usage.tokens_remaining == 0
24
+ assert usage.should_warn is True
25
+ assert usage.should_compact is True
26
+
27
+
28
+ def test_secret_redaction_covers_common_patterns():
29
+ text = (
30
+ "OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz "
31
+ "Authorization: Bearer abcdefghijklmnopqrstuvwxyz "
32
+ "password=hunter2"
33
+ )
34
+ redacted = redact_secrets(text)
35
+
36
+ assert "sk-abcdefghijklmnopqrstuvwxyz" not in redacted
37
+ assert "abcdefghijklmnopqrstuvwxyz" not in redacted
38
+ assert "hunter2" not in redacted
39
+ assert "[REDACTED_SECRET]" in redacted
40
+
41
+
42
+ def test_manual_compaction_creates_summary_and_keeps_recent_messages():
43
+ messages = [
44
+ Message(role="user", content="initial goal"),
45
+ Message(role="assistant", content="decision made"),
46
+ Message(role="tool", content="api_key=sk-secretsecretsecret", tool_name="run"),
47
+ ]
48
+ messages.extend(Message(role="user", content=f"step {index}") for index in range(12))
49
+
50
+ compacted, summary = manual_compact_messages(messages, max_tokens=128_000, keep_recent=4)
51
+
52
+ assert summary is not None
53
+ assert "initial goal" in summary.content
54
+ assert "decision made" in summary.content
55
+ assert "sk-secretsecretsecret" not in summary.content
56
+ assert compacted[-1].content == "step 11"
57
+ assert compacted_summaries(compacted)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.9"
@@ -19,6 +19,7 @@ from ..core.auth import (
19
19
  )
20
20
  from ..core.agent import plan_agent, run_agent, undo_last
21
21
  from ..core.commands import get_command_prompt, list_commands, load_commands
22
+ from ..core.compaction import manual_compact_messages
22
23
  from ..core.hooks import fire as fire_hook
23
24
  from ..core.fallback import has_available_provider
24
25
  from ..core.context import ContextManager
@@ -56,7 +57,7 @@ console = Console()
56
57
  CORE_COMMANDS = {
57
58
  "help", "clear", "files", "diff", "undo", "plan", "test",
58
59
  "watch", "commit", "review", "fix", "explain", "feature",
59
- "session", "resume", "model", "providers", "context", "memory", "remember",
60
+ "session", "resume", "model", "providers", "context", "compact", "memory", "remember",
60
61
  "forget", "commands", "auth", "setup", "exit", "quit",
61
62
  }
62
63
 
@@ -68,6 +69,9 @@ INTERACTIVE_COMMANDS = [
68
69
  ("/auth login <provider>", "Connect a provider"),
69
70
  ("/providers", "Show provider connection status"),
70
71
  ("/context", "Show loaded project context"),
72
+ ("/context tokens", "Show estimated context token usage"),
73
+ ("/context summary", "Show compacted context summary"),
74
+ ("/compact", "Summarize older session history now"),
71
75
  ("/exit", "Exit interactive mode"),
72
76
  ("/quit", "Exit interactive mode"),
73
77
  ("/clear", "Clear the terminal"),
@@ -187,6 +191,9 @@ def _show_commands() -> None:
187
191
  ("Setup", "/model <name>", "Switch model"),
188
192
  ("Setup", "/exit", "Exit interactive mode"),
189
193
  ("Project", "/context", "Show loaded project context"),
194
+ ("Project", "/context tokens", "Show estimated context token usage"),
195
+ ("Project", "/context summary", "Show compacted context summary"),
196
+ ("Project", "/compact", "Summarize older session history now"),
190
197
  ("Project", "/files", "List files in the current folder"),
191
198
  ("Project", "/diff", "Show git diff"),
192
199
  ("Project", "/review", "Review code"),
@@ -479,7 +486,8 @@ def run_interactive(model: str = None) -> None:
479
486
  border_style="cyan",
480
487
  ))
481
488
 
482
- preferred = model or load_config()["default_model"]
489
+ config = load_config()
490
+ preferred = model or config["default_model"]
483
491
  add_project(Path(cwd).name, cwd)
484
492
  fire_hook("SessionStart", {"cwd": cwd, "files": visible})
485
493
  project_memory = load_project_memory(cwd)
@@ -552,8 +560,14 @@ def run_interactive(model: str = None) -> None:
552
560
  def remember_turn(user_content: str, assistant_content: str) -> None:
553
561
  session_context.add("user", user_content)
554
562
  session_context.add("assistant", assistant_content)
555
- if session_context.is_near_limit():
563
+ if config.get("auto_compact", True) and session_context.is_near_limit(
564
+ float(config.get("compact_trigger_threshold", 0.85))
565
+ ):
556
566
  session_context.compress(preferred)
567
+ console.print(
568
+ "[yellow]Context compacted: older history summarized to keep "
569
+ "the session usable. Run /context summary to inspect it.[/yellow]"
570
+ )
557
571
  save_auto_history(history, cwd)
558
572
  append_recent_task(cwd, user_content[:200])
559
573
 
@@ -605,13 +619,35 @@ def run_interactive(model: str = None) -> None:
605
619
  _run_auth_command(stripped)
606
620
  elif stripped == "/providers":
607
621
  _show_auth_status()
608
- elif stripped == "/context":
622
+ elif stripped in {"/context", "/context tokens", "/context summary"}:
609
623
  messages = session_context.get_messages()
624
+ detail = "summary" if stripped == "/context summary" else "overview"
610
625
  console.print(context_panel(
611
626
  cwd,
612
627
  message_count=len(messages),
613
628
  pinned_count=sum(1 for item in messages if item.pinned),
629
+ messages=messages,
630
+ context_limit=session_context.max_tokens,
631
+ detail=detail,
614
632
  ))
633
+ elif stripped == "/compact":
634
+ before = len(history)
635
+ compacted, summary = manual_compact_messages(
636
+ history,
637
+ max_tokens=session_context.max_tokens,
638
+ )
639
+ session_context.replace_messages(compacted)
640
+ save_auto_history(history, cwd)
641
+ if summary:
642
+ console.print(
643
+ "[green]Context compacted:[/green] older history was "
644
+ "summarized to keep the session usable. Run "
645
+ "[cyan]/context summary[/cyan] to inspect it."
646
+ )
647
+ else:
648
+ console.print(
649
+ f"[dim]Context already compact enough ({before} messages).[/dim]"
650
+ )
615
651
  elif stripped in {"/exit", "/quit"}:
616
652
  fire_hook("SessionEnd", {"cwd": cwd, "messages": len(history)})
617
653
  console.print("\n[dim]Goodbye![/dim]")
@@ -40,10 +40,13 @@ from ..core.session import (
40
40
  delete_session,
41
41
  export_session,
42
42
  get_session_info,
43
+ load_history,
43
44
  list_sessions,
44
45
  rename_session,
46
+ save_history,
45
47
  search_sessions,
46
48
  )
49
+ from ..core.compaction import manual_compact_messages
47
50
  from ..core.undo import (
48
51
  clear_actions,
49
52
  get_action,
@@ -431,9 +434,15 @@ def memory(
431
434
  )
432
435
 
433
436
 
434
- def context():
437
+ def context(
438
+ action: str = typer.Argument("show", help="show, tokens, or summary"),
439
+ ):
435
440
  """Show what project context is currently visible to zai."""
436
- console.print("\n".join(context_lines(".")), soft_wrap=True)
441
+ if action in {"show", "tokens", "summary"}:
442
+ detail = "summary" if action == "summary" else "overview"
443
+ console.print("\n".join(context_lines(".", detail=detail)), soft_wrap=True)
444
+ else:
445
+ console.print("[red]Usage: zai context [show|tokens|summary][/red]")
437
446
 
438
447
 
439
448
  def session(
@@ -507,6 +516,28 @@ def session(
507
516
  console.print(f"[green]Session deleted:[/green] {name}")
508
517
  else:
509
518
  console.print("[red]Session not found or identifier is ambiguous.[/red]")
519
+ elif action == "compact" and name:
520
+ history = load_history(name)
521
+ info = get_session_info(name)
522
+ if not history or not info:
523
+ console.print("[red]Session not found or identifier is ambiguous.[/red]")
524
+ return
525
+ compacted, summary = manual_compact_messages(
526
+ history,
527
+ max_tokens=128_000,
528
+ )
529
+ save_history(
530
+ compacted,
531
+ info["name"],
532
+ project_path=info.get("project_path"),
533
+ )
534
+ if summary:
535
+ console.print(
536
+ f"[green]Session compacted:[/green] {info['name']} "
537
+ f"({len(history)} -> {len(compacted)} messages)"
538
+ )
539
+ else:
540
+ console.print(f"[dim]Session already compact enough: {info['name']}[/dim]")
510
541
  elif action == "export" and name:
511
542
  exported = export_session(
512
543
  name,
@@ -525,6 +556,7 @@ def session(
525
556
  console.print(
526
557
  "[red]Usage: zai session list | show <name-or-id> | "
527
558
  "search <query> | rename <old> <new> | delete <name-or-id> | "
559
+ "compact <name-or-id> | "
528
560
  "export <name-or-id> --format md|json [--out path][/red]"
529
561
  )
530
562
 
@@ -11,6 +11,11 @@ from ..config import get_api_key, get_model_config, get_models, load_config
11
11
  from ..core.auth import auth_status, get_auth_provider
12
12
  from ..core.project_memory import load_project_memory
13
13
  from ..core.repomap import _cache_path
14
+ from ..core.tokens import (
15
+ compacted_summaries,
16
+ context_usage,
17
+ format_usage_lines,
18
+ )
14
19
 
15
20
  LOCAL_PROVIDERS = {"ollama"}
16
21
  BEGINNER_PROVIDERS = {
@@ -140,12 +145,18 @@ def context_lines(
140
145
  *,
141
146
  message_count: int = 0,
142
147
  pinned_count: int = 0,
148
+ messages: list | None = None,
149
+ context_limit: int | None = None,
143
150
  recent_files: list[str] | None = None,
151
+ detail: str = "overview",
144
152
  ) -> list[str]:
145
153
  root = Path(cwd).resolve()
146
154
  memory = load_project_memory(root)
147
155
  repo_cache = _cache_path(root)
148
156
  recent_files = recent_files or []
157
+ messages = messages or []
158
+ usage = context_usage(messages, context_limit) if messages else None
159
+ summaries = compacted_summaries(messages)
149
160
  lines = [
150
161
  f"Folder: {root}",
151
162
  (
@@ -174,12 +185,39 @@ def context_lines(
174
185
  else "Recently touched files: not tracked yet"
175
186
  ),
176
187
  "Manually pinned files: not supported yet",
188
+ ]
189
+ if usage:
190
+ lines.extend(format_usage_lines(usage))
191
+ lines.append(
192
+ "Compacted summary: available"
193
+ if summaries
194
+ else "Compacted summary: none yet"
195
+ )
196
+ if usage.should_compact:
197
+ lines.append("Context status: compaction recommended")
198
+ elif usage.should_warn:
199
+ lines.append("Context status: approaching context limit")
200
+ else:
201
+ lines.append("Context status: OK")
202
+ else:
203
+ lines.append("Estimated tokens used: 0")
204
+ lines.append("Compacted summary: none yet")
205
+ if detail == "summary":
206
+ if summaries:
207
+ lines.append("")
208
+ lines.append("Current compacted summary:")
209
+ lines.append(summaries[-1])
210
+ else:
211
+ lines.append("No compacted summary has been created yet.")
212
+ lines.extend([
177
213
  (
178
214
  "Large repos are summarized/indexed on demand; unread files are "
179
215
  "not loaded into model context."
180
216
  ),
181
- "Token tracking and automatic compaction are planned for 0.1.9.",
217
+ "Use /context tokens to inspect estimated context usage.",
218
+ "Use /compact to summarize older session history when a session gets long.",
182
219
  ]
220
+ )
183
221
  return lines
184
222
 
185
223
 
@@ -188,14 +226,20 @@ def context_panel(
188
226
  *,
189
227
  message_count: int = 0,
190
228
  pinned_count: int = 0,
229
+ messages: list | None = None,
230
+ context_limit: int | None = None,
191
231
  recent_files: list[str] | None = None,
232
+ detail: str = "overview",
192
233
  ) -> Panel:
193
234
  return Panel(
194
235
  "\n".join(context_lines(
195
236
  cwd,
196
237
  message_count=message_count,
197
238
  pinned_count=pinned_count,
239
+ messages=messages,
240
+ context_limit=context_limit,
198
241
  recent_files=recent_files,
242
+ detail=detail,
199
243
  )),
200
244
  title="[cyan]Project context[/cyan]",
201
245
  border_style="cyan",
@@ -22,6 +22,9 @@ DEFAULT_CONFIG = {
22
22
  "auth_credentials_store": "env",
23
23
  "show_token_count": True,
24
24
  "auto_fallback": True,
25
+ "auto_compact": True,
26
+ "compact_warning_threshold": 0.70,
27
+ "compact_trigger_threshold": 0.85,
25
28
  }
26
29
 
27
30
  MODELS = {
@@ -0,0 +1,83 @@
1
+ """Manual session compaction helpers."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ from datetime import datetime
6
+
7
+ from ..providers.base import Message
8
+ from .context import compact_messages
9
+ from .tokens import COMPACTED_MARKER, redact_secrets
10
+
11
+ SUMMARY_MAX_ITEMS = 28
12
+
13
+
14
+ def _label(message: Message) -> str:
15
+ if message.role == "tool":
16
+ return f"TOOL {message.tool_name or 'result'}"
17
+ return message.role.upper()
18
+
19
+
20
+ def build_compaction_summary(messages: list[Message]) -> Message:
21
+ """Create a deterministic, secret-redacted summary of older session state."""
22
+ lines = [
23
+ COMPACTED_MARKER,
24
+ f"Created: {datetime.now().isoformat(timespec='seconds')}",
25
+ "Retained session facts:",
26
+ ]
27
+ for message in messages[:SUMMARY_MAX_ITEMS]:
28
+ content = " ".join(redact_secrets(message.content).split())
29
+ if not content and message.tool_calls:
30
+ content = "Requested tools: " + ", ".join(
31
+ call.name for call in message.tool_calls
32
+ )
33
+ if not content:
34
+ continue
35
+ lines.append(f"- {_label(message)}: {content[:700]}")
36
+ return Message(role="user", content="\n".join(lines), pinned=True)
37
+
38
+
39
+ def manual_compact_messages(
40
+ messages: list[Message],
41
+ *,
42
+ max_tokens: int,
43
+ keep_recent: int = 8,
44
+ ) -> tuple[list[Message], Message | None]:
45
+ """Summarize older messages now, keeping pinned and recent context."""
46
+ if len(messages) <= keep_recent:
47
+ return messages, None
48
+ pinned = [message for message in messages if message.pinned]
49
+ recent = messages[-keep_recent:]
50
+ recent_ids = {id(message) for message in recent}
51
+ pinned_ids = {id(message) for message in pinned}
52
+ older = [
53
+ message
54
+ for message in messages
55
+ if id(message) not in recent_ids and id(message) not in pinned_ids
56
+ ]
57
+ if not older:
58
+ return compact_messages(messages, max_tokens), None
59
+ summary = build_compaction_summary(older)
60
+ result = []
61
+ seen = set()
62
+ for message in [*pinned, summary, *recent]:
63
+ key = id(message)
64
+ if key not in seen:
65
+ result.append(message)
66
+ seen.add(key)
67
+ return compact_messages(result, max_tokens), summary
68
+
69
+
70
+ def compacted_metadata_from_messages(messages: list[Message]) -> list[dict]:
71
+ metadata = []
72
+ for message in messages:
73
+ if COMPACTED_MARKER in message.content:
74
+ metadata.append({
75
+ "created_at": datetime.now().isoformat(timespec="seconds"),
76
+ "summary": redact_secrets(message.content),
77
+ })
78
+ return metadata
79
+
80
+
81
+ def redact_message(message: Message) -> Message:
82
+ return replace(message, content=redact_secrets(message.content))
83
+