drydock-cli 3.0.136__tar.gz → 3.0.137__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 (146) hide show
  1. {drydock_cli-3.0.136/drydock_cli.egg-info → drydock_cli-3.0.137}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/graphrag.py +86 -17
  3. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tui/app.py +43 -19
  4. {drydock_cli-3.0.136 → drydock_cli-3.0.137/drydock_cli.egg-info}/PKG-INFO +1 -1
  5. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/pyproject.toml +1 -1
  6. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_graphrag_sqlite.py +31 -0
  7. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/LICENSE +0 -0
  8. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/NOTICE +0 -0
  9. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/README.md +0 -0
  10. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/__init__.py +0 -0
  11. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/__main__.py +0 -0
  12. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/advisor.py +0 -0
  13. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/agent.py +0 -0
  14. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/bash_safety.py +0 -0
  15. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/__init__.py +0 -0
  16. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/ml-data.md +0 -0
  17. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/ml-debug.md +0 -0
  18. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/ml-finetune.md +0 -0
  19. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/ml-metrics.md +0 -0
  20. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/ml-rl.md +0 -0
  21. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/ml-train.md +0 -0
  22. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  23. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/nist-csf.md +0 -0
  24. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/rmf-categorize.md +0 -0
  25. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/rmf-control.md +0 -0
  26. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/rmf-poam.md +0 -0
  27. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/rmf-review.md +0 -0
  28. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/stig-assess.md +0 -0
  29. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/builtin_skills/stig-remediate.md +0 -0
  30. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/cci.py +0 -0
  31. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/cli.py +0 -0
  32. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/compaction.py +0 -0
  33. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/config.py +0 -0
  34. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/detect.py +0 -0
  35. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/events.py +0 -0
  36. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/extract.py +0 -0
  37. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/gittools.py +0 -0
  38. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/guards.py +0 -0
  39. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/loop_detect.py +0 -0
  40. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/mcp.py +0 -0
  41. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/poam.py +0 -0
  42. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/providers.py +0 -0
  43. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/recipes.py +0 -0
  44. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/rmf.py +0 -0
  45. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/rmf_graph.py +0 -0
  46. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/skills.py +0 -0
  47. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/stig.py +0 -0
  48. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/suggest.py +0 -0
  49. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/task_state.py +0 -0
  50. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tool_registry.py +0 -0
  51. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tool_result.py +0 -0
  52. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tools/__init__.py +0 -0
  53. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tui/__init__.py +0 -0
  54. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tui/approval.py +0 -0
  55. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tui/messages.py +0 -0
  56. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tui/widgets.py +0 -0
  57. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/tuning.py +0 -0
  58. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/verification.py +0 -0
  59. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock/web.py +0 -0
  60. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock_cli.egg-info/SOURCES.txt +0 -0
  61. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock_cli.egg-info/dependency_links.txt +0 -0
  62. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock_cli.egg-info/entry_points.txt +0 -0
  63. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock_cli.egg-info/requires.txt +0 -0
  64. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/drydock_cli.egg-info/top_level.txt +0 -0
  65. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/setup.cfg +0 -0
  66. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_advisor.py +0 -0
  67. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_approval.py +0 -0
  68. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_back_command.py +0 -0
  69. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_background.py +0 -0
  70. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_binary_output.py +0 -0
  71. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_crossplatform.py +0 -0
  72. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_output_bounding.py +0 -0
  73. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_process_group.py +0 -0
  74. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_safety.py +0 -0
  75. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_sanitize.py +0 -0
  76. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_shell.py +0 -0
  77. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_stdin.py +0 -0
  78. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_stop_partial.py +0 -0
  79. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_timeout_network.py +0 -0
  80. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_bash_timeout_param.py +0 -0
  81. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_cci.py +0 -0
  82. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_cli_agents.py +0 -0
  83. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_compact_command.py +0 -0
  84. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_compaction.py +0 -0
  85. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_config.py +0 -0
  86. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_config_migration.py +0 -0
  87. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_context_limit_config.py +0 -0
  88. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_detect.py +0 -0
  89. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_dispatch.py +0 -0
  90. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_e2e_connected.py +0 -0
  91. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_edit_replace_all.py +0 -0
  92. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_empty_response.py +0 -0
  93. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_events.py +0 -0
  94. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_extract.py +0 -0
  95. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_failure_loop.py +0 -0
  96. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_first_run_setup.py +0 -0
  97. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_gittools.py +0 -0
  98. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_glob_edit_edges.py +0 -0
  99. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_graphify_example.py +0 -0
  100. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_graphrag.py +0 -0
  101. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_graphrag_quoted_path.py +0 -0
  102. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_grep_and_read_robust.py +0 -0
  103. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_guards_and_tools.py +0 -0
  104. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_hallucinated_tools.py +0 -0
  105. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_leaked_tool_call.py +0 -0
  106. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_loop_detect.py +0 -0
  107. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_mcp.py +0 -0
  108. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_oneshot_unreachable.py +0 -0
  109. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_overthink_interrupt.py +0 -0
  110. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_plan_autocontinue.py +0 -0
  111. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_poam.py +0 -0
  112. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_providers_unreachable.py +0 -0
  113. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_read_index.py +0 -0
  114. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_recipes.py +0 -0
  115. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_repetition_interrupt.py +0 -0
  116. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_rmf.py +0 -0
  117. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_rmf_graph.py +0 -0
  118. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_rmf_stig_graph.py +0 -0
  119. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_rolling_plan.py +0 -0
  120. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_runaway_repetition.py +0 -0
  121. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_screenshot.py +0 -0
  122. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_server_probe.py +0 -0
  123. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_skills.py +0 -0
  124. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_stall_retry.py +0 -0
  125. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_stig.py +0 -0
  126. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_stop.py +0 -0
  127. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_streaming_newlines.py +0 -0
  128. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_subagent.py +0 -0
  129. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_suggest.py +0 -0
  130. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_system_prompt_help.py +0 -0
  131. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_task_state.py +0 -0
  132. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_todo.py +0 -0
  133. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_tool_arg_coercion.py +0 -0
  134. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_tool_arg_coercion_more.py +0 -0
  135. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_tool_arg_parsing.py +0 -0
  136. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_tool_result.py +0 -0
  137. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_tools_undo.py +0 -0
  138. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_tui.py +0 -0
  139. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_tuning.py +0 -0
  140. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_verification_gate.py +0 -0
  141. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_viewimage.py +0 -0
  142. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_vision_input.py +0 -0
  143. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_web_tools.py +0 -0
  144. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_windows_shell.py +0 -0
  145. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_write_content_coerce.py +0 -0
  146. {drydock_cli-3.0.136 → drydock_cli-3.0.137}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.136
