drydock-cli 3.0.135__tar.gz → 3.0.136__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.135/drydock_cli.egg-info → drydock_cli-3.0.136}/PKG-INFO +18 -3
  2. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/README.md +17 -2
  3. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/graphrag.py +227 -19
  4. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tui/app.py +25 -5
  5. {drydock_cli-3.0.135 → drydock_cli-3.0.136/drydock_cli.egg-info}/PKG-INFO +18 -3
  6. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock_cli.egg-info/SOURCES.txt +1 -0
  7. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/pyproject.toml +1 -1
  8. drydock_cli-3.0.136/tests/test_graphrag_sqlite.py +70 -0
  9. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/LICENSE +0 -0
  10. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/NOTICE +0 -0
  11. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/__init__.py +0 -0
  12. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/__main__.py +0 -0
  13. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/advisor.py +0 -0
  14. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/agent.py +0 -0
  15. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/bash_safety.py +0 -0
  16. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/__init__.py +0 -0
  17. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-data.md +0 -0
  18. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-debug.md +0 -0
  19. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-finetune.md +0 -0
  20. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-metrics.md +0 -0
  21. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-rl.md +0 -0
  22. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/ml-train.md +0 -0
  23. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/nist-ai-rmf.md +0 -0
  24. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/nist-csf.md +0 -0
  25. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/rmf-categorize.md +0 -0
  26. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/rmf-control.md +0 -0
  27. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/rmf-poam.md +0 -0
  28. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/rmf-review.md +0 -0
  29. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/stig-assess.md +0 -0
  30. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/builtin_skills/stig-remediate.md +0 -0
  31. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/cci.py +0 -0
  32. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/cli.py +0 -0
  33. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/compaction.py +0 -0
  34. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/config.py +0 -0
  35. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/detect.py +0 -0
  36. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/events.py +0 -0
  37. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/extract.py +0 -0
  38. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/gittools.py +0 -0
  39. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/guards.py +0 -0
  40. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/loop_detect.py +0 -0
  41. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/mcp.py +0 -0
  42. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/poam.py +0 -0
  43. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/providers.py +0 -0
  44. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/recipes.py +0 -0
  45. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/rmf.py +0 -0
  46. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/rmf_graph.py +0 -0
  47. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/skills.py +0 -0
  48. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/stig.py +0 -0
  49. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/suggest.py +0 -0
  50. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/task_state.py +0 -0
  51. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tool_registry.py +0 -0
  52. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tool_result.py +0 -0
  53. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tools/__init__.py +0 -0
  54. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tui/__init__.py +0 -0
  55. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tui/approval.py +0 -0
  56. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tui/messages.py +0 -0
  57. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tui/widgets.py +0 -0
  58. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/tuning.py +0 -0
  59. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/verification.py +0 -0
  60. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock/web.py +0 -0
  61. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock_cli.egg-info/dependency_links.txt +0 -0
  62. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock_cli.egg-info/entry_points.txt +0 -0
  63. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock_cli.egg-info/requires.txt +0 -0
  64. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/drydock_cli.egg-info/top_level.txt +0 -0
  65. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/setup.cfg +0 -0
  66. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_advisor.py +0 -0
  67. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_approval.py +0 -0
  68. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_back_command.py +0 -0
  69. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_background.py +0 -0
  70. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_binary_output.py +0 -0
  71. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_crossplatform.py +0 -0
  72. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_output_bounding.py +0 -0
  73. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_process_group.py +0 -0
  74. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_safety.py +0 -0
  75. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_sanitize.py +0 -0
  76. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_shell.py +0 -0
  77. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_stdin.py +0 -0
  78. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_stop_partial.py +0 -0
  79. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_timeout_network.py +0 -0
  80. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_bash_timeout_param.py +0 -0
  81. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_cci.py +0 -0
  82. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_cli_agents.py +0 -0
  83. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_compact_command.py +0 -0
  84. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_compaction.py +0 -0
  85. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_config.py +0 -0
  86. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_config_migration.py +0 -0
  87. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_context_limit_config.py +0 -0
  88. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_detect.py +0 -0
  89. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_dispatch.py +0 -0
  90. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_e2e_connected.py +0 -0
  91. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_edit_replace_all.py +0 -0
  92. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_empty_response.py +0 -0
  93. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_events.py +0 -0
  94. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_extract.py +0 -0
  95. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_failure_loop.py +0 -0
  96. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_first_run_setup.py +0 -0
  97. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_gittools.py +0 -0
  98. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_glob_edit_edges.py +0 -0
  99. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_graphify_example.py +0 -0
  100. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_graphrag.py +0 -0
  101. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_graphrag_quoted_path.py +0 -0
  102. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_grep_and_read_robust.py +0 -0
  103. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_guards_and_tools.py +0 -0
  104. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_hallucinated_tools.py +0 -0
  105. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_leaked_tool_call.py +0 -0
  106. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_loop_detect.py +0 -0
  107. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_mcp.py +0 -0
  108. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_oneshot_unreachable.py +0 -0
  109. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_overthink_interrupt.py +0 -0
  110. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_plan_autocontinue.py +0 -0
  111. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_poam.py +0 -0
  112. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_providers_unreachable.py +0 -0
  113. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_read_index.py +0 -0
  114. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_recipes.py +0 -0
  115. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_repetition_interrupt.py +0 -0
  116. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_rmf.py +0 -0
  117. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_rmf_graph.py +0 -0
  118. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_rmf_stig_graph.py +0 -0
  119. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_rolling_plan.py +0 -0
  120. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_runaway_repetition.py +0 -0
  121. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_screenshot.py +0 -0
  122. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_server_probe.py +0 -0
  123. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_skills.py +0 -0
  124. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_stall_retry.py +0 -0
  125. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_stig.py +0 -0
  126. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_stop.py +0 -0
  127. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_streaming_newlines.py +0 -0
  128. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_subagent.py +0 -0
  129. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_suggest.py +0 -0
  130. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_system_prompt_help.py +0 -0
  131. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_task_state.py +0 -0
  132. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_todo.py +0 -0
  133. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_tool_arg_coercion.py +0 -0
  134. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_tool_arg_coercion_more.py +0 -0
  135. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_tool_arg_parsing.py +0 -0
  136. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_tool_result.py +0 -0
  137. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_tools_undo.py +0 -0
  138. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_tui.py +0 -0
  139. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_tuning.py +0 -0
  140. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_verification_gate.py +0 -0
  141. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_viewimage.py +0 -0
  142. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_vision_input.py +0 -0
  143. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_web_tools.py +0 -0
  144. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_windows_shell.py +0 -0
  145. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/tests/test_write_content_coerce.py +0 -0
  146. {drydock_cli-3.0.135 → drydock_cli-3.0.136}/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.135
