honcho-cli 0.1.5__tar.gz → 0.2.1__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 (51) hide show
  1. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/CHANGELOG.md +21 -0
  2. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/PKG-INFO +2 -2
  3. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/pyproject.toml +2 -2
  4. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/__init__.py +1 -1
  5. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/conclusion.py +133 -28
  6. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/peer.py +19 -3
  7. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/session.py +29 -17
  8. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/setup.py +9 -2
  9. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/workspace.py +22 -4
  10. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/common.py +41 -0
  11. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/config.py +14 -1
  12. honcho_cli-0.2.1/src/honcho_cli/local/templates/init.sql +2 -0
  13. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/output.py +14 -2
  14. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_commands.py +114 -0
  15. honcho_cli-0.2.1/tests/test_conclusion_attribution.py +142 -0
  16. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_config.py +10 -1
  17. honcho_cli-0.1.5/src/honcho_cli/local/templates/init.sql +0 -1
  18. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/.gitignore +0 -0
  19. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/README.md +0 -0
  20. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/scripts/generate_cli_docs.py +0 -0
  21. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/_help.py +0 -0
  22. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/branding.py +0 -0
  23. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/__init__.py +0 -0
  24. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/config_cmd.py +0 -0
  25. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/message.py +0 -0
  26. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/scope.py +0 -0
  27. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/commands/stack.py +0 -0
  28. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/local/__init__.py +0 -0
  29. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/local/docker.py +0 -0
  30. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/local/env.py +0 -0
  31. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/local/health.py +0 -0
  32. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/local/profile.py +0 -0
  33. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/local/setup.py +0 -0
  34. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/local/templates/__init__.py +0 -0
  35. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/local/templates/docker-compose.yml +0 -0
  36. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/main.py +0 -0
  37. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/oauth.py +0 -0
  38. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/recall.py +0 -0
  39. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/update_check.py +0 -0
  40. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/src/honcho_cli/validation.py +0 -0
  41. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/__init__.py +0 -0
  42. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/conftest.py +0 -0
  43. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_common.py +0 -0
  44. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_local.py +0 -0
  45. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_oauth.py +0 -0
  46. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_output.py +0 -0
  47. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_recall.py +0 -0
  48. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_setup.py +0 -0
  49. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_start.py +0 -0
  50. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/tests/test_validation.py +0 -0
  51. {honcho_cli-0.1.5 → honcho_cli-0.2.1}/uv.lock +0 -0
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-10-20
11
+
12
+ ### Fixed
13
+
14
+ - `honcho session summaries`, `honcho session inspect`, `honcho session context` and `honcho workspace queue-status` emit nested SDK objects (summaries, context messages, per-session queue status) as JSON objects instead of Python repr strings like `"content='...' message_id='...'"` (#1324)
15
+
16
+ ## [0.2.0] - 2026-09-21
17
+
18
+ ### Added
19
+
20
+ - CLI requests carry `X-Honcho-Host: honcho-cli/<version> (<platform>)` so telemetry counts them as CLI traffic rather than direct SDK use (#1181)
21
+ - 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)
22
+ - `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)
23
+ - `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)
24
+ - `--level` on `honcho conclusion list` and `honcho conclusion search`, and `--derived-from` on `honcho conclusion list` (#952)
25
+ - `--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)
26
+
27
+ ### Changed
28
+
29
+ - Requires `honcho-ai` 2.5.0 (was 2.4.0), which carries the attribution fields and the evidence-bearing chat response
30
+
10
31
  ## [0.1.5] - 2026-09-09
11
32
 
12
33
  ### 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.1
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.1"
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
 
@@ -27,6 +27,23 @@ app = typer.Typer(cls=HonchoTyperGroup, help="List, inspect, view, create, delet
27
27
  add_common_options(app)
28
28
 
29
29
 
30
+ def _summary_to_dict(summary) -> dict | None:
31
+ """Serialize an SDK ``Summary`` model, which ``json.dumps`` would otherwise stringify."""
32
+ return summary.model_dump(mode="json") if summary is not None else None
33
+
34
+
35
+ def _message_to_dict(m) -> dict:
36
+ """Serialize an SDK ``Message`` to the CLI's message shape."""
37
+ return {
38
+ "id": m.id,
39
+ "peer_id": m.peer_id,
40
+ "content": m.content,
41
+ "token_count": m.token_count,
42
+ "metadata": m.metadata,
43
+ "created_at": str(m.created_at),
44
+ }
45
+
46
+
30
47
  def _get_session_id(session_id: str | None) -> str:
31
48
 
32
49
  config = get_resolved_config()
@@ -132,8 +149,8 @@ def inspect(
132
149
  "peers": [{"id": p.id} for p in peers],
133
150
  "message_count": msg_page.total,
134
151
  "summaries": {
135
- "short": summaries.short_summary if hasattr(summaries, "short_summary") else None,
136
- "long": summaries.long_summary if hasattr(summaries, "long_summary") else None,
152
+ "short": _summary_to_dict(summaries.short_summary),
153
+ "long": _summary_to_dict(summaries.long_summary),
137
154
  },
138
155
  "configuration": _config_to_dict(sess_config) if sess_config else None,
139
156
  }
@@ -334,17 +351,7 @@ def view(
334
351
  if not reverse:
335
352
  msgs = list(reversed(msgs))
336
353
 
337
- items = [
338
- {
339
- "id": m.id,
340
- "peer_id": m.peer_id,
341
- "content": m.content,
342
- "token_count": m.token_count,
343
- "metadata": m.metadata,
344
- "created_at": str(m.created_at),
345
- }
346
- for m in msgs
347
- ]
354
+ items = [_message_to_dict(m) for m in msgs]
348
355
  except Exception as e:
349
356
  _handle_error(e, "session", sid)
350
357
  raise # unreachable: _handle_error always exits
@@ -394,8 +401,13 @@ def context(
394
401
 
395
402
  try:
396
403
  ctx = sess.context(tokens=tokens, summary=summary)
397
- result = ctx.__dict__ if hasattr(ctx, "__dict__") else ctx
398
- print_result(result)
404
+ print_result({
405
+ "session_id": ctx.session_id,
406
+ "summary": _summary_to_dict(ctx.summary),
407
+ "peer_representation": ctx.peer_representation,
408
+ "peer_card": ctx.peer_card,
409
+ "messages": [_message_to_dict(m) for m in ctx.messages],
410
+ })
399
411
  except Exception as e:
400
412
  _handle_error(e, "session", sid)
401
413
 
@@ -418,8 +430,8 @@ def summaries(
418
430
  s = sess.summaries()
419
431
  result = {
420
432
  "session_id": sid,
421
- "short_summary": s.short_summary if hasattr(s, "short_summary") else None,
422
- "long_summary": s.long_summary if hasattr(s, "long_summary") else None,
433
+ "short_summary": _summary_to_dict(s.short_summary),
434
+ "long_summary": _summary_to_dict(s.long_summary),
423
435
  }
424
436
  print_result(result)
425
437
  except Exception as e:
@@ -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
 
@@ -306,7 +323,7 @@ def queue_status(
306
323
 
307
324
  try:
308
325
  result = client.queue_status(observer=observer, sender=sender, session=config.session_id or None)
309
- print_result(result.__dict__ if hasattr(result, "__dict__") else result)
326
+ print_result(result.model_dump(mode="json"))
310
327
  except Exception as e:
311
328
  _handle_error(e, "queue", "status")
312
329
 
@@ -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,2 @@
1
+ CREATE EXTENSION IF NOT EXISTS vector;
2
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
@@ -11,6 +11,7 @@ import sys
11
11
  from datetime import datetime, timezone
12
12
  from typing import Any
13
13
 
14
+ from pydantic import BaseModel
14
15
  from rich.console import Console
15
16
  from rich.table import Table
16
17
  from rich.text import Text
@@ -41,9 +42,20 @@ def use_json() -> bool:
41
42
  return _force_json or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true") or not is_tty()
42
43
 
43
44
 
45
+ def _json_default(obj: Any) -> Any:
46
+ """Fallback for ``json.dumps``: dump pydantic models as objects, stringify the rest.
47
+
48
+ A bare ``default=str`` turns an SDK model into its repr
49
+ (``content='...' message_id='...'``) instead of a nested object.
50
+ """
51
+ if isinstance(obj, BaseModel):
52
+ return obj.model_dump(mode="json")
53
+ return str(obj)
54
+
55
+
44
56
  def print_json(data: Any) -> None:
45
57
  """Print a single JSON value to stdout."""
46
- print(json.dumps(data, indent=2, default=str))
58
+ print(json.dumps(data, indent=2, default=_json_default))
47
59
 
48
60
 
49
61
  def print_table(columns: list[str], rows: list[list[str]], title: str | None = None) -> None:
@@ -76,7 +88,7 @@ def print_result(data: Any, columns: list[str] | None = None, title: str | None
76
88
  table.add_column("Field", style="bold")
77
89
  table.add_column("Value")
78
90
  for k, v in data.items():
79
- val = json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v)
91
+ val = json.dumps(v, default=_json_default) if isinstance(v, (dict, list)) else str(v)
80
92
  table.add_row(k, val)
81
93
  stdout_console.print(table)
82
94
  else:
@@ -10,9 +10,12 @@ from __future__ import annotations
10
10
  import json
11
11
  import os
12
12
  from contextlib import ExitStack, contextmanager
13
+ from datetime import datetime, timezone
13
14
  from unittest.mock import MagicMock, patch
14
15
 
15
16
  import pytest
17
+ from honcho import Message, SessionContext, SessionSummaries, Summary
18
+ from honcho.api_types import QueueStatusResponse, SessionQueueStatus
16
19
  from typer.testing import CliRunner
17
20
 
18
21
  from honcho_cli.commands.session import _next_page_command
@@ -86,6 +89,16 @@ def _patch_view(session: MagicMock, *, peer_id: str = ""):
86
89
  )
87
90
 
88
91
 
92
+ def _summary(kind: str) -> Summary:
93
+ return Summary(
94
+ content=f"{kind} summary",
95
+ message_id="m1",
96
+ summary_type=f"honcho_chat_summary_{kind}",
97
+ created_at="2026-01-01T00:00:00Z",
98
+ token_count=3,
99
+ )
100
+
101
+
89
102
  @contextmanager
90
103
  def _nested(*managers):
91
104
  with ExitStack() as stack:
@@ -553,6 +566,107 @@ class TestJsonContract:
553
566
  assert payload[-1]["id"] == "m149"
554
567
  assert session.messages.call_args.kwargs["size"] == 100
555
568
 
569
+ def test_session_summaries_json_nests_summary_objects(self, cfg, runner):
570
+ """Summaries are SDK models; they were emitted as their Python repr string."""
571
+ cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
572
+ sess = MagicMock()
573
+ sess.summaries.return_value = SessionSummaries(
574
+ id="sess1", short_summary=_summary("short"), long_summary=None
575
+ )
576
+ client = MagicMock()
577
+ client.session.return_value = sess
578
+ config = MagicMock(session_id="sess1", workspace_id="ws1")
579
+ with patch("honcho_cli.commands.session.get_client", return_value=(client, config)):
580
+ result = runner.invoke(app, ["session", "summaries", "sess1", "-w", "ws1", "--json"])
581
+ assert result.exit_code == 0, result.stderr
582
+ assert json.loads(result.stdout) == {
583
+ "session_id": "sess1",
584
+ "short_summary": _summary("short").model_dump(),
585
+ "long_summary": None,
586
+ }
587
+
588
+ def test_session_inspect_json_nests_summary_objects(self, cfg, runner):
589
+ cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
590
+ sess = MagicMock()
591
+ sess.peers.return_value = []
592
+ sess.messages.return_value = _fake_page([], total=0)
593
+ sess.summaries.return_value = SessionSummaries(
594
+ id="sess1", short_summary=_summary("short"), long_summary=_summary("long")
595
+ )
596
+ sess.get_configuration.return_value = None
597
+ client = MagicMock()
598
+ client.session.return_value = sess
599
+ config = MagicMock(session_id="sess1", workspace_id="ws1")
600
+ with patch("honcho_cli.commands.session.get_client", return_value=(client, config)):
601
+ result = runner.invoke(app, ["session", "inspect", "sess1", "-w", "ws1", "--json"])
602
+ assert result.exit_code == 0, result.stderr
603
+ assert json.loads(result.stdout)["summaries"] == {
604
+ "short": _summary("short").model_dump(),
605
+ "long": _summary("long").model_dump(),
606
+ }
607
+
608
+ def test_session_context_json_nests_summary_and_messages(self, cfg, runner):
609
+ cfg.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"}))
610
+ message = Message(
611
+ id="m1",
612
+ content="hello",
613
+ peer_id="alice",
614
+ session_id="sess1",
615
+ workspace_id="ws1",
616
+ metadata={},
617
+ created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
618
+ token_count=1,
619
+ )
620
+ sess = MagicMock()
621
+ sess.context.return_value = SessionContext(
622
+ session_id="sess1", messages=[message], summary=_summary("short")
623
+ )
624
+ client = MagicMock()
625
+ client.session.return_value = sess
626
+ config = MagicMock(session_id="sess1", workspace_id="ws1")
627
+ with patch("honcho_cli.commands.session.get_client", return_value=(client, config)):
628
+ result = runner.invoke(app, ["session", "context", "sess1", "-w", "ws1", "--json"])
629
+ assert result.exit_code == 0, result.stderr
630
+ assert json.loads(result.stdout) == {
631
+ "session_id": "sess1",
632
+ "summary": _summary("short").model_dump(),
633
+ "peer_representation": None,
634
+ "peer_card": None,
635
+ "messages": [{
636
+ "id": "m1",
637
+ "peer_id": "alice",
638
+ "content": "hello",
639
+ "token_count": 1,
640
+ "metadata": {},
641
+ "created_at": "2026-01-01 00:00:00+00:00",
642
+ }],
643
+ }
644
+
645
+ def test_workspace_queue_status_json_nests_session_status(self, cfg, runner):
646
+ cfg.write_text(json.dumps({
647
+ "apiKey": "k",
648
+ "environmentUrl": "http://localhost:8000",
649
+ "workspace_id": "ws1",
650
+ }))
651
+ counts = {
652
+ "total_work_units": 2,
653
+ "completed_work_units": 1,
654
+ "in_progress_work_units": 0,
655
+ "pending_work_units": 1,
656
+ }
657
+ client = MagicMock()
658
+ client.queue_status.return_value = QueueStatusResponse(
659
+ **counts, sessions={"sess1": SessionQueueStatus(session_id="sess1", **counts)}
660
+ )
661
+ config = MagicMock(workspace_id="ws1", session_id="")
662
+ with patch("honcho_cli.commands.workspace.get_client", return_value=(client, config)):
663
+ result = runner.invoke(app, ["workspace", "queue-status", "-w", "ws1", "--json"])
664
+ assert result.exit_code == 0, result.stderr
665
+ assert json.loads(result.stdout) == {
666
+ **counts,
667
+ "sessions": {"sess1": {"session_id": "sess1", **counts}},
668
+ }
669
+
556
670
 
557
671
  # --------------------------------------------------------------------------- #
558
672
  # 4. Exit codes on error
@@ -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__} (")
@@ -1 +0,0 @@
1
- CREATE EXTENSION IF NOT EXISTS vector;
File without changes
File without changes
File without changes
File without changes
File without changes