3
+ Version: 3.0.137
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
@@ -270,16 +270,16 @@ def _iter_text_files(paths: list[str]):
270
270
  yield fp
271
271
 
272
272
 
273
- def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
274
- """Chunk + extract + graph every text file under paths into the (mutable)
275
- accumulators, skipping any source already present. Returns files added."""
276
- added = 0
273
+ def _iter_file_chunks(paths, cwd, skip_sources, progress=None):
274
+ """Yield (rel_source, [(body, entities), …]) for each ingestible file, one file
275
+ at a time (so callers never hold every file's text at once). Per-file isolation:
276
+ one unreadable/odd file (a locked Word doc, weird encoding, PDF quirk) is skipped,
277
+ not fatal. Calls progress(files_done, rel) after each file so a long build can
278
+ report instead of appearing frozen."""
277
279
  cleaned = [_unquote(p) for p in paths]
280
+ files_done = 0
278
281
  for fp in _iter_text_files([str(Path(cwd) / p) if not os.path.isabs(p) else p
279
282
  for p in cleaned]):
280
- # Per-file isolation: ONE unreadable/odd file (a locked Word doc, a weird
281
- # encoding, a PDF backend quirk) must never crash a whole-folder build —
282
- # skip it and keep indexing the rest.
283
283
  try:
284
284
  rel = os.path.relpath(str(fp), cwd)
285
285
  if rel in skip_sources:
@@ -289,14 +289,25 @@ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
289
289
  else:
290
290
  text = fp.read_text("utf-8", "ignore")
291
291
  if not text or not text.strip():
292
- continue # unreadable / empty — skip cleanly
293
- # Build this file's chunks locally; commit only if it ALL succeeds so a
294
- # mid-file failure leaves no partial state and isn't miscounted.
292
+ continue
295
293
  local = [(body, extract_entities(body)) for body in _chunk_text(text)]
296
294
  except Exception: # noqa: BLE001 — isolate a bad file, never abort the build
297
295
  continue
