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.
Files changed (50) hide show
  1. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/CHANGELOG.md +15 -0
  2. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/PKG-INFO +2 -2
  3. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/pyproject.toml +2 -2
  4. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/__init__.py +1 -1
  5. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/conclusion.py +133 -28
  6. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/peer.py +19 -3
  7. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/setup.py +9 -2
  8. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/workspace.py +21 -3
  9. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/common.py +41 -0
  10. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/config.py +14 -1
  11. honcho_cli-0.2.0/tests/test_conclusion_attribution.py +142 -0
  12. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_config.py +10 -1
  13. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/.gitignore +0 -0
  14. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/README.md +0 -0
  15. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/scripts/generate_cli_docs.py +0 -0
  16. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/_help.py +0 -0
  17. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/branding.py +0 -0
  18. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/__init__.py +0 -0
  19. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/config_cmd.py +0 -0
  20. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/message.py +0 -0
  21. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/scope.py +0 -0
  22. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/session.py +0 -0
  23. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/commands/stack.py +0 -0
  24. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/__init__.py +0 -0
  25. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/docker.py +0 -0
  26. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/env.py +0 -0
  27. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/health.py +0 -0
  28. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/profile.py +0 -0
  29. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/setup.py +0 -0
  30. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/templates/__init__.py +0 -0
  31. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/templates/docker-compose.yml +0 -0
  32. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/local/templates/init.sql +0 -0
  33. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/main.py +0 -0
  34. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/oauth.py +0 -0
  35. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/output.py +0 -0
  36. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/recall.py +0 -0
  37. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/update_check.py +0 -0
  38. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/src/honcho_cli/validation.py +0 -0
  39. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/__init__.py +0 -0
  40. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/conftest.py +0 -0
  41. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_commands.py +0 -0
  42. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_common.py +0 -0
  43. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_local.py +0 -0
  44. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_oauth.py +0 -0
  45. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_output.py +0 -0
  46. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_recall.py +0 -0
  47. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_setup.py +0 -0
  48. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_start.py +0 -0
  49. {honcho_cli-0.1.5 → honcho_cli-0.2.0}/tests/test_validation.py +0 -0
  50. {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.1.5
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.4.0
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.1.5"
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.4.0",
22
+ "honcho-ai>=2.5.0",
23
23
  "rich>=13.0.0",
24
24
  "httpx>=0.27.0",
25
25
  ]
@@ -1,3 +1,3 @@
1
1
  """Honcho CLI — a terminal for Honcho."""
2
2
 
3
- __version__ = "0.1.5"
3
+ __version__ = "0.2.0"
@@ -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
- response = p.chat(query, **chat_kwargs)
183
- print_result({"peer_id": pid, "query": query, "response": response})
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
- list(Honcho(base_url=base_url, api_key=api_key).workspaces())
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(base_url=config.base_url, api_key=key, workspace_id=config.workspace_id)
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
- response = client.chat(query, **chat_kwargs)
256
- print_result({"workspace_id": wid, "query": query, "response": response})
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.config import CLIConfig, OAuthTokens, _config_dir
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