drydock-cli 3.0.47__tar.gz → 3.0.49__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 (65) hide show
  1. {drydock_cli-3.0.47/drydock_cli.egg-info → drydock_cli-3.0.49}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/agent.py +4 -2
  3. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/cli.py +46 -5
  4. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/config.py +6 -0
  5. drydock_cli-3.0.49/drydock/graphrag.py +244 -0
  6. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/tools/__init__.py +56 -3
  7. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/tui/app.py +80 -3
  8. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/tui/messages.py +6 -0
  9. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/tui/widgets.py +24 -1
  10. {drydock_cli-3.0.47 → drydock_cli-3.0.49/drydock_cli.egg-info}/PKG-INFO +1 -1
  11. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock_cli.egg-info/SOURCES.txt +3 -0
  12. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/pyproject.toml +1 -1
  13. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_config.py +4 -1
  14. drydock_cli-3.0.49/tests/test_context_limit_config.py +44 -0
  15. drydock_cli-3.0.49/tests/test_graphrag.py +75 -0
  16. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_tuning.py +54 -0
  17. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/LICENSE +0 -0
  18. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/NOTICE +0 -0
  19. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/README.md +0 -0
  20. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/__init__.py +0 -0
  21. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/__main__.py +0 -0
  22. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/bash_safety.py +0 -0
  23. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/compaction.py +0 -0
  24. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/detect.py +0 -0
  25. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/guards.py +0 -0
  26. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/loop_detect.py +0 -0
  27. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/providers.py +0 -0
  28. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/tool_registry.py +0 -0
  29. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/tui/__init__.py +0 -0
  30. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/tui/approval.py +0 -0
  31. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock/tuning.py +0 -0
  32. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock_cli.egg-info/dependency_links.txt +0 -0
  33. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock_cli.egg-info/entry_points.txt +0 -0
  34. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock_cli.egg-info/requires.txt +0 -0
  35. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/drydock_cli.egg-info/top_level.txt +0 -0
  36. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/setup.cfg +0 -0
  37. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_approval.py +0 -0
  38. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_back_command.py +0 -0
  39. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_bash_process_group.py +0 -0
  40. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_bash_safety.py +0 -0
  41. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_bash_timeout_network.py +0 -0
  42. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_cli_agents.py +0 -0
  43. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_compact_command.py +0 -0
  44. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_compaction.py +0 -0
  45. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_config_migration.py +0 -0
  46. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_detect.py +0 -0
  47. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_empty_response.py +0 -0
  48. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_failure_loop.py +0 -0
  49. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_first_run_setup.py +0 -0
  50. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_guards_and_tools.py +0 -0
  51. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_hallucinated_tools.py +0 -0
  52. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_leaked_tool_call.py +0 -0
  53. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_loop_detect.py +0 -0
  54. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_oneshot_unreachable.py +0 -0
  55. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_plan_autocontinue.py +0 -0
  56. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_providers_unreachable.py +0 -0
  57. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_runaway_repetition.py +0 -0
  58. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_stop.py +0 -0
  59. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_streaming_newlines.py +0 -0
  60. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_subagent.py +0 -0
  61. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_todo.py +0 -0
  62. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_tool_arg_parsing.py +0 -0
  63. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_tools_undo.py +0 -0
  64. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_tui.py +0 -0
  65. {drydock_cli-3.0.47 → drydock_cli-3.0.49}/tests/test_vision_input.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.47
3
+ Version: 3.0.49
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -19,7 +19,7 @@ def _plan_has_unfinished(config: dict) -> bool:
19
19
  todo = config.get("_todo")
20
20
  return bool(todo) and any(status != "done" for _, status in todo)
21
21
 
22
- from drydock.providers import stream, AssistantTurn, TextChunk
22
+ from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk
23
23
  from drydock.tool_registry import schemas, execute
24
24
  from drydock.tools import register_all
25
25
 
@@ -168,7 +168,9 @@ def run(
168
168
  tool_schemas=filter_tool_schemas(available, turn_config.get("model")),
169
169
  config=turn_config,
170
170
  ):
171
- if isinstance(event, TextChunk):
171
+ if isinstance(event, ReasoningChunk):
172
+ yield event
173
+ elif isinstance(event, TextChunk):
172
174
  yield event
173
175
  elif isinstance(event, AssistantTurn):
174
176
  assistant_turn = event
@@ -149,7 +149,8 @@ def run_oneshot(prompt: str, config: dict) -> None:
149
149
 
150
150
  def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
151
151
  """Handle slash commands. Returns True if handled."""
152
- cmd = cmd.lower().strip()
152
+ raw = cmd.strip() # case-preserved (paths/args must not be lowercased)
153
+ cmd = raw.lower()
153
154
  if cmd == "/help":
154
155
  print_colored("Commands:", "bold")
155
156
  print(" /help — show this help")
@@ -164,7 +165,7 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
164
165
  print_colored(" Conversation cleared.", "green")
165
166
  return True
166
167
  elif cmd == "/compact":
167
- from drydock.compaction import compact, estimate_tokens
168
+ from drydock.compaction import compact, emergency_compact, estimate_tokens
168
169
 
169
170
  if not state.messages:
170
171
  print_colored(" Nothing to compact.", "dim")
@@ -172,6 +173,8 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
172
173
  before = estimate_tokens(state.messages)
173
174
  limit = config.get("context_limit", 65536) or 65536
174
175
  state.messages = compact(state.messages, limit)
176
+ if estimate_tokens(state.messages) > limit * 0.5:
177
+ state.messages = emergency_compact(state.messages, limit)
175
178
  after = estimate_tokens(state.messages)
176
179
  saved = before - after
177
180
  if saved > 0:
@@ -187,6 +190,37 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
187
190
  print_colored(f" Messages: {len(state.messages)}", "cyan")
188
191
  print_colored(f" Tokens: {state.total_input_tokens}in / {state.total_output_tokens}out", "cyan")
189
192
  return True
193
+ elif cmd.split()[0] == "/graphrag":
194
+ from drydock import graphrag
195
+
196
+ cwd = config.get("cwd") or os.getcwd()
197
+ store = graphrag.default_store_path(cwd)
198
+ parts = raw.split() # raw = case-preserved, so paths survive
199
+ sub = parts[1].lower() if len(parts) > 1 else ""
200
+ rest = " ".join(parts[2:]).strip()
201
+ if sub == "build" and rest:
202
+ print_colored(f" Building knowledge base from {rest} …", "dim")
203
+ try:
204
+ stats = graphrag.build_index([rest], store, cwd=cwd)
205
+ print_colored(
206
+ f" ✓ {stats['files']} files · {stats['chunks']} chunks · "
207
+ f"{stats['entities']} entities · {stats['edges']} edges → {store}",
208
+ "green",
209
+ )
210
+ except Exception as e: # noqa: BLE001
211
+ print_colored(f" graphrag build failed: {e}", "red")
212
+ elif sub == "clear":
213
+ store.unlink(missing_ok=True)
214
+ print_colored(" Knowledge base cleared.", "green")
215
+ else:
216
+ index = graphrag.load_index(store)
217
+ if index is None:
218
+ print_colored(" No knowledge base. Build: /graphrag build <path>", "dim")
219
+ else:
220
+ print_colored(
221
+ f" Knowledge base: {len(index.get('chunks', []))} chunks · "
222
+ f"{len(index.get('entities', {}))} entities ({store})", "cyan")
223
+ return True
190
224
  elif cmd in ("/quit", "/exit"):
191
225
  raise KeyboardInterrupt
192
226
  return False
@@ -246,6 +280,11 @@ def main():
246
280
  parser.add_argument("--provider", help="Provider: vllm, ollama, lmstudio, openai")
247
281
  parser.add_argument("--base-url", dest="base_url", help="Override API base URL")
248
282
  parser.add_argument("--max-tokens", dest="max_tokens", type=int, help="Max response tokens")
283
+ parser.add_argument(
284
+ "--context-limit", dest="context_limit", type=int,
285
+ help="Model server context window (must match the server's -c / "
286
+ "--max-model-len; drives the ctx gauge + when compaction fires)",
287
+ )
249
288
  parser.add_argument("--temperature", type=float)
250
289
  parser.add_argument("--max-tool-calls", type=int, default=0, help="Max tool calls (0=unlimited)")
251
290
  parser.add_argument("--force-first-tool", action="store_true", help="Force tool_choice=required on first turn")
@@ -268,6 +307,7 @@ def main():
268
307
  "base_url": args.base_url,
269
308
  "max_tokens": args.max_tokens,
270
309
  "temperature": args.temperature,
310
+ "context_limit": args.context_limit,
271
311
  }, cfg_path)
272
312
 
273
313
  # First launch: ask the user for their model server URL + model name and
@@ -288,10 +328,11 @@ def main():
288
328
  cfgmod.save_file(cfg, cfg_path)
289
329
 
