honcho-cli 0.1.5__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/CHANGELOG.md +15 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/PKG-INFO +2 -2
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/pyproject.toml +2 -2
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/__init__.py +1 -1
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/conclusion.py +133 -28
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/peer.py +19 -3
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/setup.py +9 -2
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/workspace.py +21 -3
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/common.py +41 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/config.py +14 -1
- honcho_cli-0.2.0/tests/test_conclusion_attribution.py +142 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_config.py +10 -1
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/.gitignore +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/README.md +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/scripts/generate_cli_docs.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/_help.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/branding.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/__init__.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/config_cmd.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/message.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/scope.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/session.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/stack.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/__init__.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/docker.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/env.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/health.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/profile.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/setup.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/templates/__init__.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/templates/docker-compose.yml +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/templates/init.sql +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/main.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/oauth.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/output.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/recall.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/update_check.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/validation.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/__init__.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/conftest.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_commands.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_common.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_local.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_oauth.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_output.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_recall.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_setup.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_start.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_validation.py +0 -0
- {honcho_cli-0.1.5 → honcho_cli-0.2.0}/uv.lock +0 -0
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- CLI requests carry `X-Honcho-Host: honcho-cli/<version> (<platform>)` so telemetry counts them as CLI traffic rather than direct SDK use (#1181)
|
|
15
|
+
- Conclusion attribution on `honcho conclusion list` and `honcho conclusion search` (Honcho v3.2.0+): every row now carries `level`, `source_ids` and `times_derived`, so an extracted fact is distinguishable from a dreamed one. Table output shows a premise count; `--json` keeps the full id list, ready to pipe into `honcho conclusion get` (#952)
|
|
16
|
+
- `honcho conclusion get <id>...` fetches conclusions by ID from anywhere in the workspace, with no observer/observed pair required. Feed it a conclusion's `source_ids` to walk a reasoning chain down to the explicit facts underneath it; IDs that no longer exist are reported rather than failing the command (#952)
|
|
17
|
+
- `honcho conclusion derived <id>` lists what was built on top of a conclusion — the same edge walked upward. Worth checking before deleting or correcting a fact (#952)
|
|
18
|
+
- `--level` on `honcho conclusion list` and `honcho conclusion search`, and `--derived-from` on `honcho conclusion list` (#952)
|
|
19
|
+
- `--evidence` on `honcho peer chat` and `honcho workspace chat` (Honcho v3.2.0+): reports the conclusions and messages the dialectic read and the tools it called. Evidence is collated from what the agent accessed rather than reported by the model, so it over-reports and costs no extra model tokens. Messages are summarised per session, since evidence carries their identity but not their text (#1129)
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Requires `honcho-ai` 2.5.0 (was 2.4.0), which carries the attribution fields and the evidence-bearing chat response
|
|
24
|
+
|
|
10
25
|
## [0.1.5] - 2026-09-09
|
|
11
26
|
|
|
12
27
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: honcho-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A terminal for Honcho — memory that reasons.
|
|
5
5
|
Project-URL: Homepage, https://github.com/plastic-labs/honcho
|
|
6
6
|
Project-URL: Repository, https://github.com/plastic-labs/honcho
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
14
14
|
Classifier: Topic :: Software Development :: Libraries
|
|
15
15
|
Requires-Python: >=3.11
|
|
16
16
|
Requires-Dist: click>=8.0.0
|
|
17
|
-
Requires-Dist: honcho-ai>=2.
|
|
17
|
+
Requires-Dist: honcho-ai>=2.5.0
|
|
18
18
|
Requires-Dist: httpx>=0.27.0
|
|
19
19
|
Requires-Dist: rich>=13.0.0
|
|
20
20
|
Requires-Dist: typer>=0.15.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "honcho-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "A terminal for Honcho — memory that reasons."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -19,7 +19,7 @@ classifiers = [
|
|
|
19
19
|
dependencies = [
|
|
20
20
|
"click>=8.0.0",
|
|
21
21
|
"typer>=0.15.0",
|
|
22
|
-
"honcho-ai>=2.
|
|
22
|
+
"honcho-ai>=2.5.0",
|
|
23
23
|
"rich>=13.0.0",
|
|
24
24
|
"httpx>=0.27.0",
|
|
25
25
|
]
|
|
@@ -18,6 +18,43 @@ app = typer.Typer(cls=HonchoTyperGroup, help="List, search, create, and delete p
|
|
|
18
18
|
add_common_options(app)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
_LEVELS = ("explicit", "deductive", "inductive", "contradiction")
|
|
22
|
+
|
|
23
|
+
#: Columns carrying a conclusion's attribution, shown alongside the content.
|
|
24
|
+
_ATTRIBUTION_COLUMNS = ["id", "level", "source_ids", "times_derived", "content"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _format_conclusion(c, workspace_id: str | None) -> dict:
|
|
28
|
+
"""Shape a Conclusion for output.
|
|
29
|
+
|
|
30
|
+
``level``, ``source_ids`` and ``times_derived`` are the attribution the
|
|
31
|
+
server started returning in Honcho v3.2.0. In table mode ``source_ids``
|
|
32
|
+
collapses to a count, since the ids are nanoids and a list of them makes
|
|
33
|
+
the row unreadable; JSON mode keeps the full list so it can be piped back
|
|
34
|
+
into ``honcho conclusion get``.
|
|
35
|
+
"""
|
|
36
|
+
source_ids = c.source_ids or []
|
|
37
|
+
return {
|
|
38
|
+
"id": c.id,
|
|
39
|
+
"level": c.level,
|
|
40
|
+
"source_ids": source_ids if use_json() else len(source_ids),
|
|
41
|
+
"times_derived": c.times_derived,
|
|
42
|
+
"content": c.content if use_json() else c.content[:160],
|
|
43
|
+
"workspace_id": workspace_id,
|
|
44
|
+
"observer_id": c.observer_id,
|
|
45
|
+
"observed_id": c.observed_id,
|
|
46
|
+
"session_id": c.session_id,
|
|
47
|
+
"created_at": str(c.created_at),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validate_level(level: str | None) -> str | None:
|
|
52
|
+
if level and level not in _LEVELS:
|
|
53
|
+
print_error("INVALID_LEVEL", f"--level must be one of: {', '.join(_LEVELS)}")
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
return level
|
|
56
|
+
|
|
57
|
+
|
|
21
58
|
def _require_observer(observer: str | None) -> str:
|
|
22
59
|
"""Resolve observer peer ID; emit combined error if peer+workspace both missing."""
|
|
23
60
|
config = get_resolved_config()
|
|
@@ -39,6 +76,16 @@ def list_conclusions(
|
|
|
39
76
|
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
|
|
40
77
|
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
|
|
41
78
|
limit: int = typer.Option(10, "--limit", help="Max results"),
|
|
79
|
+
level: Optional[str] = typer.Option(
|
|
80
|
+
None,
|
|
81
|
+
"--level",
|
|
82
|
+
help="Only this reasoning level: explicit, deductive, inductive, contradiction",
|
|
83
|
+
),
|
|
84
|
+
derived_from: Optional[str] = typer.Option(
|
|
85
|
+
None,
|
|
86
|
+
"--derived-from",
|
|
87
|
+
help="Only conclusions derived from this conclusion ID",
|
|
88
|
+
),
|
|
42
89
|
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
43
90
|
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
|
|
44
91
|
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
@@ -46,31 +93,27 @@ def list_conclusions(
|
|
|
46
93
|
"""List conclusions."""
|
|
47
94
|
|
|
48
95
|
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
|
|
96
|
+
_validate_level(level)
|
|
49
97
|
observer = _require_observer(observer)
|
|
50
98
|
client, config = get_client()
|
|
51
99
|
|
|
52
100
|
p = client.peer(observer)
|
|
53
101
|
|
|
102
|
+
filters: dict = {}
|
|
103
|
+
if level:
|
|
104
|
+
filters["level"] = level
|
|
105
|
+
if derived_from:
|
|
106
|
+
filters["source_ids"] = {"contains": derived_from}
|
|
107
|
+
|
|
54
108
|
try:
|
|
55
109
|
if observed:
|
|
56
110
|
scope = p.conclusions_of(observed)
|
|
57
111
|
else:
|
|
58
112
|
scope = p.conclusions
|
|
59
113
|
|
|
60
|
-
conclusions = scope.list(size=limit).items
|
|
61
|
-
items = [
|
|
62
|
-
|
|
63
|
-
"id": c.id,
|
|
64
|
-
"content": c.content if use_json() else c.content[:200],
|
|
65
|
-
"workspace_id": config.workspace_id,
|
|
66
|
-
"observer_id": c.observer_id,
|
|
67
|
-
"observed_id": c.observed_id,
|
|
68
|
-
"session_id": c.session_id,
|
|
69
|
-
"created_at": str(c.created_at),
|
|
70
|
-
}
|
|
71
|
-
for c in conclusions
|
|
72
|
-
]
|
|
73
|
-
print_result(items, columns=["id", "content", "workspace_id", "observer_id", "observed_id", "session_id", "created_at"], title="Conclusions")
|
|
114
|
+
conclusions = scope.list(size=limit, filters=filters or None).items
|
|
115
|
+
items = [_format_conclusion(c, config.workspace_id) for c in conclusions]
|
|
116
|
+
print_result(items, columns=_ATTRIBUTION_COLUMNS + ["observed_id", "created_at"], title="Conclusions")
|
|
74
117
|
except Exception as e:
|
|
75
118
|
_handle_error(e, "conclusion", "list")
|
|
76
119
|
|
|
@@ -81,6 +124,11 @@ def search(
|
|
|
81
124
|
observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"),
|
|
82
125
|
observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"),
|
|
83
126
|
top_k: int = typer.Option(10, help="Max results"),
|
|
127
|
+
level: Optional[str] = typer.Option(
|
|
128
|
+
None,
|
|
129
|
+
"--level",
|
|
130
|
+
help="Only this reasoning level: explicit, deductive, inductive, contradiction",
|
|
131
|
+
),
|
|
84
132
|
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
85
133
|
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
|
|
86
134
|
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
@@ -88,6 +136,7 @@ def search(
|
|
|
88
136
|
"""Semantic search over conclusions."""
|
|
89
137
|
|
|
90
138
|
handle_cmd_flags(json_output=json_output, workspace=workspace, peer=peer)
|
|
139
|
+
_validate_level(level)
|
|
91
140
|
observer = _require_observer(observer)
|
|
92
141
|
client, config = get_client()
|
|
93
142
|
|
|
@@ -99,24 +148,80 @@ def search(
|
|
|
99
148
|
else:
|
|
100
149
|
scope = p.conclusions
|
|
101
150
|
|
|
102
|
-
results = scope.query(query, top_k=top_k)
|
|
103
|
-
items = [
|
|
104
|
-
|
|
105
|
-
"id": c.id,
|
|
106
|
-
"content": c.content if use_json() else c.content[:200],
|
|
107
|
-
"workspace_id": config.workspace_id,
|
|
108
|
-
"observer_id": c.observer_id,
|
|
109
|
-
"observed_id": c.observed_id,
|
|
110
|
-
"session_id": c.session_id,
|
|
111
|
-
"created_at": str(c.created_at),
|
|
112
|
-
}
|
|
113
|
-
for c in results
|
|
114
|
-
]
|
|
115
|
-
print_result(items, columns=["id", "content", "workspace_id", "session_id", "created_at"], title=f"Conclusion search: {query}")
|
|
151
|
+
results = scope.query(query, top_k=top_k, filters={"level": level} if level else None)
|
|
152
|
+
items = [_format_conclusion(c, config.workspace_id) for c in results]
|
|
153
|
+
print_result(items, columns=_ATTRIBUTION_COLUMNS + ["created_at"], title=f"Conclusion search: {query}")
|
|
116
154
|
except Exception as e:
|
|
117
155
|
_handle_error(e, "conclusion", "search")
|
|
118
156
|
|
|
119
157
|
|
|
158
|
+
@app.command()
|
|
159
|
+
def get(
|
|
160
|
+
conclusion_ids: list[str] = typer.Argument(help="Conclusion IDs to fetch"),
|
|
161
|
+
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
162
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Fetch conclusions by ID, from anywhere in the workspace.
|
|
165
|
+
|
|
166
|
+
Pass a conclusion's source_ids to see the premises it was derived from,
|
|
167
|
+
and repeat to walk a reasoning chain down to the explicit facts it rests
|
|
168
|
+
on. IDs that no longer exist are reported rather than failing the command.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
handle_cmd_flags(json_output=json_output, workspace=workspace)
|
|
172
|
+
client, config = get_client()
|
|
173
|
+
|
|
174
|
+
for cid in conclusion_ids:
|
|
175
|
+
validate_resource_id(cid, "conclusion")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
conclusions = client.conclusions.get_many(list(conclusion_ids))
|
|
179
|
+
found = {c.id for c in conclusions}
|
|
180
|
+
missing = [cid for cid in conclusion_ids if cid not in found]
|
|
181
|
+
|
|
182
|
+
items = [_format_conclusion(c, config.workspace_id) for c in conclusions]
|
|
183
|
+
print_result(items, columns=_ATTRIBUTION_COLUMNS + ["observed_id", "created_at"], title="Conclusions")
|
|
184
|
+
if missing:
|
|
185
|
+
print_error(
|
|
186
|
+
"MISSING_CONCLUSIONS",
|
|
187
|
+
f"Not in this workspace (consolidated or deleted): {', '.join(missing)}",
|
|
188
|
+
)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
_handle_error(e, "conclusion", "get")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command()
|
|
194
|
+
def derived(
|
|
195
|
+
conclusion_id: str = typer.Argument(help="The premise conclusion ID"),
|
|
196
|
+
limit: int = typer.Option(10, "--limit", help="Max results"),
|
|
197
|
+
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
198
|
+
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
199
|
+
) -> None:
|
|
200
|
+
"""List the conclusions derived FROM a conclusion.
|
|
201
|
+
|
|
202
|
+
Walks the reasoning tree upward (premise -> conclusion); `conclusion get`
|
|
203
|
+
on a conclusion's source_ids walks it downward. Worth checking before
|
|
204
|
+
deleting or correcting a fact.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
handle_cmd_flags(json_output=json_output, workspace=workspace)
|
|
208
|
+
validate_resource_id(conclusion_id, "conclusion")
|
|
209
|
+
client, config = get_client()
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
page = client.conclusions.list(
|
|
213
|
+
size=limit, filters={"source_ids": {"contains": conclusion_id}}
|
|
214
|
+
)
|
|
215
|
+
items = [_format_conclusion(c, config.workspace_id) for c in page.items]
|
|
216
|
+
print_result(
|
|
217
|
+
items,
|
|
218
|
+
columns=_ATTRIBUTION_COLUMNS + ["observed_id", "created_at"],
|
|
219
|
+
title=f"Derived from {conclusion_id}",
|
|
220
|
+
)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
_handle_error(e, "conclusion", "derived")
|
|
223
|
+
|
|
224
|
+
|
|
120
225
|
@app.command()
|
|
121
226
|
def create(
|
|
122
227
|
content: str = typer.Argument(help="Conclusion content or JSON payload"),
|
|
@@ -15,7 +15,7 @@ from honcho_cli.recall import parse_csv_repeatable, reject_incompatible_recall,
|
|
|
15
15
|
from honcho_cli.validation import validate_resource_id
|
|
16
16
|
|
|
17
17
|
from honcho_cli._help import HonchoTyperGroup
|
|
18
|
-
from honcho_cli.common import add_common_options, get_client, get_flag_overrides, get_resolved_config, handle_cmd_flags
|
|
18
|
+
from honcho_cli.common import add_common_options, format_evidence, get_client, get_flag_overrides, get_resolved_config, handle_cmd_flags
|
|
19
19
|
|
|
20
20
|
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, chat with, search, and manage peers and their representations.")
|
|
21
21
|
add_common_options(app)
|
|
@@ -141,6 +141,11 @@ def chat(
|
|
|
141
141
|
"--sessions",
|
|
142
142
|
help="Recall only from these session IDs (repeat or comma-separate); explicit conclusions only. Excludes -s and --scope.",
|
|
143
143
|
),
|
|
144
|
+
evidence: bool = typer.Option(
|
|
145
|
+
False,
|
|
146
|
+
"--evidence",
|
|
147
|
+
help="Also report what the answer was built from: the conclusions and messages read and the tools called.",
|
|
148
|
+
),
|
|
144
149
|
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
145
150
|
peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"),
|
|
146
151
|
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
|
|
@@ -179,8 +184,19 @@ def chat(
|
|
|
179
184
|
|
|
180
185
|
try:
|
|
181
186
|
p = client.peer(pid)
|
|
182
|
-
|
|
183
|
-
|
|
187
|
+
if not evidence:
|
|
188
|
+
response = p.chat(query, **chat_kwargs)
|
|
189
|
+
print_result({"peer_id": pid, "query": query, "response": response})
|
|
190
|
+
return
|
|
191
|
+
result = p.chat(query, include_evidence=True, **chat_kwargs)
|
|
192
|
+
print_result(
|
|
193
|
+
{
|
|
194
|
+
"peer_id": pid,
|
|
195
|
+
"query": query,
|
|
196
|
+
"response": result.content,
|
|
197
|
+
"evidence": format_evidence(result.evidence),
|
|
198
|
+
}
|
|
199
|
+
)
|
|
184
200
|
except Exception as e:
|
|
185
201
|
_handle_chat_error(e, "peer", pid)
|
|
186
202
|
|
|
@@ -29,6 +29,7 @@ from honcho_cli.config import (
|
|
|
29
29
|
DEFAULT_BASE_URL,
|
|
30
30
|
CLIConfig,
|
|
31
31
|
OAuthTokens,
|
|
32
|
+
identity_headers,
|
|
32
33
|
)
|
|
33
34
|
from honcho_cli.output import print_error, print_result, set_json_mode, use_json
|
|
34
35
|
|
|
@@ -70,7 +71,8 @@ def _test_connection(base_url: str, api_key: str) -> tuple[bool, str]:
|
|
|
70
71
|
substrings of error messages — robust to SDK message changes and locale.
|
|
71
72
|
"""
|
|
72
73
|
try:
|
|
73
|
-
|
|
74
|
+
client = Honcho(base_url=base_url, api_key=api_key, default_headers=identity_headers())
|
|
75
|
+
list(client.workspaces())
|
|
74
76
|
return True, "OK"
|
|
75
77
|
except AuthenticationError:
|
|
76
78
|
return False, "Unauthorized — check your API key"
|
|
@@ -392,7 +394,12 @@ def doctor(
|
|
|
392
394
|
try:
|
|
393
395
|
|
|
394
396
|
|
|
395
|
-
client = Honcho(
|
|
397
|
+
client = Honcho(
|
|
398
|
+
base_url=config.base_url,
|
|
399
|
+
api_key=key,
|
|
400
|
+
workspace_id=config.workspace_id,
|
|
401
|
+
default_headers=identity_headers(),
|
|
402
|
+
)
|
|
396
403
|
client.get_configuration()
|
|
397
404
|
ws_ok = True
|
|
398
405
|
_add("Workspace reachable", True, config.workspace_id)
|
|
@@ -16,12 +16,13 @@ from honcho import (
|
|
|
16
16
|
ServerError,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
from honcho_cli.config import identity_headers
|
|
19
20
|
from honcho_cli.output import print_error, print_result, status, use_json
|
|
20
21
|
from honcho_cli.recall import parse_csv_repeatable, reject_incompatible_recall, scope_for_sdk
|
|
21
22
|
from honcho_cli.validation import validate_resource_id
|
|
22
23
|
|
|
23
24
|
from honcho_cli._help import HonchoTyperGroup
|
|
24
|
-
from honcho_cli.common import add_common_options, get_client, get_flag_overrides, get_resolved_config, handle_cmd_flags
|
|
25
|
+
from honcho_cli.common import add_common_options, format_evidence, get_client, get_flag_overrides, get_resolved_config, handle_cmd_flags
|
|
25
26
|
|
|
26
27
|
app = typer.Typer(cls=HonchoTyperGroup, help="List, create, inspect, chat, delete, and search workspaces.")
|
|
27
28
|
add_common_options(app)
|
|
@@ -221,6 +222,11 @@ def chat(
|
|
|
221
222
|
"--scope",
|
|
222
223
|
help="Recall only from this scope. Repeat or comma-separate for several (explicit conclusions only). Excludes -s.",
|
|
223
224
|
),
|
|
225
|
+
evidence: bool = typer.Option(
|
|
226
|
+
False,
|
|
227
|
+
"--evidence",
|
|
228
|
+
help="Also report what the answer was built from: the conclusions and messages read and the tools called.",
|
|
229
|
+
),
|
|
224
230
|
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"),
|
|
225
231
|
session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"),
|
|
226
232
|
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
|
|
@@ -252,8 +258,19 @@ def chat(
|
|
|
252
258
|
chat_kwargs["scope"] = scope_arg
|
|
253
259
|
|
|
254
260
|
try:
|
|
255
|
-
|
|
256
|
-
|
|
261
|
+
if not evidence:
|
|
262
|
+
response = client.chat(query, **chat_kwargs)
|
|
263
|
+
print_result({"workspace_id": wid, "query": query, "response": response})
|
|
264
|
+
return
|
|
265
|
+
result = client.chat(query, include_evidence=True, **chat_kwargs)
|
|
266
|
+
print_result(
|
|
267
|
+
{
|
|
268
|
+
"workspace_id": wid,
|
|
269
|
+
"query": query,
|
|
270
|
+
"response": result.content,
|
|
271
|
+
"evidence": format_evidence(result.evidence),
|
|
272
|
+
}
|
|
273
|
+
)
|
|
257
274
|
except Exception as e:
|
|
258
275
|
_handle_chat_error(e, "workspace", wid)
|
|
259
276
|
|
|
@@ -317,6 +334,7 @@ def _with_workspace(client, workspace_id: str):
|
|
|
317
334
|
base_url=str(client.base_url),
|
|
318
335
|
api_key=client._http.api_key if hasattr(client._http, "api_key") else None,
|
|
319
336
|
workspace_id=workspace_id,
|
|
337
|
+
default_headers=identity_headers(),
|
|
320
338
|
)
|
|
321
339
|
|
|
322
340
|
|
|
@@ -166,3 +166,44 @@ def add_common_options(app: typer.Typer) -> None:
|
|
|
166
166
|
|
|
167
167
|
if ctx.invoked_subcommand is None:
|
|
168
168
|
typer.echo(ctx.get_help())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def format_evidence(evidence) -> dict:
|
|
172
|
+
"""Shape a dialectic `Evidence` for output.
|
|
173
|
+
|
|
174
|
+
Evidence is collated from what the agent accessed rather than reported by
|
|
175
|
+
the model, so it over-reports: a conclusion is listed because the agent
|
|
176
|
+
read it, which is not proof the answer leaned on it. Messages carry
|
|
177
|
+
identity only -- no content -- so they are summarised per session rather
|
|
178
|
+
than listed one by one.
|
|
179
|
+
"""
|
|
180
|
+
if evidence is None:
|
|
181
|
+
return {"conclusions": [], "messages": {"total": 0, "sessions": []}, "tool_calls": []}
|
|
182
|
+
|
|
183
|
+
sessions: dict[str, int] = {}
|
|
184
|
+
for m in evidence.messages:
|
|
185
|
+
sessions[m.session_id] = sessions.get(m.session_id, 0) + 1
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"conclusions": [
|
|
189
|
+
{
|
|
190
|
+
"id": c.id,
|
|
191
|
+
"level": c.level,
|
|
192
|
+
"content": c.content,
|
|
193
|
+
"source_ids": list(c.source_ids or []),
|
|
194
|
+
"session_id": c.session_id,
|
|
195
|
+
"created_at": str(c.created_at),
|
|
196
|
+
}
|
|
197
|
+
for c in evidence.conclusions
|
|
198
|
+
],
|
|
199
|
+
"messages": {
|
|
200
|
+
"total": len(evidence.messages),
|
|
201
|
+
"sessions": [
|
|
202
|
+
{"session_id": sid, "count": n}
|
|
203
|
+
for sid, n in sorted(sessions.items(), key=lambda kv: -kv[1])
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
"tool_calls": [
|
|
207
|
+
{"tool_name": t.tool_name, "tool_input": t.tool_input} for t in evidence.tool_calls
|
|
208
|
+
],
|
|
209
|
+
}
|
|
@@ -27,11 +27,14 @@ from __future__ import annotations
|
|
|
27
27
|
|
|
28
28
|
import json
|
|
29
29
|
import os
|
|
30
|
+
import sys
|
|
30
31
|
import time
|
|
31
32
|
from dataclasses import dataclass, fields
|
|
32
33
|
from pathlib import Path
|
|
33
34
|
from typing import TYPE_CHECKING
|
|
34
35
|
|
|
36
|
+
from honcho_cli import __version__
|
|
37
|
+
|
|
35
38
|
if TYPE_CHECKING:
|
|
36
39
|
from honcho_cli.oauth import TokenResponse
|
|
37
40
|
|
|
@@ -279,9 +282,19 @@ class CLIConfig:
|
|
|
279
282
|
return result
|
|
280
283
|
|
|
281
284
|
|
|
285
|
+
def identity_headers() -> dict[str, str]:
|
|
286
|
+
"""Headers identifying the CLI to Honcho's telemetry.
|
|
287
|
+
|
|
288
|
+
Overrides the Python SDK's own ``X-Honcho-Host`` default so CLI traffic is not
|
|
289
|
+
counted as direct SDK use. Follows the identity convention harness plugins use:
|
|
290
|
+
``name/version (platform)``.
|
|
291
|
+
"""
|
|
292
|
+
return {"X-Honcho-Host": f"honcho-cli/{__version__} ({sys.platform})"}
|
|
293
|
+
|
|
294
|
+
|
|
282
295
|
def get_client_kwargs(config: CLIConfig) -> dict:
|
|
283
296
|
"""Build kwargs for Honcho client from config."""
|
|
284
|
-
kwargs: dict = {}
|
|
297
|
+
kwargs: dict = {"default_headers": identity_headers()}
|
|
285
298
|
if config.base_url:
|
|
286
299
|
kwargs["base_url"] = config.base_url
|
|
287
300
|
api_key = config.resolved_api_key()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Conclusion attribution and dialectic evidence (Honcho v3.2.0+).
|
|
2
|
+
|
|
3
|
+
Uses Typer's CliRunner against the real `app`. stdout is not a TTY under
|
|
4
|
+
CliRunner, so the CLI emits JSON — which is what scripts and agents consume.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from typer.testing import CliRunner
|
|
15
|
+
|
|
16
|
+
from honcho_cli.main import app
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def cfg(tmp_path, monkeypatch):
|
|
21
|
+
f = tmp_path / "config.json"
|
|
22
|
+
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
|
|
23
|
+
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
|
|
24
|
+
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
|
|
25
|
+
monkeypatch.delenv(k)
|
|
26
|
+
f.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
|
|
27
|
+
return f
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def runner():
|
|
32
|
+
return CliRunner()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _conclusion(cid: str, level: str = "inductive", source_ids: list[str] | None = None) -> MagicMock:
|
|
36
|
+
return MagicMock(
|
|
37
|
+
id=cid,
|
|
38
|
+
content=f"content for {cid}",
|
|
39
|
+
level=level,
|
|
40
|
+
source_ids=source_ids if source_ids is not None else ["p1", "p2"],
|
|
41
|
+
times_derived=3,
|
|
42
|
+
observer_id="alice",
|
|
43
|
+
observed_id="alice",
|
|
44
|
+
session_id="s1",
|
|
45
|
+
created_at="2026-09-15T00:00:00Z",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestConclusionAttribution:
|
|
50
|
+
def test_list_reports_level_source_ids_and_times_derived(self, cfg, runner):
|
|
51
|
+
client = MagicMock()
|
|
52
|
+
config = MagicMock(workspace_id="ws1", peer_id="alice")
|
|
53
|
+
client.peer.return_value.conclusions.list.return_value = MagicMock(items=[_conclusion("c1")])
|
|
54
|
+
|
|
55
|
+
with patch("honcho_cli.commands.conclusion.get_client", return_value=(client, config)):
|
|
56
|
+
result = runner.invoke(app, ["conclusion", "list", "--level", "inductive", "-p", "alice"])
|
|
57
|
+
|
|
58
|
+
assert result.exit_code == 0
|
|
59
|
+
row = json.loads(result.stdout)[0]
|
|
60
|
+
assert row["level"] == "inductive"
|
|
61
|
+
assert row["source_ids"] == ["p1", "p2"]
|
|
62
|
+
assert row["times_derived"] == 3
|
|
63
|
+
# --level reaches the server as a filter rather than being applied locally
|
|
64
|
+
assert client.peer.return_value.conclusions.list.call_args.kwargs["filters"] == {"level": "inductive"}
|
|
65
|
+
|
|
66
|
+
def test_list_rejects_an_unknown_level(self, cfg, runner):
|
|
67
|
+
result = runner.invoke(app, ["conclusion", "list", "--level", "nonsense", "-p", "alice"])
|
|
68
|
+
assert result.exit_code == 1
|
|
69
|
+
assert json.loads(result.stderr)["error"]["code"] == "INVALID_LEVEL"
|
|
70
|
+
|
|
71
|
+
def test_get_reports_ids_the_server_did_not_return(self, cfg, runner):
|
|
72
|
+
client = MagicMock()
|
|
73
|
+
config = MagicMock(workspace_id="ws1", peer_id="alice")
|
|
74
|
+
client.conclusions.get_many.return_value = [_conclusion("c1", level="explicit", source_ids=[])]
|
|
75
|
+
|
|
76
|
+
with patch("honcho_cli.commands.conclusion.get_client", return_value=(client, config)):
|
|
77
|
+
result = runner.invoke(app, ["conclusion", "get", "c1", "gone"])
|
|
78
|
+
|
|
79
|
+
assert result.exit_code == 0
|
|
80
|
+
assert client.conclusions.get_many.call_args.args[0] == ["c1", "gone"]
|
|
81
|
+
err = json.loads(result.stderr)["error"]
|
|
82
|
+
assert err["code"] == "MISSING_CONCLUSIONS"
|
|
83
|
+
assert "gone" in err["message"]
|
|
84
|
+
|
|
85
|
+
def test_derived_asks_for_conclusions_containing_the_premise(self, cfg, runner):
|
|
86
|
+
client = MagicMock()
|
|
87
|
+
config = MagicMock(workspace_id="ws1", peer_id="alice")
|
|
88
|
+
client.conclusions.list.return_value = MagicMock(items=[_conclusion("c1")])
|
|
89
|
+
|
|
90
|
+
with patch("honcho_cli.commands.conclusion.get_client", return_value=(client, config)):
|
|
91
|
+
result = runner.invoke(app, ["conclusion", "derived", "p1"])
|
|
92
|
+
|
|
93
|
+
assert result.exit_code == 0
|
|
94
|
+
assert client.conclusions.list.call_args.kwargs["filters"] == {"source_ids": {"contains": "p1"}}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestChatEvidence:
|
|
98
|
+
def _evidence(self) -> MagicMock:
|
|
99
|
+
return MagicMock(
|
|
100
|
+
conclusions=[_conclusion("c1", level="explicit", source_ids=[])],
|
|
101
|
+
messages=[
|
|
102
|
+
MagicMock(id="m1", session_id="s1", peer_id="alice", created_at="2026-09-15T00:00:00Z"),
|
|
103
|
+
MagicMock(id="m2", session_id="s1", peer_id="bob", created_at="2026-09-15T00:00:01Z"),
|
|
104
|
+
],
|
|
105
|
+
tool_calls=[MagicMock(tool_name="search_memory", tool_input={"query": "q"})],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def test_peer_chat_returns_evidence_only_when_asked(self, cfg, runner):
|
|
109
|
+
client = MagicMock()
|
|
110
|
+
config = MagicMock(workspace_id="ws1", peer_id="alice", session_id=None)
|
|
111
|
+
peer = client.peer.return_value
|
|
112
|
+
peer.chat.return_value = "plain answer"
|
|
113
|
+
|
|
114
|
+
with patch("honcho_cli.commands.peer.get_client", return_value=(client, config)):
|
|
115
|
+
plain = runner.invoke(app, ["peer", "chat", "q", "-p", "alice"])
|
|
116
|
+
assert plain.exit_code == 0
|
|
117
|
+
assert json.loads(plain.stdout)["response"] == "plain answer"
|
|
118
|
+
assert "include_evidence" not in peer.chat.call_args.kwargs
|
|
119
|
+
|
|
120
|
+
peer.chat.return_value = MagicMock(content="answer", evidence=self._evidence())
|
|
121
|
+
with patch("honcho_cli.commands.peer.get_client", return_value=(client, config)):
|
|
122
|
+
result = runner.invoke(app, ["peer", "chat", "q", "-p", "alice", "--evidence"])
|
|
123
|
+
|
|
124
|
+
assert result.exit_code == 0
|
|
125
|
+
assert peer.chat.call_args.kwargs["include_evidence"] is True
|
|
126
|
+
evidence = json.loads(result.stdout)["evidence"]
|
|
127
|
+
assert evidence["conclusions"][0]["level"] == "explicit"
|
|
128
|
+
assert evidence["tool_calls"] == [{"tool_name": "search_memory", "tool_input": {"query": "q"}}]
|
|
129
|
+
# Messages carry identity only, so they collapse to per-session counts
|
|
130
|
+
assert evidence["messages"] == {"total": 2, "sessions": [{"session_id": "s1", "count": 2}]}
|
|
131
|
+
|
|
132
|
+
def test_workspace_chat_passes_the_flag_through(self, cfg, runner):
|
|
133
|
+
client = MagicMock()
|
|
134
|
+
config = MagicMock(workspace_id="ws1", session_id=None)
|
|
135
|
+
client.chat.return_value = MagicMock(content="answer", evidence=self._evidence())
|
|
136
|
+
|
|
137
|
+
with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)):
|
|
138
|
+
result = runner.invoke(app, ["workspace", "chat", "q", "-w", "ws1", "--evidence"])
|
|
139
|
+
|
|
140
|
+
assert result.exit_code == 0
|
|
141
|
+
assert client.chat.call_args.kwargs["include_evidence"] is True
|
|
142
|
+
assert json.loads(result.stdout)["evidence"]["messages"]["total"] == 2
|
|
@@ -6,7 +6,8 @@ import time
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
|
-
from honcho_cli
|
|
9
|
+
from honcho_cli import __version__
|
|
10
|
+
from honcho_cli.config import CLIConfig, OAuthTokens, _config_dir, get_client_kwargs
|
|
10
11
|
from honcho_cli.oauth import TokenResponse
|
|
11
12
|
|
|
12
13
|
|
|
@@ -264,3 +265,11 @@ def test_save_sets_600_permissions(cfg_path):
|
|
|
264
265
|
mode = stat.S_IMODE(os.stat(cfg_path).st_mode)
|
|
265
266
|
# chmod(0o600) → rw- --- ---
|
|
266
267
|
assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_client_kwargs_identify_the_cli_as_the_host():
|
|
271
|
+
"""CLI traffic overrides the SDK's own X-Honcho-Host so telemetry counts it as
|
|
272
|
+
harness traffic, not direct SDK use."""
|
|
273
|
+
kwargs = get_client_kwargs(CLIConfig(workspace_id="ws"))
|
|
274
|
+
|
|
275
|
+
assert kwargs["default_headers"]["X-Honcho-Host"].startswith(f"honcho-cli/{__version__} (")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|