pyrepowiki-cli 0.1.0__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 (49) hide show
  1. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/PKG-INFO +17 -6
  2. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/README.md +16 -5
  3. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/README.zh-CN.md +11 -5
  4. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/pyproject.toml +1 -1
  5. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/zread/cli.py +124 -16
  6. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/zread/client.py +112 -23
  7. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_zread_cli.py +138 -2
  8. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_zread_client.py +95 -0
  9. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/uv.lock +34 -34
  10. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/.gitignore +0 -0
  11. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/__init__.py +0 -0
  12. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/cli.py +0 -0
  13. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/__init__.py +0 -0
  14. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/__init__.py +0 -0
  15. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/boq.py +0 -0
  16. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/cli.py +0 -0
  17. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/client.py +0 -0
  18. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/codewiki/wiki.py +0 -0
  19. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/__init__.py +0 -0
  20. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/cli.py +0 -0
  21. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/client.py +0 -0
  22. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/codemap.py +0 -0
  23. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/deepwiki/devin.py +0 -0
  24. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/zread/__init__.py +0 -0
  25. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/services/zread/flight.py +0 -0
  26. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/__init__.py +0 -0
  27. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/async_.py +0 -0
  28. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/export.py +0 -0
  29. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/model.py +0 -0
  30. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/output.py +0 -0
  31. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/repo.py +0 -0
  32. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/src/repowiki/shared/save.py +0 -0
  33. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/test.md +0 -0
  34. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/fixtures/codewiki/egixfe_response.txt +0 -0
  35. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/fixtures/codewiki/vsx6ub_response.txt +0 -0
  36. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_boq.py +0 -0
  37. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_cli.py +0 -0
  38. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_client.py +0 -0
  39. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_codemap.py +0 -0
  40. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_codewiki_cli.py +0 -0
  41. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_codewiki_client.py +0 -0
  42. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_codewiki_wiki.py +0 -0
  43. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_devin.py +0 -0
  44. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_docs.py +0 -0
  45. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_model.py +0 -0
  46. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_output.py +0 -0
  47. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_repo.py +0 -0
  48. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_save.py +0 -0
  49. {pyrepowiki_cli-0.1.0 → pyrepowiki_cli-0.2.0}/tests/test_zread_flight.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyrepowiki-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: CLI tool for querying DeepWiki/Google code wiki/Zread wiki documentation
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -679,7 +679,7 @@ read commands need **no auth**. The commands `ask` and `submit` require a token
679
679
  ```bash
680
680
  repowiki-cli zread structure REPO [--lang zh|en] [--json]
681
681
  repowiki-cli zread contents REPO [SLUG] [--file PATH] [--start N] [--end M] [--lang zh|en] [--rich] [--json]
682
- repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH]
682
+ repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
683
683
  repowiki-cli zread find QUERY [--limit N] [--lang zh|en] [--json]
684
684
  repowiki-cli zread stat REPO [--lang zh|en] [--human] [--stale] [--json]
685
685
  repowiki-cli zread search REPO QUERY [--lang zh|en] [--json]
@@ -730,12 +730,15 @@ repowiki-cli zread contents https://github.com/o/r/blob/main/src/a.py#L10-L20
730
730
  ### `zread ask`
731
731
 
732
732
  ```bash
733
- repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH]
733
+ repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
734
734
  ```
735
735
 
736
736
  With `QUESTION`, `ask` answers once and exits. Without it, `ask` starts an
737
737
  interactive REPL — type one question per line and `/exit` (or `/quit`/`/q`) to
738
- quit.
738
+ quit. In the REPL each turn is **threaded**: the same `talk_id` is reused and the
739
+ previous answer's message id is sent as `parent_message_id`, so follow-up
740
+ questions carry conversational memory. Type `/new` (or `/reset`) to start a fresh
741
+ thread.
739
742
 
740
743
  Unlike the other commands, `ask` requires an auth token (a JWT). Log in to
741
744
  zread.ai, open the browser DevTools console, and run
@@ -746,9 +749,17 @@ the token. Set it as the `ZREAD_TOKEN` environment variable.
746
749
  variable). This maps to the web UI's `CGX_CHAT_MODEL` (e.g. `glm-5.1`,
747
750
  `claude-sonnet-4.6`).
748
751
  - `--lang zh|en` — language.
749
- - `--rich` — render the answer's Markdown with `rich`.
750
- - `--json` — emit a JSON envelope. Ignored in interactive mode.
752
+ - `--rich` — render the answer's Markdown with `rich`. Has no effect with
753
+ `--stream` (the answer streams as plain text).
754
+ - `--json` — emit a JSON envelope. Ignored in interactive mode. Has no effect
755
+ with `--stream`.
751
756
  - `--save PATH` — save the answer to a Markdown file (see *Saving*).
757
+ - `--stream` — stream the answer's `answer` SSE chunks to stdout as they arrive
758
+ (plain text), instead of buffering until the full answer is ready.
759
+ - `--show-reasoning` — also show the model's reasoning trace (the
760
+ `reasoning_content` SSE field, streamed before the answer). In `--stream` mode
761
+ it prints a `reasoning:` block before the `answer:` block; otherwise it prints
762
+ the trace dimmed above the answer. Has no effect with `--json`.
752
763
 
753
764
  ### `zread find`
754
765
 
@@ -666,7 +666,7 @@ read commands need **no auth**. The commands `ask` and `submit` require a token
666
666
  ```bash
