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.
- {zai_cli-0.1.8 → zai_cli-0.1.9}/CHANGELOG.md +20 -0
- {zai_cli-0.1.8/zai_cli.egg-info → zai_cli-0.1.9}/PKG-INFO +22 -4
- {zai_cli-0.1.8 → zai_cli-0.1.9}/README.md +21 -3
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_interactive.py +54 -1
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_settings_cli.py +38 -0
- zai_cli-0.1.9/tests/test_tokens.py +57 -0
- zai_cli-0.1.9/zai/__init__.py +1 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/interactive.py +40 -4
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/settings.py +34 -2
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/status.py +45 -1
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/config.py +3 -0
- zai_cli-0.1.9/zai/core/compaction.py +83 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/context.py +16 -14
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/session.py +24 -0
- zai_cli-0.1.9/zai/core/tokens.py +158 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/main.py +7 -2
- {zai_cli-0.1.8 → zai_cli-0.1.9/zai_cli.egg-info}/PKG-INFO +22 -4
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/SOURCES.txt +3 -0
- zai_cli-0.1.8/zai/__init__.py +0 -1
- {zai_cli-0.1.8 → zai_cli-0.1.9}/LICENSE +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/MANIFEST.in +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/pyproject.toml +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/scripts/release_preflight.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/setup.cfg +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_agent.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_auth.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_browser.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_code_runner.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_config_main.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_core.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_errors.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_hooks_skills_session.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_input.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_integrations_cli.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_mcp.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_plugins.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_process.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_project_memory.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_providers.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_release_preflight.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_search.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_security.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_storage.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_streaming.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_tool_schema.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_tools.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_undo.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_utilities.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_vision.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_watch.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/tests/test_workflows.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/__main__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/__init__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/common.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/integrations.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/setup_wizard.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/utilities.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/cli/workflows.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/commit.md +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/explain.md +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/feature.md +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/fix.md +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/commands/review.md +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/__init__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/agent.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/auth.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/cancellation.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/commands.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/errors.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/fallback.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/hooks.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/memory.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/process.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/project_memory.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/repomap.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/runtime.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/security.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/storage.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/streaming.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/tool_schema.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/undo.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/core/watch.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/mcp/__init__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/mcp/client.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/mcp/manager.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/plugins/__init__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/plugins/base.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/plugins/loader.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/__init__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/anthropic.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/base.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/cerebras.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/gemini.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/groq.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/ollama.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/openai.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/openrouter.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/providers/qwen.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/skills/__init__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/skills/registry.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/__init__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/browser.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/code_runner.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/files.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/git.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/search.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/tools/vision.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/ui/__init__.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai/ui/input.py +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/dependency_links.txt +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/entry_points.txt +0 -0
- {zai_cli-0.1.8 → zai_cli-0.1.9}/zai_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
532
|
-
|
|
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
|
-
|
|
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
|
|
475
|
-
|
|
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=[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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",
|
|
@@ -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
|
+
|