3
+ Version: 3.0.136
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
@@ -107,6 +107,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
107
107
  | `/graphrag add <path>` | Incrementally add more documents to the base |
108
108
  | `/graphrag query <q>` | Test what the base returns (no model) |
109
109
  | `/graphrag status` · `clear` | List indexed sources · wipe the base |
110
+ | `/graphrag migrate` | Convert a legacy JSON index to the fast SQLite store |
110
111
  | `/skills` | List your skills |
111
112
  | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
112
113
  | `/<name>` | Run a skill |
@@ -142,8 +143,8 @@ tool) when a question touches your material. Ingests text formats
142
143
  checklists (`.ckl`/`.cklb`)** — checklists are flattened to per-rule findings so
143
144
  you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
144
145
  uses the `pdftotext` binary (poppler) if present, else `pip install
145
- drydock-cli[pdf]` (pypdf). The index is a single JSON at
146
- `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
146
+ drydock-cli[pdf]` (pypdf). The index is a SQLite database at
147
+ `<project>/.drydock/graphrag.db` (FTS5-indexed, so queries stay fast at multi-GB scale) — clean-room, no embeddings.
147
148
 
148
149
  ### Custom skills
149
150
 
@@ -260,6 +261,20 @@ LLM (llama.cpp/vLLM `:8000`, Ollama `:11434`, LM Studio `:1234`) and wires up
260
261
  the first one it finds — no account or API-key prompt. Override anytime with
261
262
  `--model` / `--provider` / `--base-url` or `~/.drydock/config.toml`.
262
263
 
264
+ ### Docker
265
+
266
+ A prebuilt image is on Docker Hub. Drydock is just the agent — point it at your
267
+ own OpenAI-compatible model server (e.g. one running on the host):
268
+
269
+ ```
270
+ docker run -it --add-host=host.docker.internal:host-gateway \
271
+ -v "$PWD:/work" fbobe3/drydock \
272
+ --base-url http://host.docker.internal:8000/v1 --model gemma4
273
+ ```
274
+
275
+ `-v "$PWD:/work"` mounts your project so the agent can read/edit it. Tags:
276
+ `fbobe3/drydock:latest` and `fbobe3/drydock:<version>` (e.g. `:3.0.135`).
277
+
263
278
  ## Using it
