drydock-cli 3.0.59__tar.gz → 3.0.60__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 (76) hide show
  1. {drydock_cli-3.0.59/drydock_cli.egg-info → drydock_cli-3.0.60}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/cli.py +14 -6
  3. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/graphrag.py +47 -13
  4. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/tui/app.py +35 -13
  5. {drydock_cli-3.0.59 → drydock_cli-3.0.60/drydock_cli.egg-info}/PKG-INFO +1 -1
  6. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/pyproject.toml +1 -1
  7. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_graphrag.py +32 -0
  8. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/LICENSE +0 -0
  9. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/NOTICE +0 -0
  10. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/README.md +0 -0
  11. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/__init__.py +0 -0
  12. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/__main__.py +0 -0
  13. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/agent.py +0 -0
  14. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/bash_safety.py +0 -0
  15. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/compaction.py +0 -0
  16. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/config.py +0 -0
  17. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/detect.py +0 -0
  18. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/gittools.py +0 -0
  19. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/guards.py +0 -0
  20. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/loop_detect.py +0 -0
  21. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/mcp.py +0 -0
  22. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/providers.py +0 -0
  23. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/skills.py +0 -0
  24. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/tool_registry.py +0 -0
  25. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/tools/__init__.py +0 -0
  26. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/tui/__init__.py +0 -0
  27. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/tui/approval.py +0 -0
  28. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/tui/messages.py +0 -0
  29. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/tui/widgets.py +0 -0
  30. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/tuning.py +0 -0
  31. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock/web.py +0 -0
  32. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock_cli.egg-info/SOURCES.txt +0 -0
  33. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock_cli.egg-info/dependency_links.txt +0 -0
  34. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock_cli.egg-info/entry_points.txt +0 -0
  35. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock_cli.egg-info/requires.txt +0 -0
  36. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/drydock_cli.egg-info/top_level.txt +0 -0
  37. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/setup.cfg +0 -0
  38. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_approval.py +0 -0
  39. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_back_command.py +0 -0
  40. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_bash_output_bounding.py +0 -0
  41. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_bash_process_group.py +0 -0
  42. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_bash_safety.py +0 -0
  43. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_bash_timeout_network.py +0 -0
  44. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_cli_agents.py +0 -0
  45. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_compact_command.py +0 -0
  46. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_compaction.py +0 -0
  47. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_config.py +0 -0
  48. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_config_migration.py +0 -0
  49. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_context_limit_config.py +0 -0
  50. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_detect.py +0 -0
  51. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_dispatch.py +0 -0
  52. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_empty_response.py +0 -0
  53. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_failure_loop.py +0 -0
  54. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_first_run_setup.py +0 -0
  55. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_gittools.py +0 -0
  56. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_guards_and_tools.py +0 -0
  57. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_hallucinated_tools.py +0 -0
  58. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_leaked_tool_call.py +0 -0
  59. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_loop_detect.py +0 -0
  60. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_mcp.py +0 -0
  61. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_oneshot_unreachable.py +0 -0
  62. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_plan_autocontinue.py +0 -0
  63. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_providers_unreachable.py +0 -0
  64. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_read_index.py +0 -0
  65. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_runaway_repetition.py +0 -0
  66. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_skills.py +0 -0
  67. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_stop.py +0 -0
  68. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_streaming_newlines.py +0 -0
  69. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_subagent.py +0 -0
  70. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_todo.py +0 -0
  71. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_tool_arg_parsing.py +0 -0
  72. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_tools_undo.py +0 -0
  73. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_tui.py +0 -0
  74. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_tuning.py +0 -0
  75. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_vision_input.py +0 -0
  76. {drydock_cli-3.0.59 → drydock_cli-3.0.60}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.59
3
+ Version: 3.0.60
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
@@ -198,17 +198,23 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
198
198
  parts = raw.split() # raw = case-preserved, so paths survive
199
199
  sub = parts[1].lower() if len(parts) > 1 else ""
200
200
  rest = " ".join(parts[2:]).strip()
201
- if sub == "build" and rest:
202
- print_colored(f" Building knowledge base from {rest} …", "dim")
201
+ if sub in ("build", "add") and rest:
203
202
  try:
204
- stats = graphrag.build_index([rest], store, cwd=cwd)
203
+ fn = graphrag.build_index if sub == "build" else graphrag.add_to_index
204
+ stats = fn([rest], store, cwd=cwd)
205
205
  print_colored(
206
- f" ✓ {stats['files']} files · {stats['chunks']} chunks · "
206
+ f" ✓ {sub}: +{stats['files']} files · {stats['chunks']} chunks · "
207
207
  f"{stats['entities']} entities · {stats['edges']} edges → {store}",
208
208
  "green",
209
209
  )
210
210
  except Exception as e: # noqa: BLE001
211
- print_colored(f" graphrag build failed: {e}", "red")
211
+ print_colored(f" graphrag {sub} failed: {e}", "red")
212
+ elif sub == "query" and rest:
213
+ index = graphrag.load_index(store)
214
+ if index is None:
215
+ print_colored(" No knowledge base. Build: /graphrag build <path>", "dim")
216
+ else:
217
+ print(graphrag.format_results(graphrag.query_index(index, rest, k=3), rest))
212
218
  elif sub == "clear":
213
219
  store.unlink(missing_ok=True)
214
220
  print_colored(" Knowledge base cleared.", "green")
@@ -217,9 +223,11 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
217
223
  if index is None:
218
224
  print_colored(" No knowledge base. Build: /graphrag build <path>", "dim")
219
225
  else:
226
+ srcs = graphrag.sources(index)
220
227
  print_colored(
221
228
  f" Knowledge base: {len(index.get('chunks', []))} chunks · "
222
- f"{len(index.get('entities', {}))} entities ({store})", "cyan")
229
+ f"{len(index.get('entities', {}))} entities · {len(srcs)} sources "
230
+ f"({store})", "cyan")
223
231
  return True
224
232
  elif cmd in ("/quit", "/exit"):
225
233
  raise KeyboardInterrupt
@@ -110,26 +110,23 @@ def _iter_text_files(paths: list[str]):
110
110
  yield fp
111
111
 
112
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
-
113
+ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
114
+ """Chunk + extract + graph every text file under paths into the (mutable)
115
+ accumulators, skipping any source already present. Returns files added."""
116
+ added = 0
123
117
  for fp in _iter_text_files([str(Path(cwd) / p) if not os.path.isabs(p) else p
124
118
  for p in paths]):
119
+ rel = os.path.relpath(str(fp), cwd)
120
+ if rel in skip_sources:
121
+ continue
125
122
  try:
126
123
  text = fp.read_text("utf-8", "ignore")
127
124
  except OSError:
128
125
  continue
129
126
  if not text.strip():
130
127
  continue
131
- files += 1
132
- rel = os.path.relpath(str(fp), cwd)
128
+ added += 1
129
+ skip_sources.add(rel) # don't double-ingest the same file in one call
133
130
  for body in _chunk_text(text):
134
131
  cid = len(chunks)
135
132
  ents = extract_entities(body)
@@ -140,7 +137,10 @@ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
140
137
  for b in ents[i + 1:]:
141
138
  edges[a][b] += 1
142
139
  edges[b][a] += 1
140
+ return added
141
+
143
142
 
