drydock-cli 3.0.136__tar.gz → 3.0.138__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 (148) hide show
  1. {drydock_cli-3.0.136/drydock_cli.egg-info → drydock_cli-3.0.138}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/agent.py +3 -0
  3. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/config.py +4 -0
  4. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/graphrag.py +86 -17
  5. drydock_cli-3.0.138/drydock/trajectory.py +75 -0
  6. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tui/app.py +43 -19
  7. {drydock_cli-3.0.136 → drydock_cli-3.0.138/drydock_cli.egg-info}/PKG-INFO +1 -1
  8. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock_cli.egg-info/SOURCES.txt +2 -0
  9. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/pyproject.toml +1 -1
  10. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_graphrag_sqlite.py +31 -0
  11. drydock_cli-3.0.138/tests/test_trajectory.py +58 -0
  12. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/LICENSE +0 -0
  13. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/NOTICE +0 -0
  14. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/README.md +0 -0
  15. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/__init__.py +0 -0
  16. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/__main__.py +0 -0
  17. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/advisor.py +0 -0
  18. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/bash_safety.py +0 -0
  19. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/__init__.py +0 -0
  20. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/ml-data.md +0 -0
  21. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/ml-debug.md +0 -0
  22. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/ml-finetune.md +0 -0
  23. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/ml-metrics.md +0 -0
  24. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/ml-rl.md +0 -0
  25. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/ml-train.md +0 -0
  26. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  27. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/nist-csf.md +0 -0
  28. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/rmf-categorize.md +0 -0
  29. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/rmf-control.md +0 -0
  30. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/rmf-poam.md +0 -0
  31. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/rmf-review.md +0 -0
  32. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/stig-assess.md +0 -0
  33. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/builtin_skills/stig-remediate.md +0 -0
  34. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/cci.py +0 -0
  35. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/cli.py +0 -0
  36. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/compaction.py +0 -0
  37. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/detect.py +0 -0
  38. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/events.py +0 -0
  39. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/extract.py +0 -0
  40. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/gittools.py +0 -0
  41. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/guards.py +0 -0
  42. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/loop_detect.py +0 -0
  43. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/mcp.py +0 -0
  44. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/poam.py +0 -0
  45. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/providers.py +0 -0
  46. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/recipes.py +0 -0
  47. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/rmf.py +0 -0
  48. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/rmf_graph.py +0 -0
  49. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/skills.py +0 -0
  50. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/stig.py +0 -0
  51. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/suggest.py +0 -0
  52. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/task_state.py +0 -0
  53. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tool_registry.py +0 -0
  54. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tool_result.py +0 -0
  55. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tools/__init__.py +0 -0
  56. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tui/__init__.py +0 -0
  57. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tui/approval.py +0 -0
  58. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tui/messages.py +0 -0
  59. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tui/widgets.py +0 -0
  60. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/tuning.py +0 -0
  61. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/verification.py +0 -0
  62. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock/web.py +0 -0
  63. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock_cli.egg-info/dependency_links.txt +0 -0
  64. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock_cli.egg-info/entry_points.txt +0 -0
  65. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock_cli.egg-info/requires.txt +0 -0
  66. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/drydock_cli.egg-info/top_level.txt +0 -0
  67. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/setup.cfg +0 -0
  68. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_advisor.py +0 -0
  69. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_approval.py +0 -0
  70. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_back_command.py +0 -0
  71. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_background.py +0 -0
  72. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_binary_output.py +0 -0
  73. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_crossplatform.py +0 -0
  74. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_output_bounding.py +0 -0
  75. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_process_group.py +0 -0
  76. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_safety.py +0 -0
  77. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_sanitize.py +0 -0
  78. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_shell.py +0 -0
  79. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_stdin.py +0 -0
  80. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_stop_partial.py +0 -0
  81. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_timeout_network.py +0 -0
  82. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_bash_timeout_param.py +0 -0
  83. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_cci.py +0 -0
  84. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_cli_agents.py +0 -0
  85. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_compact_command.py +0 -0
  86. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_compaction.py +0 -0
  87. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_config.py +0 -0
  88. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_config_migration.py +0 -0
  89. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_context_limit_config.py +0 -0
  90. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_detect.py +0 -0
  91. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_dispatch.py +0 -0
  92. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_e2e_connected.py +0 -0
  93. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_edit_replace_all.py +0 -0
  94. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_empty_response.py +0 -0
  95. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_events.py +0 -0
  96. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_extract.py +0 -0
  97. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_failure_loop.py +0 -0
  98. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_first_run_setup.py +0 -0
  99. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_gittools.py +0 -0
  100. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_glob_edit_edges.py +0 -0
  101. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_graphify_example.py +0 -0
  102. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_graphrag.py +0 -0
  103. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_graphrag_quoted_path.py +0 -0
  104. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_grep_and_read_robust.py +0 -0
  105. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_guards_and_tools.py +0 -0
  106. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_hallucinated_tools.py +0 -0
  107. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_leaked_tool_call.py +0 -0
  108. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_loop_detect.py +0 -0
  109. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_mcp.py +0 -0
  110. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_oneshot_unreachable.py +0 -0
  111. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_overthink_interrupt.py +0 -0
  112. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_plan_autocontinue.py +0 -0
  113. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_poam.py +0 -0
  114. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_providers_unreachable.py +0 -0
  115. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_read_index.py +0 -0
  116. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_recipes.py +0 -0
  117. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_repetition_interrupt.py +0 -0
  118. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_rmf.py +0 -0
  119. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_rmf_graph.py +0 -0
  120. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_rmf_stig_graph.py +0 -0
  121. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_rolling_plan.py +0 -0
  122. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_runaway_repetition.py +0 -0
  123. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_screenshot.py +0 -0
  124. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_server_probe.py +0 -0
  125. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_skills.py +0 -0
  126. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_stall_retry.py +0 -0
  127. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_stig.py +0 -0
  128. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_stop.py +0 -0
  129. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_streaming_newlines.py +0 -0
  130. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_subagent.py +0 -0
  131. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_suggest.py +0 -0
  132. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_system_prompt_help.py +0 -0
  133. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_task_state.py +0 -0
  134. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_todo.py +0 -0
  135. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_tool_arg_coercion.py +0 -0
  136. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_tool_arg_coercion_more.py +0 -0
  137. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_tool_arg_parsing.py +0 -0
  138. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_tool_result.py +0 -0
  139. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_tools_undo.py +0 -0
  140. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_tui.py +0 -0
  141. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_tuning.py +0 -0
  142. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_verification_gate.py +0 -0
  143. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_viewimage.py +0 -0
  144. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_vision_input.py +0 -0
  145. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_web_tools.py +0 -0
  146. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_windows_shell.py +0 -0
  147. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/tests/test_write_content_coerce.py +0 -0
  148. {drydock_cli-3.0.136 → drydock_cli-3.0.138}/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.138
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
@@ -383,6 +383,9 @@ def run(
383
383
  state.task.phase = "complete"
384
384
  _emit(state, "done", phase=state.task.phase, edited=session_has_edited,
385
385
  verified=bool(last_verification and last_verification.status == "pass"))
386
+ if config.get("trajectory_file"):
387
+ from drydock import trajectory
388
+ trajectory.record(system_prompt, state, config)
386
389
  break
387
390
 
388
391
  # Execute each tool call
@@ -55,6 +55,10 @@ DEFAULTS: dict[str, object] = {
55
55
  # Durable per-session execution trace (events.py) written as JSONL to
56
56
  # ~/.drydock/events/ — inspect/diagnose/replay a task. Set false to disable.
57
57
  "event_log": True,
58
+ # Verified-trajectory export (RSI training-data collection). When set to a path,
59
+ # drydock writes the full task trajectory (system prompt + transcript) there at
60
+ # task end; a benchmark harness keeps only verifier-passing ones. Empty = off.
61
+ "trajectory_file": "",
58
62
  "theme": "harbor",
59
63
  # Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
60
64
  # Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
@@ -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
 
@@ -0,0 +1,75 @@
1
+ """Verified-trajectory export — the training-data side of the RSI loop.
2
+
3
+ When `trajectory_file` is set (a benchmark harness sets it per task), drydock writes
4
+ the FULL task trajectory — system prompt + the complete message transcript (every
5
+ assistant turn with its tool calls, every tool result) — to that file at task end.
6
+ The harness then keeps ONLY the trajectories the deterministic verifier confirmed
7
+ correct, so the resulting corpus is solutions that provably work (no self-grading).
8
+
9
+ That corpus is the seed for self-distillation and the same format a stronger teacher
10
+ model feeds into. Export is opt-in and fully defensive — it must never affect a run.
11
+ All logic original to Drydock.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ _MAX_FIELD = 200_000 # cap any single message body so one huge tool dump can't bloat the record
19
+
20
+
21
+ def _clip(v):
22
+ if isinstance(v, str) and len(v) > _MAX_FIELD:
23
+ return v[:_MAX_FIELD] + f"\n[… {len(v) - _MAX_FIELD} chars truncated]"
24
+ return v
25
+
26
+
27
+ def _sanitize(messages: list) -> list:
28
+ """A JSON-safe copy of the transcript with oversized bodies clipped."""
29
+ out = []
30
+ for m in messages:
31
+ if not isinstance(m, dict):
32
+ continue
33
+ mm = {k: _clip(v) for k, v in m.items()}
34
+ out.append(mm)
35
+ return out
36
+
37
+
38
+ def build_record(system_prompt: str, state, config: dict, meta: dict | None = None) -> dict:
39
+ """Assemble the trajectory record (does not write). Verified/reward are left None
40
+ for the harness to stamp after the verifier runs."""
41
+ task = getattr(state, "task", None)
42
+ tools = sorted({m.get("name", "") for m in state.messages
43
+ if isinstance(m, dict) and m.get("role") == "tool" and m.get("name")})
44
+ return {
45
+ "task": (meta or {}).get("task", ""),
46
+ "model": config.get("model", ""),
47
+ "objective": getattr(task, "objective", "") if task else "",
48
+ "acceptance_criteria": list(getattr(task, "acceptance_criteria", []) or []) if task else [],
49
+ "phase": getattr(task, "phase", "") if task else "",
50
+ "system": system_prompt,
51
+ "messages": _sanitize(state.messages),
52
+ "n_messages": len(state.messages),
53
+ "tools": tools,
54
+ "in_tokens": getattr(state, "total_input_tokens", 0),
55
+ "out_tokens": getattr(state, "total_output_tokens", 0),
56
+ "verified": None, # stamped True by the collector iff the verifier passed
57
+ "reward": None,
58
+ }
59
+
60
+
61
+ def record(system_prompt: str, state, config: dict, meta: dict | None = None):
62
+ """Write the current full trajectory to config['trajectory_file'] (overwrite, so
63
+ it always holds the latest complete transcript). No-op if unset. Never raises."""
64
+ path = config.get("trajectory_file")
65
+ if not path:
66
+ return None
67
+ try:
68
+ p = Path(path)
69
+ if p.parent and not p.parent.exists():
70
+ p.parent.mkdir(parents=True, exist_ok=True)
71
+ p.write_text(json.dumps(build_record(system_prompt, state, config, meta), default=str),
72
+ encoding="utf-8")
73
+ return path
74
+ except Exception: # noqa: BLE001 — export must never break a run
75
+ return None
@@ -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.138
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
@@ -30,6 +30,7 @@ drydock/suggest.py
30
30
  drydock/task_state.py
31
31
  drydock/tool_registry.py
32
32
  drydock/tool_result.py
33
+ drydock/trajectory.py
33
34
  drydock/tuning.py
34
35
  drydock/verification.py
35
36
  drydock/web.py
@@ -133,6 +134,7 @@ tests/test_tool_arg_coercion_more.py
133
134
  tests/test_tool_arg_parsing.py
134
135
  tests/test_tool_result.py
135
136
  tests/test_tools_undo.py
137
+ tests/test_trajectory.py
136
138
  tests/test_tui.py
137
139
  tests/test_tuning.py
138
140
  tests/test_verification_gate.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.136"
10
+ version = "3.0.138"
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
@@ -0,0 +1,58 @@
1
+ """Verified-trajectory export (RSI training-data collection): drydock writes the
2
+ full task transcript when trajectory_file is set, and does nothing otherwise."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+
9
+ from drydock import agent, trajectory
10
+ from drydock.providers import AssistantTurn
11
+
12
+
13
+ def _run(cfg, msg="Do the task.\n1. a criterion"):
14
+ turns = [
15
+ AssistantTurn("", [{"id": "1", "name": "Write",
16
+ "input": {"file_path": "/tmp/t.py", "content": "x"}}], 10, 5),
17
+ AssistantTurn("", [{"id": "2", "name": "Bash", "input": {"command": "python /tmp/t.py"}}], 8, 4),
18
+ AssistantTurn("done, verified", [], 6, 3),
19
+ ]
20
+ i = {"n": 0}
21
+ def fake(model, system, messages, tool_schemas, config):
22
+ t = turns[min(i["n"], 2)]; i["n"] += 1; yield t
23
+ orig = agent.stream; agent.stream = fake
24
+ try:
25
+ st = agent.AgentState()
26
+ list(agent.run(msg, st, cfg, "SYSTEM"))
27
+ return st
28
+ finally:
29
+ agent.stream = orig
30
+
31
+
32
+ def test_exports_full_trajectory_when_enabled():
33
+ tf = os.path.join(tempfile.mkdtemp(), "traj.json")
34
+ cfg = {"model": "gemma4", "context_limit": 65536, "max_tokens": 8192, "trajectory_file": tf}
35
+ _run(cfg)
36
+ rec = json.load(open(tf))
37
+ assert rec["system"] == "SYSTEM" or rec["system"].startswith("SYSTEM")
38
+ assert rec["objective"].startswith("Do the task")
39
+ assert rec["n_messages"] >= 4 and set(rec["tools"]) == {"Bash", "Write"}
40
+ assert rec["verified"] is None and rec["reward"] is None # collector stamps these
41
+ assert [m["role"] for m in rec["messages"]][0] == "user"
42
+
43
+
44
+ def test_off_by_default_no_file_no_crash():
45
+ st = _run({"model": "gemma4", "context_limit": 65536, "max_tokens": 8192})
46
+ assert st.messages # ran fine; nothing exported
47
+
48
+
49
+ def test_record_never_raises_on_bad_path():
50
+ st = agent.AgentState(); st.messages = [{"role": "user", "content": "x"}]
51
+ assert trajectory.record("s", st, {"trajectory_file": "/root/no/perm/x.json"}) is None
52
+
53
+
54
+ def test_build_record_clips_huge_bodies():
55
+ st = agent.AgentState()
56
+ st.messages = [{"role": "tool", "name": "Bash", "content": "A" * 500_000}]
57
+ rec = trajectory.build_record("s", st, {"model": "m"})
58
+ assert len(rec["messages"][0]["content"]) < 250_000 and "truncated" in rec["messages"][0]["content"]
File without changes
File without changes
File without changes
File without changes