667
667
  repowiki-cli zread structure REPO [--lang zh|en] [--json]
668
668
  repowiki-cli zread contents REPO [SLUG] [--file PATH] [--start N] [--end M] [--lang zh|en] [--rich] [--json]
669
- repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH]
669
+ repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
670
670
  repowiki-cli zread find QUERY [--limit N] [--lang zh|en] [--json]
671
671
  repowiki-cli zread stat REPO [--lang zh|en] [--human] [--stale] [--json]
672
672
  repowiki-cli zread search REPO QUERY [--lang zh|en] [--json]
@@ -717,12 +717,15 @@ repowiki-cli zread contents https://github.com/o/r/blob/main/src/a.py#L10-L20
717
717
  ### `zread ask`
718
718
 
719
719
  ```bash
720
- repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH]
720
+ repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
721
721
  ```
722
722
 
723
723
  With `QUESTION`, `ask` answers once and exits. Without it, `ask` starts an
724
724
  interactive REPL — type one question per line and `/exit` (or `/quit`/`/q`) to
725
- quit.
725
+ quit. In the REPL each turn is **threaded**: the same `talk_id` is reused and the
726
+ previous answer's message id is sent as `parent_message_id`, so follow-up
727
+ questions carry conversational memory. Type `/new` (or `/reset`) to start a fresh
728
+ thread.
726
729
 
727
730
  Unlike the other commands, `ask` requires an auth token (a JWT). Log in to
728
731
  zread.ai, open the browser DevTools console, and run
@@ -733,9 +736,17 @@ the token. Set it as the `ZREAD_TOKEN` environment variable.
733
736
  variable). This maps to the web UI's `CGX_CHAT_MODEL` (e.g. `glm-5.1`,
734
737
  `claude-sonnet-4.6`).
735
738
  - `--lang zh|en` — language.
736
- - `--rich` — render the answer's Markdown with `rich`.
737
- - `--json` — emit a JSON envelope. Ignored in interactive mode.
739
+ - `--rich` — render the answer's Markdown with `rich`. Has no effect with
740
+ `--stream` (the answer streams as plain text).
741
+ - `--json` — emit a JSON envelope. Ignored in interactive mode. Has no effect
742
+ with `--stream`.
738
743
  - `--save PATH` — save the answer to a Markdown file (see *Saving*).
744
+ - `--stream` — stream the answer's `answer` SSE chunks to stdout as they arrive
745
+ (plain text), instead of buffering until the full answer is ready.
746
+ - `--show-reasoning` — also show the model's reasoning trace (the
747
+ `reasoning_content` SSE field, streamed before the answer). In `--stream` mode
748
+ it prints a `reasoning:` block before the `answer:` block; otherwise it prints
749
+ the trace dimmed above the answer. Has no effect with `--json`.
739
750
 
740
751
  ### `zread find`
741
752
 
@@ -621,7 +621,7 @@ repowiki-cli codewiki cp REPO [OUTPUT_DIR]
621
621
  ```bash
622
622
  repowiki-cli zread structure REPO [--lang zh|en] [--json]
623
623
  repowiki-cli zread contents REPO [SLUG] [--file PATH] [--start N] [--end M] [--lang zh|en] [--rich] [--json]
624
- repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH]
624
+ repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--lang zh|en] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
625
625
  repowiki-cli zread find QUERY [--limit N] [--lang zh|en] [--json]
626
626
  repowiki-cli zread stat REPO [--lang zh|en] [--human] [--stale] [--json]
627
627
  repowiki-cli zread search REPO QUERY [--lang zh|en] [--json]
@@ -668,11 +668,13 @@ repowiki-cli zread contents https://github.com/o/r/blob/main/src/a.py#L10-L20
668
668
  ### `zread ask`
669
669
 
670
670
  ```bash