143
+ def _save(store_path, chunks, entity_chunks, edges, files_added):
144
144
  index = {
145
145
  "version": 1,
146
146
  "chunks": chunks,
@@ -151,13 +151,47 @@ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
151
151
  sp.parent.mkdir(parents=True, exist_ok=True)
152
152
  sp.write_text(json.dumps(index), encoding="utf-8")
153
153
  return {
154
- "files": files,
154
+ "files": files_added,
155
155
  "chunks": len(chunks),
156
156
  "entities": len(entity_chunks),
157
157
  "edges": sum(len(v) for v in edges.values()) // 2,
158
158
  }
159
159
 
160
160
 
161
+ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
162
+ """Build (or REBUILD from scratch) the knowledge graph from paths; persist to
163
+ store_path. Returns {files, chunks, entities, edges}."""
164
+ chunks: list[dict] = []
165
+ entity_chunks: dict[str, set[int]] = defaultdict(set)
166
+ edges: dict[str, Counter] = defaultdict(Counter)
167
+ added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, set())
168
+ return _save(store_path, chunks, entity_chunks, edges, added)
169
+
170
+
171
+ def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
172
+ """Incrementally ADD documents to an existing index (build it if none yet).
173
+ Files already indexed (by relative path) are skipped — clear+build to refresh
174
+ changed files. Returns {files (added), chunks, entities, edges} totals."""
175
+ existing = load_index(store_path)
176
+ if existing is None:
177
+ return build_index(paths, store_path, cwd=cwd)
178
+ chunks: list[dict] = list(existing.get("chunks", []))
179
+ entity_chunks: dict[str, set[int]] = defaultdict(set)
180
+ for e, cids in existing.get("entities", {}).items():
181
+ entity_chunks[e] = set(cids)
182
+ edges: dict[str, Counter] = defaultdict(Counter)
183
+ for a, nbrs in existing.get("edges", {}).items():
184
+ edges[a] = Counter(nbrs)
185
+ skip = {c["source"] for c in chunks}
186
+ added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip)
187
+ return _save(store_path, chunks, entity_chunks, edges, added)
188
+
189
+
190
+ def sources(index: dict) -> list[str]:
191
+ """The distinct source files in an index, sorted."""
192
+ return sorted({c["source"] for c in index.get("chunks", [])})
193
+
194
+
161
195
  def load_index(store_path: str | Path) -> dict | None:
162
196
  sp = Path(store_path)
163
197
  if not sp.exists():
@@ -490,8 +490,8 @@ class DrydockApp(App):
490
490
  " /stop stop the running turn (or press Esc)\n"
491
491
  " /status session model, cwd, turns, tokens\n"
492
492
  " /compact shrink old context to free up the window\n"
493
- " /graphrag build/query a knowledge base from your docs\n"
494
- " /graphrag build <path> · /graphrag status · /graphrag clear\n"
493
+ " /graphrag ingest docs into a knowledge base the agent can use\n"
494
+ " build <path> · add <path> · query <q> · status · clear\n"
495
495
  " /skills list your reusable /<name> skills\n"
496
496
  " /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
497
497
  " /mcp list connected MCP servers and their tools\n"
@@ -634,33 +634,52 @@ class DrydockApp(App):
634
634
  sub = parts[0].lower() if parts else ""
635
635
  rest = parts[1].strip() if len(parts) > 1 else ""
636
636
 
637
- if sub == "build":
637
+ if sub in ("build", "add"):
638
638
  if not rest:
639
- self._info("usage: /graphrag build <path> (a file or directory of docs/code)")
639
+ self._info(f"usage: /graphrag {sub} <path> (a file or directory of docs/code)")
640
640
  return
641
- self._info(f"Building knowledge base from {rest} ")
641
+ verb = "Rebuilding" if sub == "build" else "Ingesting into"
642
+ self._info(f"{verb} knowledge base from {rest} …")
642
643
  try:
643
- stats = graphrag.build_index([rest], store, cwd=cwd)
644
+ fn = graphrag.build_index if sub == "build" else graphrag.add_to_index
645
+ stats = fn([rest], store, cwd=cwd)
644
646
  except Exception as e: # noqa: BLE001 — surface, never crash the TUI
645
- self._mount(ErrorMessage(f"graphrag build failed: {e}"))
647
+ self._mount(ErrorMessage(f"graphrag {sub} failed: {e}"))
648
+ return
649
+ if sub == "add" and stats["files"] == 0:
650
+ self._info(f"No new documents under {rest} (already indexed, or no text found).")
646
651
  return
647
652
  if not stats["chunks"]:
648
653
  self._info(f"No text found under {rest}. Nothing was indexed.")
649
654
  return
655
+ verb2 = "built" if sub == "build" else f"updated (+{stats['files']} new files)"
650
656
  self._info(
651
- f"✓ Knowledge base built: {stats['files']} files · "
652
- f"{stats['chunks']} chunks · {stats['entities']} entities · "
653
- f"{stats['edges']} edges.\nStored at {store}. The agent will now "
654
- "use the Knowledge tool to draw on it."
657
+ f"✓ Knowledge base {verb2}: {stats['chunks']} chunks · "
658
+ f"{stats['entities']} entities · {stats['edges']} edges.\n"
659
+ f"Stored at {store}. The agent draws on it via the Knowledge tool."
655
660
  )