296
+ skip_sources.add(rel)
297
+ files_done += 1
298
+ if progress:
299
+ try:
300
+ progress(files_done, rel)
301
+ except Exception: # noqa: BLE001 — a progress callback must never break a build
302
+ pass
303
+ yield rel, local
304
+
305
+
306
+ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources, progress=None):
307
+ """In-memory ingest into the accumulators (legacy JSON path — small indexes)."""
308
+ added = 0
309
+ for rel, local in _iter_file_chunks(paths, cwd, skip_sources, progress):
298
310
  added += 1
299
- skip_sources.add(rel) # don't double-ingest the same file in one call
300
311
  for body, ents in local:
301
312
  cid = len(chunks)
302
313
  chunks.append({"id": cid, "source": rel, "text": body, "entities": ents})
@@ -309,6 +320,56 @@ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
309
320
  return added
310
321
 
311
322
 
323
+ def _build_sqlite_stream(paths, store_path, cwd, skip_sources, progress=None, append=False):
324
+ """Streaming SQLite build: chunk text is inserted per-file (never held for the
325
+ whole corpus, so memory stays bounded even for a huge folder). Entities/edges
326
+ aggregate in memory (far smaller than the text) and are written at the end."""
327
+ sp = Path(store_path)
328
+ sp.parent.mkdir(parents=True, exist_ok=True)
329
+ if not append and sp.exists():
330
+ sp.unlink()
331
+ con = sqlite3.connect(str(sp))
332
+ entity_chunks: dict[str, set[int]] = defaultdict(set)
333
+ edges: dict[str, Counter] = defaultdict(Counter)
334
+ added = 0
335
+ try:
336
+ con.executescript(_SCHEMA)
337
+ # continue chunk ids after any existing rows (append mode)
338
+ row = con.execute("SELECT COALESCE(MAX(id), -1) FROM chunks").fetchone()
339
+ next_id = (row[0] if row else -1) + 1
340
+ if append: # seed the in-memory graph with what's already stored
341
+ for r in con.execute("SELECT entity, chunk_id FROM entities"):
342
+ entity_chunks[r[0]].add(r[1])
343
+ for r in con.execute("SELECT a, b, weight FROM edges"):
344
+ edges[r[0]][r[1]] = r[2]
345
+ con.execute("DELETE FROM entities"); con.execute("DELETE FROM edges")
346
+ for rel, local in _iter_file_chunks(paths, cwd, skip_sources, progress):
347
+ added += 1
348
+ rows = []
349
+ for body, ents in local:
350
+ cid = next_id; next_id += 1
351
+ con.execute("INSERT INTO chunks(id,source,text) VALUES(?,?,?)", (cid, rel, body))
352
+ con.execute("INSERT INTO chunks_fts(rowid,text) VALUES(?,?)", (cid, body))
353
+ for e in ents:
354
+ entity_chunks[e].add(cid)
355
+ for i, a in enumerate(ents):
356
+ for b in ents[i + 1:]:
357
+ edges[a][b] += 1; edges[b][a] += 1
358
+ rows.append(cid)
359
+ if added % 200 == 0:
360
+ con.commit()
361
+ con.executemany("INSERT INTO entities(entity,chunk_id) VALUES(?,?)",
362
+ ((e, cid) for e, cids in entity_chunks.items() for cid in cids))
363
+ con.executemany("INSERT INTO edges(a,b,weight) VALUES(?,?,?)",
364
+ ((a, b, w) for a, nbrs in edges.items() for b, w in nbrs.items()))
365
+ con.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('version','2')")
366
+ con.commit()
367
+ return {"files": added, "chunks": next_id, "entities": len(entity_chunks),
368
+ "edges": sum(len(n) for n in edges.values()) // 2}
369
+ finally:
370
+ con.close()
371
+
372
+
312
373
  def _save(store_path, chunks, entity_chunks, edges, files_added):
313
374
  # SQLite by default (scalable). A caller can still target a .json path
314
375
  # explicitly (small/portable indexes, tests) and get the legacy format.
@@ -334,13 +395,17 @@ def _save(store_path, chunks, entity_chunks, edges, files_added):
334
395
  }
335
396
 
336
397
 