290
330
  config = {
331
+ # context_limit now comes from cfg (DEFAULTS < config.toml < --context-limit)
332
+ # — it drives the ctx gauge AND when compaction fires, so it must match the
333
+ # server's real -c. Previously hardcoded to 65536 here, which clobbered the
334
+ # config value and overflowed servers running a smaller window.
291
335
  **cfg,
292
- # Match the llama.cpp server's -c (gemma4 runs at 64k). Drives both the
293
- # status-bar context gauge and when compaction kicks in.
294
- "context_limit": 65536,
295
336
  "max_tool_calls": args.max_tool_calls,
296
337
  "force_first_tool": args.force_first_tool,
297
338
  "_approve_all": args.dangerously_skip_permissions,
@@ -28,6 +28,12 @@ DEFAULTS: dict[str, object] = {
28
28
  "base_url": "http://localhost:8000/v1",
29
29
  "max_tokens": 8192, # 4096 truncated large file writes mid-JSON (→ _raw fail)
30
30
  "temperature": 0.2,
31
+ # The model server's context window (llama.cpp -c / vLLM --max-model-len).
32
+ # Drives the ctx gauge AND when compaction fires (it compacts at ~60% of
33
+ # this). It MUST match your server: if it's too high, drydock overflows the
34
+ # real window BEFORE compacting (the server 400s) — set this to your -c
35
+ # value. Default 65536 matches the bundled gemma4 server (-c 65536).
36
+ "context_limit": 65536,
31
37
  "theme": "harbor",
32
38
  }
33
39
 
@@ -0,0 +1,244 @@
1
+ """GraphRAG — a local, dependency-free knowledge graph for retrieval.
2
+
3
+ The user builds an index from their own docs/code; the agent then queries it
4
+ through the `Knowledge` tool while answering or coding, so it can ground its
5
+ work in project-specific information the model was never trained on.
6
+
7
+ Design (clean-room, stdlib only — no embeddings, no third-party deps, works
8
+ against any server):
9
+ • Ingest text files → split into chunks.
10
+ • Extract ENTITIES from each chunk (proper nouns, `code` terms, CamelCase /
11
+ snake_case identifiers, ACRONYMS) → graph NODES.
12
+ • Entities co-occurring in a chunk get an EDGE (weighted by co-occurrence) →
13
+ the graph. Each entity also points at the chunks it appears in.
14
+ • Query: pull the query's entities + keywords, score chunks by entity and
15
+ keyword overlap, then EXPAND one hop along the entity graph so strongly
16
+ related context comes along — the "graph" in GraphRAG. Return the top
17
+ chunks plus the related entities, both fed back to the model.
18
+
19
+ The index is a single JSON file so it's inspectable, portable, and gitignorable.
20
+
21
+ All logic original to Drydock.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ from collections import Counter, defaultdict
29
+ from pathlib import Path
30
+
31
+ # Files we ingest as text. Everything else (binaries, images) is skipped.
32
+ _TEXT_EXT = {
33
+ ".md", ".txt", ".rst", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".go",
34
+ ".rs", ".c", ".h", ".cpp", ".hpp", ".cs", ".rb", ".php", ".sh", ".toml",
35
+ ".yaml", ".yml", ".json", ".cfg", ".ini", ".sql", ".html", ".css", ".org",
36
+ ".tex", ".csv", ".log", ".",
37
+ }
38
+ _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist",
39
+ "build", ".mypy_cache", ".pytest_cache", ".drydock"}
40
+
41
+ _STOPWORDS = frozenset("""
42
+ the a an and or but if then else for while of to in on at by with from into as
43
+ is are was were be been being do does did this that these those it its their
44
+ your you we they he she his her our not no yes can will would should could may
45
+ """.split())
46
+
47
+ # Entity patterns (kept simple + fast; tuned to surface useful nodes, not noise)
48
+ _RE_BACKTICK = re.compile(r"`([^`\n]{2,60})`")
49
+ _RE_IDENT = re.compile(r"\b([a-zA-Z_][a-zA-Z0-9_]*(?:[_./][a-zA-Z0-9_]+)+)\b")
50
+ _RE_CAMEL = re.compile(r"\b([A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+)\b")
51
+ _RE_PROPER = re.compile(r"\b([A-Z][a-zA-Z0-9]+(?:[ \t]+[A-Z][a-zA-Z0-9]+){0,3})\b")
52
+ _RE_ACRONYM = re.compile(r"\b([A-Z]{2,6})\b")
53
+ _RE_WORD = re.compile(r"[a-zA-Z0-9_]+")
54
+
55
+
56
+ def default_store_path(cwd: str) -> Path:
57
+ """Project-local index (travels with the project, easy to .gitignore)."""
58
+ return Path(cwd) / ".drydock" / "graphrag.json"
59
+
60
+
61
+ def extract_entities(text: str) -> list[str]:
62
+ """Heuristic entity extraction → normalized (lowercased) entity keys."""
63
+ found: set[str] = set()
64
+ for rx in (_RE_BACKTICK, _RE_IDENT, _RE_CAMEL, _RE_PROPER, _RE_ACRONYM):
65
+ for m in rx.findall(text):
66
+ e = m.strip().lower()
67
+ # Drop trivial / stopword-only entities and overlong noise.
68
+ if len(e) < 3 or len(e) > 60:
69
+ continue
70
+ if all(w in _STOPWORDS for w in _RE_WORD.findall(e)):
71
+ continue
72
+ found.add(e)
73
+ return sorted(found)
74
+
75
+
76
+ def _chunk_text(text: str, max_chars: int = 1200) -> list[str]:
77
+ """Split into paragraph-ish chunks, capping size so a chunk stays focused."""
78
+ chunks: list[str] = []
79
+ buf: list[str] = []
80
+ size = 0
81
+ for para in re.split(r"\n\s*\n", text):
82
+ para = para.strip()
83
+ if not para:
84
+ continue
85
+ if size + len(para) > max_chars and buf:
86
+ chunks.append("\n\n".join(buf))
87
+ buf, size = [], 0
88
+ if len(para) > max_chars: # a single huge paragraph → hard-split
89
+ for i in range(0, len(para), max_chars):
90
+ chunks.append(para[i:i + max_chars])
91
+ continue
92
+ buf.append(para)
93
+ size += len(para)
94
+ if buf:
95
+ chunks.append("\n\n".join(buf))
96
+ return chunks
97
+
98
+
99
+ def _iter_text_files(paths: list[str]):
100
+ for p in paths:
101
+ path = Path(p)
102
+ if path.is_file():
103
+ yield path
104
+ elif path.is_dir():
105
+ for root, dirs, files in os.walk(path):
106
+ dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
107
+ for f in files:
108
+ fp = Path(root) / f
109
+ if fp.suffix.lower() in _TEXT_EXT or fp.suffix == "":
110
+ yield fp
111
+
112
+
113
+ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
114
+ """Build (or rebuild) the knowledge graph from paths; persist to store_path.
115
+
116
+ Returns a stats dict: {files, chunks, entities, edges}.
117
+ """
118
+ chunks: list[dict] = []
119
+ entity_chunks: dict[str, set[int]] = defaultdict(set)
120
+ edges: dict[str, Counter] = defaultdict(Counter)
121
+ files = 0
122
+
123
+ for fp in _iter_text_files([str(Path(cwd) / p) if not os.path.isabs(p) else p
124
+ for p in paths]):
125
+ try:
126
+ text = fp.read_text("utf-8", "ignore")
127
+ except OSError:
128
+ continue
129
+ if not text.strip():
130
+ continue
131
+ files += 1
132
+ rel = os.path.relpath(str(fp), cwd)
133
+ for body in _chunk_text(text):
134
+ cid = len(chunks)
135
+ ents = extract_entities(body)
136
+ chunks.append({"id": cid, "source": rel, "text": body, "entities": ents})
137
+ for e in ents:
138
+ entity_chunks[e].add(cid)
139
+ for i, a in enumerate(ents): # co-occurrence edges within the chunk
140
+ for b in ents[i + 1:]:
141
+ edges[a][b] += 1
142
+ edges[b][a] += 1
143
+
144
+ index = {
145
+ "version": 1,
146
+ "chunks": chunks,
147
+ "entities": {e: sorted(cids) for e, cids in entity_chunks.items()},
148
+ "edges": {a: dict(nbrs) for a, nbrs in edges.items()},
149
+ }
150
+ sp = Path(store_path)
151
+ sp.parent.mkdir(parents=True, exist_ok=True)
152
+ sp.write_text(json.dumps(index), encoding="utf-8")
153
+ return {
154
+ "files": files,
155
+ "chunks": len(chunks),
156
+ "entities": len(entity_chunks),
157
+ "edges": sum(len(v) for v in edges.values()) // 2,
158
+ }
159
+
160
+
161
+ def load_index(store_path: str | Path) -> dict | None:
162
+ sp = Path(store_path)
163
+ if not sp.exists():
164
+ return None
165
+ try:
166
+ return json.loads(sp.read_text("utf-8"))
167
+ except (OSError, ValueError):
168
+ return None
169
+
170
+
171
+ def query_index(index: dict, query: str, *, k: int = 5, hops: bool = True) -> dict:
172
+ """Retrieve the top-k chunks for a query, expanded one hop over the graph.
173
+
174
+ Returns {chunks: [{source, text, score}], related: [entities]}.
175
+ """
176
+ chunks = index.get("chunks", [])
177
+ entities = index.get("entities", {})
178
+ edges = index.get("edges", {})
179
+ if not chunks:
180
+ return {"chunks": [], "related": []}
181
+
182
+ q_entities = set(extract_entities(query))
183
+ q_words = {w for w in (w.lower() for w in _RE_WORD.findall(query))
184
+ if len(w) > 2 and w not in _STOPWORDS}
185
+
186
+ # Match query entities to graph entities (exact + substring both ways).
187
+ matched: set[str] = set()
188
+ for qe in q_entities | q_words:
189
+ for ge in entities:
190
+ if qe == ge or qe in ge or ge in qe:
191
+ matched.add(ge)
192
+
193
+ # 1-hop graph expansion: pull the strongest neighbors of matched entities.
194
+ related: list[str] = []
195
+ expanded: set[str] = set(matched)
196
+ if hops:
197
+ for e in list(matched):
198
+ nbrs = sorted(edges.get(e, {}).items(), key=lambda kv: -kv[1])[:5]
199
+ for nbr, _w in nbrs:
200
+ expanded.add(nbr)
201
+ if nbr not in matched:
202
+ related.append(nbr)
203
+
204
+ # Score chunks: direct entity hits weigh most, then graph-neighbor hits,
205
+ # then raw keyword overlap (so retrieval still works if entities miss).
206
+ scores: dict[int, float] = defaultdict(float)
207
+ for e in matched:
208
+ for cid in entities.get(e, []):
209
+ scores[cid] += 3.0
210
+ for e in expanded - matched:
211
+ for cid in entities.get(e, []):
212
+ scores[cid] += 1.0
213
+ for c in chunks:
214
+ if q_words:
215
+ overlap = len(q_words & {w.lower() for w in _RE_WORD.findall(c["text"])})
216
+ if overlap:
217
+ scores[c["id"]] += 0.5 * overlap
218
+
219
+ ranked = sorted(scores.items(), key=lambda kv: -kv[1])[:k]
220
+ out_chunks = [
221
+ {"source": chunks[cid]["source"], "text": chunks[cid]["text"], "score": round(sc, 1)}
222
+ for cid, sc in ranked if sc > 0
223
+ ]
224
+ # de-dup related, keep order, cap
225
+ seen: set[str] = set()
226
+ rel_unique = [r for r in related if not (r in seen or seen.add(r))][:12]
227
+ return {"chunks": out_chunks, "related": rel_unique}
228
+
229
+
230
+ def format_results(result: dict, query: str) -> str:
231
+ """Render query results as the tool's string output for the model."""
232
+ chunks = result.get("chunks", [])
233
+ if not chunks:
234
+ return (
235
+ f"No knowledge-base matches for: {query!r}. The index may not cover "
236
+ "this topic — answer from your own knowledge, or ask the user to add "
237
+ "the relevant docs with /graphrag build <path>."
238
+ )
239
+ parts = [f"Knowledge base — {len(chunks)} relevant passage(s) for {query!r}:"]
240
+ for i, c in enumerate(chunks, 1):
241
+ parts.append(f"\n[{i}] (source: {c['source']}, score {c['score']})\n{c['text']}")
242
+ if result.get("related"):
243
+ parts.append("\nRelated entities in the graph: " + ", ".join(result["related"]))
244
+ return "\n".join(parts)
@@ -161,6 +161,32 @@ SCHEMAS = [
161
161
  "required": ["prompt"],
162
162
  },
163
163
  },