264
279
 
265
280
  Type a task and press **Enter**. Drydock reads/writes/edits files and runs
@@ -83,6 +83,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
83
83
  | `/graphrag add <path>` | Incrementally add more documents to the base |
84
84
  | `/graphrag query <q>` | Test what the base returns (no model) |
85
85
  | `/graphrag status` · `clear` | List indexed sources · wipe the base |
86
+ | `/graphrag migrate` | Convert a legacy JSON index to the fast SQLite store |
86
87
  | `/skills` | List your skills |
87
88
  | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
88
89
  | `/<name>` | Run a skill |
@@ -118,8 +119,8 @@ tool) when a question touches your material. Ingests text formats
118
119
  checklists (`.ckl`/`.cklb`)** — checklists are flattened to per-rule findings so
119
120
  you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
120
121
  uses the `pdftotext` binary (poppler) if present, else `pip install
121
- drydock-cli[pdf]` (pypdf). The index is a single JSON at
122
- `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
122
+ drydock-cli[pdf]` (pypdf). The index is a SQLite database at
123
+ `<project>/.drydock/graphrag.db` (FTS5-indexed, so queries stay fast at multi-GB scale) — clean-room, no embeddings.
123
124
 
124
125
  ### Custom skills
125
126
 
@@ -236,6 +237,20 @@ LLM (llama.cpp/vLLM `:8000`, Ollama `:11434`, LM Studio `:1234`) and wires up
236
237
  the first one it finds — no account or API-key prompt. Override anytime with
237
238
  `--model` / `--provider` / `--base-url` or `~/.drydock/config.toml`.
238
239
 
240
+ ### Docker
241
+
242
+ A prebuilt image is on Docker Hub. Drydock is just the agent — point it at your
243
+ own OpenAI-compatible model server (e.g. one running on the host):
244
+
245
+ ```
246
+ docker run -it --add-host=host.docker.internal:host-gateway \
247
+ -v "$PWD:/work" fbobe3/drydock \
248
+ --base-url http://host.docker.internal:8000/v1 --model gemma4
249
+ ```
250
+
251
+ `-v "$PWD:/work"` mounts your project so the agent can read/edit it. Tags:
252
+ `fbobe3/drydock:latest` and `fbobe3/drydock:<version>` (e.g. `:3.0.135`).
253
+
239
254
  ## Using it
240
255
 
241
256
  Type a task and press **Enter**. Drydock reads/writes/edits files and runs
@@ -25,10 +25,11 @@ from __future__ import annotations
25
25
  import json
26
26
  import os
27
27
  import re
28
+ import sqlite3
28
29
  from collections import Counter, defaultdict
30
+ from pathlib import Path
29
31
 
30
32
  from drydock import extract
31
- from pathlib import Path
32
33
 
33
34
  # Files we ingest as text. Everything else (binaries, images) is skipped.
