akernel-runtime 0.1.0__py3-none-any.whl
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.
- akernel_runtime-0.1.0.dist-info/METADATA +270 -0
- akernel_runtime-0.1.0.dist-info/RECORD +40 -0
- akernel_runtime-0.1.0.dist-info/WHEEL +5 -0
- akernel_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- akernel_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
- akernel_runtime-0.1.0.dist-info/licenses/NOTICE +4 -0
- akernel_runtime-0.1.0.dist-info/top_level.txt +1 -0
- context_kernel/__init__.py +4 -0
- context_kernel/__main__.py +5 -0
- context_kernel/agent_reports.py +188 -0
- context_kernel/benchmarks.py +493 -0
- context_kernel/budget.py +72 -0
- context_kernel/cli.py +2953 -0
- context_kernel/context.py +161 -0
- context_kernel/evals.py +347 -0
- context_kernel/global_memory.py +126 -0
- context_kernel/loop.py +1617 -0
- context_kernel/marketplace.py +194 -0
- context_kernel/marketplace_data/skills/context_budget.json +27 -0
- context_kernel/marketplace_data/skills/context_compaction.json +27 -0
- context_kernel/marketplace_data/skills/edit_file.json +27 -0
- context_kernel/marketplace_data/skills/index.json +66 -0
- context_kernel/marketplace_data/skills/long_task_planning.json +27 -0
- context_kernel/marketplace_data/skills/multi_file_bugfix.json +28 -0
- context_kernel/memory.py +515 -0
- context_kernel/models.py +144 -0
- context_kernel/planner.py +155 -0
- context_kernel/policy.py +271 -0
- context_kernel/project.py +317 -0
- context_kernel/providers.py +1264 -0
- context_kernel/report_costs.py +375 -0
- context_kernel/runner.py +78 -0
- context_kernel/skills.py +318 -0
- context_kernel/state_writer.py +108 -0
- context_kernel/storage.py +171 -0
- context_kernel/tasks.py +549 -0
- context_kernel/text.py +42 -0
- context_kernel/tokenizer.py +22 -0
- context_kernel/tools.py +544 -0
- context_kernel/verifier.py +77 -0
context_kernel/cli.py
ADDED
|
@@ -0,0 +1,2953 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from contextlib import redirect_stdout
|
|
5
|
+
from getpass import getpass
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .agent_reports import build_agent_cost_report, load_agent_report, render_agent_cost_report
|
|
15
|
+
from .benchmarks import BenchmarkRunner, benchmark_ref, render_benchmark_evidence_markdown
|
|
16
|
+
from .budget import DEFAULT_PROFILE, profile_names
|
|
17
|
+
from .context import ContextBuilder
|
|
18
|
+
from .evals import EvalRunner
|
|
19
|
+
from .global_memory import pull_global_memories, push_global_memories
|
|
20
|
+
from .loop import AgentLoop, summarize_tool_result
|
|
21
|
+
from .marketplace import install_marketplace_skill, is_remote_reference, list_marketplace_skills
|
|
22
|
+
from .memory import ALLOWED_KINDS, MemoryStore
|
|
23
|
+
from .planner import ExecutionPlanner
|
|
24
|
+
from .policy import FILE_OPERATIONS, check_command_policy, check_file_policy, summarize_command_policy
|
|
25
|
+
from .project import load_project_profile, scan_project
|
|
26
|
+
from .providers import env_value, list_provider_models, normalize_openai_base_url
|
|
27
|
+
from .report_costs import build_benchmark_cost_report, build_eval_cost_report, render_cost_report
|
|
28
|
+
from .runner import AgentRunner
|
|
29
|
+
from .skills import (
|
|
30
|
+
SkillRegistry,
|
|
31
|
+
compile_markdown_skill,
|
|
32
|
+
compile_markdown_skill_with_provider,
|
|
33
|
+
inspect_skill,
|
|
34
|
+
validate_skill_file,
|
|
35
|
+
)
|
|
36
|
+
from .state_writer import StateWriter
|
|
37
|
+
from .storage import Workspace
|
|
38
|
+
from .tasks import MILESTONE_STATUSES, TASK_STATUSES, TaskStore
|
|
39
|
+
from .tools import ToolExecutor
|
|
40
|
+
from .verifier import verify_trace
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
DEFAULT_PRIMARY_MODEL = "gpt-5.5"
|
|
44
|
+
DEFAULT_AUXILIARY_MODEL = "gpt-5.3-codex"
|
|
45
|
+
COMMAND_NAMES = {
|
|
46
|
+
"agent",
|
|
47
|
+
"bench",
|
|
48
|
+
"chat",
|
|
49
|
+
"compare",
|
|
50
|
+
"context",
|
|
51
|
+
"doctor",
|
|
52
|
+
"eval",
|
|
53
|
+
"init",
|
|
54
|
+
"memory",
|
|
55
|
+
"models",
|
|
56
|
+
"plan",
|
|
57
|
+
"policy",
|
|
58
|
+
"project",
|
|
59
|
+
"run",
|
|
60
|
+
"setup",
|
|
61
|
+
"skill",
|
|
62
|
+
"task",
|
|
63
|
+
"tool",
|
|
64
|
+
"trace",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> None:
|
|
69
|
+
configure_console_output()
|
|
70
|
+
parser = build_parser()
|
|
71
|
+
raw_argv = sys.argv[1:] if argv is None else list(argv)
|
|
72
|
+
raw_argv = normalize_default_chat_args(raw_argv)
|
|
73
|
+
args = parser.parse_args(raw_argv)
|
|
74
|
+
try:
|
|
75
|
+
args.func(args)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise SystemExit(f"error: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def normalize_default_chat_args(raw_argv: list[str]) -> list[str]:
|
|
81
|
+
"""Let bare `akernel` accept chat flags without spelling out `chat`."""
|
|
82
|
+
if not raw_argv:
|
|
83
|
+
return ["chat"]
|
|
84
|
+
if "-h" in raw_argv or "--help" in raw_argv:
|
|
85
|
+
return raw_argv
|
|
86
|
+
|
|
87
|
+
index = 0
|
|
88
|
+
while index < len(raw_argv):
|
|
89
|
+
token = raw_argv[index]
|
|
90
|
+
if token == "--workspace":
|
|
91
|
+
if index + 1 >= len(raw_argv):
|
|
92
|
+
return raw_argv
|
|
93
|
+
index += 2
|
|
94
|
+
continue
|
|
95
|
+
if token.startswith("--workspace="):
|
|
96
|
+
index += 1
|
|
97
|
+
continue
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
if index >= len(raw_argv):
|
|
101
|
+
return raw_argv[:index] + ["chat"]
|
|
102
|
+
if raw_argv[index] in COMMAND_NAMES:
|
|
103
|
+
return raw_argv
|
|
104
|
+
if raw_argv[index].startswith("-"):
|
|
105
|
+
return raw_argv[:index] + ["chat"] + raw_argv[index:]
|
|
106
|
+
return raw_argv
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
110
|
+
parser = argparse.ArgumentParser(prog="akernel", description="Context Kernel CLI")
|
|
111
|
+
parser.add_argument("--workspace", default=".", help="Workspace root containing .akernel state.")
|
|
112
|
+
parser.set_defaults(
|
|
113
|
+
func=cmd_chat,
|
|
114
|
+
provider="openai",
|
|
115
|
+
model=None,
|
|
116
|
+
aux_model=None,
|
|
117
|
+
model_routing="auto",
|
|
118
|
+
aux_review="auto",
|
|
119
|
+
base_url=None,
|
|
120
|
+
budget=None,
|
|
121
|
+
profile=DEFAULT_PROFILE,
|
|
122
|
+
task=None,
|
|
123
|
+
title="Interactive chat",
|
|
124
|
+
max_steps=5,
|
|
125
|
+
no_remember=False,
|
|
126
|
+
allow_over_budget=False,
|
|
127
|
+
expect_json=False,
|
|
128
|
+
ui="auto",
|
|
129
|
+
)
|
|
130
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
131
|
+
|
|
132
|
+
init_parser = subparsers.add_parser("init", help="Initialize a Context Kernel workspace.")
|
|
133
|
+
init_parser.add_argument("path", nargs="?", default=".")
|
|
134
|
+
init_parser.add_argument("--scan", action="store_true", help="Scan the project and save .akernel/project.json.")
|
|
135
|
+
init_parser.add_argument("--no-config-update", action="store_true", help="Do not extend safe command roots from scan results.")
|
|
136
|
+
init_parser.set_defaults(func=cmd_init)
|
|
137
|
+
|
|
138
|
+
setup_parser = subparsers.add_parser("setup", help="Configure project-local provider environment.")
|
|
139
|
+
setup_parser.add_argument("--api-key", default=None, help="OpenAI-compatible API key. If omitted, prompt securely.")
|
|
140
|
+
setup_parser.add_argument("--base-url", default=None, help="OpenAI-compatible base URL. `/v1` is added when missing.")
|
|
141
|
+
setup_parser.add_argument("--model", default=None, help="Default model id, for example gpt-5.5.")
|
|
142
|
+
setup_parser.add_argument("--aux-model", default=None, help="Auxiliary model id for planning, review, and compression.")
|
|
143
|
+
setup_parser.add_argument("--env-file", default=None, help="Environment file path. Defaults to .env in the current project.")
|
|
144
|
+
setup_parser.add_argument("--force", action="store_true", help="Rewrite an existing env file without keeping old values.")
|
|
145
|
+
setup_parser.add_argument("--verify", action="store_true", help="List provider models after writing configuration.")
|
|
146
|
+
setup_parser.set_defaults(func=cmd_setup)
|
|
147
|
+
|
|
148
|
+
skill_parser = subparsers.add_parser("skill", help="Manage skill contracts.")
|
|
149
|
+
skill_sub = skill_parser.add_subparsers(dest="skill_command", required=True)
|
|
150
|
+
skill_register = skill_sub.add_parser("register", help="Register a skill JSON file.")
|
|
151
|
+
skill_register.add_argument("json_file")
|
|
152
|
+
skill_register.set_defaults(func=cmd_skill_register)
|
|
153
|
+
skill_list = skill_sub.add_parser("list", help="List registered skills.")
|
|
154
|
+
skill_list.set_defaults(func=cmd_skill_list)
|
|
155
|
+
skill_show = skill_sub.add_parser("show", help="Show a registered skill.")
|
|
156
|
+
skill_show.add_argument("skill_id")
|
|
157
|
+
skill_show.add_argument("--level", choices=["l0", "l1", "l2", "l3"], default="l1")
|
|
158
|
+
skill_show.set_defaults(func=cmd_skill_show)
|
|
159
|
+
skill_compile = skill_sub.add_parser("compile", help="Compile a Markdown skill into structured JSON.")
|
|
160
|
+
skill_compile.add_argument("markdown_file")
|
|
161
|
+
skill_compile.add_argument("--id", default=None, help="Override compiled skill id.")
|
|
162
|
+
skill_compile.add_argument("--output", default=None, help="Output JSON path. Defaults next to source.")
|
|
163
|
+
skill_compile.add_argument("--provider", choices=["local", "openai"], default="local")
|
|
164
|
+
skill_compile.add_argument("--model", default=None, help="Provider model id used when --provider is openai.")
|
|
165
|
+
skill_compile.add_argument("--base-url", default=None, help="OpenAI-compatible base URL override.")
|
|
166
|
+
skill_compile.add_argument("--register", action="store_true", help="Register the compiled skill in the current workspace.")
|
|
167
|
+
skill_compile.set_defaults(func=cmd_skill_compile)
|
|
168
|
+
skill_validate = skill_sub.add_parser("validate", help="Validate a skill JSON file.")
|
|
169
|
+
skill_validate.add_argument("json_file")
|
|
170
|
+
skill_validate.set_defaults(func=cmd_skill_validate)
|
|
171
|
+
skill_inspect = skill_sub.add_parser("inspect", help="Inspect skill load levels and token estimates.")
|
|
172
|
+
skill_inspect.add_argument("skill_id")
|
|
173
|
+
skill_inspect.add_argument("--budget", type=int, default=300)
|
|
174
|
+
skill_inspect.set_defaults(func=cmd_skill_inspect)
|
|
175
|
+
skill_market_list = skill_sub.add_parser("market-list", help="List skills from a marketplace index.")
|
|
176
|
+
skill_market_list.add_argument("--index", default=None, help="Marketplace index JSON path, file URL, or HTTP(S) URL.")
|
|
177
|
+
skill_market_list.add_argument("--json", action="store_true")
|
|
178
|
+
skill_market_list.set_defaults(func=cmd_skill_market_list)
|
|
179
|
+
skill_market_install = skill_sub.add_parser("market-install", help="Install a skill from a marketplace index.")
|
|
180
|
+
skill_market_install.add_argument("skill_id")
|
|
181
|
+
skill_market_install.add_argument("--index", default=None, help="Marketplace index JSON path, file URL, or HTTP(S) URL.")
|
|
182
|
+
skill_market_install.add_argument("--trust-remote", action="store_true", help="Allow installing a skill fetched from a remote marketplace source.")
|
|
183
|
+
skill_market_install.add_argument("--ignore-compat", action="store_true", help="Install even when marketplace compatibility metadata does not match.")
|
|
184
|
+
skill_market_install.add_argument("--json", action="store_true")
|
|
185
|
+
skill_market_install.set_defaults(func=cmd_skill_market_install)
|
|
186
|
+
|
|
187
|
+
memory_parser = subparsers.add_parser("memory", help="Manage structured memory.")
|
|
188
|
+
memory_sub = memory_parser.add_subparsers(dest="memory_command", required=True)
|
|
189
|
+
memory_add = memory_sub.add_parser("add", help="Add a memory record.")
|
|
190
|
+
memory_add.add_argument("--kind", required=True, choices=sorted(ALLOWED_KINDS))
|
|
191
|
+
memory_add.add_argument("--text", required=True)
|
|
192
|
+
memory_add.add_argument("--tags", default="", help="Comma-separated tags.")
|
|
193
|
+
memory_add.set_defaults(func=cmd_memory_add)
|
|
194
|
+
memory_show = memory_sub.add_parser("show", help="Show one memory record.")
|
|
195
|
+
memory_show.add_argument("record_id")
|
|
196
|
+
memory_show.add_argument("--include-archived", action="store_true")
|
|
197
|
+
memory_show.set_defaults(func=cmd_memory_show)
|
|
198
|
+
memory_list = memory_sub.add_parser("list", help="List memory records.")
|
|
199
|
+
memory_list.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
|
|
200
|
+
memory_list.add_argument("--all", action="store_true", help="Include archived records.")
|
|
201
|
+
memory_list.set_defaults(func=cmd_memory_list)
|
|
202
|
+
memory_search = memory_sub.add_parser("search", help="Search memory records.")
|
|
203
|
+
memory_search.add_argument("query")
|
|
204
|
+
memory_search.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
|
|
205
|
+
memory_search.add_argument("--limit", type=int, default=5)
|
|
206
|
+
memory_search.set_defaults(func=cmd_memory_search)
|
|
207
|
+
memory_audit = memory_sub.add_parser("audit", help="Explain memory retention scores without archiving.")
|
|
208
|
+
memory_audit.add_argument("--json", action="store_true")
|
|
209
|
+
memory_audit.add_argument("--limit", type=int, default=20)
|
|
210
|
+
memory_audit.set_defaults(func=cmd_memory_audit)
|
|
211
|
+
memory_update = memory_sub.add_parser("update", help="Update an active memory record.")
|
|
212
|
+
memory_update.add_argument("record_id")
|
|
213
|
+
memory_update.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
|
|
214
|
+
memory_update.add_argument("--text")
|
|
215
|
+
memory_update.add_argument("--tags", default=None, help="Comma-separated tags. Pass an empty value to clear.")
|
|
216
|
+
memory_update.set_defaults(func=cmd_memory_update)
|
|
217
|
+
memory_forget = memory_sub.add_parser("forget", help="Archive a memory record so it no longer enters context.")
|
|
218
|
+
memory_forget.add_argument("record_id")
|
|
219
|
+
memory_forget.set_defaults(func=cmd_memory_forget)
|
|
220
|
+
memory_prune = memory_sub.add_parser("prune", help="Archive lower-priority memory records by count or token budget.")
|
|
221
|
+
memory_prune.add_argument("--max-records", type=int, default=None)
|
|
222
|
+
memory_prune.add_argument("--max-tokens", type=int, default=None)
|
|
223
|
+
memory_prune.add_argument("--dry-run", action="store_true")
|
|
224
|
+
memory_prune.add_argument("--json", action="store_true")
|
|
225
|
+
memory_prune.set_defaults(func=cmd_memory_prune)
|
|
226
|
+
memory_global_push = memory_sub.add_parser("global-push", help="Copy active project memories into the global memory store.")
|
|
227
|
+
memory_global_push.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
|
|
228
|
+
memory_global_push.add_argument("--namespace", default=None, help="Global memory namespace. Defaults to the project directory name.")
|
|
229
|
+
memory_global_push.add_argument("--tag", default=None, help="Only push memories containing this tag.")
|
|
230
|
+
memory_global_push.add_argument("--dry-run", action="store_true", help="Preview records without copying them.")
|
|
231
|
+
memory_global_push.add_argument("--global-root", default=None)
|
|
232
|
+
memory_global_push.add_argument("--json", action="store_true")
|
|
233
|
+
memory_global_push.set_defaults(func=cmd_memory_global_push)
|
|
234
|
+
memory_global_pull = memory_sub.add_parser("global-pull", help="Copy memories from the global memory store into this project.")
|
|
235
|
+
memory_global_pull.add_argument("--kind", choices=sorted(ALLOWED_KINDS))
|
|
236
|
+
memory_global_pull.add_argument("--namespace", default=None, help="Only pull memories from this namespace.")
|
|
237
|
+
memory_global_pull.add_argument("--source-project", default=None, help="Only pull memories pushed by this source project name.")
|
|
238
|
+
memory_global_pull.add_argument("--tag", default=None, help="Only pull memories containing this tag.")
|
|
239
|
+
memory_global_pull.add_argument("--limit", type=int, default=None)
|
|
240
|
+
memory_global_pull.add_argument("--dry-run", action="store_true", help="Preview records without copying them.")
|
|
241
|
+
memory_global_pull.add_argument("--global-root", default=None)
|
|
242
|
+
memory_global_pull.add_argument("--json", action="store_true")
|
|
243
|
+
memory_global_pull.set_defaults(func=cmd_memory_global_pull)
|
|
244
|
+
|
|
245
|
+
run_parser = subparsers.add_parser("run", help="Build context and run a provider.")
|
|
246
|
+
run_parser.add_argument("request")
|
|
247
|
+
add_provider_args(run_parser)
|
|
248
|
+
add_budget_args(run_parser)
|
|
249
|
+
run_parser.add_argument("--show-packet", action="store_true")
|
|
250
|
+
run_parser.add_argument("--allow-over-budget", action="store_true", help="Execute even when preflight budget verification fails.")
|
|
251
|
+
run_parser.add_argument("--expect-json", action="store_true", help="Require the provider response to be valid JSON.")
|
|
252
|
+
run_parser.add_argument("--remember", action="store_true", help="Write an explicit task-state memory from the run trace.")
|
|
253
|
+
run_parser.add_argument("--task", default=None, help="Attach the run trace and written memories to a task session.")
|
|
254
|
+
run_parser.add_argument("--resume", action="store_true", help="Inject the task brief into the context packet. Requires --task.")
|
|
255
|
+
run_parser.set_defaults(func=cmd_run)
|
|
256
|
+
|
|
257
|
+
agent_parser = subparsers.add_parser("agent", help="Run bounded agent loops.")
|
|
258
|
+
agent_sub = agent_parser.add_subparsers(dest="agent_command", required=True)
|
|
259
|
+
agent_run = agent_sub.add_parser("run", help="Run a bounded plan/run/verify/state loop.")
|
|
260
|
+
agent_run.add_argument("request")
|
|
261
|
+
add_provider_args(agent_run)
|
|
262
|
+
add_budget_args(agent_run)
|
|
263
|
+
agent_run.add_argument("--task", default=None, help="Continue an existing task; otherwise create a new one.")
|
|
264
|
+
agent_run.add_argument("--max-steps", type=int, default=5, help="Maximum loop steps to run.")
|
|
265
|
+
agent_run.add_argument("--model-routing", choices=["auto", "primary", "auxiliary"], default="auto", help="Choose how agent steps select primary vs auxiliary model.")
|
|
266
|
+
agent_run.add_argument("--aux-review", choices=["auto", "off", "always"], default="auto", help="Run auxiliary context review before selected agent steps.")
|
|
267
|
+
agent_run.add_argument("--no-remember", action="store_true", help="Do not write explicit task-state memory.")
|
|
268
|
+
agent_run.add_argument("--allow-over-budget", action="store_true", help="Execute even when preflight budget verification fails.")
|
|
269
|
+
agent_run.add_argument("--expect-json", action="store_true", help="Require provider responses to be valid JSON.")
|
|
270
|
+
agent_run.add_argument("--json", action="store_true")
|
|
271
|
+
agent_run.set_defaults(func=cmd_agent_run)
|
|
272
|
+
agent_list = agent_sub.add_parser("list", help="List saved agent loop reports.")
|
|
273
|
+
agent_list.set_defaults(func=cmd_agent_list)
|
|
274
|
+
agent_show = agent_sub.add_parser("show", help="Show a saved agent loop report.")
|
|
275
|
+
agent_show.add_argument("run_id")
|
|
276
|
+
agent_show.set_defaults(func=cmd_agent_show)
|
|
277
|
+
agent_cost = agent_sub.add_parser("cost", help="Inspect token cost and pressure for a saved agent loop report.")
|
|
278
|
+
agent_cost.add_argument("run_id")
|
|
279
|
+
agent_cost.add_argument("--json", action="store_true")
|
|
280
|
+
agent_cost.set_defaults(func=cmd_agent_cost)
|
|
281
|
+
|
|
282
|
+
chat_parser = subparsers.add_parser("chat", help="Start an interactive Claude Code-style task session.")
|
|
283
|
+
add_provider_args(chat_parser)
|
|
284
|
+
add_budget_args(chat_parser)
|
|
285
|
+
chat_parser.add_argument("--task", default=None, help="Continue an existing task session.")
|
|
286
|
+
chat_parser.add_argument("--title", default="Interactive chat", help="Title for a new task session.")
|
|
287
|
+
chat_parser.add_argument("--max-steps", type=int, default=5, help="Maximum agent loop steps per user message.")
|
|
288
|
+
chat_parser.add_argument("--model-routing", choices=["auto", "primary", "auxiliary"], default="auto", help="Choose how agent steps select primary vs auxiliary model.")
|
|
289
|
+
chat_parser.add_argument("--aux-review", choices=["auto", "off", "always"], default="auto", help="Run auxiliary context review before selected agent steps.")
|
|
290
|
+
chat_parser.add_argument("--no-remember", action="store_true", help="Do not write explicit task-state memory.")
|
|
291
|
+
chat_parser.add_argument("--allow-over-budget", action="store_true", help="Execute even when preflight budget verification fails.")
|
|
292
|
+
chat_parser.add_argument("--expect-json", action="store_true", help="Require provider responses to be valid JSON.")
|
|
293
|
+
chat_parser.add_argument("--ui", choices=["auto", "classic", "tui"], default="auto", help="Choose interactive UI mode. auto uses TUI only on real terminals.")
|
|
294
|
+
chat_parser.set_defaults(provider="openai", func=cmd_chat)
|
|
295
|
+
|
|
296
|
+
models_parser = subparsers.add_parser("models", help="List models from a provider.")
|
|
297
|
+
models_parser.add_argument("--provider", choices=["mock", "openai"], default="openai")
|
|
298
|
+
models_parser.add_argument("--base-url", default=None, help="OpenAI-compatible base URL override.")
|
|
299
|
+
models_parser.set_defaults(func=cmd_models)
|
|
300
|
+
|
|
301
|
+
doctor_parser = subparsers.add_parser("doctor", help="Check local project configuration.")
|
|
302
|
+
doctor_parser.set_defaults(func=cmd_doctor)
|
|
303
|
+
|
|
304
|
+
project_parser = subparsers.add_parser("project", help="Scan and inspect project profile metadata.")
|
|
305
|
+
project_sub = project_parser.add_subparsers(dest="project_command", required=True)
|
|
306
|
+
project_scan = project_sub.add_parser("scan", help="Scan the workspace and write .akernel/project.json.")
|
|
307
|
+
project_scan.add_argument("--no-config-update", action="store_true", help="Do not extend safe command roots from scan results.")
|
|
308
|
+
project_scan.add_argument("--json", action="store_true", help="Print the full project profile JSON.")
|
|
309
|
+
project_scan.set_defaults(func=cmd_project_scan)
|
|
310
|
+
project_show = project_sub.add_parser("show", help="Show the saved project profile.")
|
|
311
|
+
project_show.add_argument("--json", action="store_true", help="Print the full project profile JSON.")
|
|
312
|
+
project_show.set_defaults(func=cmd_project_show)
|
|
313
|
+
|
|
314
|
+
plan_parser = subparsers.add_parser("plan", help="Create an execution plan without calling a provider.")
|
|
315
|
+
plan_parser.add_argument("request")
|
|
316
|
+
add_budget_args(plan_parser)
|
|
317
|
+
plan_parser.add_argument("--task", default=None, help="Use a task session for planning.")
|
|
318
|
+
plan_parser.add_argument("--resume", action="store_true", help="Inject the task brief into the planned context. Requires --task.")
|
|
319
|
+
plan_parser.add_argument("--json", action="store_true", help="Print the full plan JSON.")
|
|
320
|
+
plan_parser.set_defaults(func=cmd_plan)
|
|
321
|
+
|
|
322
|
+
policy_parser = subparsers.add_parser("policy", help="Check tool and file operation policy contracts.")
|
|
323
|
+
policy_sub = policy_parser.add_subparsers(dest="policy_command", required=True)
|
|
324
|
+
policy_file = policy_sub.add_parser("file", help="Check a planned file operation.")
|
|
325
|
+
policy_file.add_argument("operation", choices=sorted(FILE_OPERATIONS))
|
|
326
|
+
policy_file.add_argument("path")
|
|
327
|
+
policy_file.add_argument("--allow-destructive", action="store_true")
|
|
328
|
+
policy_file.add_argument("--json", action="store_true")
|
|
329
|
+
policy_file.set_defaults(func=cmd_policy_file)
|
|
330
|
+
policy_command = policy_sub.add_parser("command", help="Check a planned shell command without running it.")
|
|
331
|
+
policy_command.add_argument("--allow-destructive", action="store_true")
|
|
332
|
+
policy_command.add_argument("--json", action="store_true")
|
|
333
|
+
policy_command.add_argument("command", nargs=argparse.REMAINDER)
|
|
334
|
+
policy_command.set_defaults(func=cmd_policy_command)
|
|
335
|
+
|
|
336
|
+
tool_parser = subparsers.add_parser("tool", help="Execute local tools through policy contracts.")
|
|
337
|
+
tool_sub = tool_parser.add_subparsers(dest="tool_command", required=True)
|
|
338
|
+
tool_read = tool_sub.add_parser("read", help="Read a workspace file through policy.")
|
|
339
|
+
tool_read.add_argument("path")
|
|
340
|
+
tool_read.add_argument("--max-chars", type=int, default=8000)
|
|
341
|
+
tool_read.add_argument("--json", action="store_true")
|
|
342
|
+
tool_read.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
|
|
343
|
+
tool_read.set_defaults(func=cmd_tool_read)
|
|
344
|
+
tool_write = tool_sub.add_parser("write", help="Write a workspace file through policy.")
|
|
345
|
+
tool_write.add_argument("path")
|
|
346
|
+
tool_write.add_argument("--text", required=True)
|
|
347
|
+
tool_write.add_argument("--json", action="store_true")
|
|
348
|
+
tool_write.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
|
|
349
|
+
tool_write.set_defaults(func=cmd_tool_write)
|
|
350
|
+
tool_patch = tool_sub.add_parser("patch", help="Patch a workspace file with structured replacement modes.")
|
|
351
|
+
tool_patch.add_argument("path")
|
|
352
|
+
tool_patch.add_argument("--old")
|
|
353
|
+
tool_patch.add_argument("--new", required=True)
|
|
354
|
+
tool_patch.add_argument("--replace-all", action="store_true", help="Replace every match of --old instead of requiring a single match.")
|
|
355
|
+
tool_patch.add_argument("--occurrence", type=int, default=None, help="Replace only the nth match of --old.")
|
|
356
|
+
tool_patch.add_argument("--start-anchor", default=None, help="Replace the block that starts after this anchor.")
|
|
357
|
+
tool_patch.add_argument("--end-anchor", default=None, help="Replace the block that ends before this anchor.")
|
|
358
|
+
tool_patch.add_argument("--include-anchors", action="store_true", help="Replace the anchors together with the block body.")
|
|
359
|
+
tool_patch.add_argument("--json", action="store_true")
|
|
360
|
+
tool_patch.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
|
|
361
|
+
tool_patch.set_defaults(func=cmd_tool_patch)
|
|
362
|
+
tool_batch_patch = tool_sub.add_parser("batch-patch", help="Apply multiple structured patches from a JSON spec file.")
|
|
363
|
+
tool_batch_patch.add_argument("--specs-file", required=True, help="JSON file containing an array of patch specs, or an object with an edits array.")
|
|
364
|
+
tool_batch_patch.add_argument("--json", action="store_true")
|
|
365
|
+
tool_batch_patch.add_argument("--task", default=None, help="Attach the batch trace to a task session.")
|
|
366
|
+
tool_batch_patch.set_defaults(func=cmd_tool_batch_patch)
|
|
367
|
+
tool_delete = tool_sub.add_parser("delete", help="Delete a workspace file through destructive policy.")
|
|
368
|
+
tool_delete.add_argument("path")
|
|
369
|
+
tool_delete.add_argument("--allow-destructive", action="store_true")
|
|
370
|
+
tool_delete.add_argument("--json", action="store_true")
|
|
371
|
+
tool_delete.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
|
|
372
|
+
tool_delete.set_defaults(func=cmd_tool_delete)
|
|
373
|
+
tool_exec = tool_sub.add_parser("exec", help="Run a safe command through policy.")
|
|
374
|
+
tool_exec.add_argument("--allow-destructive", action="store_true")
|
|
375
|
+
tool_exec.add_argument("--timeout", type=int, default=30)
|
|
376
|
+
tool_exec.add_argument("--json", action="store_true")
|
|
377
|
+
tool_exec.add_argument("--task", default=None, help="Attach the tool trace to a task session.")
|
|
378
|
+
tool_exec.add_argument("command", nargs=argparse.REMAINDER)
|
|
379
|
+
tool_exec.set_defaults(func=cmd_tool_exec)
|
|
380
|
+
tool_list = tool_sub.add_parser("list", help="List tool execution traces.")
|
|
381
|
+
tool_list.set_defaults(func=cmd_tool_list)
|
|
382
|
+
tool_show = tool_sub.add_parser("show", help="Show a tool execution trace.")
|
|
383
|
+
tool_show.add_argument("trace_id")
|
|
384
|
+
tool_show.set_defaults(func=cmd_tool_show)
|
|
385
|
+
|
|
386
|
+
task_parser = subparsers.add_parser("task", help="Manage resumable task sessions.")
|
|
387
|
+
task_sub = task_parser.add_subparsers(dest="task_command", required=True)
|
|
388
|
+
task_start = task_sub.add_parser("start", help="Start a task session.")
|
|
389
|
+
task_start.add_argument("title")
|
|
390
|
+
task_start.add_argument("--goal", default=None)
|
|
391
|
+
task_start.add_argument("--plan", action="store_true", help="Create a structured long-task plan immediately.")
|
|
392
|
+
task_start.set_defaults(func=cmd_task_start)
|
|
393
|
+
task_list = task_sub.add_parser("list", help="List task sessions.")
|
|
394
|
+
task_list.add_argument("--status", choices=sorted(TASK_STATUSES))
|
|
395
|
+
task_list.set_defaults(func=cmd_task_list)
|
|
396
|
+
task_status = task_sub.add_parser("status", help="Show a task session.")
|
|
397
|
+
task_status.add_argument("task_id")
|
|
398
|
+
task_status.add_argument("--json", action="store_true")
|
|
399
|
+
task_status.set_defaults(func=cmd_task_status)
|
|
400
|
+
task_brief = task_sub.add_parser("brief", help="Build a compact resume brief for a task.")
|
|
401
|
+
task_brief.add_argument("task_id")
|
|
402
|
+
task_brief.add_argument("--json", action="store_true")
|
|
403
|
+
task_brief.set_defaults(func=cmd_task_brief)
|
|
404
|
+
task_plan = task_sub.add_parser("plan", help="Create or refresh a structured long-task plan.")
|
|
405
|
+
task_plan.add_argument("task_id")
|
|
406
|
+
task_plan.add_argument("--goal", default=None)
|
|
407
|
+
task_plan.add_argument("--force", action="store_true", help="Replace an existing structured plan.")
|
|
408
|
+
task_plan.add_argument("--json", action="store_true")
|
|
409
|
+
task_plan.set_defaults(func=cmd_task_plan)
|
|
410
|
+
task_next = task_sub.add_parser("next", help="Show the next resumable checkpoint for a task.")
|
|
411
|
+
task_next.add_argument("task_id")
|
|
412
|
+
task_next.add_argument("--json", action="store_true")
|
|
413
|
+
task_next.set_defaults(func=cmd_task_next)
|
|
414
|
+
task_checkpoint = task_sub.add_parser("checkpoint", help="Record long-task checkpoint progress.")
|
|
415
|
+
task_checkpoint.add_argument("task_id")
|
|
416
|
+
task_checkpoint.add_argument("--note", required=True)
|
|
417
|
+
task_checkpoint.add_argument("--milestone", default=None)
|
|
418
|
+
task_checkpoint.add_argument("--status", choices=sorted(MILESTONE_STATUSES), default=None)
|
|
419
|
+
task_checkpoint.set_defaults(func=cmd_task_checkpoint)
|
|
420
|
+
task_step = task_sub.add_parser("step", help="Append a checkpoint note.")
|
|
421
|
+
task_step.add_argument("task_id")
|
|
422
|
+
task_step.add_argument("--note", required=True)
|
|
423
|
+
task_step.set_defaults(func=cmd_task_step)
|
|
424
|
+
task_attach = task_sub.add_parser("attach", help="Attach a run/tool/memory reference.")
|
|
425
|
+
task_attach.add_argument("task_id")
|
|
426
|
+
task_attach.add_argument("kind", choices=["run", "tool", "memory"])
|
|
427
|
+
task_attach.add_argument("ref_id")
|
|
428
|
+
task_attach.set_defaults(func=cmd_task_attach)
|
|
429
|
+
task_block = task_sub.add_parser("block", help="Mark a task as blocked.")
|
|
430
|
+
task_block.add_argument("task_id")
|
|
431
|
+
task_block.add_argument("--note", required=True)
|
|
432
|
+
task_block.set_defaults(func=cmd_task_block)
|
|
433
|
+
task_complete = task_sub.add_parser("complete", help="Mark a task as completed.")
|
|
434
|
+
task_complete.add_argument("task_id")
|
|
435
|
+
task_complete.add_argument("--note", default=None)
|
|
436
|
+
task_complete.set_defaults(func=cmd_task_complete)
|
|
437
|
+
|
|
438
|
+
context_parser = subparsers.add_parser("context", help="Inspect context assembly without provider execution.")
|
|
439
|
+
context_parser.add_argument("request")
|
|
440
|
+
add_budget_args(context_parser)
|
|
441
|
+
context_parser.add_argument("--task", default=None, help="Use a task session for context assembly.")
|
|
442
|
+
context_parser.add_argument("--resume", action="store_true", help="Inject the task brief into the context packet. Requires --task.")
|
|
443
|
+
context_parser.set_defaults(func=cmd_context)
|
|
444
|
+
|
|
445
|
+
compare_parser = subparsers.add_parser("compare", help="Compare minimal context against a full-load baseline.")
|
|
446
|
+
compare_parser.add_argument("request")
|
|
447
|
+
add_budget_args(compare_parser)
|
|
448
|
+
compare_parser.add_argument("--json", action="store_true", help="Print the full comparison JSON.")
|
|
449
|
+
compare_parser.set_defaults(func=cmd_compare)
|
|
450
|
+
|
|
451
|
+
eval_parser = subparsers.add_parser("eval", help="Run comparison fixtures.")
|
|
452
|
+
eval_sub = eval_parser.add_subparsers(dest="eval_command", required=True)
|
|
453
|
+
eval_run = eval_sub.add_parser("run", help="Run eval fixture JSON.")
|
|
454
|
+
eval_run.add_argument("fixture")
|
|
455
|
+
add_budget_args(eval_run)
|
|
456
|
+
add_eval_provider_args(eval_run)
|
|
457
|
+
eval_run.add_argument("--json", action="store_true", help="Print the full eval report JSON.")
|
|
458
|
+
eval_run.add_argument("--no-save", action="store_true", help="Do not persist the eval report.")
|
|
459
|
+
eval_run.set_defaults(func=cmd_eval_run)
|
|
460
|
+
eval_list = eval_sub.add_parser("list", help="List saved eval reports.")
|
|
461
|
+
eval_list.set_defaults(func=cmd_eval_list)
|
|
462
|
+
eval_show = eval_sub.add_parser("show", help="Show a saved eval report.")
|
|
463
|
+
eval_show.add_argument("report_id")
|
|
464
|
+
eval_show.set_defaults(func=cmd_eval_show)
|
|
465
|
+
eval_cost = eval_sub.add_parser("cost", help="Inspect token cost hotspots for a saved eval report.")
|
|
466
|
+
eval_cost.add_argument("report_id")
|
|
467
|
+
eval_cost.add_argument("--json", action="store_true", help="Print the full eval cost JSON.")
|
|
468
|
+
eval_cost.set_defaults(func=cmd_eval_cost)
|
|
469
|
+
eval_diff = eval_sub.add_parser("diff", help="Compare two saved eval reports.")
|
|
470
|
+
eval_diff.add_argument("before_id")
|
|
471
|
+
eval_diff.add_argument("after_id")
|
|
472
|
+
eval_diff.add_argument("--json", action="store_true", help="Print the full diff JSON.")
|
|
473
|
+
eval_diff.add_argument("--fail-on-regression", action="store_true", help="Exit non-zero when regressions are detected.")
|
|
474
|
+
eval_diff.set_defaults(func=cmd_eval_diff)
|
|
475
|
+
|
|
476
|
+
bench_parser = subparsers.add_parser("bench", help="Run benchmark fixture directories.")
|
|
477
|
+
bench_sub = bench_parser.add_subparsers(dest="bench_command", required=True)
|
|
478
|
+
bench_run = bench_sub.add_parser("run", help="Run all eval fixtures in a directory.")
|
|
479
|
+
bench_run.add_argument("directory")
|
|
480
|
+
add_budget_args(bench_run)
|
|
481
|
+
add_eval_provider_args(bench_run)
|
|
482
|
+
bench_run.add_argument("--json", action="store_true", help="Print the full benchmark report JSON.")
|
|
483
|
+
bench_run.add_argument("--no-save", action="store_true", help="Do not persist the benchmark report.")
|
|
484
|
+
bench_run.set_defaults(func=cmd_bench_run)
|
|
485
|
+
bench_gate = bench_sub.add_parser("gate", help="Run a benchmark and fail if it regresses against a saved baseline.")
|
|
486
|
+
bench_gate.add_argument("directory")
|
|
487
|
+
add_budget_args(bench_gate)
|
|
488
|
+
add_eval_provider_args(bench_gate)
|
|
489
|
+
bench_gate.add_argument("--baseline-report", default=None, help="Saved benchmark report id to compare against.")
|
|
490
|
+
bench_gate.add_argument("--require-baseline", action="store_true", help="Exit non-zero when no matching baseline report exists.")
|
|
491
|
+
bench_gate.add_argument("--json", action="store_true", help="Print the full benchmark gate JSON.")
|
|
492
|
+
bench_gate.set_defaults(func=cmd_bench_gate)
|
|
493
|
+
bench_list = bench_sub.add_parser("list", help="List saved benchmark reports.")
|
|
494
|
+
bench_list.set_defaults(func=cmd_bench_list)
|
|
495
|
+
bench_show = bench_sub.add_parser("show", help="Show a saved benchmark report.")
|
|
496
|
+
bench_show.add_argument("report_id")
|
|
497
|
+
bench_show.set_defaults(func=cmd_bench_show)
|
|
498
|
+
bench_cost = bench_sub.add_parser("cost", help="Inspect token cost hotspots for a saved benchmark report.")
|
|
499
|
+
bench_cost.add_argument("report_id")
|
|
500
|
+
bench_cost.add_argument("--json", action="store_true", help="Print the full benchmark cost JSON.")
|
|
501
|
+
bench_cost.set_defaults(func=cmd_bench_cost)
|
|
502
|
+
bench_diff = bench_sub.add_parser("diff", help="Compare two saved benchmark reports.")
|
|
503
|
+
bench_diff.add_argument("before_id")
|
|
504
|
+
bench_diff.add_argument("after_id")
|
|
505
|
+
bench_diff.add_argument("--json", action="store_true", help="Print the full benchmark diff JSON.")
|
|
506
|
+
bench_diff.add_argument("--fail-on-regression", action="store_true", help="Exit non-zero when regressions are detected.")
|
|
507
|
+
bench_diff.set_defaults(func=cmd_bench_diff)
|
|
508
|
+
bench_export = bench_sub.add_parser("export", help="Export a benchmark report as Markdown.")
|
|
509
|
+
bench_export.add_argument("report_id")
|
|
510
|
+
bench_export.add_argument("--output", default=None, help="Output markdown path.")
|
|
511
|
+
bench_export.set_defaults(func=cmd_bench_export)
|
|
512
|
+
bench_evidence = bench_sub.add_parser("evidence", help="Summarize saved benchmark reports as token-savings evidence.")
|
|
513
|
+
bench_evidence.add_argument("report_ids", nargs="*", help="Specific report ids. Defaults to recent saved reports.")
|
|
514
|
+
bench_evidence.add_argument("--limit", type=int, default=None, help="Limit recent reports when no ids are provided.")
|
|
515
|
+
bench_evidence.add_argument("--output", default=None, help="Write Markdown evidence to this path.")
|
|
516
|
+
bench_evidence.add_argument("--json", action="store_true", help="Print the full evidence JSON.")
|
|
517
|
+
bench_evidence.add_argument("--fail-under", type=float, default=None, help="Exit non-zero if total savings percent is below this threshold.")
|
|
518
|
+
bench_evidence.set_defaults(func=cmd_bench_evidence)
|
|
519
|
+
|
|
520
|
+
trace_parser = subparsers.add_parser("trace", help="Inspect run traces.")
|
|
521
|
+
trace_sub = trace_parser.add_subparsers(dest="trace_command", required=True)
|
|
522
|
+
trace_list = trace_sub.add_parser("list", help="List traces.")
|
|
523
|
+
trace_list.set_defaults(func=cmd_trace_list)
|
|
524
|
+
trace_show = trace_sub.add_parser("show", help="Show a trace.")
|
|
525
|
+
trace_show.add_argument("trace_id")
|
|
526
|
+
trace_show.set_defaults(func=cmd_trace_show)
|
|
527
|
+
trace_verify = trace_sub.add_parser("verify", help="Re-run verifier checks on a saved trace.")
|
|
528
|
+
trace_verify.add_argument("trace_id")
|
|
529
|
+
trace_verify.add_argument("--expect-json", action="store_true")
|
|
530
|
+
trace_verify.set_defaults(func=cmd_trace_verify)
|
|
531
|
+
trace_remember = trace_sub.add_parser("remember", help="Write memory records from a saved trace.")
|
|
532
|
+
trace_remember.add_argument("trace_id")
|
|
533
|
+
trace_remember.add_argument("--dry-run", action="store_true", help="Show proposed memory records without writing them.")
|
|
534
|
+
trace_remember.set_defaults(func=cmd_trace_remember)
|
|
535
|
+
|
|
536
|
+
return parser
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def configure_console_output() -> None:
|
|
540
|
+
for stream in [sys.stdout, sys.stderr]:
|
|
541
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
542
|
+
if callable(reconfigure):
|
|
543
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def add_budget_args(parser: argparse.ArgumentParser) -> None:
|
|
547
|
+
parser.add_argument("--budget", type=int, default=None, help="Override the selected profile's token budget.")
|
|
548
|
+
parser.add_argument("--profile", choices=profile_names(), default=DEFAULT_PROFILE, help="Budget profile.")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def add_provider_args(parser: argparse.ArgumentParser) -> None:
|
|
552
|
+
parser.add_argument("--provider", choices=["mock", "openai"], default="mock")
|
|
553
|
+
parser.add_argument("--model", default=None, help="Provider model id. Defaults to gpt-5.5 for openai.")
|
|
554
|
+
parser.add_argument("--aux-model", default=None, help="Auxiliary model id for planning, review, and compression.")
|
|
555
|
+
parser.add_argument("--base-url", default=None, help="OpenAI-compatible base URL override.")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def add_eval_provider_args(parser: argparse.ArgumentParser) -> None:
|
|
559
|
+
parser.add_argument("--execute", action="store_true", help="Execute each eval task with a provider.")
|
|
560
|
+
parser.add_argument("--provider", choices=["mock", "openai"], default="mock", help="Provider used with --execute.")
|
|
561
|
+
parser.add_argument("--model", default=None, help="Provider model id used with --execute.")
|
|
562
|
+
parser.add_argument("--base-url", default=None, help="OpenAI-compatible base URL override used with --execute.")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def workspace_from_args(args: argparse.Namespace, *, initialized: bool = True) -> Workspace:
|
|
566
|
+
workspace = Workspace(Path(args.workspace))
|
|
567
|
+
if initialized:
|
|
568
|
+
workspace.require_initialized()
|
|
569
|
+
return workspace
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def chat_workspace_from_args(args: argparse.Namespace) -> Workspace:
|
|
573
|
+
workspace = Workspace(Path(args.workspace))
|
|
574
|
+
if not workspace.state.exists():
|
|
575
|
+
workspace.init()
|
|
576
|
+
print(f"initialized workspace: {workspace.state}")
|
|
577
|
+
return workspace
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
581
|
+
workspace = Workspace(Path(args.path))
|
|
582
|
+
workspace.init()
|
|
583
|
+
print(f"initialized: {workspace.state}")
|
|
584
|
+
if args.scan:
|
|
585
|
+
profile = scan_project(workspace, update_config=not args.no_config_update)
|
|
586
|
+
print_project_scan_summary(profile, config_updated=not args.no_config_update)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def cmd_project_scan(args: argparse.Namespace) -> None:
|
|
590
|
+
workspace = workspace_from_args(args)
|
|
591
|
+
profile = scan_project(workspace, update_config=not args.no_config_update)
|
|
592
|
+
if args.json:
|
|
593
|
+
print_json(profile)
|
|
594
|
+
return
|
|
595
|
+
print_project_scan_summary(profile, config_updated=not args.no_config_update)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def cmd_project_show(args: argparse.Namespace) -> None:
|
|
599
|
+
workspace = workspace_from_args(args)
|
|
600
|
+
profile = load_project_profile(workspace)
|
|
601
|
+
if not profile:
|
|
602
|
+
raise FileNotFoundError(f"No project profile found: {workspace.project_file}. Run `akernel project scan` first.")
|
|
603
|
+
if args.json:
|
|
604
|
+
print_json(profile)
|
|
605
|
+
return
|
|
606
|
+
print_project_scan_summary(profile, config_updated=False)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def cmd_setup(args: argparse.Namespace) -> None:
|
|
610
|
+
env_path = Path(args.env_file) if args.env_file else Path.cwd() / ".env"
|
|
611
|
+
existing = parse_simple_env(env_path) if env_path.exists() and not args.force else {}
|
|
612
|
+
interactive = sys.stdin.isatty()
|
|
613
|
+
|
|
614
|
+
api_key = args.api_key
|
|
615
|
+
if api_key is None:
|
|
616
|
+
current_key = existing.get("CONTEXT_KERNEL_OPENAI_API_KEY", "")
|
|
617
|
+
if current_key and interactive:
|
|
618
|
+
typed = getpass("API key [keep existing]: ").strip()
|
|
619
|
+
api_key = typed or current_key
|
|
620
|
+
elif interactive:
|
|
621
|
+
api_key = getpass("API key: ").strip()
|
|
622
|
+
else:
|
|
623
|
+
api_key = current_key
|
|
624
|
+
if not api_key:
|
|
625
|
+
raise ValueError("Missing API key. Run `akernel setup --api-key <key>` or use interactive setup.")
|
|
626
|
+
|
|
627
|
+
base_url = args.base_url
|
|
628
|
+
if base_url is None:
|
|
629
|
+
default_base_url = existing.get("CONTEXT_KERNEL_OPENAI_BASE_URL") or "https://clarmy.cloud/v1"
|
|
630
|
+
base_url = prompt_text("Base URL", default_base_url, interactive=interactive)
|
|
631
|
+
base_url = normalize_openai_base_url(base_url)
|
|
632
|
+
|
|
633
|
+
model = args.model
|
|
634
|
+
if model is None:
|
|
635
|
+
default_model = existing.get("CONTEXT_KERNEL_OPENAI_MODEL") or DEFAULT_PRIMARY_MODEL
|
|
636
|
+
model = prompt_text("Primary model", default_model, interactive=interactive)
|
|
637
|
+
|
|
638
|
+
aux_model = args.aux_model
|
|
639
|
+
if aux_model is None:
|
|
640
|
+
default_aux_model = existing.get("CONTEXT_KERNEL_OPENAI_AUX_MODEL") or DEFAULT_AUXILIARY_MODEL
|
|
641
|
+
aux_model = prompt_text("Auxiliary model", default_aux_model, interactive=interactive)
|
|
642
|
+
|
|
643
|
+
write_project_env(env_path, api_key=api_key, base_url=base_url, model=model, aux_model=aux_model)
|
|
644
|
+
print(f"configured: {env_path}")
|
|
645
|
+
print("api_key: set")
|
|
646
|
+
print(f"base_url: {base_url}")
|
|
647
|
+
print(f"primary_model: {model}")
|
|
648
|
+
print(f"auxiliary_model: {aux_model}")
|
|
649
|
+
if args.verify:
|
|
650
|
+
previous = {
|
|
651
|
+
"CONTEXT_KERNEL_OPENAI_API_KEY": os.environ.get("CONTEXT_KERNEL_OPENAI_API_KEY"),
|
|
652
|
+
"CONTEXT_KERNEL_OPENAI_BASE_URL": os.environ.get("CONTEXT_KERNEL_OPENAI_BASE_URL"),
|
|
653
|
+
"CONTEXT_KERNEL_OPENAI_MODEL": os.environ.get("CONTEXT_KERNEL_OPENAI_MODEL"),
|
|
654
|
+
"CONTEXT_KERNEL_OPENAI_AUX_MODEL": os.environ.get("CONTEXT_KERNEL_OPENAI_AUX_MODEL"),
|
|
655
|
+
}
|
|
656
|
+
try:
|
|
657
|
+
os.environ["CONTEXT_KERNEL_OPENAI_API_KEY"] = api_key
|
|
658
|
+
os.environ["CONTEXT_KERNEL_OPENAI_BASE_URL"] = base_url
|
|
659
|
+
os.environ["CONTEXT_KERNEL_OPENAI_MODEL"] = model
|
|
660
|
+
os.environ["CONTEXT_KERNEL_OPENAI_AUX_MODEL"] = aux_model
|
|
661
|
+
models = list_provider_models("openai", base_url=base_url)
|
|
662
|
+
finally:
|
|
663
|
+
restore_env(previous)
|
|
664
|
+
print("models:")
|
|
665
|
+
for item in models[:20]:
|
|
666
|
+
print(f"- {item}")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def parse_simple_env(path: Path) -> dict[str, str]:
|
|
670
|
+
values: dict[str, str] = {}
|
|
671
|
+
if not path.exists():
|
|
672
|
+
return values
|
|
673
|
+
for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
674
|
+
line = raw_line.strip()
|
|
675
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
676
|
+
continue
|
|
677
|
+
key, value = line.split("=", 1)
|
|
678
|
+
values[key.strip().lstrip("\ufeff")] = value.strip().strip('"').strip("'")
|
|
679
|
+
return values
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def prompt_text(label: str, default: str, *, interactive: bool) -> str:
|
|
683
|
+
if not interactive:
|
|
684
|
+
return default
|
|
685
|
+
value = input(f"{label} [{default}]: ").strip()
|
|
686
|
+
return value or default
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def write_project_env(path: Path, *, api_key: str, base_url: str, model: str, aux_model: str) -> None:
|
|
690
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
691
|
+
lines = [
|
|
692
|
+
f"CONTEXT_KERNEL_OPENAI_API_KEY={api_key}",
|
|
693
|
+
f"CONTEXT_KERNEL_OPENAI_BASE_URL={base_url}",
|
|
694
|
+
f"CONTEXT_KERNEL_OPENAI_MODEL={model}",
|
|
695
|
+
f"CONTEXT_KERNEL_OPENAI_AUX_MODEL={aux_model}",
|
|
696
|
+
]
|
|
697
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def restore_env(previous: dict[str, str | None]) -> None:
|
|
701
|
+
for key, value in previous.items():
|
|
702
|
+
if value is None:
|
|
703
|
+
os.environ.pop(key, None)
|
|
704
|
+
else:
|
|
705
|
+
os.environ[key] = value
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def cmd_skill_register(args: argparse.Namespace) -> None:
|
|
709
|
+
workspace = workspace_from_args(args)
|
|
710
|
+
skill = SkillRegistry(workspace).register(Path(args.json_file))
|
|
711
|
+
print(f"registered skill: {skill.id} ({skill.name})")
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def cmd_skill_list(args: argparse.Namespace) -> None:
|
|
715
|
+
workspace = workspace_from_args(args)
|
|
716
|
+
skills = SkillRegistry(workspace).all()
|
|
717
|
+
if not skills:
|
|
718
|
+
print("no skills registered")
|
|
719
|
+
return
|
|
720
|
+
for skill in skills:
|
|
721
|
+
print(f"{skill.id}\t{skill.name}\t{skill.summary}")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def cmd_skill_show(args: argparse.Namespace) -> None:
|
|
725
|
+
workspace = workspace_from_args(args)
|
|
726
|
+
skill = SkillRegistry(workspace).get(args.skill_id)
|
|
727
|
+
print_json(skill.render_level(args.level))
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def cmd_skill_compile(args: argparse.Namespace) -> None:
|
|
731
|
+
source = Path(args.markdown_file)
|
|
732
|
+
metadata = None
|
|
733
|
+
if args.provider == "local":
|
|
734
|
+
skill = compile_markdown_skill(source, skill_id=args.id)
|
|
735
|
+
else:
|
|
736
|
+
skill, metadata = compile_markdown_skill_with_provider(
|
|
737
|
+
source,
|
|
738
|
+
provider_name=args.provider,
|
|
739
|
+
model=args.model,
|
|
740
|
+
base_url=args.base_url,
|
|
741
|
+
skill_id=args.id,
|
|
742
|
+
)
|
|
743
|
+
output = Path(args.output) if args.output else source.with_suffix(".json")
|
|
744
|
+
Workspace.write_json(output, skill.to_dict())
|
|
745
|
+
print(f"compiled skill: {skill.id} -> {output}")
|
|
746
|
+
if metadata:
|
|
747
|
+
print(f"provider: {metadata['provider']} model={metadata['model']} tokens={metadata['total_tokens']}")
|
|
748
|
+
if args.register:
|
|
749
|
+
workspace = workspace_from_args(args)
|
|
750
|
+
registered = SkillRegistry(workspace).register(output)
|
|
751
|
+
print(f"registered skill: {registered.id} ({registered.name})")
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def cmd_skill_validate(args: argparse.Namespace) -> None:
|
|
755
|
+
print_json(validate_skill_file(Path(args.json_file)))
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def cmd_skill_inspect(args: argparse.Namespace) -> None:
|
|
759
|
+
workspace = workspace_from_args(args)
|
|
760
|
+
skill = SkillRegistry(workspace).get(args.skill_id)
|
|
761
|
+
print_json(inspect_skill(skill, args.budget))
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def cmd_skill_market_list(args: argparse.Namespace) -> None:
|
|
765
|
+
index = args.index if args.index else None
|
|
766
|
+
skills = list_marketplace_skills(index)
|
|
767
|
+
if args.json:
|
|
768
|
+
print_json({"count": len(skills), "skills": skills})
|
|
769
|
+
return
|
|
770
|
+
if not skills:
|
|
771
|
+
print("no marketplace skills")
|
|
772
|
+
return
|
|
773
|
+
for skill in skills:
|
|
774
|
+
compat = skill.get("compatibility_check", {})
|
|
775
|
+
remote = "remote" if skill.get("remote") else "local"
|
|
776
|
+
print(
|
|
777
|
+
f"{skill.get('id')}\t{skill.get('name')}\t"
|
|
778
|
+
f"v{skill.get('version', '0.0.0')}\t{remote}\t"
|
|
779
|
+
f"compat={'ok' if compat.get('ok', True) else 'blocked'}\t"
|
|
780
|
+
f"{skill.get('summary', '')}"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def cmd_skill_market_install(args: argparse.Namespace) -> None:
|
|
785
|
+
workspace = workspace_from_args(args)
|
|
786
|
+
index = args.index if args.index else None
|
|
787
|
+
trust_remote = args.trust_remote
|
|
788
|
+
if index and is_remote_reference(index) and not trust_remote and sys.stdin.isatty():
|
|
789
|
+
answer = input(f"Install from remote marketplace {index}? Type 'yes' to trust this source: ").strip().casefold()
|
|
790
|
+
trust_remote = answer == "yes"
|
|
791
|
+
result = install_marketplace_skill(
|
|
792
|
+
workspace,
|
|
793
|
+
args.skill_id,
|
|
794
|
+
index=index,
|
|
795
|
+
trust_remote=trust_remote,
|
|
796
|
+
ignore_compat=args.ignore_compat,
|
|
797
|
+
)
|
|
798
|
+
if args.json:
|
|
799
|
+
print_json(result)
|
|
800
|
+
return
|
|
801
|
+
print(f"installed marketplace skill: {result['id']} ({result['name']})")
|
|
802
|
+
print(f"version: {result.get('version')}")
|
|
803
|
+
print(f"source: {result.get('source')}")
|
|
804
|
+
print(f"compatibility: {'ok' if result.get('compatibility', {}).get('ok') else 'warning'}")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def cmd_memory_add(args: argparse.Namespace) -> None:
|
|
808
|
+
workspace = workspace_from_args(args)
|
|
809
|
+
tags = [tag.strip() for tag in args.tags.split(",") if tag.strip()]
|
|
810
|
+
record = MemoryStore(workspace).add(kind=args.kind, text=args.text, tags=tags)
|
|
811
|
+
print(f"memory: {record.id} ({record.kind})")
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def cmd_memory_show(args: argparse.Namespace) -> None:
|
|
815
|
+
workspace = workspace_from_args(args)
|
|
816
|
+
record = MemoryStore(workspace).get(args.record_id, include_archived=args.include_archived)
|
|
817
|
+
print_json(record.to_dict())
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def cmd_memory_list(args: argparse.Namespace) -> None:
|
|
821
|
+
workspace = workspace_from_args(args)
|
|
822
|
+
records = MemoryStore(workspace).all(kind=args.kind, include_archived=args.all)
|
|
823
|
+
if not records:
|
|
824
|
+
print("no memory records")
|
|
825
|
+
return
|
|
826
|
+
for record in records:
|
|
827
|
+
status = "archived" if record.archived_at else "active"
|
|
828
|
+
print(f"{record.id}\t{record.kind}\t{status}\t{record.text}")
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def cmd_memory_search(args: argparse.Namespace) -> None:
|
|
832
|
+
workspace = workspace_from_args(args)
|
|
833
|
+
results = MemoryStore(workspace).search(args.query, kind=args.kind, limit=args.limit)
|
|
834
|
+
if not results:
|
|
835
|
+
print("no matching memory")
|
|
836
|
+
return
|
|
837
|
+
for item in results:
|
|
838
|
+
print(f"{item.record.id}\t{item.record.kind}\tscore={item.score}\t{item.record.text}")
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def cmd_memory_audit(args: argparse.Namespace) -> None:
|
|
842
|
+
workspace = workspace_from_args(args)
|
|
843
|
+
decisions = sorted(MemoryStore(workspace).retention_analysis(), key=lambda item: item["retention_key"], reverse=True)
|
|
844
|
+
if args.json:
|
|
845
|
+
print_json({"count": len(decisions), "decisions": [strip_cli_retention_key(item) for item in decisions]})
|
|
846
|
+
return
|
|
847
|
+
if not decisions:
|
|
848
|
+
print("no memory records")
|
|
849
|
+
return
|
|
850
|
+
for decision in decisions[: args.limit]:
|
|
851
|
+
record = decision["record"]
|
|
852
|
+
recoverability = decision["recoverability"]
|
|
853
|
+
print(
|
|
854
|
+
f"{record['id']}\t{record['kind']}\tscore={decision['score']}\t"
|
|
855
|
+
f"tokens={decision['token_cost']}\trecoverable={recoverability['level']}\t{record['text']}"
|
|
856
|
+
)
|
|
857
|
+
print(f" reasons: {', '.join(decision['reasons'])}")
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def cmd_memory_update(args: argparse.Namespace) -> None:
|
|
861
|
+
workspace = workspace_from_args(args)
|
|
862
|
+
tags = None if args.tags is None else [tag.strip() for tag in args.tags.split(",") if tag.strip()]
|
|
863
|
+
record = MemoryStore(workspace).update(args.record_id, kind=args.kind, text=args.text, tags=tags)
|
|
864
|
+
print(f"updated memory: {record.id} ({record.kind})")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def cmd_memory_forget(args: argparse.Namespace) -> None:
|
|
868
|
+
workspace = workspace_from_args(args)
|
|
869
|
+
removed = MemoryStore(workspace).forget(args.record_id)
|
|
870
|
+
if not removed:
|
|
871
|
+
raise KeyError(f"Memory record not found: {args.record_id}")
|
|
872
|
+
print(f"forgot memory: {args.record_id}")
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def cmd_memory_prune(args: argparse.Namespace) -> None:
|
|
876
|
+
workspace = workspace_from_args(args)
|
|
877
|
+
result = MemoryStore(workspace).prune(
|
|
878
|
+
max_records=args.max_records,
|
|
879
|
+
max_tokens=args.max_tokens,
|
|
880
|
+
dry_run=args.dry_run,
|
|
881
|
+
)
|
|
882
|
+
if args.json:
|
|
883
|
+
print_json(result)
|
|
884
|
+
return
|
|
885
|
+
action = "would archive" if args.dry_run else "archived"
|
|
886
|
+
print(
|
|
887
|
+
f"memory_prune: active_before={result['active_before']} "
|
|
888
|
+
f"kept={result['kept']} {action}={result['candidate_count']} "
|
|
889
|
+
f"kept_tokens={result['kept_tokens']}"
|
|
890
|
+
)
|
|
891
|
+
for decision in result.get("candidate_decisions", [])[:5]:
|
|
892
|
+
record = decision["record"]
|
|
893
|
+
recoverability = decision["recoverability"]
|
|
894
|
+
print(
|
|
895
|
+
f"- {record['id']} {record['kind']} score={decision['score']} "
|
|
896
|
+
f"tokens={decision['token_cost']} recoverable={recoverability['level']}: {record['text']}"
|
|
897
|
+
)
|
|
898
|
+
print(f" reasons: {', '.join(decision['reasons'])}")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def strip_cli_retention_key(decision: dict[str, object]) -> dict[str, object]:
|
|
902
|
+
return {key: value for key, value in decision.items() if key != "retention_key"}
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def cmd_memory_global_push(args: argparse.Namespace) -> None:
|
|
906
|
+
workspace = workspace_from_args(args)
|
|
907
|
+
result = push_global_memories(
|
|
908
|
+
workspace,
|
|
909
|
+
kind=args.kind,
|
|
910
|
+
namespace=args.namespace,
|
|
911
|
+
tag=args.tag,
|
|
912
|
+
dry_run=args.dry_run,
|
|
913
|
+
global_root=Path(args.global_root) if args.global_root else None,
|
|
914
|
+
)
|
|
915
|
+
if args.json:
|
|
916
|
+
print_json(result)
|
|
917
|
+
return
|
|
918
|
+
action = "would copy" if result.get("dry_run") else "copied"
|
|
919
|
+
print(
|
|
920
|
+
f"global_push: {action} {result['candidate_count']} memory record(s) "
|
|
921
|
+
f"to {result['target']} namespace={result['namespace']}"
|
|
922
|
+
)
|
|
923
|
+
print_global_memory_preview(result)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def cmd_memory_global_pull(args: argparse.Namespace) -> None:
|
|
927
|
+
workspace = workspace_from_args(args)
|
|
928
|
+
result = pull_global_memories(
|
|
929
|
+
workspace,
|
|
930
|
+
kind=args.kind,
|
|
931
|
+
namespace=args.namespace,
|
|
932
|
+
source_project=args.source_project,
|
|
933
|
+
tag=args.tag,
|
|
934
|
+
limit=args.limit,
|
|
935
|
+
dry_run=args.dry_run,
|
|
936
|
+
global_root=Path(args.global_root) if args.global_root else None,
|
|
937
|
+
)
|
|
938
|
+
if args.json:
|
|
939
|
+
print_json(result)
|
|
940
|
+
return
|
|
941
|
+
action = "would copy" if result.get("dry_run") else "copied"
|
|
942
|
+
print(f"global_pull: {action} {result['candidate_count']} memory record(s) from {result['source']}")
|
|
943
|
+
print_global_memory_preview(result)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def print_global_memory_preview(result: dict[str, Any], *, limit: int = 5) -> None:
|
|
947
|
+
records = result.get("records", [])
|
|
948
|
+
if not isinstance(records, list) or not records:
|
|
949
|
+
return
|
|
950
|
+
for record in records[:limit]:
|
|
951
|
+
if not isinstance(record, dict):
|
|
952
|
+
continue
|
|
953
|
+
tags = ", ".join(record.get("tags", [])[:6])
|
|
954
|
+
print(f"- {record.get('id')} {record.get('kind')} tags=[{tags}] {record.get('text')}")
|
|
955
|
+
if len(records) > limit:
|
|
956
|
+
print(f"... {len(records) - limit} more")
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def cmd_context(args: argparse.Namespace) -> None:
|
|
960
|
+
workspace = workspace_from_args(args)
|
|
961
|
+
ensure_resume_args(args)
|
|
962
|
+
packet = ContextBuilder(workspace).build(
|
|
963
|
+
args.request,
|
|
964
|
+
args.budget,
|
|
965
|
+
args.profile,
|
|
966
|
+
task_id=args.task,
|
|
967
|
+
resume=args.resume,
|
|
968
|
+
)
|
|
969
|
+
print_json(packet)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def cmd_compare(args: argparse.Namespace) -> None:
|
|
973
|
+
workspace = workspace_from_args(args)
|
|
974
|
+
comparison = ContextBuilder(workspace).compare(args.request, args.budget, args.profile)
|
|
975
|
+
if args.json:
|
|
976
|
+
print_json(comparison)
|
|
977
|
+
return
|
|
978
|
+
|
|
979
|
+
print(f"request: {comparison['request']}")
|
|
980
|
+
print(f"profile: {comparison['profile']}")
|
|
981
|
+
print(f"budget: {comparison['budget']}")
|
|
982
|
+
print(f"kernel_tokens: {comparison['kernel']['estimated_tokens']}")
|
|
983
|
+
print(f"baseline_tokens: {comparison['baseline']['estimated_tokens']}")
|
|
984
|
+
print(f"savings: {comparison['savings']['estimated_tokens']} tokens ({comparison['savings']['percent']}%)")
|
|
985
|
+
print(f"kernel_selected: memory={comparison['kernel']['selected_memory']} skills={comparison['kernel']['selected_skills']}")
|
|
986
|
+
print(f"baseline_loaded: memory={comparison['baseline']['loaded_memory']} skills={comparison['baseline']['loaded_skills']} level={comparison['baseline']['skill_level']}")
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
990
|
+
workspace = workspace_from_args(args)
|
|
991
|
+
ensure_resume_args(args)
|
|
992
|
+
ensure_task_attachable(workspace, args.task)
|
|
993
|
+
trace = AgentRunner(workspace).run(
|
|
994
|
+
args.request,
|
|
995
|
+
provider_name=args.provider,
|
|
996
|
+
budget=args.budget,
|
|
997
|
+
profile=args.profile,
|
|
998
|
+
model=args.model,
|
|
999
|
+
base_url=args.base_url,
|
|
1000
|
+
allow_over_budget=args.allow_over_budget,
|
|
1001
|
+
expect_json=args.expect_json,
|
|
1002
|
+
remember=args.remember,
|
|
1003
|
+
task_id=args.task,
|
|
1004
|
+
resume=args.resume,
|
|
1005
|
+
)
|
|
1006
|
+
print(trace["response"]["text"])
|
|
1007
|
+
print("")
|
|
1008
|
+
print(f"trace: {trace['id']}")
|
|
1009
|
+
print(f"tokens: input={trace['response']['input_tokens']} output={trace['response']['output_tokens']} total={trace['response']['total_tokens']}")
|
|
1010
|
+
print(f"verifier: {'ok' if trace['verifier']['ok'] else 'failed'}")
|
|
1011
|
+
if trace.get("state", {}).get("enabled"):
|
|
1012
|
+
print(f"state: wrote {trace['state']['written_count']} memory record(s)")
|
|
1013
|
+
if args.task:
|
|
1014
|
+
attach_run_to_task(workspace, args.task, trace)
|
|
1015
|
+
print(f"task: attached run {trace['id']} to {args.task}")
|
|
1016
|
+
if args.show_packet:
|
|
1017
|
+
print_json(trace["context_packet"])
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def cmd_agent_run(args: argparse.Namespace) -> None:
|
|
1021
|
+
workspace = workspace_from_args(args)
|
|
1022
|
+
ensure_task_attachable(workspace, args.task)
|
|
1023
|
+
report = AgentLoop(workspace).run(
|
|
1024
|
+
args.request,
|
|
1025
|
+
provider_name=args.provider,
|
|
1026
|
+
budget=args.budget,
|
|
1027
|
+
profile=args.profile,
|
|
1028
|
+
model=args.model,
|
|
1029
|
+
aux_model=args.aux_model,
|
|
1030
|
+
model_routing=args.model_routing,
|
|
1031
|
+
aux_review=args.aux_review,
|
|
1032
|
+
base_url=args.base_url,
|
|
1033
|
+
task_id=args.task,
|
|
1034
|
+
max_steps=args.max_steps,
|
|
1035
|
+
remember=not args.no_remember,
|
|
1036
|
+
allow_over_budget=args.allow_over_budget,
|
|
1037
|
+
expect_json=args.expect_json,
|
|
1038
|
+
)
|
|
1039
|
+
if args.json:
|
|
1040
|
+
print_json(report)
|
|
1041
|
+
return
|
|
1042
|
+
|
|
1043
|
+
print_agent_report(report)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def cmd_chat(args: argparse.Namespace) -> None:
|
|
1047
|
+
workspace = chat_workspace_from_args(args)
|
|
1048
|
+
tasks = TaskStore(workspace)
|
|
1049
|
+
if args.task:
|
|
1050
|
+
ensure_task_attachable(workspace, args.task)
|
|
1051
|
+
task_id = args.task
|
|
1052
|
+
else:
|
|
1053
|
+
task = tasks.start(args.title, goal="Interactive agent chat session")
|
|
1054
|
+
task_id = task["id"]
|
|
1055
|
+
|
|
1056
|
+
last_report: dict[str, Any] | None = None
|
|
1057
|
+
state: dict[str, Any] = {"last_report": None}
|
|
1058
|
+
pending_context: list[str] = []
|
|
1059
|
+
if resolve_chat_ui(args) == "tui":
|
|
1060
|
+
run_chat_loop_tui(workspace, tasks, task_id, args)
|
|
1061
|
+
return
|
|
1062
|
+
|
|
1063
|
+
print_chat_header(workspace, task_id, args)
|
|
1064
|
+
while True:
|
|
1065
|
+
try:
|
|
1066
|
+
request = input(chat_prompt(args)).strip()
|
|
1067
|
+
except EOFError:
|
|
1068
|
+
print("")
|
|
1069
|
+
break
|
|
1070
|
+
except KeyboardInterrupt:
|
|
1071
|
+
print("")
|
|
1072
|
+
print("interrupted")
|
|
1073
|
+
break
|
|
1074
|
+
if not request:
|
|
1075
|
+
continue
|
|
1076
|
+
lowered = request.lower()
|
|
1077
|
+
if lowered in {"/exit", "/quit", "exit", "quit"}:
|
|
1078
|
+
print("bye")
|
|
1079
|
+
break
|
|
1080
|
+
if lowered == "/help":
|
|
1081
|
+
print_chat_help()
|
|
1082
|
+
continue
|
|
1083
|
+
if lowered == "/compact":
|
|
1084
|
+
print_task_brief_panel(tasks, task_id)
|
|
1085
|
+
continue
|
|
1086
|
+
if lowered == "/paste":
|
|
1087
|
+
pasted = read_paste_block()
|
|
1088
|
+
if not pasted:
|
|
1089
|
+
chat_notice("Paste", "No pasted task was captured.")
|
|
1090
|
+
continue
|
|
1091
|
+
request = pasted
|
|
1092
|
+
elif request.startswith("@"):
|
|
1093
|
+
attach_chat_file(workspace, tasks, task_id, request[1:].strip(), pending_context)
|
|
1094
|
+
continue
|
|
1095
|
+
elif request.startswith("!"):
|
|
1096
|
+
run_chat_command(workspace, tasks, task_id, request[1:].strip(), pending_context)
|
|
1097
|
+
continue
|
|
1098
|
+
if lowered == "/status":
|
|
1099
|
+
print_status_panel(workspace, task_id, args)
|
|
1100
|
+
continue
|
|
1101
|
+
if lowered == "/config":
|
|
1102
|
+
print_config_panel()
|
|
1103
|
+
continue
|
|
1104
|
+
if lowered == "/task":
|
|
1105
|
+
print_json(tasks.get(task_id))
|
|
1106
|
+
continue
|
|
1107
|
+
if lowered == "/model":
|
|
1108
|
+
print_model_panel(args)
|
|
1109
|
+
continue
|
|
1110
|
+
if lowered == "/runs":
|
|
1111
|
+
print_recent_agent_runs(workspace, limit=5)
|
|
1112
|
+
continue
|
|
1113
|
+
if lowered == "/clear":
|
|
1114
|
+
clear_chat_screen()
|
|
1115
|
+
print_chat_header(workspace, task_id, args)
|
|
1116
|
+
continue
|
|
1117
|
+
if lowered == "/cost":
|
|
1118
|
+
if last_report is None:
|
|
1119
|
+
print("no agent run yet")
|
|
1120
|
+
else:
|
|
1121
|
+
print(render_agent_cost_report(build_agent_cost_report(last_report)))
|
|
1122
|
+
continue
|
|
1123
|
+
|
|
1124
|
+
request_for_agent = merge_pending_context(request, pending_context)
|
|
1125
|
+
pending_context.clear()
|
|
1126
|
+
print_chat_turn_start(request_for_agent, args)
|
|
1127
|
+
last_report = AgentLoop(workspace).run(
|
|
1128
|
+
request_for_agent,
|
|
1129
|
+
provider_name=args.provider,
|
|
1130
|
+
budget=args.budget,
|
|
1131
|
+
profile=args.profile,
|
|
1132
|
+
model=args.model,
|
|
1133
|
+
aux_model=args.aux_model,
|
|
1134
|
+
model_routing=args.model_routing,
|
|
1135
|
+
aux_review=args.aux_review,
|
|
1136
|
+
base_url=args.base_url,
|
|
1137
|
+
task_id=task_id,
|
|
1138
|
+
max_steps=args.max_steps,
|
|
1139
|
+
remember=not args.no_remember,
|
|
1140
|
+
allow_over_budget=args.allow_over_budget,
|
|
1141
|
+
expect_json=args.expect_json,
|
|
1142
|
+
)
|
|
1143
|
+
print_chat_report(last_report)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def print_chat_header(workspace: Workspace, task_id: str, args: argparse.Namespace) -> None:
|
|
1147
|
+
model = primary_model(args)
|
|
1148
|
+
base_url = args.base_url or env_value("CONTEXT_KERNEL_OPENAI_BASE_URL") or ""
|
|
1149
|
+
api_key_set = bool(env_value("CONTEXT_KERNEL_OPENAI_API_KEY"))
|
|
1150
|
+
chat_banner(
|
|
1151
|
+
"Context Kernel Agent",
|
|
1152
|
+
"Token-frugal task runner for long-lived AI workspaces.",
|
|
1153
|
+
)
|
|
1154
|
+
chat_panel(
|
|
1155
|
+
"Session",
|
|
1156
|
+
[
|
|
1157
|
+
("cwd", compact_path(Path.cwd())),
|
|
1158
|
+
("workspace", compact_path(workspace.root)),
|
|
1159
|
+
("task", task_id),
|
|
1160
|
+
("provider", args.provider),
|
|
1161
|
+
("primary", model),
|
|
1162
|
+
("auxiliary", auxiliary_model(args)),
|
|
1163
|
+
("routing", args.model_routing),
|
|
1164
|
+
("review", args.aux_review),
|
|
1165
|
+
("profile", args.profile),
|
|
1166
|
+
("loop", f"max {args.max_steps} steps per message"),
|
|
1167
|
+
("state", workspace_state_summary(workspace)),
|
|
1168
|
+
],
|
|
1169
|
+
)
|
|
1170
|
+
if args.provider == "openai" and (not api_key_set or not base_url):
|
|
1171
|
+
chat_notice("Setup needed", "Run `akernel setup` before sending OpenAI-backed tasks.")
|
|
1172
|
+
chat_panel(
|
|
1173
|
+
"Start",
|
|
1174
|
+
[
|
|
1175
|
+
("type", "Describe a task in natural language and press Enter."),
|
|
1176
|
+
("include", "@path attaches a workspace file; !cmd runs a policy-checked command."),
|
|
1177
|
+
("compose", "/paste captures a multi-line task; /compact shows the task brief."),
|
|
1178
|
+
("inspect", "/status, /model, /task, /runs, /cost"),
|
|
1179
|
+
("control", "/help, /config, /clear, /exit"),
|
|
1180
|
+
],
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def resolve_chat_ui(args: argparse.Namespace) -> str:
|
|
1185
|
+
requested = getattr(args, "ui", "auto")
|
|
1186
|
+
if requested in {"classic", "tui"}:
|
|
1187
|
+
return requested
|
|
1188
|
+
if os.environ.get("AKERNEL_UI"):
|
|
1189
|
+
value = os.environ["AKERNEL_UI"].strip().lower()
|
|
1190
|
+
if value in {"classic", "tui"}:
|
|
1191
|
+
return value
|
|
1192
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
1193
|
+
return "classic"
|
|
1194
|
+
if os.environ.get("TERM", "").lower() == "dumb":
|
|
1195
|
+
return "classic"
|
|
1196
|
+
return "tui"
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def run_chat_loop_tui(
|
|
1200
|
+
workspace: Workspace,
|
|
1201
|
+
tasks: TaskStore,
|
|
1202
|
+
task_id: str,
|
|
1203
|
+
args: argparse.Namespace,
|
|
1204
|
+
) -> None:
|
|
1205
|
+
last_report: dict[str, Any] | None = None
|
|
1206
|
+
pending_context: list[str] = []
|
|
1207
|
+
transcript: list[dict[str, str]] = [
|
|
1208
|
+
{
|
|
1209
|
+
"role": "system",
|
|
1210
|
+
"title": "Welcome",
|
|
1211
|
+
"text": "Describe a task, attach files with @path, run safe commands with !command, or type /help.",
|
|
1212
|
+
}
|
|
1213
|
+
]
|
|
1214
|
+
use_alt_screen = sys.stdout.isatty() and not os.environ.get("AKERNEL_NO_ALT_SCREEN")
|
|
1215
|
+
if use_alt_screen:
|
|
1216
|
+
print("\033[?1049h", end="")
|
|
1217
|
+
try:
|
|
1218
|
+
render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="ready")
|
|
1219
|
+
while True:
|
|
1220
|
+
try:
|
|
1221
|
+
request = input(tui_prompt(args)).strip()
|
|
1222
|
+
except EOFError:
|
|
1223
|
+
break
|
|
1224
|
+
except KeyboardInterrupt:
|
|
1225
|
+
transcript.append({"role": "system", "title": "Interrupted", "text": "Keyboard interrupt received."})
|
|
1226
|
+
render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="interrupted")
|
|
1227
|
+
break
|
|
1228
|
+
if not request:
|
|
1229
|
+
render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="ready")
|
|
1230
|
+
continue
|
|
1231
|
+
lowered = request.lower()
|
|
1232
|
+
if lowered in {"/exit", "/quit", "exit", "quit"}:
|
|
1233
|
+
break
|
|
1234
|
+
if lowered == "/clear":
|
|
1235
|
+
transcript.clear()
|
|
1236
|
+
render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="cleared")
|
|
1237
|
+
continue
|
|
1238
|
+
state["last_report"] = last_report
|
|
1239
|
+
if handle_tui_command(
|
|
1240
|
+
request,
|
|
1241
|
+
lowered,
|
|
1242
|
+
workspace=workspace,
|
|
1243
|
+
tasks=tasks,
|
|
1244
|
+
task_id=task_id,
|
|
1245
|
+
args=args,
|
|
1246
|
+
pending_context=pending_context,
|
|
1247
|
+
transcript=transcript,
|
|
1248
|
+
state=state,
|
|
1249
|
+
):
|
|
1250
|
+
last_report = state.get("last_report")
|
|
1251
|
+
render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="ready")
|
|
1252
|
+
continue
|
|
1253
|
+
|
|
1254
|
+
request_for_agent = merge_pending_context(request, pending_context)
|
|
1255
|
+
pending_context.clear()
|
|
1256
|
+
transcript.append({"role": "user", "title": "You", "text": request_for_agent})
|
|
1257
|
+
render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="running")
|
|
1258
|
+
last_report = AgentLoop(workspace).run(
|
|
1259
|
+
request_for_agent,
|
|
1260
|
+
provider_name=args.provider,
|
|
1261
|
+
budget=args.budget,
|
|
1262
|
+
profile=args.profile,
|
|
1263
|
+
model=args.model,
|
|
1264
|
+
aux_model=args.aux_model,
|
|
1265
|
+
model_routing=args.model_routing,
|
|
1266
|
+
aux_review=args.aux_review,
|
|
1267
|
+
base_url=args.base_url,
|
|
1268
|
+
task_id=task_id,
|
|
1269
|
+
max_steps=args.max_steps,
|
|
1270
|
+
remember=not args.no_remember,
|
|
1271
|
+
allow_over_budget=args.allow_over_budget,
|
|
1272
|
+
expect_json=args.expect_json,
|
|
1273
|
+
)
|
|
1274
|
+
transcript.append({"role": "assistant", "title": "Assistant", "text": format_tui_report(last_report)})
|
|
1275
|
+
render_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status="ready")
|
|
1276
|
+
finally:
|
|
1277
|
+
if use_alt_screen:
|
|
1278
|
+
print("\033[?1049l", end="")
|
|
1279
|
+
print("bye")
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def handle_tui_command(
|
|
1283
|
+
request: str,
|
|
1284
|
+
lowered: str,
|
|
1285
|
+
*,
|
|
1286
|
+
workspace: Workspace,
|
|
1287
|
+
tasks: TaskStore,
|
|
1288
|
+
task_id: str,
|
|
1289
|
+
args: argparse.Namespace,
|
|
1290
|
+
pending_context: list[str],
|
|
1291
|
+
transcript: list[dict[str, str]],
|
|
1292
|
+
state: dict[str, Any],
|
|
1293
|
+
) -> bool:
|
|
1294
|
+
last_report = state.get("last_report")
|
|
1295
|
+
if lowered == "/help":
|
|
1296
|
+
transcript.append({"role": "system", "title": "Help", "text": format_chat_help_text()})
|
|
1297
|
+
return True
|
|
1298
|
+
if lowered == "/compact":
|
|
1299
|
+
transcript.append({"role": "system", "title": "Compact Brief", "text": capture_chat_output(lambda: print_task_brief_panel(tasks, task_id))})
|
|
1300
|
+
return True
|
|
1301
|
+
if lowered == "/status":
|
|
1302
|
+
transcript.append({"role": "system", "title": "Status", "text": capture_chat_output(lambda: print_status_panel(workspace, task_id, args))})
|
|
1303
|
+
return True
|
|
1304
|
+
if lowered == "/config":
|
|
1305
|
+
transcript.append({"role": "system", "title": "Config", "text": capture_chat_output(print_config_panel)})
|
|
1306
|
+
return True
|
|
1307
|
+
if lowered == "/task":
|
|
1308
|
+
transcript.append({"role": "system", "title": "Task", "text": json.dumps(tasks.get(task_id), indent=2, ensure_ascii=False)})
|
|
1309
|
+
return True
|
|
1310
|
+
if lowered == "/model":
|
|
1311
|
+
transcript.append({"role": "system", "title": "Model Roles", "text": capture_chat_output(lambda: print_model_panel(args))})
|
|
1312
|
+
return True
|
|
1313
|
+
if lowered == "/runs":
|
|
1314
|
+
transcript.append({"role": "system", "title": "Recent Runs", "text": capture_chat_output(lambda: print_recent_agent_runs(workspace, limit=5))})
|
|
1315
|
+
return True
|
|
1316
|
+
if lowered == "/cost":
|
|
1317
|
+
text = "no agent run yet" if last_report is None else render_agent_cost_report(build_agent_cost_report(last_report))
|
|
1318
|
+
transcript.append({"role": "system", "title": "Cost", "text": text})
|
|
1319
|
+
return True
|
|
1320
|
+
if lowered == "/paste":
|
|
1321
|
+
transcript.append({"role": "system", "title": "Paste", "text": "Paste mode uses the terminal below. Finish with /end."})
|
|
1322
|
+
pasted = read_paste_block()
|
|
1323
|
+
if pasted:
|
|
1324
|
+
transcript.append({"role": "user", "title": "Pasted Task", "text": pasted})
|
|
1325
|
+
request_for_agent = merge_pending_context(pasted, pending_context)
|
|
1326
|
+
pending_context.clear()
|
|
1327
|
+
report = AgentLoop(workspace).run(
|
|
1328
|
+
request_for_agent,
|
|
1329
|
+
provider_name=args.provider,
|
|
1330
|
+
budget=args.budget,
|
|
1331
|
+
profile=args.profile,
|
|
1332
|
+
model=args.model,
|
|
1333
|
+
aux_model=args.aux_model,
|
|
1334
|
+
model_routing=args.model_routing,
|
|
1335
|
+
aux_review=args.aux_review,
|
|
1336
|
+
base_url=args.base_url,
|
|
1337
|
+
task_id=task_id,
|
|
1338
|
+
max_steps=args.max_steps,
|
|
1339
|
+
remember=not args.no_remember,
|
|
1340
|
+
allow_over_budget=args.allow_over_budget,
|
|
1341
|
+
expect_json=args.expect_json,
|
|
1342
|
+
)
|
|
1343
|
+
state["last_report"] = report
|
|
1344
|
+
transcript.append({"role": "assistant", "title": "Assistant", "text": format_tui_report(report)})
|
|
1345
|
+
return True
|
|
1346
|
+
if request.startswith("@"):
|
|
1347
|
+
text = capture_chat_output(lambda: attach_chat_file(workspace, tasks, task_id, request[1:].strip(), pending_context))
|
|
1348
|
+
transcript.append({"role": "system", "title": "Attach File", "text": text})
|
|
1349
|
+
return True
|
|
1350
|
+
if request.startswith("!"):
|
|
1351
|
+
text = capture_chat_output(lambda: run_chat_command(workspace, tasks, task_id, request[1:].strip(), pending_context))
|
|
1352
|
+
transcript.append({"role": "system", "title": "Command", "text": text})
|
|
1353
|
+
return True
|
|
1354
|
+
return False
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def capture_chat_output(func: Any) -> str:
|
|
1358
|
+
buffer = io.StringIO()
|
|
1359
|
+
with redirect_stdout(buffer):
|
|
1360
|
+
func()
|
|
1361
|
+
return buffer.getvalue().strip()
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def format_chat_help_text() -> str:
|
|
1365
|
+
rows = [
|
|
1366
|
+
("/help", "show this command palette"),
|
|
1367
|
+
("/status", "show workspace and runtime status"),
|
|
1368
|
+
("/model", "show primary and auxiliary model roles"),
|
|
1369
|
+
("/config", "show setup and environment guidance"),
|
|
1370
|
+
("/compact", "show the compact task brief used for resume context"),
|
|
1371
|
+
("/paste", "enter a multi-line task; finish with /end"),
|
|
1372
|
+
("@path", "attach a workspace file to the next task"),
|
|
1373
|
+
("!command", "run a policy-checked command and attach its summary"),
|
|
1374
|
+
("/task", "print the current task session JSON"),
|
|
1375
|
+
("/runs", "list recent agent runs"),
|
|
1376
|
+
("/cost", "print the last agent run cost report"),
|
|
1377
|
+
("/clear", "clear the transcript"),
|
|
1378
|
+
("/exit", "leave the interactive session"),
|
|
1379
|
+
]
|
|
1380
|
+
return "\n".join(f"{name:<10} {description}" for name, description in rows)
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def tui_prompt(args: argparse.Namespace) -> str:
|
|
1384
|
+
return chat_color("\nakernel", "cyan", bold=True) + chat_color(f" [{primary_model(args)}]", "dim") + "> "
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
def render_chat_tui_screen(
|
|
1388
|
+
workspace: Workspace,
|
|
1389
|
+
task_id: str,
|
|
1390
|
+
args: argparse.Namespace,
|
|
1391
|
+
transcript: list[dict[str, str]],
|
|
1392
|
+
last_report: dict[str, Any] | None,
|
|
1393
|
+
pending_context: list[str],
|
|
1394
|
+
*,
|
|
1395
|
+
status: str,
|
|
1396
|
+
) -> None:
|
|
1397
|
+
screen = build_chat_tui_screen(workspace, task_id, args, transcript, last_report, pending_context, status=status)
|
|
1398
|
+
print("\033[2J\033[H" + screen, end="")
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def build_chat_tui_screen(
|
|
1402
|
+
workspace: Workspace,
|
|
1403
|
+
task_id: str,
|
|
1404
|
+
args: argparse.Namespace,
|
|
1405
|
+
transcript: list[dict[str, str]],
|
|
1406
|
+
last_report: dict[str, Any] | None,
|
|
1407
|
+
pending_context: list[str],
|
|
1408
|
+
*,
|
|
1409
|
+
status: str,
|
|
1410
|
+
) -> str:
|
|
1411
|
+
width = chat_width()
|
|
1412
|
+
height = max(24, shutil.get_terminal_size((width, 32)).lines)
|
|
1413
|
+
right_width = min(40, max(32, width // 3))
|
|
1414
|
+
left_width = max(46, width - right_width - 3)
|
|
1415
|
+
header = tui_header_lines(workspace, args, last_report, status=status, width=width)
|
|
1416
|
+
footer = tui_footer_lines(width)
|
|
1417
|
+
body_height = max(10, height - len(header) - len(footer) - 1)
|
|
1418
|
+
lines = header
|
|
1419
|
+
body = tui_body_lines(transcript, left_width, status=status)
|
|
1420
|
+
side = tui_sidebar_lines(workspace, task_id, args, last_report, pending_context, right_width)
|
|
1421
|
+
body = body[-body_height:]
|
|
1422
|
+
side = side[:body_height]
|
|
1423
|
+
for index in range(body_height):
|
|
1424
|
+
left = body[index] if index < len(body) else ""
|
|
1425
|
+
right = side[index] if index < len(side) else ""
|
|
1426
|
+
lines.append(f"{left:<{left_width}} {chat_color('|', 'dim')} {right:<{right_width}}")
|
|
1427
|
+
lines.extend(footer)
|
|
1428
|
+
return "\n".join(lines)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def tui_header_lines(
|
|
1432
|
+
workspace: Workspace,
|
|
1433
|
+
args: argparse.Namespace,
|
|
1434
|
+
last_report: dict[str, Any] | None,
|
|
1435
|
+
*,
|
|
1436
|
+
status: str,
|
|
1437
|
+
width: int,
|
|
1438
|
+
) -> list[str]:
|
|
1439
|
+
status_label = status.upper()
|
|
1440
|
+
status_color = "green" if status == "ready" else "yellow" if status == "running" else "cyan"
|
|
1441
|
+
tokens = 0 if not last_report else last_report.get("totals", {}).get("total_tokens", 0)
|
|
1442
|
+
title = f" Context Kernel TUI // AKERNEL // {status_label} "
|
|
1443
|
+
subtitle = (
|
|
1444
|
+
f"{compact_path(workspace.root)} | provider={args.provider} | "
|
|
1445
|
+
f"primary={primary_model(args)} | aux={auxiliary_model(args)} | last_tokens={tokens}"
|
|
1446
|
+
)
|
|
1447
|
+
return [
|
|
1448
|
+
chat_color(tui_rule(title, width), status_color, bold=True),
|
|
1449
|
+
truncate_line(subtitle, width),
|
|
1450
|
+
tui_command_strip(width),
|
|
1451
|
+
]
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def tui_footer_lines(width: int) -> list[str]:
|
|
1455
|
+
return [
|
|
1456
|
+
tui_rule(" Input ", width),
|
|
1457
|
+
truncate_line("Type a task. Use /help for palette, /compact for resume brief, @path for files, !command for checked shell, /exit to quit.", width),
|
|
1458
|
+
"",
|
|
1459
|
+
]
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def tui_command_strip(width: int) -> str:
|
|
1463
|
+
commands = " /help /status /model /compact /runs /cost @file !cmd "
|
|
1464
|
+
return chat_color(truncate_line(commands.center(width, "-"), width), "dim")
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def tui_body_lines(transcript: list[dict[str, str]], width: int, *, status: str = "ready") -> list[str]:
|
|
1468
|
+
if not transcript:
|
|
1469
|
+
return ["No messages yet. Start with one concrete task."]
|
|
1470
|
+
lines: list[str] = []
|
|
1471
|
+
lines.append(f"Transcript [{status}]")
|
|
1472
|
+
lines.append("-" * min(width, 22))
|
|
1473
|
+
for item in transcript:
|
|
1474
|
+
title = item.get("title", item.get("role", "message"))
|
|
1475
|
+
role = item.get("role", "system")
|
|
1476
|
+
label = tui_role_label(role, title)
|
|
1477
|
+
lines.append("")
|
|
1478
|
+
lines.append(truncate_line(f"+-- {label} " + "-" * max(0, width - len(label) - 5), width))
|
|
1479
|
+
prefix = "| " if role != "user" else "> "
|
|
1480
|
+
for line in wrap_plain(item.get("text", ""), width=max(20, width - len(prefix))).splitlines():
|
|
1481
|
+
lines.append(truncate_line(prefix + line, width))
|
|
1482
|
+
lines.append("")
|
|
1483
|
+
return lines
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def tui_role_label(role: str, title: str) -> str:
|
|
1487
|
+
labels = {
|
|
1488
|
+
"user": "YOU",
|
|
1489
|
+
"assistant": "AGENT",
|
|
1490
|
+
"system": "SYSTEM",
|
|
1491
|
+
}
|
|
1492
|
+
base = labels.get(role, role.upper())
|
|
1493
|
+
return f"{base}: {title}" if title and title.casefold() != base.casefold() else base
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def tui_sidebar_lines(
|
|
1497
|
+
workspace: Workspace,
|
|
1498
|
+
task_id: str,
|
|
1499
|
+
args: argparse.Namespace,
|
|
1500
|
+
last_report: dict[str, Any] | None,
|
|
1501
|
+
pending_context: list[str],
|
|
1502
|
+
width: int,
|
|
1503
|
+
) -> list[str]:
|
|
1504
|
+
rows = tui_section("Cockpit", width)
|
|
1505
|
+
rows.extend(
|
|
1506
|
+
[
|
|
1507
|
+
f"provider: {args.provider}",
|
|
1508
|
+
f"profile: {getattr(args, 'profile', DEFAULT_PROFILE)}",
|
|
1509
|
+
f"routing: {getattr(args, 'model_routing', 'auto')}",
|
|
1510
|
+
f"steps: {getattr(args, 'max_steps', '?')}",
|
|
1511
|
+
f"pending: {len(pending_context)}",
|
|
1512
|
+
"",
|
|
1513
|
+
]
|
|
1514
|
+
)
|
|
1515
|
+
rows.extend(tui_section("Model Stack", width))
|
|
1516
|
+
rows.extend(
|
|
1517
|
+
[
|
|
1518
|
+
f"primary: {primary_model(args)}",
|
|
1519
|
+
f"auxiliary: {auxiliary_model(args)}",
|
|
1520
|
+
f"review: {getattr(args, 'aux_review', 'auto')}",
|
|
1521
|
+
"",
|
|
1522
|
+
]
|
|
1523
|
+
)
|
|
1524
|
+
rows.extend(tui_task_panel(workspace, task_id, width))
|
|
1525
|
+
if last_report:
|
|
1526
|
+
rows.extend(tui_last_run_panel(last_report, width))
|
|
1527
|
+
diagnostic = last_report.get("diagnostic")
|
|
1528
|
+
if isinstance(diagnostic, dict) and diagnostic:
|
|
1529
|
+
rows.extend([""])
|
|
1530
|
+
rows.extend(tui_section("Diagnostic", width))
|
|
1531
|
+
rows.append(str(diagnostic.get("category", "")))
|
|
1532
|
+
rows.extend(wrap_plain(str(diagnostic.get("suggestion", "")), width=max(20, width)).splitlines())
|
|
1533
|
+
rows.append("")
|
|
1534
|
+
rows.extend(tui_section("Workspace", width))
|
|
1535
|
+
rows.extend(wrap_plain(workspace_state_summary(workspace), width=max(20, width)).splitlines())
|
|
1536
|
+
return [truncate_line(line, width) for line in rows]
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def tui_section(title: str, width: int) -> list[str]:
|
|
1540
|
+
label = f"[ {title} ]"
|
|
1541
|
+
return [label, "-" * min(width, len(label) + 6)]
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def tui_task_panel(workspace: Workspace, task_id: str, width: int) -> list[str]:
|
|
1545
|
+
rows = tui_section("Mission", width)
|
|
1546
|
+
try:
|
|
1547
|
+
task = TaskStore(workspace).get(task_id)
|
|
1548
|
+
except (KeyError, FileNotFoundError):
|
|
1549
|
+
rows.extend([f"task {task_id}", "status unknown", ""])
|
|
1550
|
+
return rows
|
|
1551
|
+
rows.extend(
|
|
1552
|
+
[
|
|
1553
|
+
f"task: {task.get('id', task_id)}",
|
|
1554
|
+
f"status: {task.get('status', 'unknown')}",
|
|
1555
|
+
f"title: {truncate_line(str(task.get('title', '')), max(10, width - 10))}",
|
|
1556
|
+
]
|
|
1557
|
+
)
|
|
1558
|
+
plan = task.get("plan")
|
|
1559
|
+
if isinstance(plan, dict):
|
|
1560
|
+
progress = plan.get("milestones", [])
|
|
1561
|
+
completed = sum(1 for item in progress if item.get("status") == "completed")
|
|
1562
|
+
active = next((item for item in progress if item.get("status") == "active"), None)
|
|
1563
|
+
rows.append(f"plan: {completed}/{len(progress)} done")
|
|
1564
|
+
if active:
|
|
1565
|
+
rows.append(f"active: {active.get('id')} {truncate_line(str(active.get('title', '')), max(8, width - 13))}")
|
|
1566
|
+
rows.append("")
|
|
1567
|
+
return rows
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def tui_last_run_panel(report: dict[str, Any], width: int) -> list[str]:
|
|
1571
|
+
rows = tui_section("Last Run Timeline", width)
|
|
1572
|
+
rows.extend(
|
|
1573
|
+
[
|
|
1574
|
+
f"id: {report.get('id')}",
|
|
1575
|
+
f"status: {report.get('status')}",
|
|
1576
|
+
f"tokens: {report.get('totals', {}).get('total_tokens', 0)}",
|
|
1577
|
+
]
|
|
1578
|
+
)
|
|
1579
|
+
steps = report.get("steps", [])
|
|
1580
|
+
if steps:
|
|
1581
|
+
compact_actions = " -> ".join(str((step.get("action") or {}).get("action") or "none") for step in steps)
|
|
1582
|
+
rows.append(f"actions: {truncate_line(compact_actions, max(10, width - 9))}")
|
|
1583
|
+
for step in steps[:4]:
|
|
1584
|
+
action = str((step.get("action") or {}).get("action") or "none")
|
|
1585
|
+
ok = "ok" if step.get("verifier_ok", True) else "check"
|
|
1586
|
+
rows.append(f" {step.get('index', '?')}. {action} [{ok}]")
|
|
1587
|
+
if len(steps) > 4:
|
|
1588
|
+
rows.append(f" ... +{len(steps) - 4} more")
|
|
1589
|
+
return rows
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def format_tui_report(report: dict[str, Any]) -> str:
|
|
1593
|
+
actions = " -> ".join(str((step.get("action") or {}).get("action") or "none") for step in report.get("steps", []))
|
|
1594
|
+
parts = [
|
|
1595
|
+
f"status: {report.get('status')}",
|
|
1596
|
+
f"agent_run: {report.get('id')}",
|
|
1597
|
+
f"steps: {len(report.get('steps', []))}/{report.get('max_steps')}",
|
|
1598
|
+
f"tokens: {report.get('totals', {}).get('total_tokens', 0)}",
|
|
1599
|
+
]
|
|
1600
|
+
if actions:
|
|
1601
|
+
parts.append(f"actions: {actions}")
|
|
1602
|
+
diagnostic = report.get("diagnostic")
|
|
1603
|
+
if isinstance(diagnostic, dict) and diagnostic:
|
|
1604
|
+
parts.append(f"diagnostic: {diagnostic.get('category')}")
|
|
1605
|
+
parts.append(f"next: {diagnostic.get('suggestion')}")
|
|
1606
|
+
if report.get("final_response"):
|
|
1607
|
+
parts.append("")
|
|
1608
|
+
parts.append(str(report["final_response"]))
|
|
1609
|
+
return "\n".join(parts)
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def tui_rule(title: str, width: int) -> str:
|
|
1613
|
+
text = title[:width]
|
|
1614
|
+
remaining = max(0, width - len(text))
|
|
1615
|
+
left = remaining // 2
|
|
1616
|
+
right = remaining - left
|
|
1617
|
+
return "=" * left + text + "=" * right
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def wrap_plain(text: str, *, width: int) -> str:
|
|
1621
|
+
return "\n".join(wrap_chat_text(text, width=width).splitlines())
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def truncate_line(text: str, width: int) -> str:
|
|
1625
|
+
value = str(text)
|
|
1626
|
+
return value if len(value) <= width else value[: max(0, width - 3)] + "..."
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def print_chat_turn_start(request: str, args: argparse.Namespace) -> None:
|
|
1630
|
+
preview = request if len(request) <= chat_width() - 14 else request[: chat_width() - 17] + "..."
|
|
1631
|
+
print("")
|
|
1632
|
+
print(chat_rule("New Task"))
|
|
1633
|
+
print(chat_color(f"you {preview}", "bold"))
|
|
1634
|
+
print(chat_color("agent building minimal context -> planning -> running bounded loop", "dim"))
|
|
1635
|
+
print(chat_color(f"runtime provider={args.provider} max_steps={args.max_steps}", "dim"))
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def print_chat_report(report: dict[str, Any]) -> None:
|
|
1639
|
+
actions = [
|
|
1640
|
+
str((step.get("action") or {}).get("action") or "none")
|
|
1641
|
+
for step in report.get("steps", [])
|
|
1642
|
+
]
|
|
1643
|
+
print("")
|
|
1644
|
+
print(chat_rule("Result"))
|
|
1645
|
+
chat_panel(
|
|
1646
|
+
"Run Summary",
|
|
1647
|
+
[
|
|
1648
|
+
("status", str(report["status"])),
|
|
1649
|
+
("steps", f"{len(report['steps'])}/{report['max_steps']}"),
|
|
1650
|
+
("tokens", str(report["totals"]["total_tokens"])),
|
|
1651
|
+
("agent_run:", str(report["id"])),
|
|
1652
|
+
],
|
|
1653
|
+
)
|
|
1654
|
+
if actions:
|
|
1655
|
+
print(chat_color("Actions", "cyan"))
|
|
1656
|
+
print(wrap_chat_text(" -> ".join(actions), indent=" "))
|
|
1657
|
+
print(chat_color("Models", "cyan"))
|
|
1658
|
+
print(wrap_chat_text(model_routing_summary(report), indent=" "))
|
|
1659
|
+
review_text = aux_review_summary(report)
|
|
1660
|
+
if review_text:
|
|
1661
|
+
print(chat_color("Review", "cyan"))
|
|
1662
|
+
print(wrap_chat_text(review_text, indent=" "))
|
|
1663
|
+
if report.get("state", {}).get("enabled"):
|
|
1664
|
+
print(chat_color(f"Memory wrote {report['state']['written_count']} record(s)", "dim"))
|
|
1665
|
+
if report.get("final_response"):
|
|
1666
|
+
print("")
|
|
1667
|
+
print(chat_color("Assistant", "green", bold=True))
|
|
1668
|
+
print(wrap_chat_text(str(report["final_response"]), indent=" "))
|
|
1669
|
+
print("")
|
|
1670
|
+
print(chat_color(f"Next /cost for cost report | akernel agent show {report['id']} for trace", "dim"))
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def print_chat_help() -> None:
|
|
1674
|
+
chat_panel(
|
|
1675
|
+
"Command Palette",
|
|
1676
|
+
[
|
|
1677
|
+
("/help", "show this command palette"),
|
|
1678
|
+
("/status", "show workspace and runtime status"),
|
|
1679
|
+
("/model", "show primary and auxiliary model roles"),
|
|
1680
|
+
("/config", "show setup and environment guidance"),
|
|
1681
|
+
("/compact", "show the compact task brief used for resume context"),
|
|
1682
|
+
("/paste", "enter a multi-line task; finish with /end"),
|
|
1683
|
+
("@path", "attach a workspace file to the next task"),
|
|
1684
|
+
("!command", "run a policy-checked command and attach its summary"),
|
|
1685
|
+
("/task", "print the current task session JSON"),
|
|
1686
|
+
("/runs", "list recent agent runs"),
|
|
1687
|
+
("/cost", "print the last agent run cost report"),
|
|
1688
|
+
("/clear", "clear and redraw the session header"),
|
|
1689
|
+
("/exit", "leave the interactive session"),
|
|
1690
|
+
],
|
|
1691
|
+
)
|
|
1692
|
+
print(chat_color("Tip Ask one concrete task at a time; Context Kernel keeps the packet lean.", "dim"))
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def print_recent_agent_runs(workspace: Workspace, *, limit: int) -> None:
|
|
1696
|
+
reports = list_agent_reports(workspace)[:limit]
|
|
1697
|
+
if not reports:
|
|
1698
|
+
chat_notice("Recent Runs", "No agent runs yet.")
|
|
1699
|
+
return
|
|
1700
|
+
print(chat_rule("Recent Runs"))
|
|
1701
|
+
for report in reports:
|
|
1702
|
+
request = str(report.get("request", ""))
|
|
1703
|
+
if len(request) > 52:
|
|
1704
|
+
request = request[:49] + "..."
|
|
1705
|
+
print(
|
|
1706
|
+
f" {report['id']} "
|
|
1707
|
+
f"{report.get('status', ''):<10} "
|
|
1708
|
+
f"steps={len(report.get('steps', [])):<2} "
|
|
1709
|
+
f"tokens={report.get('totals', {}).get('total_tokens', 0):<5} "
|
|
1710
|
+
f"{request}"
|
|
1711
|
+
)
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def attach_chat_file(
|
|
1715
|
+
workspace: Workspace,
|
|
1716
|
+
tasks: TaskStore,
|
|
1717
|
+
task_id: str,
|
|
1718
|
+
path: str,
|
|
1719
|
+
pending_context: list[str],
|
|
1720
|
+
) -> None:
|
|
1721
|
+
if not path:
|
|
1722
|
+
chat_notice("Attach File", "Usage: @relative/path.txt")
|
|
1723
|
+
return
|
|
1724
|
+
result = ToolExecutor(workspace).read_file(path)
|
|
1725
|
+
tasks.attach(task_id, "tool", result["id"])
|
|
1726
|
+
summary = summarize_tool_result(result)
|
|
1727
|
+
tasks.step(
|
|
1728
|
+
task_id,
|
|
1729
|
+
f"User attached file {path}: {summary}",
|
|
1730
|
+
kind="chat_file",
|
|
1731
|
+
refs={"tool_traces": [result["id"]]},
|
|
1732
|
+
)
|
|
1733
|
+
if result["ok"] and not result["blocked"]:
|
|
1734
|
+
output = result.get("output", {})
|
|
1735
|
+
content = str(output.get("content", ""))
|
|
1736
|
+
pending_context.append(
|
|
1737
|
+
f"Attached file `{path}` ({output.get('size_chars', len(content))} chars, "
|
|
1738
|
+
f"truncated={bool(output.get('truncated'))}):\n{content}"
|
|
1739
|
+
)
|
|
1740
|
+
chat_notice("Attached File", f"{path} is attached to the next task.")
|
|
1741
|
+
else:
|
|
1742
|
+
chat_notice("Attach Failed", summary)
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def run_chat_command(
|
|
1746
|
+
workspace: Workspace,
|
|
1747
|
+
tasks: TaskStore,
|
|
1748
|
+
task_id: str,
|
|
1749
|
+
command: str,
|
|
1750
|
+
pending_context: list[str],
|
|
1751
|
+
) -> None:
|
|
1752
|
+
if not command:
|
|
1753
|
+
chat_notice("Command", "Usage: !python -c \"print(123)\"")
|
|
1754
|
+
return
|
|
1755
|
+
result = ToolExecutor(workspace).run_command(command)
|
|
1756
|
+
tasks.attach(task_id, "tool", result["id"])
|
|
1757
|
+
summary = summarize_tool_result(result)
|
|
1758
|
+
tasks.step(
|
|
1759
|
+
task_id,
|
|
1760
|
+
f"User ran command `{command}`: {summary}",
|
|
1761
|
+
kind="chat_command",
|
|
1762
|
+
refs={"tool_traces": [result["id"]]},
|
|
1763
|
+
)
|
|
1764
|
+
output = result.get("output", {})
|
|
1765
|
+
pending_context.append(
|
|
1766
|
+
"Command result attached to the next task:\n"
|
|
1767
|
+
f"command: {command}\n"
|
|
1768
|
+
f"ok: {result.get('ok')}\n"
|
|
1769
|
+
f"blocked: {result.get('blocked')}\n"
|
|
1770
|
+
f"summary: {summary}\n"
|
|
1771
|
+
f"stdout: {str(output.get('stdout', ''))[:1200]}\n"
|
|
1772
|
+
f"stderr: {str(output.get('stderr', ''))[:800]}"
|
|
1773
|
+
)
|
|
1774
|
+
title = "Command Complete" if result.get("ok") else "Command Blocked" if result.get("blocked") else "Command Failed"
|
|
1775
|
+
chat_notice(title, summary)
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def read_paste_block() -> str:
|
|
1779
|
+
print(chat_color("Paste mode. Finish with /end on its own line.", "dim"))
|
|
1780
|
+
lines: list[str] = []
|
|
1781
|
+
while True:
|
|
1782
|
+
try:
|
|
1783
|
+
line = input(chat_color("paste> ", "dim"))
|
|
1784
|
+
except EOFError:
|
|
1785
|
+
break
|
|
1786
|
+
if line.strip().lower() == "/end":
|
|
1787
|
+
break
|
|
1788
|
+
lines.append(line)
|
|
1789
|
+
return "\n".join(lines).strip()
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
def merge_pending_context(request: str, pending_context: list[str]) -> str:
|
|
1793
|
+
if not pending_context:
|
|
1794
|
+
return request
|
|
1795
|
+
context = "\n\n".join(pending_context)
|
|
1796
|
+
return (
|
|
1797
|
+
"Use the attached local context below when helpful. "
|
|
1798
|
+
"Do not assume it is complete if the task needs more evidence.\n\n"
|
|
1799
|
+
f"{context}\n\nUser task:\n{request}"
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def print_task_brief_panel(tasks: TaskStore, task_id: str) -> None:
|
|
1804
|
+
brief = tasks.brief(task_id)
|
|
1805
|
+
rows = [
|
|
1806
|
+
("task", brief["task"]["id"]),
|
|
1807
|
+
("title", brief["task"]["title"]),
|
|
1808
|
+
("status", brief["task"]["status"]),
|
|
1809
|
+
("estimated_tokens", str(brief.get("estimated_tokens", 0))),
|
|
1810
|
+
("recent_steps", str(len(brief.get("recent_steps", [])))),
|
|
1811
|
+
("run_traces", str(len(brief.get("linked_run_traces", [])))),
|
|
1812
|
+
("tool_traces", str(len(brief.get("linked_tool_traces", [])))),
|
|
1813
|
+
("memories", str(len(brief.get("linked_memory", [])))),
|
|
1814
|
+
]
|
|
1815
|
+
chat_panel("Compact Brief", rows)
|
|
1816
|
+
latest = brief.get("recent_steps", [])[-3:]
|
|
1817
|
+
if latest:
|
|
1818
|
+
print(chat_color("Recent", "cyan"))
|
|
1819
|
+
for step in latest:
|
|
1820
|
+
print(wrap_chat_text(f"{step.get('kind')}: {step.get('note')}", indent=" "))
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def print_model_panel(args: argparse.Namespace) -> None:
|
|
1824
|
+
chat_panel(
|
|
1825
|
+
"Model Roles",
|
|
1826
|
+
[
|
|
1827
|
+
("provider", args.provider),
|
|
1828
|
+
("primary", primary_model(args)),
|
|
1829
|
+
("auxiliary", auxiliary_model(args)),
|
|
1830
|
+
("routing", "auto can delegate low/medium first-step planning to auxiliary"),
|
|
1831
|
+
("review_role", "auxiliary reviews primary-model steps when enabled"),
|
|
1832
|
+
("mode", args.model_routing),
|
|
1833
|
+
("review", args.aux_review),
|
|
1834
|
+
("base_url", args.base_url or env_value("CONTEXT_KERNEL_OPENAI_BASE_URL") or "default"),
|
|
1835
|
+
],
|
|
1836
|
+
)
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def print_status_panel(workspace: Workspace, task_id: str, args: argparse.Namespace) -> None:
|
|
1840
|
+
chat_panel(
|
|
1841
|
+
"Status",
|
|
1842
|
+
[
|
|
1843
|
+
("cwd", compact_path(Path.cwd())),
|
|
1844
|
+
("workspace", compact_path(workspace.root)),
|
|
1845
|
+
("task", task_id),
|
|
1846
|
+
("provider", args.provider),
|
|
1847
|
+
("primary", primary_model(args)),
|
|
1848
|
+
("auxiliary", auxiliary_model(args)),
|
|
1849
|
+
("routing", args.model_routing),
|
|
1850
|
+
("review", args.aux_review),
|
|
1851
|
+
("profile", args.profile),
|
|
1852
|
+
("state", workspace_state_summary(workspace)),
|
|
1853
|
+
],
|
|
1854
|
+
)
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
def print_config_panel() -> None:
|
|
1858
|
+
chat_panel(
|
|
1859
|
+
"Config",
|
|
1860
|
+
[
|
|
1861
|
+
("setup", "akernel setup"),
|
|
1862
|
+
("env", "CONTEXT_KERNEL_OPENAI_API_KEY, CONTEXT_KERNEL_OPENAI_BASE_URL"),
|
|
1863
|
+
("models", "CONTEXT_KERNEL_OPENAI_MODEL, CONTEXT_KERNEL_OPENAI_AUX_MODEL"),
|
|
1864
|
+
("scope", "current project .env first, installed Context Kernel .env fallback"),
|
|
1865
|
+
],
|
|
1866
|
+
)
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
def chat_prompt(args: argparse.Namespace) -> str:
|
|
1870
|
+
model = primary_model(args)
|
|
1871
|
+
return "\n" + chat_color("akernel", "cyan", bold=True) + chat_color(f" [{model}]", "dim") + "> "
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def primary_model(args: argparse.Namespace) -> str:
|
|
1875
|
+
return args.model or env_value("CONTEXT_KERNEL_OPENAI_MODEL") or DEFAULT_PRIMARY_MODEL
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
def auxiliary_model(args: argparse.Namespace) -> str:
|
|
1879
|
+
return getattr(args, "aux_model", None) or env_value("CONTEXT_KERNEL_OPENAI_AUX_MODEL") or DEFAULT_AUXILIARY_MODEL
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def model_routing_summary(report: dict[str, Any]) -> str:
|
|
1883
|
+
parts = []
|
|
1884
|
+
for step in report.get("steps", []):
|
|
1885
|
+
role = step.get("model_role") or "primary"
|
|
1886
|
+
model = step.get("model") or "default"
|
|
1887
|
+
reason = step.get("routing_reason") or ""
|
|
1888
|
+
label = f"step {step.get('index')}: {role} ({model})"
|
|
1889
|
+
parts.append(f"{label} - {reason}" if reason else label)
|
|
1890
|
+
if not parts:
|
|
1891
|
+
routing = report.get("model_routing", {})
|
|
1892
|
+
return f"{routing.get('mode', 'auto')}: no provider step was executed"
|
|
1893
|
+
return "; ".join(parts)
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
def aux_review_summary(report: dict[str, Any]) -> str:
|
|
1897
|
+
parts = []
|
|
1898
|
+
for step in report.get("steps", []):
|
|
1899
|
+
review = step.get("aux_review", {})
|
|
1900
|
+
if not isinstance(review, dict) or not review.get("enabled"):
|
|
1901
|
+
continue
|
|
1902
|
+
parts.append(
|
|
1903
|
+
f"step {step.get('index')}: {review.get('risk')} risk, "
|
|
1904
|
+
f"{review.get('recommendation')} via {review.get('model')} "
|
|
1905
|
+
f"({review.get('tokens', {}).get('total_tokens', 0)}t)"
|
|
1906
|
+
)
|
|
1907
|
+
return "; ".join(parts)
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def clear_chat_screen() -> None:
|
|
1911
|
+
if sys.stdout.isatty():
|
|
1912
|
+
print("\033[2J\033[H", end="")
|
|
1913
|
+
else:
|
|
1914
|
+
print("\n" * 30)
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def chat_width() -> int:
|
|
1918
|
+
return max(88, min(shutil.get_terminal_size((112, 20)).columns, 132))
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
def chat_color(text: str, color: str, *, bold: bool = False) -> str:
|
|
1922
|
+
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
|
|
1923
|
+
return text
|
|
1924
|
+
codes = {
|
|
1925
|
+
"cyan": "36",
|
|
1926
|
+
"green": "32",
|
|
1927
|
+
"yellow": "33",
|
|
1928
|
+
"red": "31",
|
|
1929
|
+
"dim": "2",
|
|
1930
|
+
"bold": "1",
|
|
1931
|
+
}
|
|
1932
|
+
selected: list[str] = []
|
|
1933
|
+
if bold:
|
|
1934
|
+
selected.append("1")
|
|
1935
|
+
selected.append(codes.get(color, "0"))
|
|
1936
|
+
return f"\033[{';'.join(selected)}m{text}\033[0m"
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def chat_banner(title: str, subtitle: str) -> None:
|
|
1940
|
+
width = chat_width()
|
|
1941
|
+
print("")
|
|
1942
|
+
print(chat_color("=" * width, "cyan", bold=True))
|
|
1943
|
+
print(chat_color(title, "cyan", bold=True))
|
|
1944
|
+
print(chat_color(subtitle, "dim"))
|
|
1945
|
+
print(chat_color("=" * width, "cyan", bold=True))
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
def chat_rule(title: str) -> str:
|
|
1949
|
+
width = chat_width()
|
|
1950
|
+
label = f" {title} "
|
|
1951
|
+
remaining = max(0, width - len(label))
|
|
1952
|
+
left = remaining // 2
|
|
1953
|
+
right = remaining - left
|
|
1954
|
+
return chat_color("-" * left + label + "-" * right, "cyan")
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
def chat_panel(title: str, rows: list[tuple[str, str]]) -> None:
|
|
1958
|
+
width = chat_width()
|
|
1959
|
+
print("")
|
|
1960
|
+
print(chat_color(f"[ {title} ]", "cyan", bold=True))
|
|
1961
|
+
key_width = max(len(key) for key, _ in rows)
|
|
1962
|
+
for key, value in rows:
|
|
1963
|
+
prefix = f" {key:<{key_width}} "
|
|
1964
|
+
wrapped = wrap_chat_text(str(value), indent=" " * len(prefix), width=width)
|
|
1965
|
+
lines = wrapped.splitlines() or [""]
|
|
1966
|
+
print(chat_color(prefix, "dim") + lines[0].lstrip())
|
|
1967
|
+
for line in lines[1:]:
|
|
1968
|
+
print(line)
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
def chat_notice(title: str, message: str) -> None:
|
|
1972
|
+
print("")
|
|
1973
|
+
print(chat_color(f"! {title}", "yellow", bold=True))
|
|
1974
|
+
print(wrap_chat_text(message, indent=" "))
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
def wrap_chat_text(text: str, *, indent: str = "", width: int | None = None) -> str:
|
|
1978
|
+
width = width or chat_width()
|
|
1979
|
+
usable = max(30, width - len(indent))
|
|
1980
|
+
lines: list[str] = []
|
|
1981
|
+
for paragraph in text.splitlines() or [""]:
|
|
1982
|
+
words = paragraph.split()
|
|
1983
|
+
if not words:
|
|
1984
|
+
lines.append(indent.rstrip())
|
|
1985
|
+
continue
|
|
1986
|
+
current = words[0]
|
|
1987
|
+
for word in words[1:]:
|
|
1988
|
+
if len(current) + 1 + len(word) > usable:
|
|
1989
|
+
lines.append(indent + current)
|
|
1990
|
+
current = word
|
|
1991
|
+
else:
|
|
1992
|
+
current += " " + word
|
|
1993
|
+
lines.append(indent + current)
|
|
1994
|
+
return "\n".join(lines)
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
def compact_path(path: Path) -> str:
|
|
1998
|
+
text = str(path)
|
|
1999
|
+
width = chat_width() - 18
|
|
2000
|
+
if len(text) <= width:
|
|
2001
|
+
return text
|
|
2002
|
+
return "..." + text[-max(12, width - 3) :]
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
def workspace_state_summary(workspace: Workspace) -> str:
|
|
2006
|
+
skills = len(list(workspace.skills_dir.glob("*.json"))) if workspace.skills_dir.exists() else 0
|
|
2007
|
+
runs = len(list(workspace.agent_runs_dir.glob("*.json"))) if workspace.agent_runs_dir.exists() else 0
|
|
2008
|
+
project = 1 if workspace.project_file.exists() else 0
|
|
2009
|
+
try:
|
|
2010
|
+
memories = len(MemoryStore(workspace).all())
|
|
2011
|
+
except Exception:
|
|
2012
|
+
memories = 0
|
|
2013
|
+
return f"{skills} skills, {memories} memories, {runs} runs, {project} project profiles"
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
def print_project_scan_summary(profile: dict[str, Any], *, config_updated: bool) -> None:
|
|
2017
|
+
print(f"project_profile: {profile.get('root')}")
|
|
2018
|
+
print(f"languages: {', '.join(profile.get('languages', [])) or 'unknown'}")
|
|
2019
|
+
managers = profile.get("package_managers", [])
|
|
2020
|
+
print(f"package_managers: {', '.join(managers) if managers else 'none'}")
|
|
2021
|
+
commands = profile.get("commands", {})
|
|
2022
|
+
if commands:
|
|
2023
|
+
for name, command in commands.items():
|
|
2024
|
+
print(f"command_{name}: {command}")
|
|
2025
|
+
else:
|
|
2026
|
+
print("commands: none")
|
|
2027
|
+
print(f"key_files: {', '.join(profile.get('key_files', [])[:8]) or 'none'}")
|
|
2028
|
+
instructions = profile.get("instructions", [])
|
|
2029
|
+
print(f"instructions: {', '.join(item.get('path', '') for item in instructions[:4]) if instructions else 'none'}")
|
|
2030
|
+
print(f"command_roots: {', '.join(profile.get('command_roots', [])[:16])}")
|
|
2031
|
+
print(f"config_updated: {config_updated}")
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def print_agent_report(report: dict[str, Any]) -> None:
|
|
2035
|
+
print(f"agent_run: {report['id']}")
|
|
2036
|
+
print(f"task: {report['task_id']}")
|
|
2037
|
+
print(f"status: {report['status']}")
|
|
2038
|
+
print(f"steps: {len(report['steps'])}/{report['max_steps']}")
|
|
2039
|
+
print(f"tokens: total={report['totals']['total_tokens']} input={report['totals']['input_tokens']} output={report['totals']['output_tokens']}")
|
|
2040
|
+
routing = report.get("model_routing", {})
|
|
2041
|
+
if routing:
|
|
2042
|
+
print(
|
|
2043
|
+
"model_routing: "
|
|
2044
|
+
f"mode={routing.get('mode')} "
|
|
2045
|
+
f"primary={routing.get('primary_model')} "
|
|
2046
|
+
f"auxiliary={routing.get('auxiliary_model')} "
|
|
2047
|
+
f"review={routing.get('aux_review')}"
|
|
2048
|
+
)
|
|
2049
|
+
if report.get("state", {}).get("enabled"):
|
|
2050
|
+
print(f"state: wrote {report['state']['written_count']} memory record(s)")
|
|
2051
|
+
print_agent_diagnostic(report.get("diagnostic"))
|
|
2052
|
+
for step in report["steps"]:
|
|
2053
|
+
trace = step["trace_id"] or "none"
|
|
2054
|
+
tokens = step.get("tokens", {}).get("total_tokens", 0)
|
|
2055
|
+
action = (step.get("action") or {}).get("action", "none")
|
|
2056
|
+
model_part = f" model={step.get('model_role')}:{step.get('model') or 'default'}" if step.get("model_role") else ""
|
|
2057
|
+
review = step.get("aux_review", {})
|
|
2058
|
+
review_part = ""
|
|
2059
|
+
if isinstance(review, dict) and review.get("enabled"):
|
|
2060
|
+
review_part = f" review={review.get('risk')}:{review.get('recommendation')}"
|
|
2061
|
+
tool = step.get("tool", {})
|
|
2062
|
+
tool_part = f" tool={tool.get('name')}:{tool.get('id')}" if tool else ""
|
|
2063
|
+
print(f"- step {step['index']}: {step['status']} action={action} trace={trace} tokens={tokens}{model_part}{review_part}{tool_part}")
|
|
2064
|
+
print_agent_diagnostic(step.get("diagnostic"), prefix=" ")
|
|
2065
|
+
if report.get("final_response"):
|
|
2066
|
+
print("")
|
|
2067
|
+
print(report["final_response"])
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
def print_agent_diagnostic(diagnostic: Any, *, prefix: str = "") -> None:
|
|
2071
|
+
if not isinstance(diagnostic, dict) or not diagnostic:
|
|
2072
|
+
return
|
|
2073
|
+
category = diagnostic.get("category") or "unknown"
|
|
2074
|
+
message = diagnostic.get("message") or ""
|
|
2075
|
+
suggestion = diagnostic.get("suggestion") or ""
|
|
2076
|
+
print(f"{prefix}diagnostic: {category}")
|
|
2077
|
+
if message:
|
|
2078
|
+
print(f"{prefix}reason: {message}")
|
|
2079
|
+
if suggestion:
|
|
2080
|
+
print(f"{prefix}next: {suggestion}")
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
def cmd_agent_list(args: argparse.Namespace) -> None:
|
|
2084
|
+
workspace = workspace_from_args(args)
|
|
2085
|
+
reports = list_agent_reports(workspace)
|
|
2086
|
+
if not reports:
|
|
2087
|
+
print("no agent runs")
|
|
2088
|
+
return
|
|
2089
|
+
for report in reports:
|
|
2090
|
+
print(
|
|
2091
|
+
f"{report['id']}\t"
|
|
2092
|
+
f"{report.get('created_at', '')}\t"
|
|
2093
|
+
f"{report.get('status', '')}\t"
|
|
2094
|
+
f"steps={len(report.get('steps', []))}\t"
|
|
2095
|
+
f"task={report.get('task_id', '')}\t"
|
|
2096
|
+
f"{report.get('request', '')}"
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
def cmd_agent_show(args: argparse.Namespace) -> None:
|
|
2101
|
+
workspace = workspace_from_args(args)
|
|
2102
|
+
print_json(load_agent_report(workspace, args.run_id))
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
def cmd_agent_cost(args: argparse.Namespace) -> None:
|
|
2106
|
+
workspace = workspace_from_args(args)
|
|
2107
|
+
cost = build_agent_cost_report(load_agent_report(workspace, args.run_id))
|
|
2108
|
+
if args.json:
|
|
2109
|
+
print_json(cost)
|
|
2110
|
+
return
|
|
2111
|
+
print(render_agent_cost_report(cost))
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
def cmd_models(args: argparse.Namespace) -> None:
|
|
2115
|
+
for model in list_provider_models(args.provider, base_url=args.base_url):
|
|
2116
|
+
print(model)
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
2120
|
+
workspace = Workspace(Path(args.workspace))
|
|
2121
|
+
config = workspace.load_config()
|
|
2122
|
+
command_policy = summarize_command_policy(workspace)
|
|
2123
|
+
base_url = env_value("CONTEXT_KERNEL_OPENAI_BASE_URL")
|
|
2124
|
+
api_key = env_value("CONTEXT_KERNEL_OPENAI_API_KEY")
|
|
2125
|
+
model = env_value("CONTEXT_KERNEL_OPENAI_MODEL") or DEFAULT_PRIMARY_MODEL
|
|
2126
|
+
aux_model = env_value("CONTEXT_KERNEL_OPENAI_AUX_MODEL") or DEFAULT_AUXILIARY_MODEL
|
|
2127
|
+
print(f"project_root: {Path.cwd().resolve()}")
|
|
2128
|
+
print(f"workspace: {workspace.root}")
|
|
2129
|
+
print(f"workspace_initialized: {workspace.state.exists()}")
|
|
2130
|
+
print(f"workspace_config: {workspace.config_file}")
|
|
2131
|
+
print(f"workspace_config_version: {config.get('version')}")
|
|
2132
|
+
print(f"project_env_api_key_set: {bool(api_key)}")
|
|
2133
|
+
print(f"project_env_base_url: {normalize_openai_base_url(base_url or '') if base_url else ''}")
|
|
2134
|
+
print(f"project_env_primary_model: {model}")
|
|
2135
|
+
print(f"project_env_auxiliary_model: {aux_model}")
|
|
2136
|
+
profile = load_project_profile(workspace)
|
|
2137
|
+
print(f"project_profile: {workspace.project_file if profile else ''}")
|
|
2138
|
+
print(f"project_summary: {profile.get('summary', '') if profile else ''}")
|
|
2139
|
+
print(f"command_allowed_roots: {', '.join(command_policy['allowed_roots'])}")
|
|
2140
|
+
print(f"command_blocked_terms: {', '.join(command_policy['blocked_terms'])}")
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
def cmd_plan(args: argparse.Namespace) -> None:
|
|
2144
|
+
workspace = workspace_from_args(args)
|
|
2145
|
+
ensure_resume_args(args)
|
|
2146
|
+
plan = ExecutionPlanner(workspace).plan(
|
|
2147
|
+
args.request,
|
|
2148
|
+
args.budget,
|
|
2149
|
+
args.profile,
|
|
2150
|
+
task_id=args.task,
|
|
2151
|
+
resume=args.resume,
|
|
2152
|
+
)
|
|
2153
|
+
if args.json:
|
|
2154
|
+
print_json(plan)
|
|
2155
|
+
return
|
|
2156
|
+
|
|
2157
|
+
print(f"request: {plan['request']}")
|
|
2158
|
+
print(f"profile: {plan['profile']}")
|
|
2159
|
+
print(f"route: {plan['route']['mode']} ({plan['route']['complexity']})")
|
|
2160
|
+
if plan["task"]["resume"]:
|
|
2161
|
+
print(f"task: {plan['task']['id']} resume tokens={plan['task']['estimated_tokens']}")
|
|
2162
|
+
print(f"tokens: used={plan['budget']['estimated_used']} total={plan['budget']['total']} remaining={plan['budget']['estimated_remaining']}")
|
|
2163
|
+
print(f"savings: {plan['savings']['estimated_tokens']} tokens ({plan['savings']['percent']}%)")
|
|
2164
|
+
print(f"selected: memory={len(plan['selection']['memory'])} skills={len(plan['selection']['skills'])}")
|
|
2165
|
+
print(f"policy: {'review required' if plan['policy']['requires_policy_check'] else 'clear'}")
|
|
2166
|
+
print(f"command_roots: {', '.join(plan['policy']['command_policy']['allowed_roots'])}")
|
|
2167
|
+
if plan["warnings"]:
|
|
2168
|
+
print("warnings:")
|
|
2169
|
+
for warning in plan["warnings"]:
|
|
2170
|
+
print(f"- {warning}")
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
def cmd_policy_file(args: argparse.Namespace) -> None:
|
|
2174
|
+
workspace = workspace_from_args(args)
|
|
2175
|
+
result = check_file_policy(
|
|
2176
|
+
workspace,
|
|
2177
|
+
args.operation,
|
|
2178
|
+
args.path,
|
|
2179
|
+
allow_destructive=args.allow_destructive,
|
|
2180
|
+
)
|
|
2181
|
+
print_policy_result(result, args.json)
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
def cmd_policy_command(args: argparse.Namespace) -> None:
|
|
2185
|
+
workspace = workspace_from_args(args, initialized=False)
|
|
2186
|
+
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
|
2187
|
+
result = check_command_policy(
|
|
2188
|
+
" ".join(command),
|
|
2189
|
+
workspace=workspace if workspace.state.exists() else None,
|
|
2190
|
+
allow_destructive=args.allow_destructive,
|
|
2191
|
+
)
|
|
2192
|
+
print_policy_result(result, args.json)
|
|
2193
|
+
|
|
2194
|
+
|
|
2195
|
+
def cmd_tool_read(args: argparse.Namespace) -> None:
|
|
2196
|
+
workspace = workspace_from_args(args)
|
|
2197
|
+
ensure_task_attachable(workspace, args.task)
|
|
2198
|
+
result = ToolExecutor(workspace).read_file(args.path, max_chars=args.max_chars)
|
|
2199
|
+
attach_tool_to_task_if_requested(workspace, args.task, result)
|
|
2200
|
+
if args.json:
|
|
2201
|
+
print_json(result)
|
|
2202
|
+
return
|
|
2203
|
+
print_tool_result(result)
|
|
2204
|
+
if result["ok"]:
|
|
2205
|
+
print(result["output"]["content"])
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
def cmd_tool_write(args: argparse.Namespace) -> None:
|
|
2209
|
+
workspace = workspace_from_args(args)
|
|
2210
|
+
ensure_task_attachable(workspace, args.task)
|
|
2211
|
+
result = ToolExecutor(workspace).write_file(args.path, args.text)
|
|
2212
|
+
attach_tool_to_task_if_requested(workspace, args.task, result)
|
|
2213
|
+
if args.json:
|
|
2214
|
+
print_json(result)
|
|
2215
|
+
return
|
|
2216
|
+
print_tool_result(result)
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
def cmd_tool_patch(args: argparse.Namespace) -> None:
|
|
2220
|
+
workspace = workspace_from_args(args)
|
|
2221
|
+
ensure_task_attachable(workspace, args.task)
|
|
2222
|
+
if (args.start_anchor or args.end_anchor) and args.old:
|
|
2223
|
+
raise ValueError("tool patch cannot combine --old with --start-anchor/--end-anchor")
|
|
2224
|
+
if not (args.start_anchor or args.end_anchor) and not args.old:
|
|
2225
|
+
raise ValueError("tool patch requires --old, or both --start-anchor and --end-anchor")
|
|
2226
|
+
result = ToolExecutor(workspace).patch_file(
|
|
2227
|
+
args.path,
|
|
2228
|
+
args.old or "",
|
|
2229
|
+
args.new,
|
|
2230
|
+
replace_all=args.replace_all,
|
|
2231
|
+
occurrence=args.occurrence,
|
|
2232
|
+
start_anchor=args.start_anchor,
|
|
2233
|
+
end_anchor=args.end_anchor,
|
|
2234
|
+
include_anchors=args.include_anchors,
|
|
2235
|
+
)
|
|
2236
|
+
attach_tool_to_task_if_requested(workspace, args.task, result)
|
|
2237
|
+
if args.json:
|
|
2238
|
+
print_json(result)
|
|
2239
|
+
return
|
|
2240
|
+
print_tool_result(result)
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def cmd_tool_batch_patch(args: argparse.Namespace) -> None:
|
|
2244
|
+
workspace = workspace_from_args(args)
|
|
2245
|
+
ensure_task_attachable(workspace, args.task)
|
|
2246
|
+
edits = load_batch_patch_specs(Path(args.specs_file))
|
|
2247
|
+
result = ToolExecutor(workspace).batch_patch(edits)
|
|
2248
|
+
attach_tool_to_task_if_requested(workspace, args.task, result)
|
|
2249
|
+
if args.json:
|
|
2250
|
+
print_json(result)
|
|
2251
|
+
return
|
|
2252
|
+
print_tool_result(result)
|
|
2253
|
+
output = result.get("output", {})
|
|
2254
|
+
if "applied_count" in output:
|
|
2255
|
+
print(f"applied_count: {output['applied_count']}")
|
|
2256
|
+
if output.get("rolled_back"):
|
|
2257
|
+
print("rolled_back: true")
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
def load_batch_patch_specs(path: Path) -> list[dict[str, object]]:
|
|
2261
|
+
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
2262
|
+
edits = payload.get("edits") if isinstance(payload, dict) else payload
|
|
2263
|
+
if not isinstance(edits, list):
|
|
2264
|
+
raise ValueError("batch-patch specs file must contain a JSON array or an object with an `edits` array.")
|
|
2265
|
+
if not all(isinstance(edit, dict) for edit in edits):
|
|
2266
|
+
raise ValueError("batch-patch edits must be JSON objects.")
|
|
2267
|
+
return edits
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def cmd_tool_delete(args: argparse.Namespace) -> None:
|
|
2271
|
+
workspace = workspace_from_args(args)
|
|
2272
|
+
ensure_task_attachable(workspace, args.task)
|
|
2273
|
+
result = ToolExecutor(workspace).delete_file(args.path, allow_destructive=args.allow_destructive)
|
|
2274
|
+
attach_tool_to_task_if_requested(workspace, args.task, result)
|
|
2275
|
+
if args.json:
|
|
2276
|
+
print_json(result)
|
|
2277
|
+
return
|
|
2278
|
+
print_tool_result(result)
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def cmd_tool_exec(args: argparse.Namespace) -> None:
|
|
2282
|
+
workspace = workspace_from_args(args)
|
|
2283
|
+
ensure_task_attachable(workspace, args.task)
|
|
2284
|
+
command = args.command[1:] if args.command[:1] == ["--"] else args.command
|
|
2285
|
+
result = ToolExecutor(workspace).run_command(
|
|
2286
|
+
" ".join(command),
|
|
2287
|
+
allow_destructive=args.allow_destructive,
|
|
2288
|
+
timeout_seconds=args.timeout,
|
|
2289
|
+
)
|
|
2290
|
+
attach_tool_to_task_if_requested(workspace, args.task, result)
|
|
2291
|
+
if args.json:
|
|
2292
|
+
print_json(result)
|
|
2293
|
+
return
|
|
2294
|
+
print_tool_result(result)
|
|
2295
|
+
output = result.get("output", {})
|
|
2296
|
+
if "exit_code" in output:
|
|
2297
|
+
print(f"exit_code: {output['exit_code']}")
|
|
2298
|
+
if output.get("stdout"):
|
|
2299
|
+
print("stdout:")
|
|
2300
|
+
print(output["stdout"].rstrip())
|
|
2301
|
+
if output.get("stderr"):
|
|
2302
|
+
print("stderr:")
|
|
2303
|
+
print(output["stderr"].rstrip())
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
def cmd_tool_list(args: argparse.Namespace) -> None:
|
|
2307
|
+
workspace = workspace_from_args(args)
|
|
2308
|
+
traces = ToolExecutor(workspace).list_traces()
|
|
2309
|
+
if not traces:
|
|
2310
|
+
print("no tool traces")
|
|
2311
|
+
return
|
|
2312
|
+
for trace in traces:
|
|
2313
|
+
status = "blocked" if trace["blocked"] else "ok" if trace["ok"] else "failed"
|
|
2314
|
+
print(f"{trace['id']}\t{trace['created_at']}\t{trace['tool']}\t{status}\t{trace['subject']}")
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
def cmd_tool_show(args: argparse.Namespace) -> None:
|
|
2318
|
+
workspace = workspace_from_args(args)
|
|
2319
|
+
print_json(ToolExecutor(workspace).get_trace(args.trace_id))
|
|
2320
|
+
|
|
2321
|
+
|
|
2322
|
+
def cmd_task_start(args: argparse.Namespace) -> None:
|
|
2323
|
+
workspace = workspace_from_args(args)
|
|
2324
|
+
task = TaskStore(workspace).start(args.title, args.goal, with_plan=args.plan)
|
|
2325
|
+
print(f"task: {task['id']} active {task['title']}")
|
|
2326
|
+
if task.get("plan"):
|
|
2327
|
+
active = task["plan"]["milestones"][0]
|
|
2328
|
+
print(f"plan: {len(task['plan']['milestones'])} milestones active={active['id']} {active['title']}")
|
|
2329
|
+
|
|
2330
|
+
|
|
2331
|
+
def cmd_task_list(args: argparse.Namespace) -> None:
|
|
2332
|
+
workspace = workspace_from_args(args)
|
|
2333
|
+
tasks = TaskStore(workspace).list(status=args.status)
|
|
2334
|
+
if not tasks:
|
|
2335
|
+
print("no tasks")
|
|
2336
|
+
return
|
|
2337
|
+
for task in tasks:
|
|
2338
|
+
print(f"{task['id']}\t{task['status']}\tsteps={len(task['steps'])}\t{task['title']}")
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
def cmd_task_status(args: argparse.Namespace) -> None:
|
|
2342
|
+
workspace = workspace_from_args(args)
|
|
2343
|
+
task = TaskStore(workspace).get(args.task_id)
|
|
2344
|
+
if args.json:
|
|
2345
|
+
print_json(task)
|
|
2346
|
+
return
|
|
2347
|
+
print(f"task: {task['id']}")
|
|
2348
|
+
print(f"title: {task['title']}")
|
|
2349
|
+
print(f"status: {task['status']}")
|
|
2350
|
+
print(f"steps: {len(task['steps'])}")
|
|
2351
|
+
print(
|
|
2352
|
+
"refs: "
|
|
2353
|
+
f"runs={len(task['refs']['run_traces'])} "
|
|
2354
|
+
f"tools={len(task['refs']['tool_traces'])} "
|
|
2355
|
+
f"memories={len(task['refs']['memories'])}"
|
|
2356
|
+
)
|
|
2357
|
+
if task.get("plan"):
|
|
2358
|
+
progress = task_plan_progress_text(task["plan"])
|
|
2359
|
+
active = next((item for item in task["plan"]["milestones"] if item.get("status") == "active"), None)
|
|
2360
|
+
print(f"plan: {progress}")
|
|
2361
|
+
if active:
|
|
2362
|
+
print(f"active: {active['id']} {active['title']}")
|
|
2363
|
+
if task["steps"]:
|
|
2364
|
+
latest = task["steps"][-1]
|
|
2365
|
+
print(f"latest: [{latest['kind']}] {latest['note']}")
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
def cmd_task_brief(args: argparse.Namespace) -> None:
|
|
2369
|
+
workspace = workspace_from_args(args)
|
|
2370
|
+
brief = TaskStore(workspace).brief(args.task_id)
|
|
2371
|
+
if args.json:
|
|
2372
|
+
print_json(brief)
|
|
2373
|
+
return
|
|
2374
|
+
task = brief["task"]
|
|
2375
|
+
print(f"task: {task['id']} {task['status']}")
|
|
2376
|
+
print(f"title: {task['title']}")
|
|
2377
|
+
print(f"goal: {task['goal']}")
|
|
2378
|
+
print(f"estimated_tokens: {brief['estimated_tokens']}")
|
|
2379
|
+
print(f"recent_steps: {len(brief['recent_steps'])}")
|
|
2380
|
+
for step in brief["recent_steps"][-3:]:
|
|
2381
|
+
print(f"- [{step['kind']}] {step['note']}")
|
|
2382
|
+
if brief.get("plan"):
|
|
2383
|
+
plan = brief["plan"]
|
|
2384
|
+
progress = plan["progress"]
|
|
2385
|
+
active = plan.get("active_milestone")
|
|
2386
|
+
print(
|
|
2387
|
+
"plan: "
|
|
2388
|
+
f"{progress['completed']}/{progress['total']} completed "
|
|
2389
|
+
f"blocked={progress['blocked']} skipped={progress['skipped']}"
|
|
2390
|
+
)
|
|
2391
|
+
if active:
|
|
2392
|
+
print(f"active: {active['id']} {active['title']}")
|
|
2393
|
+
print(
|
|
2394
|
+
"linked: "
|
|
2395
|
+
f"memory={len(brief['linked_memory'])} "
|
|
2396
|
+
f"runs={len(brief['linked_run_traces'])} "
|
|
2397
|
+
f"tools={len(brief['linked_tool_traces'])}"
|
|
2398
|
+
)
|
|
2399
|
+
|
|
2400
|
+
|
|
2401
|
+
def cmd_task_plan(args: argparse.Namespace) -> None:
|
|
2402
|
+
workspace = workspace_from_args(args)
|
|
2403
|
+
task = TaskStore(workspace).plan(args.task_id, goal=args.goal, force=args.force)
|
|
2404
|
+
if args.json:
|
|
2405
|
+
print_json(task["plan"])
|
|
2406
|
+
return
|
|
2407
|
+
print_task_plan(task)
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
def cmd_task_next(args: argparse.Namespace) -> None:
|
|
2411
|
+
workspace = workspace_from_args(args)
|
|
2412
|
+
checkpoint = TaskStore(workspace).next_checkpoint(args.task_id)
|
|
2413
|
+
if args.json:
|
|
2414
|
+
print_json(checkpoint)
|
|
2415
|
+
return
|
|
2416
|
+
milestone = checkpoint.get("milestone")
|
|
2417
|
+
print(f"task: {checkpoint['task_id']} {checkpoint['task_status']}")
|
|
2418
|
+
print(f"progress: {checkpoint['plan_progress']['completed']}/{checkpoint['plan_progress']['total']} completed")
|
|
2419
|
+
if milestone:
|
|
2420
|
+
print(f"next: {milestone['id']} {milestone['title']} [{milestone['status']}]")
|
|
2421
|
+
print(f"objective: {milestone['objective']}")
|
|
2422
|
+
for item in milestone.get("acceptance", []):
|
|
2423
|
+
print(f"- {item}")
|
|
2424
|
+
print(f"resume: {checkpoint['resume_prompt']}")
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def cmd_task_checkpoint(args: argparse.Namespace) -> None:
|
|
2428
|
+
workspace = workspace_from_args(args)
|
|
2429
|
+
task = TaskStore(workspace).checkpoint(
|
|
2430
|
+
args.task_id,
|
|
2431
|
+
args.note,
|
|
2432
|
+
milestone_id=args.milestone,
|
|
2433
|
+
status=args.status,
|
|
2434
|
+
)
|
|
2435
|
+
print(f"task: {task['id']} checkpoint added steps={len(task['steps'])}")
|
|
2436
|
+
if task.get("plan"):
|
|
2437
|
+
print(f"plan: {task_plan_progress_text(task['plan'])}")
|
|
2438
|
+
|
|
2439
|
+
|
|
2440
|
+
def print_task_plan(task: dict[str, Any]) -> None:
|
|
2441
|
+
plan = task["plan"]
|
|
2442
|
+
print(f"task: {task['id']}")
|
|
2443
|
+
print(f"objective: {plan['objective']}")
|
|
2444
|
+
print(f"progress: {task_plan_progress_text(plan)}")
|
|
2445
|
+
for milestone in plan.get("milestones", []):
|
|
2446
|
+
print(f"- {milestone['id']} [{milestone['status']}] {milestone['title']}")
|
|
2447
|
+
print(f" objective: {milestone['objective']}")
|
|
2448
|
+
|
|
2449
|
+
|
|
2450
|
+
def task_plan_progress_text(plan: dict[str, Any]) -> str:
|
|
2451
|
+
milestones = plan.get("milestones", [])
|
|
2452
|
+
total = len(milestones)
|
|
2453
|
+
completed = sum(1 for item in milestones if item.get("status") == "completed")
|
|
2454
|
+
blocked = sum(1 for item in milestones if item.get("status") == "blocked")
|
|
2455
|
+
skipped = sum(1 for item in milestones if item.get("status") == "skipped")
|
|
2456
|
+
return f"{completed}/{total} completed blocked={blocked} skipped={skipped}"
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
def attach_run_to_task(workspace: Workspace, task_id: str, trace: dict[str, Any]) -> None:
|
|
2460
|
+
store = TaskStore(workspace)
|
|
2461
|
+
store.attach(task_id, "run", trace["id"])
|
|
2462
|
+
for record in trace.get("state", {}).get("records", []):
|
|
2463
|
+
store.attach(task_id, "memory", record["id"])
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def ensure_task_attachable(workspace: Workspace, task_id: str | None) -> None:
|
|
2467
|
+
if not task_id:
|
|
2468
|
+
return
|
|
2469
|
+
task = TaskStore(workspace).get(task_id)
|
|
2470
|
+
if task.get("status") == "completed":
|
|
2471
|
+
raise ValueError(f"Task is completed and cannot receive new traces: {task_id}")
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
def ensure_resume_args(args: argparse.Namespace) -> None:
|
|
2475
|
+
if getattr(args, "resume", False) and not getattr(args, "task", None):
|
|
2476
|
+
raise ValueError("--resume requires --task <task-id>")
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
def attach_tool_to_task_if_requested(workspace: Workspace, task_id: str | None, result: dict[str, Any]) -> None:
|
|
2480
|
+
if not task_id:
|
|
2481
|
+
return
|
|
2482
|
+
TaskStore(workspace).attach(task_id, "tool", result["id"])
|
|
2483
|
+
|
|
2484
|
+
|
|
2485
|
+
def cmd_task_step(args: argparse.Namespace) -> None:
|
|
2486
|
+
workspace = workspace_from_args(args)
|
|
2487
|
+
task = TaskStore(workspace).step(args.task_id, args.note)
|
|
2488
|
+
print(f"task: {task['id']} step added steps={len(task['steps'])}")
|
|
2489
|
+
|
|
2490
|
+
|
|
2491
|
+
def cmd_task_attach(args: argparse.Namespace) -> None:
|
|
2492
|
+
workspace = workspace_from_args(args)
|
|
2493
|
+
task = TaskStore(workspace).attach(args.task_id, args.kind, args.ref_id)
|
|
2494
|
+
print(f"task: {task['id']} attached {args.kind}:{args.ref_id}")
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
def cmd_task_block(args: argparse.Namespace) -> None:
|
|
2498
|
+
workspace = workspace_from_args(args)
|
|
2499
|
+
task = TaskStore(workspace).set_status(args.task_id, "blocked", note=args.note)
|
|
2500
|
+
print(f"task: {task['id']} blocked")
|
|
2501
|
+
|
|
2502
|
+
|
|
2503
|
+
def cmd_task_complete(args: argparse.Namespace) -> None:
|
|
2504
|
+
workspace = workspace_from_args(args)
|
|
2505
|
+
task = TaskStore(workspace).set_status(args.task_id, "completed", note=args.note)
|
|
2506
|
+
print(f"task: {task['id']} completed")
|
|
2507
|
+
|
|
2508
|
+
|
|
2509
|
+
def cmd_eval_run(args: argparse.Namespace) -> None:
|
|
2510
|
+
workspace = workspace_from_args(args)
|
|
2511
|
+
report = EvalRunner(workspace).run_fixture(
|
|
2512
|
+
Path(args.fixture),
|
|
2513
|
+
default_budget=args.budget,
|
|
2514
|
+
default_profile=args.profile,
|
|
2515
|
+
save=not args.no_save,
|
|
2516
|
+
execute_provider=args.provider if args.execute else None,
|
|
2517
|
+
execute_model=args.model,
|
|
2518
|
+
execute_base_url=args.base_url,
|
|
2519
|
+
)
|
|
2520
|
+
if args.json:
|
|
2521
|
+
print_json(report)
|
|
2522
|
+
return
|
|
2523
|
+
|
|
2524
|
+
summary = report["summary"]
|
|
2525
|
+
print(f"report: {report['id']}")
|
|
2526
|
+
print(f"fixture: {report['fixture']}")
|
|
2527
|
+
print(f"tasks: {summary['task_count']}")
|
|
2528
|
+
print(f"profile: {args.profile}")
|
|
2529
|
+
print(f"avg_savings: {summary['average_savings_percent']}%")
|
|
2530
|
+
print(f"total_kernel_tokens: {summary['total_kernel_tokens']}")
|
|
2531
|
+
print(f"total_baseline_tokens: {summary['total_baseline_tokens']}")
|
|
2532
|
+
if summary["executed_tasks"]:
|
|
2533
|
+
print(f"executed_tasks: {summary['executed_tasks']}")
|
|
2534
|
+
print(f"execution_tokens: {summary['total_execution_tokens']}")
|
|
2535
|
+
if summary.get("blocked_tasks"):
|
|
2536
|
+
print(f"blocked_tasks: {summary['blocked_tasks']}")
|
|
2537
|
+
print(f"checks: {summary['passed_checks']}/{summary['total_checks']}")
|
|
2538
|
+
for task in report["tasks"]:
|
|
2539
|
+
execution = task.get("execution", {})
|
|
2540
|
+
execution_text = f"\texec_tokens={execution.get('total_tokens')}" if execution else ""
|
|
2541
|
+
print(
|
|
2542
|
+
f"{task['id']}\t"
|
|
2543
|
+
f"savings={task['savings']['percent']}%\t"
|
|
2544
|
+
f"kernel={task['kernel']['estimated_tokens']}\t"
|
|
2545
|
+
f"baseline={task['baseline']['estimated_tokens']}\t"
|
|
2546
|
+
f"checks={task['checks']['passed']}/{task['checks']['total']}"
|
|
2547
|
+
f"{execution_text}"
|
|
2548
|
+
)
|
|
2549
|
+
|
|
2550
|
+
|
|
2551
|
+
def cmd_eval_list(args: argparse.Namespace) -> None:
|
|
2552
|
+
workspace = workspace_from_args(args)
|
|
2553
|
+
reports = EvalRunner(workspace).list_reports()
|
|
2554
|
+
if not reports:
|
|
2555
|
+
print("no eval reports")
|
|
2556
|
+
return
|
|
2557
|
+
for report in reports:
|
|
2558
|
+
print(
|
|
2559
|
+
f"{report['id']}\t"
|
|
2560
|
+
f"{report['created_at']}\t"
|
|
2561
|
+
f"tasks={report['task_count']}\t"
|
|
2562
|
+
f"avg_savings={report['average_savings_percent']}%\t"
|
|
2563
|
+
f"checks={report['checks']}\t"
|
|
2564
|
+
f"{report['name']}"
|
|
2565
|
+
)
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def cmd_eval_show(args: argparse.Namespace) -> None:
|
|
2569
|
+
workspace = workspace_from_args(args)
|
|
2570
|
+
print_json(EvalRunner(workspace).get_report(args.report_id))
|
|
2571
|
+
|
|
2572
|
+
|
|
2573
|
+
def cmd_eval_cost(args: argparse.Namespace) -> None:
|
|
2574
|
+
workspace = workspace_from_args(args)
|
|
2575
|
+
cost = build_eval_cost_report(EvalRunner(workspace).get_report(args.report_id))
|
|
2576
|
+
if args.json:
|
|
2577
|
+
print_json(cost)
|
|
2578
|
+
return
|
|
2579
|
+
print(render_cost_report(cost))
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
def cmd_eval_diff(args: argparse.Namespace) -> None:
|
|
2583
|
+
workspace = workspace_from_args(args)
|
|
2584
|
+
diff = EvalRunner(workspace).diff_reports(args.before_id, args.after_id)
|
|
2585
|
+
if args.json:
|
|
2586
|
+
print_json(diff)
|
|
2587
|
+
enforce_regression_gate(diff, enabled=args.fail_on_regression, label="eval diff")
|
|
2588
|
+
return
|
|
2589
|
+
|
|
2590
|
+
summary = diff["summary_delta"]
|
|
2591
|
+
print(f"before: {diff['before']['id']}")
|
|
2592
|
+
print(f"after: {diff['after']['id']}")
|
|
2593
|
+
print(f"kernel_tokens_delta: {summary['kernel_tokens']}")
|
|
2594
|
+
print(f"baseline_tokens_delta: {summary['baseline_tokens']}")
|
|
2595
|
+
print(f"savings_tokens_delta: {summary['savings_tokens']}")
|
|
2596
|
+
print(f"savings_percent_delta: {summary['savings_percent']}")
|
|
2597
|
+
print(f"checks_delta: {summary['passed_checks']}/{summary['total_checks']}")
|
|
2598
|
+
print(f"regressions: {len(diff['regressions'])}")
|
|
2599
|
+
cost_diff = diff.get("cost_diff", {})
|
|
2600
|
+
if cost_diff:
|
|
2601
|
+
hotspot = cost_diff.get("hotspot_change", {})
|
|
2602
|
+
weakest = cost_diff.get("weakest_savings_change", {})
|
|
2603
|
+
print(f"cost_regressions: {len(diff.get('cost_regressions', []))}")
|
|
2604
|
+
print(
|
|
2605
|
+
f"hotspot_delta: {hotspot.get('before_scope', '')} -> {hotspot.get('after_scope', '')} "
|
|
2606
|
+
f"({hotspot.get('metric_delta', 0)})"
|
|
2607
|
+
)
|
|
2608
|
+
print(
|
|
2609
|
+
f"weakest_savings_delta: {weakest.get('before_scope', '')} -> {weakest.get('after_scope', '')} "
|
|
2610
|
+
f"({weakest.get('metric_delta', 0)})"
|
|
2611
|
+
)
|
|
2612
|
+
for task in diff["tasks"]:
|
|
2613
|
+
if task["status"] != "changed":
|
|
2614
|
+
print(f"{task['id']}\t{task['status']}")
|
|
2615
|
+
continue
|
|
2616
|
+
print(
|
|
2617
|
+
f"{task['id']}\t"
|
|
2618
|
+
f"kernel_delta={task['kernel_token_delta']}\t"
|
|
2619
|
+
f"savings_delta={task['savings_percent_delta']}%\t"
|
|
2620
|
+
f"checks_delta={task['passed_check_delta']}/{task['total_check_delta']}"
|
|
2621
|
+
)
|
|
2622
|
+
enforce_regression_gate(diff, enabled=args.fail_on_regression, label="eval diff")
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
def cmd_bench_run(args: argparse.Namespace) -> None:
|
|
2626
|
+
workspace = workspace_from_args(args)
|
|
2627
|
+
report = BenchmarkRunner(workspace).run_directory(
|
|
2628
|
+
Path(args.directory),
|
|
2629
|
+
default_budget=args.budget,
|
|
2630
|
+
default_profile=args.profile,
|
|
2631
|
+
save=not args.no_save,
|
|
2632
|
+
execute_provider=args.provider if args.execute else None,
|
|
2633
|
+
execute_model=args.model,
|
|
2634
|
+
execute_base_url=args.base_url,
|
|
2635
|
+
)
|
|
2636
|
+
if args.json:
|
|
2637
|
+
print_json(report)
|
|
2638
|
+
return
|
|
2639
|
+
|
|
2640
|
+
print_benchmark_report(report)
|
|
2641
|
+
|
|
2642
|
+
|
|
2643
|
+
def cmd_bench_gate(args: argparse.Namespace) -> None:
|
|
2644
|
+
workspace = workspace_from_args(args)
|
|
2645
|
+
runner = BenchmarkRunner(workspace)
|
|
2646
|
+
report = runner.run_directory(
|
|
2647
|
+
Path(args.directory),
|
|
2648
|
+
default_budget=args.budget,
|
|
2649
|
+
default_profile=args.profile,
|
|
2650
|
+
save=True,
|
|
2651
|
+
execute_provider=args.provider if args.execute else None,
|
|
2652
|
+
execute_model=args.model,
|
|
2653
|
+
execute_base_url=args.base_url,
|
|
2654
|
+
)
|
|
2655
|
+
baseline_result = runner.find_baseline(
|
|
2656
|
+
Path(args.directory),
|
|
2657
|
+
baseline_id=args.baseline_report,
|
|
2658
|
+
exclude_id=report["id"],
|
|
2659
|
+
)
|
|
2660
|
+
|
|
2661
|
+
if baseline_result is None:
|
|
2662
|
+
report_ok = benchmark_report_ok(report)
|
|
2663
|
+
result = {
|
|
2664
|
+
"status": "missing_baseline" if report_ok else "failed",
|
|
2665
|
+
"report": report,
|
|
2666
|
+
"report_ok": report_ok,
|
|
2667
|
+
"baseline": None,
|
|
2668
|
+
"baseline_match": None,
|
|
2669
|
+
"diff": None,
|
|
2670
|
+
}
|
|
2671
|
+
if args.json:
|
|
2672
|
+
print_json(result)
|
|
2673
|
+
else:
|
|
2674
|
+
print(f"report: {report['id']}")
|
|
2675
|
+
print(f"benchmark: {report['benchmark']}")
|
|
2676
|
+
print("baseline: none")
|
|
2677
|
+
print(f"status: {result['status']}")
|
|
2678
|
+
print_benchmark_check_summary(report)
|
|
2679
|
+
print("note: no saved benchmark report matched this directory")
|
|
2680
|
+
enforce_benchmark_report_gate(report, label="benchmark gate")
|
|
2681
|
+
if args.require_baseline:
|
|
2682
|
+
raise RuntimeError(f"benchmark gate could not find baseline for {Path(args.directory)}")
|
|
2683
|
+
return
|
|
2684
|
+
|
|
2685
|
+
baseline = baseline_result["report"]
|
|
2686
|
+
diff = runner.diff_reports(baseline["id"], report["id"])
|
|
2687
|
+
report_ok = benchmark_report_ok(report)
|
|
2688
|
+
result = {
|
|
2689
|
+
"status": "passed" if diff.get("ok", False) and report_ok else "failed",
|
|
2690
|
+
"report": report,
|
|
2691
|
+
"report_ok": report_ok,
|
|
2692
|
+
"baseline": benchmark_ref(baseline),
|
|
2693
|
+
"baseline_match": baseline_result["match"],
|
|
2694
|
+
"diff": diff,
|
|
2695
|
+
}
|
|
2696
|
+
if args.json:
|
|
2697
|
+
print_json(result)
|
|
2698
|
+
enforce_benchmark_report_gate(report, label="benchmark gate")
|
|
2699
|
+
enforce_regression_gate(diff, enabled=True, label="benchmark gate")
|
|
2700
|
+
return
|
|
2701
|
+
|
|
2702
|
+
print(f"report: {report['id']}")
|
|
2703
|
+
print(f"benchmark: {report['benchmark']}")
|
|
2704
|
+
print(f"baseline: {baseline['id']}")
|
|
2705
|
+
print(f"baseline_match: {baseline_result['match']}")
|
|
2706
|
+
print(f"status: {result['status']}")
|
|
2707
|
+
print_benchmark_check_summary(report)
|
|
2708
|
+
print_benchmark_diff(diff)
|
|
2709
|
+
enforce_benchmark_report_gate(report, label="benchmark gate")
|
|
2710
|
+
enforce_regression_gate(diff, enabled=True, label="benchmark gate")
|
|
2711
|
+
|
|
2712
|
+
|
|
2713
|
+
def cmd_bench_list(args: argparse.Namespace) -> None:
|
|
2714
|
+
workspace = workspace_from_args(args)
|
|
2715
|
+
reports = BenchmarkRunner(workspace).list_reports()
|
|
2716
|
+
if not reports:
|
|
2717
|
+
print("no benchmark reports")
|
|
2718
|
+
return
|
|
2719
|
+
for report in reports:
|
|
2720
|
+
print(
|
|
2721
|
+
f"{report['id']}\t"
|
|
2722
|
+
f"{report['created_at']}\t"
|
|
2723
|
+
f"fixtures={report['fixture_count']}\t"
|
|
2724
|
+
f"tasks={report['task_count']}\t"
|
|
2725
|
+
f"avg_savings={report['average_savings_percent']}%\t"
|
|
2726
|
+
f"checks={report['checks']}\t"
|
|
2727
|
+
f"{report['name']}"
|
|
2728
|
+
)
|
|
2729
|
+
|
|
2730
|
+
|
|
2731
|
+
def cmd_bench_show(args: argparse.Namespace) -> None:
|
|
2732
|
+
workspace = workspace_from_args(args)
|
|
2733
|
+
print_json(BenchmarkRunner(workspace).get_report(args.report_id))
|
|
2734
|
+
|
|
2735
|
+
|
|
2736
|
+
def cmd_bench_cost(args: argparse.Namespace) -> None:
|
|
2737
|
+
workspace = workspace_from_args(args)
|
|
2738
|
+
cost = build_benchmark_cost_report(BenchmarkRunner(workspace).get_report(args.report_id))
|
|
2739
|
+
if args.json:
|
|
2740
|
+
print_json(cost)
|
|
2741
|
+
return
|
|
2742
|
+
print(render_cost_report(cost))
|
|
2743
|
+
|
|
2744
|
+
|
|
2745
|
+
def cmd_bench_diff(args: argparse.Namespace) -> None:
|
|
2746
|
+
workspace = workspace_from_args(args)
|
|
2747
|
+
diff = BenchmarkRunner(workspace).diff_reports(args.before_id, args.after_id)
|
|
2748
|
+
if args.json:
|
|
2749
|
+
print_json(diff)
|
|
2750
|
+
enforce_regression_gate(diff, enabled=args.fail_on_regression, label="benchmark diff")
|
|
2751
|
+
return
|
|
2752
|
+
|
|
2753
|
+
print_benchmark_diff(diff)
|
|
2754
|
+
enforce_regression_gate(diff, enabled=args.fail_on_regression, label="benchmark diff")
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def cmd_bench_export(args: argparse.Namespace) -> None:
|
|
2758
|
+
workspace = workspace_from_args(args)
|
|
2759
|
+
output = Path(args.output) if args.output else None
|
|
2760
|
+
path = BenchmarkRunner(workspace).export_markdown(args.report_id, output=output)
|
|
2761
|
+
print(f"exported: {path}")
|
|
2762
|
+
|
|
2763
|
+
|
|
2764
|
+
def cmd_bench_evidence(args: argparse.Namespace) -> None:
|
|
2765
|
+
workspace = workspace_from_args(args)
|
|
2766
|
+
runner = BenchmarkRunner(workspace)
|
|
2767
|
+
report_ids = args.report_ids or None
|
|
2768
|
+
evidence = runner.evidence(report_ids, limit=args.limit)
|
|
2769
|
+
if args.output:
|
|
2770
|
+
path = runner.export_evidence_markdown(report_ids, limit=args.limit, output=Path(args.output))
|
|
2771
|
+
print(f"exported: {path}")
|
|
2772
|
+
if args.json:
|
|
2773
|
+
print_json(evidence)
|
|
2774
|
+
elif not args.output:
|
|
2775
|
+
print(render_benchmark_evidence_markdown(evidence).rstrip())
|
|
2776
|
+
if args.fail_under is not None and evidence["total_savings_percent"] < args.fail_under:
|
|
2777
|
+
raise SystemExit(
|
|
2778
|
+
f"benchmark evidence below threshold: {evidence['total_savings_percent']}% < {args.fail_under}%"
|
|
2779
|
+
)
|
|
2780
|
+
|
|
2781
|
+
|
|
2782
|
+
def cmd_trace_list(args: argparse.Namespace) -> None:
|
|
2783
|
+
workspace = workspace_from_args(args)
|
|
2784
|
+
paths = sorted(workspace.traces_dir.glob("*.json"))
|
|
2785
|
+
if not paths:
|
|
2786
|
+
print("no traces")
|
|
2787
|
+
return
|
|
2788
|
+
for path in paths:
|
|
2789
|
+
trace = Workspace.read_json(path)
|
|
2790
|
+
response = trace.get("response", {})
|
|
2791
|
+
print(f"{trace['id']}\t{trace['created_at']}\t{trace['provider']}\ttokens={response.get('total_tokens')}\t{trace['request']}")
|
|
2792
|
+
|
|
2793
|
+
|
|
2794
|
+
def cmd_trace_show(args: argparse.Namespace) -> None:
|
|
2795
|
+
workspace = workspace_from_args(args)
|
|
2796
|
+
path = workspace.traces_dir / f"{args.trace_id}.json"
|
|
2797
|
+
if not path.exists():
|
|
2798
|
+
raise KeyError(f"Unknown trace: {args.trace_id}")
|
|
2799
|
+
print_json(Workspace.read_json(path))
|
|
2800
|
+
|
|
2801
|
+
|
|
2802
|
+
def cmd_trace_verify(args: argparse.Namespace) -> None:
|
|
2803
|
+
workspace = workspace_from_args(args)
|
|
2804
|
+
path = workspace.traces_dir / f"{args.trace_id}.json"
|
|
2805
|
+
if not path.exists():
|
|
2806
|
+
raise KeyError(f"Unknown trace: {args.trace_id}")
|
|
2807
|
+
result = verify_trace(Workspace.read_json(path), expect_json=args.expect_json)
|
|
2808
|
+
print_json(result)
|
|
2809
|
+
|
|
2810
|
+
|
|
2811
|
+
def cmd_trace_remember(args: argparse.Namespace) -> None:
|
|
2812
|
+
workspace = workspace_from_args(args)
|
|
2813
|
+
path = workspace.traces_dir / f"{args.trace_id}.json"
|
|
2814
|
+
if not path.exists():
|
|
2815
|
+
raise KeyError(f"Unknown trace: {args.trace_id}")
|
|
2816
|
+
trace = Workspace.read_json(path)
|
|
2817
|
+
writer = StateWriter(workspace)
|
|
2818
|
+
if args.dry_run:
|
|
2819
|
+
print_json({"enabled": False, "candidates": writer.propose_from_trace(trace)})
|
|
2820
|
+
return
|
|
2821
|
+
result = writer.write_from_trace(trace)
|
|
2822
|
+
trace["state"] = result
|
|
2823
|
+
Workspace.write_json(path, trace)
|
|
2824
|
+
print(f"state: wrote {result['written_count']} memory record(s)")
|
|
2825
|
+
|
|
2826
|
+
|
|
2827
|
+
def print_policy_result(result: dict[str, Any], as_json: bool) -> None:
|
|
2828
|
+
if as_json:
|
|
2829
|
+
print_json(result)
|
|
2830
|
+
return
|
|
2831
|
+
print(f"{result['status']}: {result['kind']} {result['operation']} {result['subject']}")
|
|
2832
|
+
for reason in result["reasons"]:
|
|
2833
|
+
print(f"- {reason}")
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
def print_tool_result(result: dict[str, Any]) -> None:
|
|
2837
|
+
status = "blocked" if result["blocked"] else "ok" if result["ok"] else "failed"
|
|
2838
|
+
print(f"{status}: {result['tool']} trace={result['id']}")
|
|
2839
|
+
if result.get("error"):
|
|
2840
|
+
print(f"error: {result['error']}")
|
|
2841
|
+
print(f"policy: {result['policy']['status']}")
|
|
2842
|
+
|
|
2843
|
+
|
|
2844
|
+
def list_agent_reports(workspace: Workspace) -> list[dict[str, Any]]:
|
|
2845
|
+
workspace.agent_runs_dir.mkdir(parents=True, exist_ok=True)
|
|
2846
|
+
reports = [Workspace.read_json(path) for path in sorted(workspace.agent_runs_dir.glob("*.json"))]
|
|
2847
|
+
return sorted(reports, key=lambda report: report.get("created_at", ""), reverse=True)
|
|
2848
|
+
|
|
2849
|
+
|
|
2850
|
+
def print_benchmark_report(report: dict[str, Any]) -> None:
|
|
2851
|
+
summary = report["summary"]
|
|
2852
|
+
print(f"report: {report['id']}")
|
|
2853
|
+
print(f"benchmark: {report['benchmark']}")
|
|
2854
|
+
print(f"fixtures: {summary['fixture_count']}")
|
|
2855
|
+
print(f"tasks: {summary['task_count']}")
|
|
2856
|
+
print(f"avg_savings: {summary['average_savings_percent']}%")
|
|
2857
|
+
print(f"total_kernel_tokens: {summary['total_kernel_tokens']}")
|
|
2858
|
+
print(f"total_baseline_tokens: {summary['total_baseline_tokens']}")
|
|
2859
|
+
if summary["executed_tasks"]:
|
|
2860
|
+
print(f"executed_tasks: {summary['executed_tasks']}")
|
|
2861
|
+
print(f"execution_tokens: {summary['total_execution_tokens']}")
|
|
2862
|
+
if summary.get("blocked_tasks"):
|
|
2863
|
+
print(f"blocked_tasks: {summary['blocked_tasks']}")
|
|
2864
|
+
print(f"checks: {summary['passed_checks']}/{summary['total_checks']}")
|
|
2865
|
+
for fixture in report["fixtures"]:
|
|
2866
|
+
fixture_summary = fixture["summary"]
|
|
2867
|
+
print(
|
|
2868
|
+
f"{Path(fixture['fixture']).name}\t"
|
|
2869
|
+
f"tasks={fixture_summary['task_count']}\t"
|
|
2870
|
+
f"avg_savings={fixture_summary['average_savings_percent']}%\t"
|
|
2871
|
+
f"checks={fixture_summary['passed_checks']}/{fixture_summary['total_checks']}"
|
|
2872
|
+
)
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
def print_benchmark_check_summary(report: dict[str, Any]) -> None:
|
|
2876
|
+
summary = report.get("summary", {})
|
|
2877
|
+
print(f"current_checks: {summary.get('passed_checks', 0)}/{summary.get('total_checks', 0)}")
|
|
2878
|
+
|
|
2879
|
+
|
|
2880
|
+
def benchmark_report_ok(report: dict[str, Any]) -> bool:
|
|
2881
|
+
return bool(report.get("summary", {}).get("ok", False))
|
|
2882
|
+
|
|
2883
|
+
|
|
2884
|
+
def enforce_benchmark_report_gate(report: dict[str, Any], *, label: str) -> None:
|
|
2885
|
+
if benchmark_report_ok(report):
|
|
2886
|
+
return
|
|
2887
|
+
summary = report.get("summary", {})
|
|
2888
|
+
passed = summary.get("passed_checks", 0)
|
|
2889
|
+
total = summary.get("total_checks", 0)
|
|
2890
|
+
raise RuntimeError(f"{label} current benchmark checks failed: {passed}/{total}")
|
|
2891
|
+
|
|
2892
|
+
|
|
2893
|
+
def print_benchmark_diff(diff: dict[str, Any]) -> None:
|
|
2894
|
+
summary = diff["summary_delta"]
|
|
2895
|
+
print(f"before: {diff['before']['id']}")
|
|
2896
|
+
print(f"after: {diff['after']['id']}")
|
|
2897
|
+
print(f"fixtures_delta: {summary['fixtures']}")
|
|
2898
|
+
print(f"tasks_delta: {summary['tasks']}")
|
|
2899
|
+
print(f"kernel_tokens_delta: {summary['kernel_tokens']}")
|
|
2900
|
+
print(f"baseline_tokens_delta: {summary['baseline_tokens']}")
|
|
2901
|
+
print(f"savings_tokens_delta: {summary['savings_tokens']}")
|
|
2902
|
+
print(f"savings_percent_delta: {summary['savings_percent']}")
|
|
2903
|
+
print(f"checks_delta: {summary['passed_checks']}/{summary['total_checks']}")
|
|
2904
|
+
print(f"execution_tokens_delta: {summary['execution_tokens']}")
|
|
2905
|
+
print(f"regressions: {len(diff['regressions'])}")
|
|
2906
|
+
cost_diff = diff.get("cost_diff", {})
|
|
2907
|
+
if cost_diff:
|
|
2908
|
+
hotspot = cost_diff.get("hotspot_change", {})
|
|
2909
|
+
weakest = cost_diff.get("weakest_savings_change", {})
|
|
2910
|
+
print(f"cost_regressions: {len(diff.get('cost_regressions', []))}")
|
|
2911
|
+
print(
|
|
2912
|
+
f"hotspot_delta: {hotspot.get('before_scope', '')} -> {hotspot.get('after_scope', '')} "
|
|
2913
|
+
f"({hotspot.get('metric_delta', 0)})"
|
|
2914
|
+
)
|
|
2915
|
+
print(
|
|
2916
|
+
f"weakest_savings_delta: {weakest.get('before_scope', '')} -> {weakest.get('after_scope', '')} "
|
|
2917
|
+
f"({weakest.get('metric_delta', 0)})"
|
|
2918
|
+
)
|
|
2919
|
+
for fixture in diff["fixtures"]:
|
|
2920
|
+
if fixture["status"] != "changed":
|
|
2921
|
+
print(f"{fixture['fixture']}\t{fixture['status']}")
|
|
2922
|
+
continue
|
|
2923
|
+
fixture_summary = fixture["summary_delta"]
|
|
2924
|
+
print(
|
|
2925
|
+
f"{fixture['fixture']}\t"
|
|
2926
|
+
f"kernel_delta={fixture_summary['kernel_tokens']}\t"
|
|
2927
|
+
f"savings_delta={fixture_summary['savings_percent']}%\t"
|
|
2928
|
+
f"checks_delta={fixture_summary['passed_checks']}/{fixture_summary['total_checks']}\t"
|
|
2929
|
+
f"regressions={len(fixture['regressions'])}"
|
|
2930
|
+
)
|
|
2931
|
+
|
|
2932
|
+
|
|
2933
|
+
def enforce_regression_gate(diff: dict[str, Any], *, enabled: bool, label: str) -> None:
|
|
2934
|
+
if not enabled or diff.get("ok", True):
|
|
2935
|
+
return
|
|
2936
|
+
reasons: list[str] = []
|
|
2937
|
+
regressions = diff.get("regressions", [])
|
|
2938
|
+
cost_regressions = diff.get("cost_regressions", [])
|
|
2939
|
+
if regressions:
|
|
2940
|
+
reasons.append(f"{len(regressions)} behavior regression(s)")
|
|
2941
|
+
if cost_regressions:
|
|
2942
|
+
reasons.append(f"{len(cost_regressions)} cost regression(s)")
|
|
2943
|
+
if not reasons:
|
|
2944
|
+
reasons.append("regressions detected")
|
|
2945
|
+
raise RuntimeError(f"{label} found regressions: {', '.join(reasons)}")
|
|
2946
|
+
|
|
2947
|
+
|
|
2948
|
+
def print_json(data: dict[str, Any]) -> None:
|
|
2949
|
+
print(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True))
|
|
2950
|
+
|
|
2951
|
+
|
|
2952
|
+
if __name__ == "__main__":
|
|
2953
|
+
main()
|