164
+ {
165
+ "name": "Knowledge",
166
+ "description": (
167
+ "Search the user's KNOWLEDGE BASE (a GraphRAG index they built from "
168
+ "their own docs/code) for project-specific information you were not "
169
+ "trained on. Returns the most relevant passages plus related entities "
170
+ "from the graph. Use it BEFORE answering or coding when the task may "
171
+ "depend on the user's private/project knowledge (their APIs, specs, "
172
+ "data, conventions). If it returns no matches, the topic isn't in the "
173
+ "base — answer normally."
174
+ ),
175
+ "input_schema": {
176
+ "type": "object",
177
+ "properties": {
178
+ "query": {
179
+ "type": "string",
180
+ "description": "What to look up, as a natural-language question or keywords.",
181
+ },
182
+ "k": {
183
+ "type": "integer",
184
+ "description": "Max passages to return (default 5).",
185
+ },
186
+ },
187
+ "required": ["query"],
188
+ },
189
+ },
164
190
  ]
165
191
 
166
192
  # ── Tool implementations ──────────────────────────────────────────────────
@@ -709,6 +735,32 @@ def tool_task(params: dict, config: dict) -> str:
709
735
  return f"[sub-agent finished {steps} step(s) with no summary]"
710
736
 
711
737
 
738
+ def tool_knowledge(params: dict, config: dict) -> str:
739
+ """Query the project's GraphRAG knowledge base (built with /graphrag build).
740
+ Read-only; returns the most relevant passages plus related graph entities.
741
+ If no index exists, says so cleanly rather than erroring."""
742
+ from drydock import graphrag
743
+
744
+ query = (params.get("query") or "").strip()
745
+ if not query:
746
+ return "Error: `Knowledge` needs a `query` describing what to look up."
747
+ cwd = config.get("cwd") or os.getcwd()
748
+ store = config.get("graphrag_store") or graphrag.default_store_path(cwd)
749
+ index = graphrag.load_index(store)
750
+ if index is None:
751
+ return (
752
+ "No knowledge base has been built yet. The user can build one with "
753
+ "'/graphrag build <path>' (a file or directory of docs/code). Until "
754
+ "then, answer from your own knowledge."
755
+ )
756
+ try:
757
+ k = int(params.get("k") or 5)
758
+ except (TypeError, ValueError):
759
+ k = 5
760
+ result = graphrag.query_index(index, query, k=max(1, min(k, 15)))
761
+ return graphrag.format_results(result, query)
762
+
763
+
712
764
  # ── Register all tools ────────────────────────────────────────────────────