34
35
  _TEXT_EXT = {
@@ -59,8 +60,150 @@ _RE_WORD = re.compile(r"[a-zA-Z0-9_]+")
59
60
 
60
61
 
61
62
  def default_store_path(cwd: str) -> Path:
62
- """Project-local index (travels with the project, easy to .gitignore)."""
63
- return Path(cwd) / ".drydock" / "graphrag.json"
63
+ """Project-local index. SQLite so queries touch only matching rows (fast even
64
+ at multi-GB scale) instead of parsing a giant JSON on every query."""
65
+ return Path(cwd) / ".drydock" / "graphrag.db"
66
+
67
+
68
+ def _resolve_store(store_path) -> Path:
69
+ """Given a requested store path, return the one that actually exists — a
70
+ ``.db`` (SQLite) if present, else a legacy ``.json`` sibling, else the path
71
+ as given (a fresh build will create it)."""
72
+ p = Path(store_path)
73
+ if p.exists():
74
+ return p
75
+ db = p.with_suffix(".db")
76
+ if db.exists():
77
+ return db
78
+ js = p.with_suffix(".json")
79
+ if js.exists():
80
+ return js
81
+ return p
82
+
83
+
84
+ # ── SQLite + FTS5 backend (the scalable store) ────────────────────────────
85
+
86
+ _SCHEMA = """
87
+ CREATE TABLE IF NOT EXISTS chunks(id INTEGER PRIMARY KEY, source TEXT, text TEXT);
88
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, content='chunks', content_rowid='id');
89
+ CREATE TABLE IF NOT EXISTS entities(entity TEXT, chunk_id INTEGER);
90
+ CREATE INDEX IF NOT EXISTS idx_entity ON entities(entity);
91
+ CREATE TABLE IF NOT EXISTS edges(a TEXT, b TEXT, weight INTEGER);
92
+ CREATE INDEX IF NOT EXISTS idx_edge_a ON edges(a);
93
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
94
+ """
95
+
96
+ _FTS_STRIP = re.compile(r'[^A-Za-z0-9_ ]+')
97
+
98
+
99
+ def _is_sqlite(path) -> bool:
100
+ return str(path).endswith(".db")
101
+
102
+
103
+ def _write_sqlite(store_path, chunks, entity_chunks, edges) -> None:
104
+ """(Re)build the SQLite store from the accumulated chunks/entities/edges."""
105
+ sp = Path(store_path)
106
+ sp.parent.mkdir(parents=True, exist_ok=True)
107
+ if sp.exists():
108
+ sp.unlink()
109
+ con = sqlite3.connect(str(sp))
110
+ try:
111
+ con.executescript(_SCHEMA)
112
+ con.executemany("INSERT INTO chunks(id,source,text) VALUES(?,?,?)",
113
+ ((c["id"], c["source"], c["text"]) for c in chunks))
114
+ con.executemany("INSERT INTO chunks_fts(rowid,text) VALUES(?,?)",
115
+ ((c["id"], c["text"]) for c in chunks))
116
+ con.executemany("INSERT INTO entities(entity,chunk_id) VALUES(?,?)",
117
+ ((e, cid) for e, cids in entity_chunks.items() for cid in cids))
118
+ con.executemany("INSERT INTO edges(a,b,weight) VALUES(?,?,?)",
119
+ ((a, b, w) for a, nbrs in edges.items() for b, w in nbrs.items()))
120
+ con.execute("INSERT OR REPLACE INTO meta(key,value) VALUES('version','2')")
121
+ con.commit()
122
+ finally:
123
+ con.close()
124
+
125
+
126
+ def _fts_query(words) -> str:
127
+ """Build a safe FTS5 MATCH expression (OR of quoted terms)."""
128
+ terms = []
129
+ for w in words:
130
+ w = _FTS_STRIP.sub(" ", w).strip()
131
+ if w:
132
+ terms.append('"' + w + '"')
133
+ return " OR ".join(terms)
134
+
135
+
136
+ def _query_sqlite(store_path, query: str, k: int, hops: bool) -> dict:
137
+ """Fast query: FTS5 full-text over chunk text + indexed entity/graph lookups.
138
+ Loads only the matching rows — no full-file parse."""
139
+ con = sqlite3.connect(str(store_path))
140
+ con.row_factory = sqlite3.Row
141
+ cur = con.cursor()
142
+ try:
143
+ q_entities = set(extract_entities(query))
144
+ q_words = {w for w in (w.lower() for w in _RE_WORD.findall(query))
145
+ if len(w) > 2 and w not in _STOPWORDS}
146
+ scores: dict[int, float] = defaultdict(float)
147
+
148
+ # 1) FTS5 keyword retrieval (indexed, bm25-ranked) — the main recall path.
149
+ expr = _fts_query(q_words)
150
+ if expr:
151
+ try:
152
+ rows = cur.execute(
153
+ "SELECT rowid, rank FROM chunks_fts WHERE chunks_fts MATCH ? "
154
+ "ORDER BY rank LIMIT 400", (expr,)).fetchall()
155
+ for r in rows:
156
+ scores[r["rowid"]] += 0.5 + min(2.0, -float(r["rank"]) / 4.0)
157
+ except sqlite3.OperationalError:
158
+ pass
159
+
160
+ # 2) Exact entity hits (indexed) weigh most; collect matched entities.
161
+ matched: set[str] = set()
162
+ cand = {e.lower() for e in q_entities} | q_words
163
+ for qe in cand:
164
+ for r in cur.execute("SELECT chunk_id, entity FROM entities WHERE entity=?", (qe,)):
165
+ scores[r["chunk_id"]] += 3.0
166
+ matched.add(r["entity"])
167
+
168
+ # 3) 1-hop graph expansion over the strongest neighbors of matched entities.
169
+ related: list[str] = []
170
+ if hops and matched:
171
+ for e in list(matched):
172
+ nbrs = cur.execute("SELECT b, weight FROM edges WHERE a=? ORDER BY weight DESC LIMIT 5",
173
+ (e,)).fetchall()
174
+ for nb in nbrs:
175
+ if nb["b"] not in matched:
176
+ related.append(nb["b"])
177
+ for r in cur.execute("SELECT chunk_id FROM entities WHERE entity=?", (nb["b"],)):
178
+ scores[r["chunk_id"]] += 1.0
179
+
180
+ if not scores:
181
+ return {"chunks": [], "related": []}
182
+ ranked = sorted(scores.items(), key=lambda kv: -kv[1])[:k]
183
+ out = []
184
+ for cid, sc in ranked:
185
+ if sc <= 0:
186
+ continue
187
+ row = cur.execute("SELECT source, text FROM chunks WHERE id=?", (cid,)).fetchone()
188
+ if row:
189
+ out.append({"source": row["source"], "text": row["text"], "score": round(sc, 1)})
190
+ seen: set[str] = set()
191
+ rel_unique = [r for r in related if not (r in seen or seen.add(r))][:12]
192
+ return {"chunks": out, "related": rel_unique}
193
+ finally:
194
+ con.close()
195
+
196
+
197
+ def migrate_json_to_sqlite(json_path, db_path) -> dict:
198
+ """One-time conversion of a legacy JSON index to the SQLite store. Loads the
199
+ JSON once (slow for a huge file, but one-time), then queries are fast."""
200
+ data = json.loads(Path(json_path).read_text("utf-8"))
201
+ chunks = data.get("chunks", [])
202
+ entity_chunks = {e: list(cids) for e, cids in data.get("entities", {}).items()}
203
+ edges = {a: dict(nbrs) for a, nbrs in data.get("edges", {}).items()}
204
+ _write_sqlite(db_path, chunks, entity_chunks, edges)
205
+ return {"chunks": len(chunks), "entities": len(entity_chunks),
206
+ "edges": sum(len(n) for n in edges.values())}
64
207
 
65
208
 
66
209
  def extract_entities(text: str) -> list[str]:
@@ -167,15 +310,22 @@ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
167
310
 
168
311
 
169
312
  def _save(store_path, chunks, entity_chunks, edges, files_added):
170
- index = {
171
- "version": 1,
172
- "chunks": chunks,
173
- "entities": {e: sorted(cids) for e, cids in entity_chunks.items()},
174
- "edges": {a: dict(nbrs) for a, nbrs in edges.items()},
175
- }
176
- sp = Path(store_path)
177
- sp.parent.mkdir(parents=True, exist_ok=True)
178
- sp.write_text(json.dumps(index), encoding="utf-8")
313
+ # SQLite by default (scalable). A caller can still target a .json path
314
+ # explicitly (small/portable indexes, tests) and get the legacy format.
315
+ if _is_sqlite(store_path):
316
+ _write_sqlite(store_path, chunks,
317
+ {e: sorted(cids) for e, cids in entity_chunks.items()},
318
+ {a: dict(nbrs) for a, nbrs in edges.items()})
319
+ else:
320
+ index = {
321
+ "version": 1,
322
+ "chunks": chunks,
323
+ "entities": {e: sorted(cids) for e, cids in entity_chunks.items()},
324
+ "edges": {a: dict(nbrs) for a, nbrs in edges.items()},
325
+ }
326
+ sp = Path(store_path)
327
+ sp.parent.mkdir(parents=True, exist_ok=True)
328
+ sp.write_text(json.dumps(index), encoding="utf-8")
179
329
  return {
180
330
  "files": files_added,
181
331
  "chunks": len(chunks),
@@ -194,13 +344,36 @@ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
194
344
  return _save(store_path, chunks, entity_chunks, edges, added)
195
345
 
196
346
 
347
+ def _read_all(store_path) -> dict:
348
+ """Read a whole index into a dict (chunks/entities/edges) — used by add_to_index
349
+ to append. Handles both the SQLite store and a legacy JSON file."""
350
+ sp = _resolve_store(store_path)
351
+ if _is_sqlite(sp):
352
+ con = sqlite3.connect(str(sp))
353
+ con.row_factory = sqlite3.Row
354
+ try:
355
+ chunks = [{"id": r["id"], "source": r["source"], "text": r["text"]}
356
+ for r in con.execute("SELECT id,source,text FROM chunks")]
357
+ entities: dict[str, list[int]] = defaultdict(list)
358
+ for r in con.execute("SELECT entity,chunk_id FROM entities"):
359
+ entities[r["entity"]].append(r["chunk_id"])
360
+ edges: dict[str, dict] = defaultdict(dict)
361
+ for r in con.execute("SELECT a,b,weight FROM edges"):
362
+ edges[r["a"]][r["b"]] = r["weight"]
363
+ return {"chunks": chunks, "entities": entities, "edges": edges}
364
+ finally:
365
+ con.close()
366
+ return json.loads(Path(sp).read_text("utf-8"))
367
+
368
+
197
369
  def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
198
370
  """Incrementally ADD documents to an existing index (build it if none yet).
199
371
  Files already indexed (by relative path) are skipped — clear+build to refresh
200
372
  changed files. Returns {files (added), chunks, entities, edges} totals."""
201
- existing = load_index(store_path)
202
- if existing is None:
373
+ sp = _resolve_store(store_path)
374
+ if not sp.exists():
203
375
  return build_index(paths, store_path, cwd=cwd)
376
+ existing = _read_all(sp)
204
377
  chunks: list[dict] = list(existing.get("chunks", []))
205
378
  entity_chunks: dict[str, set[int]] = defaultdict(set)
206
379
  for e, cids in existing.get("entities", {}).items():
@@ -210,20 +383,50 @@ def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
210
383
  edges[a] = Counter(nbrs)
211
384
  skip = {c["source"] for c in chunks}
212
385
  added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip)
213
- return _save(store_path, chunks, entity_chunks, edges, added)
386
+ return _save(sp, chunks, entity_chunks, edges, added)
214
387
 
215
388
 
216
389
  def sources(index: dict) -> list[str]:
217
- """The distinct source files in an index, sorted."""
390
+ """The distinct source files in an index, sorted. Accepts a loaded JSON dict
391
+ OR a SQLite handle ({'_db': path})."""
392
+ db = index.get("_db")
393
+ if db:
394
+ con = sqlite3.connect(str(db))
395
+ try:
396
+ return sorted(r[0] for r in con.execute("SELECT DISTINCT source FROM chunks"))
397
+ finally:
398
+ con.close()
218
399
  return sorted({c["source"] for c in index.get("chunks", [])})
219
400
 
220
401
 
221
- def load_index(store_path: str | Path) -> dict | None:
222
- sp = Path(store_path)
402
+ def index_stats(index: dict) -> dict:
403
+ """{chunks, entities} counts for a loaded dict OR a SQLite handle — without
404
+ reading a large index into memory."""
405
+ db = index.get("_db")
406
+ if db:
407
+ con = sqlite3.connect(str(db))
408
+ try:
409
+ c = con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
410
+ e = con.execute("SELECT COUNT(DISTINCT entity) FROM entities").fetchone()[0]
411
+ return {"chunks": c, "entities": e}
412
+ finally:
413
+ con.close()
414
+ return {"chunks": len(index.get("chunks", [])), "entities": len(index.get("entities", {}))}
415
+
416
+
417
+ def load_index(store_path: str | Path):
418
+ """Return a query handle for the index at store_path, or None if none exists.
419
+
420
+ For the SQLite store this is a tiny handle ({'_db': path}) — it does NOT read
421
+ the data (queries hit the DB directly, so a multi-GB index stays instant). For
422
+ a legacy JSON file it loads the dict (small/portable indexes)."""
423
+ sp = _resolve_store(store_path)
223
424
  if not sp.exists():
224
425
  return None
426
+ if _is_sqlite(sp):
427
+ return {"_db": str(sp)}
225
428
  try:
226
- return json.loads(sp.read_text("utf-8"))
429
+ return json.loads(Path(sp).read_text("utf-8"))
227
430
  except (OSError, ValueError):
228
431
  return None
229
432
 
@@ -233,6 +436,11 @@ def query_index(index: dict, query: str, *, k: int = 5, hops: bool = True) -> di
233
436
 
234
437
  Returns {chunks: [{source, text, score}], related: [entities]}.
235
438
  """
439
+ # SQLite handle → the fast, indexed path (no full-file load).
440
+ db = index.get("_db")
441
+ if db:
442
+ return _query_sqlite(db, query, k, hops)
443
+
236
444
  chunks = index.get("chunks", [])
237
445
  entities = index.get("entities", {})
238
446
  edges = index.get("edges", {})
@@ -1125,23 +1125,43 @@ class DrydockApp(App):
1125
1125
  self._info("No knowledge base yet. Build one: /graphrag build <path>")
1126
1126
  else:
1127
1127
  srcs = graphrag.sources(index)
1128
+ st = graphrag.index_stats(index)
1128
1129
  shown = "\n".join(f" · {s}" for s in srcs[:20])
1129
1130
  more = f"\n … +{len(srcs) - 20} more" if len(srcs) > 20 else ""
1131
+ resolved = graphrag._resolve_store(store)
1132
+ legacy = " ⚠ legacy JSON — run /graphrag migrate for fast queries" \
1133
+ if str(resolved).endswith(".json") else ""
1130
1134
  self._info(
1131
- f"Knowledge base: {len(index.get('chunks', []))} chunks · "
1132
- f"{len(index.get('entities', {}))} entities · {len(srcs)} sources "
1133
- f"({store}).\n{shown}{more}"
1135
+ f"Knowledge base: {st['chunks']} chunks · {st['entities']} entities · "
1136
+ f"{len(srcs)} sources ({resolved}).{legacy}\n{shown}{more}"
1134
1137
  )
1138
+ elif sub == "migrate":
1139
+ resolved = graphrag._resolve_store(store)
1140
+ if not str(resolved).endswith(".json"):
1141
+ self._info("Already using the fast SQLite store — nothing to migrate.")
1142
+ return
1143
+ db = resolved.with_suffix(".db")
1144
+ self._info(f"Migrating legacy index → {db} (one-time; loads the JSON once) …")
1145
+ try:
1146
+ m = graphrag.migrate_json_to_sqlite(resolved, db)
1147
+ except Exception as e: # noqa: BLE001
1148
+ self._mount(ErrorMessage(f"migrate failed: {e}"))
1149
+ return
1150
+ self._info(
1151
+ f"✓ Migrated: {m['chunks']} chunks · {m['entities']} entities → {db}. "
1152
+ f"Queries now hit the index directly (no full-file load). You can delete "
1153
+ f"the old {resolved.name} once you've confirmed queries work."
1154
+ )
1135
1155
  elif sub == "clear":
1136
1156
  try:
1137
- store.unlink(missing_ok=True)
1157
+ graphrag._resolve_store(store).unlink(missing_ok=True)
1138
1158
  self._info("Knowledge base cleared.")
1139
1159
  except OSError as e:
1140
1160
  self._mount(ErrorMessage(f"could not clear: {e}"))
1141
1161
  else:
1142
1162
  self._info(
1143
1163
  "usage: /graphrag build <path> · add <path> · query <question> "
1144
- "· status · clear"
1164
+ "· status · migrate · clear"
1145
1165
  )
1146
1166
 
1147
1167
  def _persist_config(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.135
3
+ Version: 3.0.136
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
@@ -107,6 +107,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
107
107
  | `/graphrag add <path>` | Incrementally add more documents to the base |
108
108
  | `/graphrag query <q>` | Test what the base returns (no model) |
109
109
  | `/graphrag status` · `clear` | List indexed sources · wipe the base |
110
+ | `/graphrag migrate` | Convert a legacy JSON index to the fast SQLite store |
110
111
  | `/skills` | List your skills |
111
112
  | `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
112
113
  | `/<name>` | Run a skill |
@@ -142,8 +143,8 @@ tool) when a question touches your material. Ingests text formats
142
143
  checklists (`.ckl`/`.cklb`)** — checklists are flattened to per-rule findings so
143
144
  you can ask "which findings are open?". `.docx`/`.ckl` need nothing extra; PDF
144
145
  uses the `pdftotext` binary (poppler) if present, else `pip install
145
- drydock-cli[pdf]` (pypdf). The index is a single JSON at
146
- `<project>/.drydock/graphrag.json` — clean-room, no embeddings.
146
+ drydock-cli[pdf]` (pypdf). The index is a SQLite database at
147
+ `<project>/.drydock/graphrag.db` (FTS5-indexed, so queries stay fast at multi-GB scale) — clean-room, no embeddings.
147
148
 
148
149
  ### Custom skills
149
150
 
@@ -260,6 +261,20 @@ LLM (llama.cpp/vLLM `:8000`, Ollama `:11434`, LM Studio `:1234`) and wires up
260
261
  the first one it finds — no account or API-key prompt. Override anytime with
261
262
  `--model` / `--provider` / `--base-url` or `~/.drydock/config.toml`.
262
263
 
264
+ ### Docker
265
+
266
+ A prebuilt image is on Docker Hub. Drydock is just the agent — point it at your
267
+ own OpenAI-compatible model server (e.g. one running on the host):
268
+
269
+ ```
270
+ docker run -it --add-host=host.docker.internal:host-gateway \
271
+ -v "$PWD:/work" fbobe3/drydock \
272
+ --base-url http://host.docker.internal:8000/v1 --model gemma4
273
+ ```
274
+
275
+ `-v "$PWD:/work"` mounts your project so the agent can read/edit it. Tags:
276
+ `fbobe3/drydock:latest` and `fbobe3/drydock:<version>` (e.g. `:3.0.135`).
277
+
263
278
  ## Using it
264
279
 
265
280
  Type a task and press **Enter**. Drydock reads/writes/edits files and runs
@@ -96,6 +96,7 @@ tests/test_glob_edit_edges.py
96
96
  tests/test_graphify_example.py
97
97
  tests/test_graphrag.py
98
98
  tests/test_graphrag_quoted_path.py
99
+ tests/test_graphrag_sqlite.py
99
100
  tests/test_grep_and_read_robust.py
100
101
  tests/test_guards_and_tools.py
101
102
  tests/test_hallucinated_tools.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.135"
10
+ version = "3.0.136"
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"
@@ -0,0 +1,70 @@
1
+ """GraphRAG SQLite+FTS5 backend: scalable store that queries only matching rows
2
+ (no full-file load), with legacy-JSON migration."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+
8
+ from drydock import graphrag as g
9
+
10
+
11
+ def _docs():
12
+ d = tempfile.mkdtemp()
13
+ docs = os.path.join(d, "docs"); os.makedirs(docs)
14
+ open(os.path.join(docs, "a.md"), "w").write(
15
+ "# Auth\nThe TokenValidator checks JWT expiry. Refunds via the BillingService.")
16
+ open(os.path.join(docs, "b.md"), "w").write(
17
+ "# Billing\nThe BillingService issues refunds within 30 days.")
18
+ return d, docs
19
+
20
+
21
+ def test_default_store_is_sqlite():
22
+ assert str(g.default_store_path("/x")).endswith(".db")
23
+
24
+
25
+ def test_build_query_sqlite():
26
+ d, docs = _docs()
27
+ store = os.path.join(d, ".drydock", "graphrag.db")
28
+ stats = g.build_index([docs], store, cwd=d)
29
+ assert stats["chunks"] == 2 and os.path.exists(store)
30
+ idx = g.load_index(store)
31
+ assert idx == {"_db": store} # lazy handle, not the data
32
+ r = g.query_index(idx, "how are refunds handled", k=3)
33
+ assert r["chunks"] and any("refund" in c["text"].lower() for c in r["chunks"])
34
+ assert g.sources(idx) == ["docs/a.md", "docs/b.md"]
35
+ assert g.index_stats(idx)["chunks"] == 2
36
+
37
+
38
+ def test_add_to_sqlite_appends():
39
+ d, docs = _docs()
40
+ store = os.path.join(d, ".drydock", "graphrag.db")
41
+ g.build_index([docs], store, cwd=d)
42
+ open(os.path.join(docs, "c.md"), "w").write("# Refund policy\nPartial refunds allowed after 30 days.")
43
+ s2 = g.add_to_index([docs], store, cwd=d)
44
+ assert s2["files"] == 1 and s2["chunks"] == 3
45
+ r = g.query_index(g.load_index(store), "partial refund policy", k=3)
46
+ assert any(c["source"] == "docs/c.md" for c in r["chunks"])
47
+
48
+
49
+ def test_migrate_json_to_sqlite():
50
+ d, docs = _docs()
51
+ js = os.path.join(d, "old.json")
52
+ g.build_index([docs], js, cwd=d) # explicit .json → legacy format
53
+ assert os.path.getsize(js) > 0
54
+ db = os.path.join(d, "new.db")
55
+ m = g.migrate_json_to_sqlite(js, db)
56
+ assert m["chunks"] == 2 and os.path.exists(db)
57
+ r = g.query_index(g.load_index(db), "refunds", k=2)
58
+ assert r["chunks"]
59
+
60
+
61
+ def test_resolve_prefers_db_then_json():
62
+ d, docs = _docs()
63
+ base = os.path.join(d, ".drydock", "graphrag.db")
64
+ g.build_index([docs], base, cwd=d)
65
+ # asking for the .json path resolves to the existing .db
66
+ assert str(g._resolve_store(base.replace(".db", ".json"))).endswith(".db")
67
+
68
+
69
+ def test_missing_store_returns_none():
70
+ assert g.load_index("/no/such/graphrag.db") is None
File without changes
File without changes
File without changes