671
- repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH]
671
+ repowiki-cli zread ask REPO [QUESTION] [--model MODEL] [--rich] [--json] [--save PATH] [--stream] [--show-reasoning]
672
672
  ```
673
673
 
674
674
  带 `QUESTION` 则单次回答后退出;不带则进入交互式 REPL——每行一个问题,输入
675
- `/exit`(或 `/quit`/`/q`)退出。
675
+ `/exit`(或 `/quit`/`/q`)退出。REPL 中每轮都是**多轮续接**的:复用同一个
676
+ `talk_id`,并把上一轮答案的 message id 作为 `parent_message_id` 发送,因此追问
677
+ 能携带上下文记忆。输入 `/new`(或 `/reset`)可开启一个新线程。
676
678
 
677
679
  与其他命令不同,`ask` 需要鉴权 token(一个 JWT)。登录 zread.ai 后,在浏览器
678
680
  DevTools 控制台执行
@@ -682,9 +684,13 @@ DevTools 控制台执行
682
684
  - `--model MODEL` — 模型(默认 `glm-5.1`,或环境变量 `ZREAD_MODEL`)。对应网页端的
683
685
  `CGX_CHAT_MODEL`(如 `glm-5.1`、`claude-sonnet-4.6`)。
684
686
  - `--lang zh|en` — 语言。
685
- - `--rich` — 用 `rich` 渲染答案的 Markdown。
686
- - `--json` — 输出 JSON 信封;交互模式下忽略。
687
+ - `--rich` — 用 `rich` 渲染答案的 Markdown。与 `--stream` 一起使用时无效(流式输出为纯文本)。
688
+ - `--json` — 输出 JSON 信封;交互模式下忽略。与 `--stream` 一起使用时无效。
687
689
  - `--save PATH` — 把答案保存为 Markdown 文件(见「保存」)。
690
+ - `--stream` — 通过 SSE 把 `answer` 事件的分片逐字写到 stdout,流式输出纯文本答案。
691
+ - `--show-reasoning` — 同时展示模型的思考过程(SSE 的 `reasoning_content` 字段,
692
+ 在回答之前流式下发)。`--stream` 模式下会先输出 `reasoning:` 块再输出
693
+ `answer:` 块;否则以暗色打印在答案上方。与 `--json` 一起使用时无效。
688
694
 
689
695
  ### `zread find`
690
696
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyrepowiki-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "CLI tool for querying DeepWiki/Google code wiki/Zread wiki documentation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -137,6 +137,49 @@ def _repl_prompt() -> str:
137
137
  return typer.style(">> ", fg=typer.colors.CYAN, bold=True)
138
138
 
139
139
 
140
+ def _stream_chunk(text: str) -> None:
141
+ sys.stdout.write(text)
142
+ sys.stdout.flush()
143
+
144
+
145
+ def _streamers(show_reasoning: bool):
146
+ """Return ``(on_reasoning, on_chunk)`` callbacks for ``--stream``.
147
+
148
+ Without ``--show-reasoning`` this streams only the answer (reasoning is
149
+ dropped). With it, the reasoning trace streams first under a ``reasoning:``
150
+ label, then the answer under an ``answer:`` label.
151
+ """
152
+ if not show_reasoning:
153
+ return None, _stream_chunk
154
+ state = {"reasoning": False, "answer": False}
155
+
156
+ def on_reasoning(text: str) -> None:
157
+ if not state["reasoning"]:
158
+ sys.stdout.write("\nreasoning:\n")
159
+ state["reasoning"] = True
160
+ sys.stdout.write(text)
161
+ sys.stdout.flush()
162
+
163
+ def on_chunk(text: str) -> None:
164
+ if not state["answer"]:
165
+ if state["reasoning"]:
166
+ sys.stdout.write("\n\nanswer:\n")
167
+ state["answer"] = True
168
+ sys.stdout.write(text)
169
+ sys.stdout.flush()
170
+
171
+ return on_reasoning, on_chunk
172
+
173
+
174
+ def _print_reasoning(reasoning: str) -> None:
175
+ """Print a captured reasoning trace (dimmed) above the answer."""
176
+ if not reasoning.strip():
177
+ return
178
+ typer.secho("reasoning:", fg=typer.colors.YELLOW, dim=True)
179
+ typer.secho(reasoning.strip(), fg=typer.colors.YELLOW, dim=True)
180
+ typer.echo()
181
+
182
+
140
183
  def _print_error(exc: Exception) -> None:
141
184
  if isinstance(exc, ZreadError):
142
185
  message = str(exc)
@@ -155,10 +198,16 @@ def _append_save(path: Optional[str], repo: str, question: str, answer: str) ->
155
198
 
156
199
 
157
200
  async def _repl(
158
- resolved: str, rich: bool, save_path: Optional[str], model: Optional[str], lang: Optional[str]
201
+ resolved: str, rich: bool, save_path: Optional[str], model: Optional[str], lang: Optional[str],
202
+ stream: bool = False, show_reasoning: bool = False,
159
203
  ) -> None:
160
204
  prompt = _repl_prompt()
161
205
  client = _make_client(lang, model)
206
+ try:
207
+ talk = await client.start_talk(resolved)
208
+ except Exception as exc:
209
+ _print_error(exc)
210
+ return
162
211
  while True:
163
212
  try:
164
213
  line = input(prompt)
@@ -170,19 +219,37 @@ async def _repl(
170
219
  continue
171
220
  if q in ("/exit", "/quit", "/q"):
172
221
  break
222
+ if q in ("/new", "/reset"):
223
+ try:
224
+ talk = await client.start_talk(resolved)
225
+ typer.echo("Started a new thread.")
226
+ except Exception as exc:
227
+ _print_error(exc)
228
+ continue
173
229
  try:
174
- with status("Thinking..."):
175
- answer = await client.ask(resolved, q)
230
+ if stream:
231
+ on_reasoning, on_chunk = _streamers(show_reasoning)
232
+ answer = await talk.ask(q, on_chunk=on_chunk, on_reasoning=on_reasoning)
233
+ else:
234
+ reasoning_parts: list[str] = []
235
+ with status("Thinking..."):
236
+ answer = await talk.ask(
237
+ q, on_reasoning=reasoning_parts.append if show_reasoning else None
238
+ )
176
239
  except Exception as exc:
177
240
  _print_error(exc)
178
241
  continue
179
242
  _append_save(save_path, resolved, q, answer)
180
- typer.echo()
181
- if rich:
182
- render_rich(answer.strip())
243
+ if stream:
244
+ typer.echo()
183
245
  else:
184
- typer.echo(answer.strip())
185
- typer.echo()
246
+ _print_reasoning("".join(reasoning_parts))
247
+ typer.echo()
248
+ if rich:
249
+ render_rich(answer.strip())
250
+ else:
251
+ typer.echo(answer.strip())
252
+ typer.echo()
186
253
 
187
254
 
188
255
  def _render_structure(pages: list[Page]) -> str:
@@ -416,24 +483,65 @@ def ask(
416
483
  rich: bool = typer.Option(False, "--rich", help="Render Markdown with rich"),
417
484
  json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
418
485
  save: Optional[str] = typer.Option(None, "--save", help="Save the answer to a Markdown file."),
486
+ stream: bool = typer.Option(False, "--stream", help="Stream answer chunks as they arrive"),
487
+ show_reasoning: bool = typer.Option(
488
+ False, "--show-reasoning", help="Also show the model's reasoning trace",
489
+ ),
419
490
  ) -> None:
420
491
  """Ask a question about a repository (single-shot or interactive)."""
421
492
  resolved = _resolve_repo(repo, json)
422
493
  if question is not None:
423
494
  if not question.strip():
424
495
  _fail("Question must not be empty.", "invalid_input", json)
496
+ if stream and json:
497
+ typer.secho(
498
+ "Warning: --stream has no effect with --json.",
499
+ fg=typer.colors.YELLOW, err=True,
500
+ )
501
+ if stream and rich:
502
+ typer.secho(
503
+ "Warning: --rich has no effect with --stream; the answer streams as plain text.",
504
+ fg=typer.colors.YELLOW, err=True,
505
+ )
506
+ if show_reasoning and json:
507
+ typer.secho(
508
+ "Warning: --show-reasoning has no effect with --json.",
509
+ fg=typer.colors.YELLOW, err=True,
510
+ )
425
511
  if (mock := _mock_text()) is not None:
426
512
  answer = Answer(body=mock)
427
- else:
428
- client = _make_client(lang, model)
429
- try:
513
+ if json:
514
+ typer.echo(format_json(resolved, "ask", question=question, answer=answer.body))
515
+ else:
516
+ _emit(resolved, "ask", format_answer(answer), rich, False)
517
+ _append_save(save, resolved, question, answer.body)
518
+ return
519
+ client = _make_client(lang, model)
520
+ streamed = False
521
+ reasoning_parts: list[str] = []
522
+ try:
523
+ if stream and not json:
524
+ on_reasoning, on_chunk = _streamers(show_reasoning)
525
+ answer = Answer(body=run_async(
526
+ client.ask(resolved, question, on_chunk=on_chunk, on_reasoning=on_reasoning)
527
+ ))
528
+ streamed = True
529
+ else:
430
530
  with status("Thinking..."):
431
- answer = Answer(body=run_async(client.ask(resolved, question)))
432
- except Exception as exc:
433
- _handle_exception(exc, json)
531
+ answer = Answer(body=run_async(
532
+ client.ask(
533
+ resolved, question,
534
+ on_reasoning=reasoning_parts.append if show_reasoning else None,
535
+ )
536
+ ))
537
+ except Exception as exc:
538
+ _handle_exception(exc, json)
434
539
  if json:
435
540
  typer.echo(format_json(resolved, "ask", question=question, answer=answer.body))
541
+ elif streamed:
542
+ typer.echo()
436
543
  else:
544
+ _print_reasoning("".join(reasoning_parts))
437
545
  _emit(resolved, "ask", format_answer(answer), rich, False)
438
546
  _append_save(save, resolved, question, answer.body)
439
547
  return
@@ -441,10 +549,10 @@ def ask(
441
549
  typer.secho("Warning: --json has no effect in interactive mode.", fg=typer.colors.YELLOW, err=True)
442
550
  typer.echo(format_header("Zread", resolved, "ask"))
443
551
  typer.echo()
444
- typer.echo("Ask a question, or /exit to quit.")
552
+ typer.echo("Ask a question, or /exit to quit. (/new starts a new thread)")
445
553
  typer.echo()
446
554
  try:
447
- run_async(_repl(resolved, rich, save, model, lang))
555
+ run_async(_repl(resolved, rich, save, model, lang, stream, show_reasoning))
448
556
  except Exception as exc:
449
557
  _handle_exception(exc, False)
450
558
 
@@ -84,31 +84,76 @@ def _unwrap(resp: httpx.Response) -> object:
84
84
  return result.get("data")
85
85
 
86
86
 
87
- def _parse_sse_body(lines: list[str]) -> str:
88
- event = None
89
- finished: list[str] = []
90
- chunks: list[str] = []
91
- for line in lines:
87
+ class _SSEParser:
88
+ """Incrementally parse the talk SSE stream.
89
+
90
+ ``answer`` events carry incremental chunks — ``data.text`` is the answer and
91
+ ``data.reasoning_content`` is the model's thinking trace (streamed first, then
92
+ empty during the answer phase). ``round_finish`` carries the complete text,
93
+ ``finish`` ends the stream. ``on_chunk`` / ``on_reasoning``, when given, are
94
+ invoked with each ``answer`` chunk as it arrives.
95
+ """
96
+
97
+ def __init__(self, on_chunk=None, on_reasoning=None) -> None:
98
+ self._on_chunk = on_chunk
99
+ self._on_reasoning = on_reasoning
100
+ self._event: str | None = None
101
+ self._finished: list[str] = []
102
+ self._chunks: list[str] = []
103
+ self._reasoning: list[str] = []
104
+ self._message_id = ""
105
+
106
+ def feed(self, line: str) -> bool:
107
+ """Consume one raw SSE line; return True when the stream is done."""
92
108
  line = line.strip()
93
109
  if not line:
94
- continue
110
+ return False
95
111
  if line.startswith("event:"):
96
- event = line[6:].strip()
97
- if event == "finish":
98
- break
99
- if event == "error":
112
+ self._event = line[6:].strip()
113
+ if self._event == "finish":
114
+ return True
115
+ if self._event == "error":
100
116
  raise ZreadError("talk stream returned an error event")
101
117
  elif line.startswith("data:"):
102
118
  try:
103
119
  data = json.loads(line[5:].strip())
104
120
  except json.JSONDecodeError:
105
- continue
106
- if event == "round_finish":
107
- finished.append(data.get("text", ""))
108
- elif event == "answer":
109
- chunks.append(data.get("text", ""))
110
- text = "\n".join(finished) or "".join(chunks)
111
- return text.strip()
121
+ return False
122
+ if data.get("id"):
123
+ self._message_id = data["id"]
124
+ if self._event == "round_finish":
125
+ self._finished.append(data.get("text", ""))
126
+ elif self._event == "answer":
127
+ reasoning = data.get("reasoning_content", "")
128
+ if reasoning:
129
+ self._reasoning.append(reasoning)
130
+ if self._on_reasoning is not None:
131
+ self._on_reasoning(reasoning)
132
+ text = data.get("text", "")
133
+ self._chunks.append(text)
134
+ if self._on_chunk is not None and text:
135
+ self._on_chunk(text)
136
+ return False
137
+
138
+ @property
139
+ def text(self) -> str:
140
+ return ("\n".join(self._finished) or "".join(self._chunks)).strip()
141
+
142
+ @property
143
+ def reasoning(self) -> str:
144
+ return "".join(self._reasoning).strip()
145
+
146
+ @property
147
+ def message_id(self) -> str:
148
+ return self._message_id
149
+
150
+
151
+ def _parse_sse_body(lines: list[str], on_chunk=None, on_reasoning=None) -> str:
152
+ parser = _SSEParser(on_chunk, on_reasoning)
153
+ for line in lines:
154
+ if parser.feed(line):
155
+ break
156
+ return parser.text
112
157
 
113
158
 
114
159
  class ZreadClient:
@@ -349,7 +394,18 @@ class ZreadClient:
349
394
  raise ZreadNotFoundError(f"no wiki_id for {repo}")
350
395
  return wiki_id
351
396
 
352
- async def ask(self, repo: str, question: str) -> str:
397
+ async def ask(self, repo: str, question: str, on_chunk=None, on_reasoning=None) -> str:
398
+ if not self._token:
399
+ raise ZreadError(
400
+ "ZREAD_TOKEN is required for ask. Get it from zread.ai after login: "
401
+ "run JSON.parse(localStorage.getItem('CGX_AUTH_STORAGE')).state.token in "
402
+ "the DevTools console, then export ZREAD_TOKEN."
403
+ )
404
+ talk = await self.start_talk(repo)
405
+ return await talk.ask(question, on_chunk=on_chunk, on_reasoning=on_reasoning)
406
+
407
+ async def start_talk(self, repo: str) -> "ZreadTalk":
408
+ """Resolve a repo and open a new talk thread (``talk_id``)."""
353
409
  if not self._token:
354
410
  raise ZreadError(
355
411
  "ZREAD_TOKEN is required for ask. Get it from zread.ai after login: "
@@ -365,7 +421,7 @@ class ZreadClient:
365
421
  if not pages:
366
422
  raise ZreadNotFoundError(f"{repo} has no wiki pages")
367
423
  talk_id = await self._create_talk()
368
- return await self._send_message(talk_id, question, wiki_id, pages[0].page_id, repo_id)
424
+ return ZreadTalk(self, repo_id, wiki_id, pages[0].page_id, talk_id)
369
425
 
370
426
  async def _create_talk(self) -> str:
371
427
  resp = await self._request(
@@ -382,7 +438,8 @@ class ZreadClient:
382
438
  async def _send_message(
383
439
  self, talk_id: str, question: str,
384
440
  wiki_id: str, page_id: str, repo_id: str,
385
- ) -> str:
441
+ on_chunk=None, on_reasoning=None, parent_message_id: str = "",
442
+ ) -> tuple[str, str]:
386
443
  url = f"{BASE}/api/v1/talk/{talk_id}/message"
387
444
  headers = self._headers({
388
445
  "Authorization": f"Bearer {self._token}",
@@ -392,7 +449,7 @@ class ZreadClient:
392
449
  if repo_id:
393
450
  context["repo"] = {"repo_id": repo_id}
394
451
  body = {
395
- "parent_message_id": "",
452
+ "parent_message_id": parent_message_id,
396
453
  "query": question,
397
454
  "context": context,
398
455
  "model": self._model,
@@ -410,5 +467,37 @@ class ZreadClient:
410
467
  f"Zread returned HTTP {resp.status_code} for {url}"
411
468
  + (f": {detail}" if detail else "")
412
469
  )
413
- lines = [line async for line in resp.aiter_lines()]
414
- return _parse_sse_body(lines)
470
+ parser = _SSEParser(on_chunk, on_reasoning)
471
+ async for line in resp.aiter_lines():
472
+ if parser.feed(line):
473
+ break
474
+ return parser.text, parser.message_id
475
+
476
+
477
+ class ZreadTalk:
478
+ """A single threaded conversation against one repo.
479
+
480
+ Holds the ``talk_id`` and chains messages via ``parent_message_id`` (the id
481
+ of the previous assistant message, captured from the SSE ``id`` field) so
482
+ follow-up questions carry conversational memory.
483
+ """
484
+
485
+ def __init__(
486
+ self, client: "ZreadClient", repo_id: str, wiki_id: str, page_id: str, talk_id: str
487
+ ) -> None:
488
+ self._client = client
489
+ self._repo_id = repo_id
490
+ self._wiki_id = wiki_id
491
+ self._page_id = page_id
492
+ self._talk_id = talk_id
493
+ self._parent_message_id = ""
494
+
495
+ async def ask(self, question: str, on_chunk=None, on_reasoning=None) -> str:
496
+ answer, message_id = await self._client._send_message(
497
+ self._talk_id, question, self._wiki_id, self._page_id, self._repo_id,
498
+ on_chunk=on_chunk, on_reasoning=on_reasoning,
499
+ parent_message_id=self._parent_message_id,
500
+ )
501
+ if message_id:
502
+ self._parent_message_id = message_id
503
+ return answer
@@ -156,12 +156,16 @@ def test_ask_repl(monkeypatch):
156
156
  inputs = iter(["What is Fiber?", "/exit"])
157
157
  monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
158
158
 
159
+ class FakeTalk:
160
+ async def ask(self, question, on_chunk=None, on_reasoning=None):
161
+ return f"answer to {question}"
162
+
159
163
  class FakeClient:
160
164
  def __init__(self, **kwargs):
161
165
  pass
162
166
 
163
- async def ask(self, repo, question):
164
- return f"answer to {question}"
167
+ async def start_talk(self, repo):
168
+ return FakeTalk()
165
169
 
166
170
  monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
167
171
  result = runner.invoke(zread_app, ["ask", "facebook/react"])
@@ -169,6 +173,138 @@ def test_ask_repl(monkeypatch):
169
173
  assert "answer to What is Fiber?" in result.output
170
174
 
171
175
 
176
+ def test_ask_repl_reuses_talk(monkeypatch):
177
+ inputs = iter(["q1", "q2", "/exit"])
178
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
179
+ started = {"n": 0}
180
+
181
+ class FakeTalk:
182
+ async def ask(self, question, on_chunk=None, on_reasoning=None):
183
+ return f"answer to {question}"
184
+
185
+ class FakeClient:
186
+ def __init__(self, **kwargs):
187
+ pass
188
+
189
+ async def start_talk(self, repo):
190
+ started["n"] += 1
191
+ return FakeTalk()
192
+
193
+ monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
194
+ result = runner.invoke(zread_app, ["ask", "facebook/react"])
195
+ assert result.exit_code == 0
196
+ assert "answer to q1" in result.output
197
+ assert "answer to q2" in result.output
198
+ assert started["n"] == 1
199
+
200
+
201
+ def test_ask_repl_new_thread(monkeypatch):
202
+ inputs = iter(["q1", "/new", "/exit"])
203
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
204
+ started = {"n": 0}
205
+
206
+ class FakeTalk:
207
+ async def ask(self, question, on_chunk=None, on_reasoning=None):
208
+ return f"answer to {question}"
209
+
210
+ class FakeClient:
211
+ def __init__(self, **kwargs):
212
+ pass
213
+
214
+ async def start_talk(self, repo):
215
+ started["n"] += 1
216
+ return FakeTalk()
217
+
218
+ monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
219
+ result = runner.invoke(zread_app, ["ask", "facebook/react"])
220
+ assert result.exit_code == 0
221
+ assert started["n"] == 2
222
+
223
+
224
+ def test_ask_stream_emits_chunks(monkeypatch):
225
+ class FakeClient:
226
+ def __init__(self, **kwargs):
227
+ pass
228
+
229
+ async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
230
+ if on_chunk is not None:
231
+ on_chunk("hel")
232
+ on_chunk("lo")
233
+ return "hello"
234
+
235
+ monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
236
+ result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--stream"])
237
+ assert result.exit_code == 0
238
+ assert "hello" in result.output
239
+
240
+
241
+ def test_ask_stream_warns_json(monkeypatch):
242
+ class FakeClient:
243
+ def __init__(self, **kwargs):
244
+ pass
245
+
246
+ async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
247
+ return "hello"
248
+
249
+ monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
250
+ result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--stream", "--json"])
251
+ assert result.exit_code == 0
252
+ assert "--stream has no effect with --json" in result.output
253
+
254
+
255
+ def test_ask_show_reasoning(monkeypatch):
256
+ class FakeClient:
257
+ def __init__(self, **kwargs):
258
+ pass
259
+
260
+ async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
261
+ if on_reasoning is not None:
262
+ on_reasoning("think ")
263
+ on_reasoning("hard")
264
+ return "hello"
265
+
266
+ monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
267
+ result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--show-reasoning"])
268
+ assert result.exit_code == 0
269
+ assert "think hard" in result.output
270
+ assert "hello" in result.output
271
+
272
+
273
+ def test_ask_show_reasoning_warns_json(monkeypatch):
274
+ class FakeClient:
275
+ def __init__(self, **kwargs):
276
+ pass
277
+
278
+ async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
279
+ return "hello"
280
+
281
+ monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
282
+ result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--show-reasoning", "--json"])
283
+ assert result.exit_code == 0
284
+ assert "--show-reasoning has no effect with --json" in result.output
285
+
286
+
287
+ def test_ask_stream_show_reasoning(monkeypatch):
288
+ class FakeClient:
289
+ def __init__(self, **kwargs):
290
+ pass
291
+
292
+ async def ask(self, repo, question, on_chunk=None, on_reasoning=None):
293
+ if on_reasoning is not None:
294
+ on_reasoning("think")
295
+ if on_chunk is not None:
296
+ on_chunk("hello")
297
+ return "hello"
298
+
299
+ monkeypatch.setattr("repowiki.services.zread.cli.ZreadClient", FakeClient)
300
+ result = runner.invoke(zread_app, ["ask", "owner/example", "q?", "--stream", "--show-reasoning"])
301
+ assert result.exit_code == 0
302
+ assert "reasoning:" in result.output
303
+ assert "answer:" in result.output
304
+ assert "think" in result.output
305
+ assert "hello" in result.output
306
+
307
+
172
308
  def test_cp_concurrency_zero_fails(monkeypatch):
173
309
  monkeypatch.setenv("REPOWIKI_ZREAD_MOCK", "- Overview")
174
310
  result = runner.invoke(zread_app, ["cp", "owner/example", "--concurrency", "0"])
@@ -297,6 +297,40 @@ def test_parse_sse_body_errors_on_error_event():
297
297
  _parse_sse_body(["event: error", "data: {}"])
298
298
 
299
299
 
300
+ def test_parse_sse_body_streams_answer_chunks():
301
+ chunks = []
302
+ lines = [
303
+ "event: answer",
304
+ 'data: {"text": "hel"}',
305
+ "event: answer",
306
+ 'data: {"text": "lo"}',
307
+ "event: round_finish",
308
+ 'data: {"text": "hello"}',
309
+ "event: finish",
310
+ "data: {}",
311
+ ]
312
+ assert _parse_sse_body(lines, on_chunk=chunks.append) == "hello"
313
+ assert chunks == ["hel", "lo"]
314
+
315
+
316
+ def test_parse_sse_body_streams_reasoning():
317
+ reasoning = []
318
+ lines = [
319
+ "event: answer",
320
+ 'data: {"text": "", "reasoning_content": "think "}',
321
+ "event: answer",
322
+ 'data: {"text": "", "reasoning_content": "hard"}',
323
+ "event: answer",
324
+ 'data: {"text": "ans", "reasoning_content": ""}',
325
+ "event: round_finish",
326
+ 'data: {"text": "answer"}',
327
+ "event: finish",
328
+ "data: {}",
329
+ ]
330
+ assert _parse_sse_body(lines, on_reasoning=reasoning.append) == "answer"
331
+ assert reasoning == ["think ", "hard"]
332
+
333
+
300
334
  def test_ask_requires_token():
301
335
  client = ZreadClient(transport=httpx.MockTransport(lambda r: httpx.Response(200)), token=None)
302
336
  with pytest.raises(ZreadError, match="ZREAD_TOKEN"):
@@ -337,6 +371,67 @@ def test_ask_flow(monkeypatch):
337
371
  assert seen["body"]["context"]["wiki"] == {"page_id": "p0", "wiki_id": "w1"}
338
372
 
339
373
 
374
+ def test_ask_streams_chunks(monkeypatch):
375
+ import repowiki.services.zread.client as mod
376
+
377
+ monkeypatch.setattr(mod.asyncio, "sleep", _no_sleep)
378
+
379
+ sse = (
380
+ 'event: answer\ndata: {"text": "hel"}\n\n'
381
+ 'event: answer\ndata: {"text": "lo"}\n\n'
382
+ 'event: round_finish\ndata: {"text": "hello"}\n\n'
383
+ 'event: finish\ndata: {}\n\n'
384
+ )
385
+
386
+ def handler(request):
387
+ url = str(request.url)
388
+ if url.endswith("/api/v1/talk"):
389
+ return httpx.Response(200, json={"code": 0, "data": {"talk_id": "t1"}})
390
+ if "/message" in url:
391
+ return httpx.Response(200, content=sse.encode(), headers={"content-type": "text/event-stream"})
392
+ if "repo/github" in url:
393
+ return httpx.Response(200, json={"code": 0, "data": {"repo_id": "r1", "wiki_id": "w1"}})
394
+ if "/api/v1/wiki/" in url:
395
+ return httpx.Response(200, json=_wiki_json(1))
396
+ return httpx.Response(404)
397
+
398
+ client = ZreadClient(transport=httpx.MockTransport(handler), token="tok")
399
+ chunks = []
400
+ answer = run_async(client.ask("o/r", "q?", on_chunk=chunks.append))
401
+ assert answer == "hello"
402
+ assert chunks == ["hel", "lo"]
403
+
404
+
405
+ def test_talk_threads_messages(monkeypatch):
406
+ import repowiki.services.zread.client as mod
407
+
408
+ monkeypatch.setattr(mod.asyncio, "sleep", _no_sleep)
409
+ parents = []
410
+
411
+ def handler(request):
412
+ url = str(request.url)
413
+ if url.endswith("/api/v1/talk"):
414
+ return httpx.Response(200, json={"code": 0, "data": {"talk_id": "t1"}})
415
+ if "/message" in url:
416
+ parents.append(json.loads(request.content)["parent_message_id"])
417
+ sse = (
418
+ 'event: round_finish\ndata: {"id": "m1", "text": "first"}\n\n'
419
+ 'event: finish\ndata: {"id": "m1"}\n\n'
420
+ )
421
+ return httpx.Response(200, content=sse.encode(), headers={"content-type": "text/event-stream"})
422
+ if "repo/github" in url:
423
+ return httpx.Response(200, json={"code": 0, "data": {"repo_id": "r1", "wiki_id": "w1"}})
424
+ if "/api/v1/wiki/" in url:
425
+ return httpx.Response(200, json=_wiki_json(1))
426
+ return httpx.Response(404)
427
+
428
+ client = ZreadClient(transport=httpx.MockTransport(handler), token="tok")
429
+ talk = run_async(client.start_talk("o/r"))
430
+ assert run_async(talk.ask("q1?")) == "first"
431
+ assert run_async(talk.ask("q2?")) == "first"
432
+ assert parents == ["", "m1"]
433
+
434
+
340
435
  def test_is_cert_error_detects_ssl_verification():
341
436
  from repowiki.services.zread.client import _is_cert_error
342
437
 
@@ -600,6 +600,40 @@ crypto = [
600
600
  { name = "cryptography" },
601
601
  ]
602
602
 
603
+ [[package]]
604
+ name = "pyrepowiki-cli"
605
+ version = "0.1.0"
606
+ source = { editable = "." }
607
+ dependencies = [
608
+ { name = "httpx" },
609
+ { name = "mcp" },
610
+ { name = "rich" },
611
+ { name = "typer" },
612
+ { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
613
+ { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
614
+ ]
615
+
616
+ [package.dev-dependencies]
617
+ dev = [
618
+ { name = "pytest" },
619
+ { name = "pytest-asyncio" },
620
+ ]
621
+
622
+ [package.metadata]
623
+ requires-dist = [
624
+ { name = "httpx", specifier = ">=0.27.0" },
625
+ { name = "mcp", specifier = ">=1.0.0,<2.0.0" },
626
+ { name = "rich", specifier = ">=13.0.0" },
627
+ { name = "typer", specifier = ">=0.12.0" },
628
+ { name = "websockets", specifier = ">=13.0.0" },
629
+ ]
630
+
631
+ [package.metadata.requires-dev]
632
+ dev = [
633
+ { name = "pytest", specifier = ">=8.0.0" },
634
+ { name = "pytest-asyncio", specifier = ">=0.23.0" },
635
+ ]
636
+
603
637
  [[package]]
604
638
  name = "pytest"
605
639
  version = "9.1.1"
@@ -690,40 +724,6 @@ wheels = [
690
724
  { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
691
725
  ]
692
726
 
693
- [[package]]
694
- name = "repowiki-cli"
695
- version = "0.1.0"
696
- source = { editable = "." }
697
- dependencies = [
698
- { name = "httpx" },
699
- { name = "mcp" },
700
- { name = "rich" },
701
- { name = "typer" },
702
- { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
703
- { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
704
- ]
705
-
706
- [package.dev-dependencies]
707
- dev = [
708
- { name = "pytest" },
709
- { name = "pytest-asyncio" },
710
- ]
711
-
712
- [package.metadata]
713
- requires-dist = [
714
- { name = "httpx", specifier = ">=0.27.0" },
715
- { name = "mcp", specifier = ">=1.0.0,<2.0.0" },
716
- { name = "rich", specifier = ">=13.0.0" },
717
- { name = "typer", specifier = ">=0.12.0" },
718
- { name = "websockets", specifier = ">=13.0.0" },
719
- ]
720
-
721
- [package.metadata.requires-dev]
722
- dev = [
723
- { name = "pytest", specifier = ">=8.0.0" },
724
- { name = "pytest-asyncio", specifier = ">=0.23.0" },
725
- ]
726
-
727
727
  [[package]]
728
728
  name = "rich"
729
729
  version = "15.0.0"
File without changes