713
765
 
714
766
  _TOOLS = [
@@ -720,6 +772,7 @@ _TOOLS = [
720
772
  ("Grep", tool_grep, True),
721
773
  ("todo", tool_todo, False),
722
774
  ("task", tool_task, True),
775
+ ("Knowledge", tool_knowledge, True),
723
776
  ]
724
777
 
725
778
  def register_all():
@@ -728,10 +781,10 @@ def register_all():
728
781
  func = {
729
782
  "Read": tool_read, "Write": tool_write, "Edit": tool_edit,
730
783
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
731
- "todo": tool_todo, "task": tool_task,
784
+ "todo": tool_todo, "task": tool_task, "Knowledge": tool_knowledge,
732
785
  }[name]
733
- # `task` is read-only w.r.t. the parent's files (it can't Write/Edit).
734
- read_only = name in ("Read", "Glob", "Grep", "task")
786
+ # task + Knowledge are read-only w.r.t. the parent's files.
787
+ read_only = name in ("Read", "Glob", "Grep", "task", "Knowledge")
735
788
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
736
789
 
737
790
  register_all()
@@ -19,6 +19,7 @@ from textual.widgets import Static
19
19
 
20
20
  from drydock.agent import (
21
21
  AgentState,
22
+ ReasoningChunk,
22
23
  TextChunk,
23
24
  ToolEnd,
24
25
  ToolStart,
@@ -28,6 +29,7 @@ from drydock.agent import (
28
29
  from drydock.tui.messages import (
29
30
  AgentError,
30
31
  AgentFinished,
32
+ AgentReasoning,
31
33
  AgentText,
32
34
  AgentToolEnd,
33
35
  AgentToolStart,
@@ -38,6 +40,7 @@ from drydock.tui.widgets import (
38
40
  AssistantMessage,
39
41
  ErrorMessage,
40
42
  PromptArea,
43
+ ReasoningCard,
41
44
  ToolCard,
42
45
  UserMessage,
43
46
  result_is_ok,
@@ -92,6 +95,10 @@ class DrydockApp(App):
92
95
  .tool-card.ok { border-left: thick #2e8b6b; }
93
96
  .tool-card.fail { border-left: thick #b3503e; }
94
97
  .tool-body { color: #9bb4c0; padding: 0 1; }
98
+ .reasoning-card {
99
+ margin: 0 0 0 2; border-left: thick #6a5acd; background: #14132a;
100
+ }
101
+ .reasoning-body { color: #9a93c0; padding: 0 1; }
95
102
  /* Pinned task checklist in the footer (height auto → 0 lines when empty). */
96
103
  #todo {
97
104
  height: auto; margin: 0 2 1 2; padding: 0 1; color: #d7e6ee;
@@ -455,6 +462,8 @@ class DrydockApp(App):
455
462
  self._info("Nothing to go back to.")
456
463
  elif cmd == "/compact":
457
464
  self._cmd_compact()
465
+ elif cmd == "/graphrag":
466
+ self._cmd_graphrag(arg)
458
467
  elif cmd == "/status":
459
468
  t = self.state
460
469
  self._info(
@@ -474,6 +483,8 @@ class DrydockApp(App):
474
483
  " /stop stop the running turn (or press Esc)\n"
475
484
  " /status session model, cwd, turns, tokens\n"
476
485
  " /compact shrink old context to free up the window\n"
486
+ " /graphrag build/query a knowledge base from your docs\n"
487
+ " /graphrag build <path> · /graphrag status · /graphrag clear\n"
477
488
  " /clear reset the conversation\n"
478
489
  " /quit exit\n"
479
490
  "Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
@@ -485,8 +496,11 @@ class DrydockApp(App):
485
496
  """Manually compact the conversation to reclaim context NOW, without
486
497
  waiting for the automatic 60%-of-window threshold (agent.maybe_compact).
487
498
  Truncates/drops old tool results and oversized tool-call arguments while
488
- keeping recent turns intact (see compaction.compact)."""
489
- from drydock.compaction import compact, estimate_tokens
499
+ keeping recent turns intact (see compaction.compact). If a normal pass
500
+ can't free enough (history dominated by big messages, not tool output),
501
+ it ESCALATES to emergency_compact so an explicit /compact always helps —
502
+ the user asked to free space, so being aggressive is the right call."""
503
+ from drydock.compaction import compact, emergency_compact, estimate_tokens
490
504
 
491
505
  msgs = self.state.messages
492
506
  if not msgs:
@@ -495,6 +509,11 @@ class DrydockApp(App):
495
509
  before = estimate_tokens(msgs)
496
510
  limit = self.config.get("context_limit", 65536) or 65536
497
511
  self.state.messages = compact(msgs, limit)
512
+ # Escalate when the normal pass left us still heavy (>50% of the window):
513
+ # this is exactly the "tried /compact, it said nothing, then OOM again"
514
+ # case — the bloat isn't in droppable tool results, so go aggressive.
515
+ if estimate_tokens(self.state.messages) > limit * 0.5:
516
+ self.state.messages = emergency_compact(self.state.messages, limit)
498
517
  after = estimate_tokens(self.state.messages)
499
518
  saved = before - after
500
519
  if saved > 0:
@@ -517,6 +536,54 @@ class DrydockApp(App):
517
536
  "(recent turns are always preserved)."
518
537
  )
519
538
 
539
+ def _cmd_graphrag(self, arg: str) -> None:
540
+ """Build / inspect / clear the project's GraphRAG knowledge base. Once
541
+ built, the agent retrieves from it via the read-only Knowledge tool."""
542
+ from drydock import graphrag
543
+
544
+ cwd = self.config.get("cwd") or "."
545
+ store = graphrag.default_store_path(cwd)
546
+ parts = arg.split(maxsplit=1)
547
+ sub = parts[0].lower() if parts else ""
548
+ rest = parts[1].strip() if len(parts) > 1 else ""
549
+
550
+ if sub == "build":
551
+ if not rest:
552
+ self._info("usage: /graphrag build <path> (a file or directory of docs/code)")
553
+ return
554
+ self._info(f"Building knowledge base from {rest} …")
555
+ try:
556
+ stats = graphrag.build_index([rest], store, cwd=cwd)
557
+ except Exception as e: # noqa: BLE001 — surface, never crash the TUI
558
+ self._mount(ErrorMessage(f"graphrag build failed: {e}"))
559
+ return
560
+ if not stats["chunks"]:
561
+ self._info(f"No text found under {rest}. Nothing was indexed.")
562
+ return
563
+ self._info(
564
+ f"✓ Knowledge base built: {stats['files']} files · "
565
+ f"{stats['chunks']} chunks · {stats['entities']} entities · "
566
+ f"{stats['edges']} edges.\nStored at {store}. The agent will now "
567
+ "use the Knowledge tool to draw on it."
568
+ )
569
+ elif sub in ("", "status"):
570
+ index = graphrag.load_index(store)
571
+ if index is None:
572
+ self._info("No knowledge base yet. Build one: /graphrag build <path>")
573
+ else:
574
+ self._info(
575
+ f"Knowledge base: {len(index.get('chunks', []))} chunks · "
576
+ f"{len(index.get('entities', {}))} entities ({store})."
577
+ )
578
+ elif sub == "clear":
579
+ try:
580
+ store.unlink(missing_ok=True)
581
+ self._info("Knowledge base cleared.")
582
+ except OSError as e:
583
+ self._mount(ErrorMessage(f"could not clear: {e}"))
584
+ else:
585
+ self._info("usage: /graphrag build <path> · /graphrag status · /graphrag clear")
586
+
520
587
  def _persist_config(self) -> None:
521
588
  """Save the persistable settings (model/provider/base_url/… — save_file
522
589
  filters to those) to ~/.drydock/config.toml so setup survives restart."""
@@ -600,7 +667,9 @@ class DrydockApp(App):
600
667
  def _run_agent(self, text: str) -> None:
601
668
  try:
602
669
  for ev in run(text, self.state, self.config, self.system):
603
- if isinstance(ev, TextChunk):
670
+ if isinstance(ev, ReasoningChunk):
671
+ self.post_message(AgentReasoning(ev.text))
672
+ elif isinstance(ev, TextChunk):
604
673
  self.post_message(AgentText(ev.text))
605
674
  elif isinstance(ev, ToolStart):
606
675
  self.post_message(AgentToolStart(ev.name, ev.inputs))
@@ -615,6 +684,14 @@ class DrydockApp(App):
615
684
 
616
685
  # ── agent → UI handlers ───────────────────────────────────────────────
617
686
 
687
+ def on_agent_reasoning(self, m: AgentReasoning) -> None:
688
+ # The model's thinking for this turn, rendered as a collapsed card BEFORE
689
+ # the answer text (reasoning is yielded before TextChunk). End any current
690
+ # text block so the answer starts fresh below the card.
691
+ self._current_assistant = None
692
+ self._mount(ReasoningCard(m.text))
693
+ self._scroll.scroll_end(animate=False)
694
+
618
695
  def on_agent_text(self, m: AgentText) -> None:
619
696
  self._ensure_assistant().append(m.text)
620
697
  self._work_chars += len(m.text)
@@ -9,6 +9,12 @@ from __future__ import annotations
9
9
  from textual.message import Message
10
10
 
11
11
 
12
+ class AgentReasoning(Message):
13
+ def __init__(self, text: str) -> None:
14
+ self.text = text
15
+ super().__init__()
16
+
17
+
12
18
  class AgentText(Message):
13
19
  def __init__(self, text: str) -> None:
14
20
  self.text = text
@@ -153,7 +153,7 @@ class PromptHistory:
153
153
  # completes the prefix.
154
154
  SLASH_COMMANDS = [
155
155
  "/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
156
- "/compact", "/clear", "/quit",
156
+ "/compact", "/graphrag", "/clear", "/quit",
157
157
  ]
158
158
 
159
159
 
@@ -266,6 +266,29 @@ class ErrorMessage(Static):
266
266
  super().__init__(f"⚠ {text}", classes="error-msg", markup=False)
267
267
 
268
268
 
269
+ class ReasoningCard(Collapsible):
270
+ """The model's thinking for a turn — collapsed by default so a long reasoning
271
+ block (gemma runs a 20k-token budget) doesn't flood the transcript; expand
272
+ to read it. Distinct from the answer, which renders as an AssistantMessage."""
273
+
274
+ def __init__(self, text: str) -> None:
275
+ self._buf = text
276
+ self._body = Static(text, classes="reasoning-body", markup=False)
277
+ n = len(text)
278
+ super().__init__(
279
+ self._body,
280
+ title=f"💭 thinking · {n:,} chars",
281
+ collapsed=True,
282
+ )
283
+ self.add_class("reasoning-card")
284
+
285
+ def append(self, text: str) -> None:
286
+ """Grow the block if a turn streams reasoning in multiple chunks."""
287
+ self._buf += text
288
+ self._body.update(self._buf)
289
+ self.title = f"💭 thinking · {len(self._buf):,} chars"
290
+
291
+
269
292
  class ToolCard(Collapsible):
270
293
  """A tool call: compact header (name + summary), expandable full output."""
271
294
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.47
3
+ Version: 3.0.49
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -10,6 +10,7 @@ drydock/cli.py
10
10
  drydock/compaction.py
11
11
  drydock/config.py
12
12
  drydock/detect.py
13
+ drydock/graphrag.py
13
14
  drydock/guards.py
14
15
  drydock/loop_detect.py
15
16
  drydock/providers.py
@@ -37,10 +38,12 @@ tests/test_compact_command.py
37
38
  tests/test_compaction.py
38
39
  tests/test_config.py
39
40
  tests/test_config_migration.py
41
+ tests/test_context_limit_config.py
40
42
  tests/test_detect.py
41
43
  tests/test_empty_response.py
42
44
  tests/test_failure_loop.py
43
45
  tests/test_first_run_setup.py
46
+ tests/test_graphrag.py
44
47
  tests/test_guards_and_tools.py
45
48
  tests/test_hallucinated_tools.py
46
49
  tests/test_leaked_tool_call.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.47"
10
+ version = "3.0.49"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -84,5 +84,8 @@ def test_save_drops_runtime_only_keys(tmp_path):
84
84
  cfg.save_file({"model": "g", "cwd": "/tmp", "context_limit": 999}, path)
85
85
  with path.open("rb") as f:
86
86
  written = tomllib.load(f)
87
- assert "cwd" not in written and "context_limit" not in written
87
+ # cwd is runtime-only (dropped); context_limit is a real persistent setting
88
+ # (users must be able to match it to their server's window).
89
+ assert "cwd" not in written
90
+ assert written["context_limit"] == 999
88
91
  assert written["model"] == "g"
@@ -0,0 +1,44 @@
1
+ """context_limit must be a config.toml setting (not hardcoded), so users whose
2
+ model server runs a smaller window can lower it — otherwise drydock overflows
3
+ the real context before compaction fires. Reported from real use: OOM at 32k
4
+ while the hardcoded limit was 65536, and /compact found 'nothing to compact'."""
5
+ from __future__ import annotations
6
+
7
+ from drydock import config as cfgmod
8
+ from drydock.agent import AgentState
9
+ from drydock.compaction import estimate_tokens
10
+ from drydock import cli
11
+
12
+
13
+ def test_context_limit_is_a_default_and_written_to_file(tmp_path):
14
+ path = tmp_path / "config.toml"
15
+ cfg = cfgmod.resolve({}, path)
16
+ assert "context_limit" in cfg # present in resolved config
17
+ assert "context_limit" in path.read_text() # persisted to the file
18
+
19
+
20
+ def test_config_file_value_is_respected(tmp_path):
21
+ path = tmp_path / "config.toml"
22
+ path.write_text('model = "gemma4"\ncontext_limit = 32768\n')
23
+ cfg = cfgmod.resolve({}, path)
24
+ assert cfg["context_limit"] == 32768
25
+
26
+
27
+ def test_cli_flag_overrides_file(tmp_path):
28
+ path = tmp_path / "config.toml"
29
+ path.write_text('context_limit = 32768\n')
30
+ cfg = cfgmod.resolve({"context_limit": 16384}, path)
31
+ assert cfg["context_limit"] == 16384
32
+
33
+
34
+ def test_compact_escalates_when_bloat_is_not_tool_results(capsys):
35
+ # History dominated by big ASSISTANT text — normal compact() barely helps,
36
+ # so an explicit /compact must escalate (emergency_compact) and free space.
37
+ state = AgentState()
38
+ state.messages = [{"role": "user", "content": "start"}]
39
+ for i in range(8):
40
+ state.messages.append({"role": "assistant", "content": f"answer {i} " + "x" * 4000})
41
+ before = estimate_tokens(state.messages)
42
+ cli.handle_command("/compact", state, {"context_limit": 16384})
43
+ after = estimate_tokens(state.messages)
44
+ assert after < before * 0.7 # escalation actually reclaimed space
@@ -0,0 +1,75 @@
1
+ """GraphRAG: a local, dependency-free knowledge graph the user builds and the
2
+ agent queries via the read-only Knowledge tool."""
3
+ from __future__ import annotations
4
+
5
+ from drydock import graphrag
6
+ from drydock.tools import tool_knowledge
7
+
8
+
9
+ def _corpus(tmp_path):
10
+ d = tmp_path / "docs"
11
+ d.mkdir()
12
+ (d / "payments.md").write_text(
13
+ "The Acme Payments Service authorizes a charge via the Zephyr-Key header "
14
+ "for idempotency. Webhooks are signed with WEBHOOK_SECRET using HMAC-SHA256."
15
+ )
16
+ (d / "inventory.md").write_text(
17
+ "The InventoryManager fires low-stock alerts when quantity drops below "
18
+ "the reorder_threshold. The nightly ReorderJob emails procurement."
19
+ )
20
+ return d
21
+
22
+
23
+ def test_build_and_query_routes_to_right_doc(tmp_path):
24
+ store = tmp_path / ".drydock" / "graphrag.json"
25
+ stats = graphrag.build_index(["docs"], store, cwd=str(tmp_path))
26
+ assert stats["files"] == 2 and stats["chunks"] >= 2 and stats["entities"] > 0
27
+
28
+ idx = graphrag.load_index(store)
29
+ pay = graphrag.query_index(idx, "how do I authorize a charge with idempotency?")
30
+ assert pay["chunks"] and pay["chunks"][0]["source"].endswith("payments.md")
31
+
32
+ inv = graphrag.query_index(idx, "low stock alerts and reorder threshold")
33
+ assert inv["chunks"] and inv["chunks"][0]["source"].endswith("inventory.md")
34
+
35
+
36
+ def test_entities_do_not_span_newlines(tmp_path):
37
+ store = tmp_path / ".drydock" / "graphrag.json"
38
+ graphrag.build_index(["docs"], store, cwd=str(tmp_path))
39
+ idx = graphrag.load_index(store)
40
+ assert all("\n" not in e for e in idx["entities"]) # proper nouns stay on one line
41
+
42
+
43
+ def test_graph_expansion_returns_related_entities(tmp_path):
44
+ store = tmp_path / ".drydock" / "graphrag.json"
45
+ graphrag.build_index(["docs"], store, cwd=str(tmp_path))
46
+ idx = graphrag.load_index(store)
47
+ res = graphrag.query_index(idx, "ReorderJob")
48
+ # 1-hop expansion should pull co-occurring entities from the same chunk.
49
+ assert any("reorder_threshold" in r or "inventorymanager" in r for r in res["related"])
50
+
51
+
52
+ def test_knowledge_tool_without_index_is_graceful(tmp_path):
53
+ out = tool_knowledge({"query": "anything"}, {"cwd": str(tmp_path)})
54
+ assert "No knowledge base" in out and "graphrag build" in out
55
+
56
+
57
+ def test_knowledge_tool_returns_passages(tmp_path):
58
+ store = graphrag.default_store_path(str(tmp_path))
59
+ graphrag.build_index(["docs"], store, cwd=str(tmp_path))
60
+ out = tool_knowledge({"query": "authorize a charge", "k": 2}, {"cwd": str(tmp_path)})
61
+ assert "payments.md" in out and "Zephyr-Key" in out
62
+
63
+
64
+ def test_knowledge_tool_needs_a_query(tmp_path):
65
+ assert "needs a `query`" in tool_knowledge({}, {"cwd": str(tmp_path)})
66
+
67
+
68
+ # Make the corpus available to every test via an autouse fixture.
69
+ import pytest # noqa: E402
70
+
71
+
72
+ @pytest.fixture(autouse=True)
73
+ def _make_corpus(tmp_path):
74
+ _corpus(tmp_path)
75
+ yield
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  from drydock.tuning import (
5
5
  GEMMA_DISABLED_TOOLS,
6
+ extract_thinking,
6
7
  filter_tool_schemas,
7
8
  is_gemma,
8
9
  strip_thinking_tokens,
@@ -96,3 +97,56 @@ def test_thinking_level_adaptive():
96
97
  assert thinking_level_for_turn(1, is_user_turn=False) == "high"
97
98
  assert thinking_level_for_turn(5, is_user_turn=False) == "off"
98
99
  assert thinking_level_for_turn(5, is_user_turn=True) == "high"
100
+
101
+
102
+ # --- extract_thinking ---
103
+
104
+ def test_extract_thinking_no_block():
105
+ thinking, text = extract_thinking("hello world")
106
+ assert thinking == ""
107
+ assert text == "hello world"
108
+
109
+
110
+ def test_extract_thinking_empty_string():
111
+ thinking, text = extract_thinking("")
112
+ assert thinking == ""
113
+ assert text == ""
114
+
115
+
116
+ def test_extract_thinking_extracts_and_strips():
117
+ raw = "<|channel>my thought<channel|>the answer"
118
+ thinking, text = extract_thinking(raw)
119
+ assert thinking == "my thought"
120
+ assert text == "the answer"
121
+
122
+
123
+ def test_extract_thinking_multiline_block():
124
+ raw = "<|channel>line1\nline2<channel|>result"
125
+ thinking, text = extract_thinking(raw)
126
+ assert thinking == "line1\nline2"
127
+ assert text == "result"
128
+
129
+
130
+ def test_extract_thinking_tool_call_closer():
131
+ # Thought running into a malformed tool call closes with <tool_call|>.
132
+ raw = "<|channel>secret thought<tool_call|>visible"
133
+ thinking, text = extract_thinking(raw)
134
+ assert thinking == "secret thought"
135
+ assert text == "visible"
136
+
137
+
138
+ def test_extract_thinking_multiple_blocks():
139
+ raw = "<|channel>thought1<channel|>text1<|channel>thought2<channel|>text2"
140
+ thinking, text = extract_thinking(raw)
141
+ assert "thought1" in thinking
142
+ assert "thought2" in thinking
143
+ assert "text1" in text
144
+ assert "text2" in text
145
+ assert "<|channel>" not in text
146
+
147
+
148
+ def test_extract_thinking_noop_when_no_marker():
149
+ raw = "no markers here <b>html</b>"
150
+ thinking, text = extract_thinking(raw)
151
+ assert thinking == ""
152
+ assert text == raw
File without changes
File without changes
File without changes
File without changes