337
- def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
398
+ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".",
399
+ progress=None) -> dict:
338
400
  """Build (or REBUILD from scratch) the knowledge graph from paths; persist to
339
- store_path. Returns {files, chunks, entities, edges}."""
401
+ store_path. Returns {files, chunks, entities, edges}. `progress(files_done, src)`
402
+ is called per file (for a UI). SQLite stores stream to disk (bounded memory)."""
403
+ if _is_sqlite(store_path):
404
+ return _build_sqlite_stream(paths, store_path, cwd, set(), progress, append=False)
340
405
  chunks: list[dict] = []
341
406
  entity_chunks: dict[str, set[int]] = defaultdict(set)
342
407
  edges: dict[str, Counter] = defaultdict(Counter)
343
- added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, set())
408
+ added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, set(), progress)
344
409
  return _save(store_path, chunks, entity_chunks, edges, added)
345
410
 
346
411
 
@@ -366,13 +431,17 @@ def _read_all(store_path) -> dict:
366
431
  return json.loads(Path(sp).read_text("utf-8"))
367
432
 
368
433
 
369
- def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
434
+ def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".",
435
+ progress=None) -> dict:
370
436
  """Incrementally ADD documents to an existing index (build it if none yet).
371
437
  Files already indexed (by relative path) are skipped — clear+build to refresh
372
438
  changed files. Returns {files (added), chunks, entities, edges} totals."""
373
439
  sp = _resolve_store(store_path)
374
440
  if not sp.exists():
375
- return build_index(paths, store_path, cwd=cwd)
441
+ return build_index(paths, store_path, cwd=cwd, progress=progress)
442
+ if _is_sqlite(sp):
443
+ skip = {r[0] for r in sqlite3.connect(str(sp)).execute("SELECT DISTINCT source FROM chunks")}
444
+ return _build_sqlite_stream(paths, sp, cwd, skip, progress, append=True)
376
445
  existing = _read_all(sp)
377
446
  chunks: list[dict] = list(existing.get("chunks", []))
378
447
  entity_chunks: dict[str, set[int]] = defaultdict(set)
@@ -382,7 +451,7 @@ def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
382
451
  for a, nbrs in existing.get("edges", {}).items():
383
452
  edges[a] = Counter(nbrs)
384
453
  skip = {c["source"] for c in chunks}
385
- added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip)
454
+ added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip, progress)
386
455
  return _save(sp, chunks, entity_chunks, edges, added)
387
456
 
388
457
 
@@ -1069,6 +1069,44 @@ class DrydockApp(App):
1069
1069
  "(recent turns are always preserved)."
1070
1070
  )
1071
1071
 
1072
+ def _graphrag_build(self, sub: str, rest: str, store, cwd: str) -> None:
1073
+ """Worker: build/add a knowledge base off the UI thread, streaming a
1074
+ throttled progress line so a huge folder never looks frozen."""
1075
+ from drydock import graphrag
1076
+ import time as _t
1077
+
1078
+ last = [0.0]
1079
+
1080
+ def progress(files_done: int, src: str) -> None:
1081
+ now = _t.monotonic()
1082
+ if now - last[0] >= 1.0: # throttle UI updates to ~1/s
1083
+ last[0] = now
1084
+ self.call_from_thread(
1085
+ self.query_one("#working", Static).update,
1086
+ f" ⚓ indexing… {files_done} files ({src[-48:]})")
1087
+
1088
+ try:
1089
+ fn = graphrag.build_index if sub == "build" else graphrag.add_to_index
1090
+ stats = fn([rest], store, cwd=cwd, progress=progress)
1091
+ except Exception as e: # noqa: BLE001 — surface, never crash the TUI
1092
+ self.call_from_thread(self.query_one("#working", Static).update, "")
1093
+ self.call_from_thread(self._mount, ErrorMessage(f"graphrag {sub} failed: {e}"))
1094
+ return
1095
+ self.call_from_thread(self.query_one("#working", Static).update, "")
1096
+ if sub == "add" and stats["files"] == 0:
1097
+ self.call_from_thread(
1098
+ self._info, f"No new documents under {rest} (already indexed, or no text found).")
1099
+ return
1100
+ if not stats["chunks"]:
1101
+ self.call_from_thread(self._info, f"No text found under {rest}. Nothing was indexed.")
1102
+ return
1103
+ verb2 = "built" if sub == "build" else f"updated (+{stats['files']} new files)"
1104
+ self.call_from_thread(
1105
+ self._info,
1106
+ f"✓ Knowledge base {verb2}: {stats['chunks']} chunks · "
1107
+ f"{stats['entities']} entities · {stats['edges']} edges.\n"
1108
+ f"Stored at {store}. The agent draws on it via the Knowledge tool.")
1109
+
1072
1110
  def _cmd_graphrag(self, arg: str) -> None:
1073
1111
  """Build / inspect / clear the project's GraphRAG knowledge base. Once