661
+ elif sub == "query":
662
+ if not rest:
663
+ self._info("usage: /graphrag query <question> (test what the KB returns)")
664
+ return
665
+ index = graphrag.load_index(store)
666
+ if index is None:
667
+ self._info("No knowledge base yet. Build one: /graphrag build <path>")
668
+ return
669
+ res = graphrag.query_index(index, rest, k=3)
670
+ self._info(graphrag.format_results(res, rest))
656
671
  elif sub in ("", "status"):
657
672
  index = graphrag.load_index(store)
658
673
  if index is None:
659
674
  self._info("No knowledge base yet. Build one: /graphrag build <path>")
660
675
  else:
676
+ srcs = graphrag.sources(index)
677
+ shown = "\n".join(f" · {s}" for s in srcs[:20])
678
+ more = f"\n … +{len(srcs) - 20} more" if len(srcs) > 20 else ""
661
679
  self._info(
662
680
  f"Knowledge base: {len(index.get('chunks', []))} chunks · "
663
- f"{len(index.get('entities', {}))} entities ({store})."
681
+ f"{len(index.get('entities', {}))} entities · {len(srcs)} sources "
682
+ f"({store}).\n{shown}{more}"
664
683
  )
665
684
  elif sub == "clear":
666
685
  try:
@@ -669,7 +688,10 @@ class DrydockApp(App):
669
688
  except OSError as e:
670
689
  self._mount(ErrorMessage(f"could not clear: {e}"))
671
690
  else:
672
- self._info("usage: /graphrag build <path> · /graphrag status · /graphrag clear")
691
+ self._info(
692
+ "usage: /graphrag build <path> · add <path> · query <question> "
693
+ "· status · clear"
694
+ )
673
695
 
674
696
  def _persist_config(self) -> None:
675
697
  """Save the persistable settings (model/provider/base_url/… — save_file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.59
3
+ Version: 3.0.60
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
@@ -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.59"
10
+ version = "3.0.60"
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"
@@ -73,3 +73,35 @@ import pytest # noqa: E402
73
73
  def _make_corpus(tmp_path):
74
74
  _corpus(tmp_path)
75
75
  yield
76
+
77
+
78
+ def test_add_to_index_is_incremental(tmp_path):
79
+ store = tmp_path / ".drydock" / "graphrag.json"
80
+ (tmp_path / "one.md").write_text("The Alpha service uses the Beacon token.")
81
+ s1 = graphrag.build_index(["one.md"], store, cwd=str(tmp_path))
82
+ assert s1["files"] == 1
83
+ # add a second doc — existing chunks preserved, new ones appended
84
+ (tmp_path / "two.md").write_text("The Gamma module writes to the Delta table.")
85
+ s2 = graphrag.add_to_index(["two.md"], store, cwd=str(tmp_path))
86
+ assert s2["files"] == 1 # only the NEW file counted
87
+ assert s2["chunks"] > s1["chunks"] # grew
88
+ idx = graphrag.load_index(store)
89
+ assert set(graphrag.sources(idx)) == {"one.md", "two.md"}
90
+ # both docs are queryable
91
+ assert graphrag.query_index(idx, "Beacon token")["chunks"][0]["source"] == "one.md"
92
+ assert graphrag.query_index(idx, "Delta table")["chunks"][0]["source"] == "two.md"
93
+
94
+
95
+ def test_add_skips_already_indexed(tmp_path):
96
+ store = tmp_path / ".drydock" / "graphrag.json"
97
+ (tmp_path / "one.md").write_text("Alpha Beacon content here.")
98
+ graphrag.build_index(["one.md"], store, cwd=str(tmp_path))
99
+ s = graphrag.add_to_index(["one.md"], store, cwd=str(tmp_path)) # same file again
100
+ assert s["files"] == 0 # nothing re-ingested
101
+
102
+
103
+ def test_add_builds_when_no_index_yet(tmp_path):
104
+ store = tmp_path / ".drydock" / "graphrag.json"
105
+ (tmp_path / "x.md").write_text("Some Echo content.")
106
+ s = graphrag.add_to_index(["x.md"], store, cwd=str(tmp_path))
107
+ assert s["files"] == 1 and graphrag.load_index(store) is not None
File without changes
File without changes
File without changes
File without changes