1074
1112
  built, the agent retrieves from it via the read-only Knowledge tool."""
@@ -1085,25 +1123,11 @@ class DrydockApp(App):
1085
1123
  self._info(f"usage: /graphrag {sub} <path> (a file or directory of docs/code)")
1086
1124
  return
1087
1125
  verb = "Rebuilding" if sub == "build" else "Ingesting into"
1088
- self._info(f"{verb} knowledge base from {rest} …")
1089
- try:
1090
- fn = graphrag.build_index if sub == "build" else graphrag.add_to_index
1091
- stats = fn([rest], store, cwd=cwd)
1092
- except Exception as e: # noqa: BLE001 surface, never crash the TUI
1093
- self._mount(ErrorMessage(f"graphrag {sub} failed: {e}"))
1094
- return
1095
- if sub == "add" and stats["files"] == 0:
1096
- self._info(f"No new documents under {rest} (already indexed, or no text found).")
1097
- return
1098
- if not stats["chunks"]:
1099
- self._info(f"No text found under {rest}. Nothing was indexed.")
1100
- return
1101
- verb2 = "built" if sub == "build" else f"updated (+{stats['files']} new files)"
1102
- self._info(
1103
- f"✓ Knowledge base {verb2}: {stats['chunks']} chunks · "
1104
- f"{stats['entities']} entities · {stats['edges']} edges.\n"
1105
- f"Stored at {store}. The agent draws on it via the Knowledge tool."
1106
- )
1126
+ self._info(f"{verb} knowledge base from {rest} … (runs in the background; "
1127
+ "progress below — the TUI stays responsive)")
1128
+ # Run OFF the UI thread so a large folder can't freeze the interface,
1129
+ # and stream progress (files indexed) back so it never looks locked up.
1130
+ self.run_worker(lambda: self._graphrag_build(sub, rest, store, cwd), thread=True)
1107
1131
  elif sub == "query":
1108
1132
  if not rest:
1109
1133
  self._info("usage: /graphrag query <question> (test what the KB returns)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.136
3
+ Version: 3.0.137
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.136"
10
+ version = "3.0.137"
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"
@@ -68,3 +68,34 @@ def test_resolve_prefers_db_then_json():
68
68
 
69
69
  def test_missing_store_returns_none():
70
70
  assert g.load_index("/no/such/graphrag.db") is None
71
+
72
+
73
+ def test_streaming_build_reports_progress():
74
+ """SQLite build streams to disk and calls progress per file (so the UI can show
75
+ it and never looks frozen)."""
76
+ d, docs = _docs()
77
+ open(os.path.join(docs, "c.md"), "w").write("# More\nExtra content about billing.")
78
+ seen = []
79
+ store = os.path.join(d, ".drydock", "graphrag.db")
80
+ stats = g.build_index([docs], store, cwd=d, progress=lambda n, s: seen.append((n, s)))
81
+ assert stats["files"] == 3 and len(seen) == 3
82
+ assert seen[-1][0] == 3 # last progress reports 3 files done
83
+ # index is queryable
84
+ assert g.query_index(g.load_index(store), "billing", k=2)["chunks"]
85
+
86
+
87
+ def test_streaming_append_skips_existing_and_reports():
88
+ d, docs = _docs()
89
+ store = os.path.join(d, ".drydock", "graphrag.db")
90
+ g.build_index([docs], store, cwd=d)
91
+ open(os.path.join(docs, "n.md"), "w").write("# New\nThe RefundBot processes claims.")
92
+ seen = []
93
+ s2 = g.add_to_index([docs], store, cwd=d, progress=lambda n, s: seen.append(s))
94
+ assert s2["files"] == 1 and seen == ["docs/n.md"] # only the new file ingested
95
+ assert g.query_index(g.load_index(store), "refundbot claims", k=2)["chunks"]
96
+
97
+
98
+ def test_build_without_progress_is_backward_compatible():
99
+ d, docs = _docs()
100
+ store = os.path.join(d, ".drydock", "graphrag.db")
101
+ assert g.build_index([docs], store, cwd=d)["files"] == 2 # no progress arg → still works
File without changes
File without changes
File without changes
File without changes