drydock-cli 3.0.60__tar.gz → 3.0.62__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.
- {drydock_cli-3.0.60/drydock_cli.egg-info → drydock_cli-3.0.62}/PKG-INFO +50 -1
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/README.md +47 -0
- drydock_cli-3.0.62/drydock/extract.py +94 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/graphrag.py +13 -5
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/skills.py +19 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/app.py +30 -7
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tuning.py +25 -1
- {drydock_cli-3.0.60 → drydock_cli-3.0.62/drydock_cli.egg-info}/PKG-INFO +50 -1
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/SOURCES.txt +3 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/requires.txt +3 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/pyproject.toml +4 -1
- drydock_cli-3.0.62/tests/test_extract.py +71 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_skills.py +25 -0
- drydock_cli-3.0.62/tests/test_system_prompt_help.py +15 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/LICENSE +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/NOTICE +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/agent.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/cli.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/config.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/detect.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/guards.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/providers.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock/web.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/setup.cfg +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_config.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.60 → drydock_cli-3.0.62}/tests/test_web_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: drydock-cli
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.62
|
|
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
|
|
@@ -18,6 +18,8 @@ Requires-Dist: pytest; extra == "dev"
|
|
|
18
18
|
Requires-Dist: pytest-timeout; extra == "dev"
|
|
19
19
|
Requires-Dist: ruff; extra == "dev"
|
|
20
20
|
Requires-Dist: pyright; extra == "dev"
|
|
21
|
+
Provides-Extra: pdf
|
|
22
|
+
Requires-Dist: pypdf>=4; extra == "pdf"
|
|
21
23
|
Dynamic: license-file
|
|
22
24
|
|
|
23
25
|
# ⚓ Drydock
|
|
@@ -71,6 +73,53 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
|
|
|
71
73
|
`~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
|
|
72
74
|
- **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
|
|
73
75
|
|
|
76
|
+
## Slash commands
|
|
77
|
+
|
|
78
|
+
Typed into the prompt. The agent also knows these, so you can just **ask it**
|
|
79
|
+
("how do I add my own docs?") and it'll point you to the right one.
|
|
80
|
+
|
|
81
|
+
| Command | What it does |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
|
|
84
|
+
| `/graphrag add <path>` | Incrementally add more documents to the base |
|
|
85
|
+
| `/graphrag query <q>` | Test what the base returns (no model) |
|
|
86
|
+
| `/graphrag status` · `clear` | List indexed sources · wipe the base |
|
|
87
|
+
| `/skills` | List your skills |
|
|
88
|
+
| `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
|
|
89
|
+
| `/<name>` | Run a skill |
|
|
90
|
+
| `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
|
|
91
|
+
| `/mcp` | List connected MCP servers + their tools |
|
|
92
|
+
| `/model` · `/cwd` | Show/set model & endpoint · working directory |
|
|
93
|
+
| `/undo` · `/back` | Revert the last write · rewind the last turn |
|
|
94
|
+
| `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
|
|
95
|
+
| `/help` · `/quit` | Help · exit |
|
|
96
|
+
|
|
97
|
+
### Knowledge base (GraphRAG) — ingesting your documents
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
/graphrag build ./docs # index a file or a whole folder
|
|
101
|
+
/graphrag add ./more_docs # add more later, incrementally
|
|
102
|
+
/graphrag query "how are refunds handled?" # check retrieval
|
|
103
|
+
/graphrag status # what's indexed
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
|
|
107
|
+
tool) when a question touches your material. Ingests text formats
|
|
108
|
+
(`.md .txt .py .js .json .yaml .sql …`) **plus PDF and Word (`.docx`)**. `.docx`
|
|
109
|
+
needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
|
|
110
|
+
`pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
|
|
111
|
+
`<project>/.drydock/graphrag.json` — clean-room, no embeddings.
|
|
112
|
+
|
|
113
|
+
### Custom skills
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
/skills new commitmsg Write a concise conventional-commit message for: $ARGS
|
|
117
|
+
/commitmsg the staged auth changes # runs the skill with $ARGS substituted
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
121
|
+
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
122
|
+
|
|
74
123
|
## Install
|
|
75
124
|
|
|
76
125
|
```bash
|
|
@@ -49,6 +49,53 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
|
|
|
49
49
|
`~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
|
|
50
50
|
- **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
|
|
51
51
|
|
|
52
|
+
## Slash commands
|
|
53
|
+
|
|
54
|
+
Typed into the prompt. The agent also knows these, so you can just **ask it**
|
|
55
|
+
("how do I add my own docs?") and it'll point you to the right one.
|
|
56
|
+
|
|
57
|
+
| Command | What it does |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
|
|
60
|
+
| `/graphrag add <path>` | Incrementally add more documents to the base |
|
|
61
|
+
| `/graphrag query <q>` | Test what the base returns (no model) |
|
|
62
|
+
| `/graphrag status` · `clear` | List indexed sources · wipe the base |
|
|
63
|
+
| `/skills` | List your skills |
|
|
64
|
+
| `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
|
|
65
|
+
| `/<name>` | Run a skill |
|
|
66
|
+
| `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
|
|
67
|
+
| `/mcp` | List connected MCP servers + their tools |
|
|
68
|
+
| `/model` · `/cwd` | Show/set model & endpoint · working directory |
|
|
69
|
+
| `/undo` · `/back` | Revert the last write · rewind the last turn |
|
|
70
|
+
| `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
|
|
71
|
+
| `/help` · `/quit` | Help · exit |
|
|
72
|
+
|
|
73
|
+
### Knowledge base (GraphRAG) — ingesting your documents
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
/graphrag build ./docs # index a file or a whole folder
|
|
77
|
+
/graphrag add ./more_docs # add more later, incrementally
|
|
78
|
+
/graphrag query "how are refunds handled?" # check retrieval
|
|
79
|
+
/graphrag status # what's indexed
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
|
|
83
|
+
tool) when a question touches your material. Ingests text formats
|
|
84
|
+
(`.md .txt .py .js .json .yaml .sql …`) **plus PDF and Word (`.docx`)**. `.docx`
|
|
85
|
+
needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
|
|
86
|
+
`pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
|
|
87
|
+
`<project>/.drydock/graphrag.json` — clean-room, no embeddings.
|
|
88
|
+
|
|
89
|
+
### Custom skills
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
/skills new commitmsg Write a concise conventional-commit message for: $ARGS
|
|
93
|
+
/commitmsg the staged auth changes # runs the skill with $ARGS substituted
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
97
|
+
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
98
|
+
|
|
52
99
|
## Install
|
|
53
100
|
|
|
54
101
|
```bash
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Document text extraction for GraphRAG ingestion — PDF + Word (.docx).
|
|
2
|
+
|
|
3
|
+
Kept clean-room and dependency-light:
|
|
4
|
+
• .docx is a zip of XML, so we read it with STDLIB only (zipfile + ElementTree)
|
|
5
|
+
— no dependency.
|
|
6
|
+
• .pdf uses whatever the system already has: the `pdftotext` binary (poppler)
|
|
7
|
+
if present, else the optional `pypdf` package (`pip install drydock-cli[pdf]`).
|
|
8
|
+
|
|
9
|
+
Returns None when a document can't be read (unsupported, corrupt, or no PDF
|
|
10
|
+
backend available) so ingestion skips it cleanly instead of failing.
|
|
11
|
+
|
|
12
|
+
All logic original to Drydock.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
import xml.etree.ElementTree as ET
|
|
18
|
+
import zipfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Extensions we can pull text out of (beyond the plain-text formats GraphRAG
|
|
22
|
+
# already reads directly).
|
|
23
|
+
EXTRACTABLE_EXT = {".pdf", ".docx"}
|
|
24
|
+
|
|
25
|
+
_W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract_docx(path: str | Path) -> str | None:
|
|
29
|
+
"""Pull paragraph text from a .docx (stdlib only). Each <w:p> is a paragraph;
|
|
30
|
+
its text lives in <w:t> runs."""
|
|
31
|
+
try:
|
|
32
|
+
with zipfile.ZipFile(path) as z:
|
|
33
|
+
xml = z.read("word/document.xml")
|
|
34
|
+
except (zipfile.BadZipFile, KeyError, OSError):
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
root = ET.fromstring(xml)
|
|
38
|
+
except ET.ParseError:
|
|
39
|
+
return None
|
|
40
|
+
paras: list[str] = []
|
|
41
|
+
for p in root.iter(f"{_W}p"):
|
|
42
|
+
runs = [t.text for t in p.iter(f"{_W}t") if t.text]
|
|
43
|
+
if runs:
|
|
44
|
+
paras.append("".join(runs))
|
|
45
|
+
text = "\n\n".join(paras).strip()
|
|
46
|
+
return text or None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_pdf(path: str | Path) -> str | None:
|
|
50
|
+
"""Extract text from a PDF: try the `pdftotext` binary first, then `pypdf`.
|
|
51
|
+
Returns None if neither is available or the PDF has no extractable text."""
|
|
52
|
+
# 1) pdftotext (poppler) — fast and common on Linux/macOS.
|
|
53
|
+
try:
|
|
54
|
+
r = subprocess.run(
|
|
55
|
+
["pdftotext", "-q", str(path), "-"],
|
|
56
|
+
capture_output=True, text=True, timeout=120,
|
|
57
|
+
)
|
|
58
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
59
|
+
return r.stdout
|
|
60
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
61
|
+
pass
|
|
62
|
+
# 2) pypdf (optional dependency: drydock-cli[pdf]).
|
|
63
|
+
try:
|
|
64
|
+
import pypdf
|
|
65
|
+
|
|
66
|
+
reader = pypdf.PdfReader(str(path))
|
|
67
|
+
text = "\n\n".join((pg.extract_text() or "") for pg in reader.pages).strip()
|
|
68
|
+
return text or None
|
|
69
|
+
except Exception: # noqa: BLE001 — optional dep missing / unreadable PDF
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extract_document(path: str | Path) -> str | None:
|
|
74
|
+
"""Extract text from a supported document (.pdf/.docx), or None."""
|
|
75
|
+
ext = Path(path).suffix.lower()
|
|
76
|
+
if ext == ".docx":
|
|
77
|
+
return _extract_docx(path)
|
|
78
|
+
if ext == ".pdf":
|
|
79
|
+
return _extract_pdf(path)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pdf_backend_available() -> bool:
|
|
84
|
+
"""Whether a PDF can be extracted at all (pdftotext binary or pypdf)."""
|
|
85
|
+
from shutil import which
|
|
86
|
+
|
|
87
|
+
if which("pdftotext"):
|
|
88
|
+
return True
|
|
89
|
+
try:
|
|
90
|
+
import pypdf # noqa: F401
|
|
91
|
+
|
|
92
|
+
return True
|
|
93
|
+
except ImportError:
|
|
94
|
+
return False
|
|
@@ -26,6 +26,8 @@ import json
|
|
|
26
26
|
import os
|
|
27
27
|
import re
|
|
28
28
|
from collections import Counter, defaultdict
|
|
29
|
+
|
|
30
|
+
from drydock import extract
|
|
29
31
|
from pathlib import Path
|
|
30
32
|
|
|
31
33
|
# Files we ingest as text. Everything else (binaries, images) is skipped.
|
|
@@ -106,7 +108,8 @@ def _iter_text_files(paths: list[str]):
|
|
|
106
108
|
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
|
|
107
109
|
for f in files:
|
|
108
110
|
fp = Path(root) / f
|
|
109
|
-
|
|
111
|
+
ext = fp.suffix.lower()
|
|
112
|
+
if ext in _TEXT_EXT or ext in extract.EXTRACTABLE_EXT or fp.suffix == "":
|
|
110
113
|
yield fp
|
|
111
114
|
|
|
112
115
|
|
|
@@ -119,10 +122,15 @@ def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
|
|
|
119
122
|
rel = os.path.relpath(str(fp), cwd)
|
|
120
123
|
if rel in skip_sources:
|
|
121
124
|
continue
|
|
122
|
-
|
|
123
|
-
text =
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
if fp.suffix.lower() in extract.EXTRACTABLE_EXT:
|
|
126
|
+
text = extract.extract_document(fp) # PDF/Word → text (or None)
|
|
127
|
+
if not text:
|
|
128
|
+
continue # unreadable / no PDF backend — skip cleanly
|
|
129
|
+
else:
|
|
130
|
+
try:
|
|
131
|
+
text = fp.read_text("utf-8", "ignore")
|
|
132
|
+
except OSError:
|
|
133
|
+
continue
|
|
126
134
|
if not text.strip():
|
|
127
135
|
continue
|
|
128
136
|
added += 1
|
|
@@ -77,6 +77,25 @@ def skills_dirs(cwd: str) -> list[Path]:
|
|
|
77
77
|
return [Path.home() / ".drydock" / "skills", Path(cwd) / ".drydock" / "skills"]
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def create_skill(name: str, body: str, *, description: str = "", scope: str = "user",
|
|
81
|
+
cwd: str = ".") -> Path:
|
|
82
|
+
"""Author a new skill markdown file and return its path. scope='user' writes
|
|
83
|
+
to ~/.drydock/skills (available everywhere); scope='project' writes to
|
|
84
|
+
<cwd>/.drydock/skills. Raises ValueError on a bad name / empty body."""
|
|
85
|
+
name = (name or "").strip().lower()
|
|
86
|
+
body = (body or "").strip()
|
|
87
|
+
if not _NAME_RE.match(name):
|
|
88
|
+
raise ValueError("skill name must be letters/digits/-/_ (e.g. 'review')")
|
|
89
|
+
if not body:
|
|
90
|
+
raise ValueError("a skill needs a prompt body")
|
|
91
|
+
d = (Path(cwd) if scope == "project" else Path.home()) / ".drydock" / "skills"
|
|
92
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
path = d / f"{name}.md"
|
|
94
|
+
front = f"---\nname: {name}\ndescription: {description or 'user skill'}\n---\n"
|
|
95
|
+
path.write_text(front + body + "\n", encoding="utf-8")
|
|
96
|
+
return path
|
|
97
|
+
|
|
98
|
+
|
|
80
99
|
def load_skills(cwd: str) -> dict[str, Skill]:
|
|
81
100
|
"""Load all skills; later dirs (project) override earlier (user) by name."""
|
|
82
101
|
skills: dict[str, Skill] = {}
|
|
@@ -492,7 +492,7 @@ class DrydockApp(App):
|
|
|
492
492
|
" /compact shrink old context to free up the window\n"
|
|
493
493
|
" /graphrag ingest docs into a knowledge base the agent can use\n"
|
|
494
494
|
" build <path> · add <path> · query <q> · status · clear\n"
|
|
495
|
-
" /skills list
|
|
495
|
+
" /skills list skills · /skills new <name> <prompt> to create one\n"
|
|
496
496
|
" /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
|
|
497
497
|
" /mcp list connected MCP servers and their tools\n"
|
|
498
498
|
" /clear reset the conversation\n"
|
|
@@ -504,7 +504,7 @@ class DrydockApp(App):
|
|
|
504
504
|
elif cmd == "/loop":
|
|
505
505
|
self._cmd_loop(arg)
|
|
506
506
|
elif cmd == "/skills":
|
|
507
|
-
self._cmd_skills()
|
|
507
|
+
self._cmd_skills(arg)
|
|
508
508
|
elif cmd[1:] in self._skills:
|
|
509
509
|
self._run_skill(self._skills[cmd[1:]], arg)
|
|
510
510
|
else:
|
|
@@ -554,19 +554,42 @@ class DrydockApp(App):
|
|
|
554
554
|
self._mount(UserMessage(prompt))
|
|
555
555
|
self._begin(prompt)
|
|
556
556
|
|
|
557
|
-
def _cmd_skills(self) -> None:
|
|
557
|
+
def _cmd_skills(self, arg: str = "") -> None:
|
|
558
|
+
"""List skills, or create one: /skills new <name> <prompt…> (use $ARGS in
|
|
559
|
+
the prompt for trailing input). Created skills are usable as /<name>
|
|
560
|
+
immediately."""
|
|
561
|
+
from drydock import skills as skillsmod
|
|
562
|
+
|
|
563
|
+
parts = arg.split(maxsplit=2)
|
|
564
|
+
if parts and parts[0].lower() == "new":
|
|
565
|
+
if len(parts) < 3:
|
|
566
|
+
self._info(
|
|
567
|
+
"usage: /skills new <name> <prompt text>\n"
|
|
568
|
+
" e.g. /skills new review Run GitDiff, then review the changes "
|
|
569
|
+
"for bugs.\n (use $ARGS in the prompt for trailing input)"
|
|
570
|
+
)
|
|
571
|
+
return
|
|
572
|
+
name, body = parts[1], parts[2]
|
|
573
|
+
try:
|
|
574
|
+
path = skillsmod.create_skill(name, body, cwd=self.config.get("cwd") or ".")
|
|
575
|
+
except ValueError as e:
|
|
576
|
+
self._info(f"Couldn't create skill: {e}")
|
|
577
|
+
return
|
|
578
|
+
self._skills = skillsmod.load_skills(self.config.get("cwd") or ".") # reload
|
|
579
|
+
self._info(f"✓ Created skill /{name.lower()} ({path}). Invoke it as /{name.lower()}.")
|
|
580
|
+
return
|
|
558
581
|
if not self._skills:
|
|
559
582
|
self._info(
|
|
560
|
-
"No skills yet. Create one
|
|
561
|
-
"~/.drydock/skills/<name>.md
|
|
562
|
-
"
|
|
563
|
-
"trailing input."
|
|
583
|
+
"No skills yet. Create one with: /skills new <name> <prompt text>\n"
|
|
584
|
+
"(or drop a markdown file in ~/.drydock/skills/<name>.md). Invoke as "
|
|
585
|
+
"/<name>; use $ARGS for trailing input."
|
|
564
586
|
)
|
|
565
587
|
return
|
|
566
588
|
lines = ["Skills (invoke as /<name>):"]
|
|
567
589
|
for name in sorted(self._skills):
|
|
568
590
|
sk = self._skills[name]
|
|
569
591
|
lines.append(f" /{name}" + (f" — {sk.description}" if sk.description else ""))
|
|
592
|
+
lines.append("Create one: /skills new <name> <prompt text>")
|
|
570
593
|
self._info("\n".join(lines))
|
|
571
594
|
|
|
572
595
|
def _run_skill(self, skill, arg: str) -> None:
|
|
@@ -206,9 +206,33 @@ def filter_tool_schemas(tool_schemas: list, model: str | None) -> list:
|
|
|
206
206
|
return [t for t in tool_schemas if t.get("name") not in GEMMA_DISABLED_TOOLS]
|
|
207
207
|
|
|
208
208
|
|
|
209
|
+
# Reference for the model so it can answer "how do I …" questions about Drydock
|
|
210
|
+
# itself. The user TYPES these slash commands; you (the model) do NOT run them —
|
|
211
|
+
# you just explain the right one when asked. Appended to every system prompt.
|
|
212
|
+
_DRYDOCK_COMMANDS_HELP = (
|
|
213
|
+
"\n\nAbout Drydock (the tool you run inside). If the user asks how to do "
|
|
214
|
+
"something with Drydock, tell them the slash command — they type it, you do "
|
|
215
|
+
"NOT run it:\n"
|
|
216
|
+
"- Knowledge base from their docs: `/graphrag build <path>` (a file or "
|
|
217
|
+
"folder), `/graphrag add <path>` to add more, `/graphrag query <q>` to test "
|
|
218
|
+
"it, `/graphrag status`, `/graphrag clear`. Once built, you automatically use "
|
|
219
|
+
"the `Knowledge` tool to draw on it. It ingests text formats (md/txt/code/"
|
|
220
|
+
"json/yaml/…) plus PDF and Word (.docx) directly.\n"
|
|
221
|
+
"- Custom skills (reusable `/<name>` prompts): `/skills new <name> <prompt "
|
|
222
|
+
"text>` creates one (use $ARGS in the prompt for trailing input); `/skills` "
|
|
223
|
+
"lists them; then they run it as `/<name>`.\n"
|
|
224
|
+
"- Other: `/model` (model/endpoint), `/cwd`, `/undo` (revert last write), "
|
|
225
|
+
"`/back` (rewind a turn), `/compact` (shrink context), `/loop <n> <prompt>` "
|
|
226
|
+
"(repeat a prompt), `/mcp` (connected MCP servers), `/status`, `/clear`, "
|
|
227
|
+
"`/help`, `/quit`. Internet + git are tools you call yourself "
|
|
228
|
+
"(WebSearch/WebFetch, GitStatus/GitDiff/GitLog/GitCommit)."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
209
232
|
def system_prompt_for_model(model: str | None) -> str:
|
|
210
233
|
"""Return the system prompt best suited to the model."""
|
|
211
|
-
|
|
234
|
+
base = _GEMMA_SYSTEM_PROMPT if is_gemma(model) else _DEFAULT_SYSTEM_PROMPT
|
|
235
|
+
return base + _DRYDOCK_COMMANDS_HELP
|
|
212
236
|
|
|
213
237
|
|
|
214
238
|
def thinking_level_for_turn(turn_count: int, is_user_turn: bool) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: drydock-cli
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.62
|
|
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
|
|
@@ -18,6 +18,8 @@ Requires-Dist: pytest; extra == "dev"
|
|
|
18
18
|
Requires-Dist: pytest-timeout; extra == "dev"
|
|
19
19
|
Requires-Dist: ruff; extra == "dev"
|
|
20
20
|
Requires-Dist: pyright; extra == "dev"
|
|
21
|
+
Provides-Extra: pdf
|
|
22
|
+
Requires-Dist: pypdf>=4; extra == "pdf"
|
|
21
23
|
Dynamic: license-file
|
|
22
24
|
|
|
23
25
|
# ⚓ Drydock
|
|
@@ -71,6 +73,53 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
|
|
|
71
73
|
`~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
|
|
72
74
|
- **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
|
|
73
75
|
|
|
76
|
+
## Slash commands
|
|
77
|
+
|
|
78
|
+
Typed into the prompt. The agent also knows these, so you can just **ask it**
|
|
79
|
+
("how do I add my own docs?") and it'll point you to the right one.
|
|
80
|
+
|
|
81
|
+
| Command | What it does |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
|
|
84
|
+
| `/graphrag add <path>` | Incrementally add more documents to the base |
|
|
85
|
+
| `/graphrag query <q>` | Test what the base returns (no model) |
|
|
86
|
+
| `/graphrag status` · `clear` | List indexed sources · wipe the base |
|
|
87
|
+
| `/skills` | List your skills |
|
|
88
|
+
| `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
|
|
89
|
+
| `/<name>` | Run a skill |
|
|
90
|
+
| `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
|
|
91
|
+
| `/mcp` | List connected MCP servers + their tools |
|
|
92
|
+
| `/model` · `/cwd` | Show/set model & endpoint · working directory |
|
|
93
|
+
| `/undo` · `/back` | Revert the last write · rewind the last turn |
|
|
94
|
+
| `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
|
|
95
|
+
| `/help` · `/quit` | Help · exit |
|
|
96
|
+
|
|
97
|
+
### Knowledge base (GraphRAG) — ingesting your documents
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
/graphrag build ./docs # index a file or a whole folder
|
|
101
|
+
/graphrag add ./more_docs # add more later, incrementally
|
|
102
|
+
/graphrag query "how are refunds handled?" # check retrieval
|
|
103
|
+
/graphrag status # what's indexed
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
|
|
107
|
+
tool) when a question touches your material. Ingests text formats
|
|
108
|
+
(`.md .txt .py .js .json .yaml .sql …`) **plus PDF and Word (`.docx`)**. `.docx`
|
|
109
|
+
needs nothing extra; PDF uses the `pdftotext` binary (poppler) if present, else
|
|
110
|
+
`pip install drydock-cli[pdf]` (pypdf). The index is a single JSON at
|
|
111
|
+
`<project>/.drydock/graphrag.json` — clean-room, no embeddings.
|
|
112
|
+
|
|
113
|
+
### Custom skills
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
/skills new commitmsg Write a concise conventional-commit message for: $ARGS
|
|
117
|
+
/commitmsg the staged auth changes # runs the skill with $ARGS substituted
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
121
|
+
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
122
|
+
|
|
74
123
|
## Install
|
|
75
124
|
|
|
76
125
|
```bash
|
|
@@ -10,6 +10,7 @@ drydock/cli.py
|
|
|
10
10
|
drydock/compaction.py
|
|
11
11
|
drydock/config.py
|
|
12
12
|
drydock/detect.py
|
|
13
|
+
drydock/extract.py
|
|
13
14
|
drydock/gittools.py
|
|
14
15
|
drydock/graphrag.py
|
|
15
16
|
drydock/guards.py
|
|
@@ -47,6 +48,7 @@ tests/test_context_limit_config.py
|
|
|
47
48
|
tests/test_detect.py
|
|
48
49
|
tests/test_dispatch.py
|
|
49
50
|
tests/test_empty_response.py
|
|
51
|
+
tests/test_extract.py
|
|
50
52
|
tests/test_failure_loop.py
|
|
51
53
|
tests/test_first_run_setup.py
|
|
52
54
|
tests/test_gittools.py
|
|
@@ -65,6 +67,7 @@ tests/test_skills.py
|
|
|
65
67
|
tests/test_stop.py
|
|
66
68
|
tests/test_streaming_newlines.py
|
|
67
69
|
tests/test_subagent.py
|
|
70
|
+
tests/test_system_prompt_help.py
|
|
68
71
|
tests/test_todo.py
|
|
69
72
|
tests/test_tool_arg_parsing.py
|
|
70
73
|
tests/test_tools_undo.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.
|
|
10
|
+
version = "3.0.62"
|
|
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"
|
|
@@ -29,6 +29,9 @@ drydock = "drydock.cli:main"
|
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
31
|
dev = ["pytest", "pytest-timeout", "ruff", "pyright"]
|
|
32
|
+
# PDF ingestion fallback when the `pdftotext` binary (poppler) isn't installed.
|
|
33
|
+
# .docx needs nothing extra (stdlib zip/xml). `pip install drydock-cli[pdf]`.
|
|
34
|
+
pdf = ["pypdf>=4"]
|
|
32
35
|
|
|
33
36
|
[tool.ruff]
|
|
34
37
|
target-version = "py312"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""PDF + Word (.docx) document extraction for GraphRAG ingestion."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import zipfile
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from drydock import extract, graphrag
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _make_docx(path, text):
|
|
12
|
+
doc = ('<?xml version="1.0"?><w:document xmlns:w='
|
|
13
|
+
'"http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
|
14
|
+
f'<w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>')
|
|
15
|
+
with zipfile.ZipFile(path, "w") as z:
|
|
16
|
+
z.writestr("word/document.xml", doc)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _make_pdf(path, text):
|
|
20
|
+
objs = [b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
21
|
+
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
22
|
+
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
|
|
23
|
+
b"/Resources << /Font << /F1 5 0 R >> >> >>"]
|
|
24
|
+
stream = b"BT /F1 18 Tf 72 700 Td (" + text.encode() + b") Tj ET"
|
|
25
|
+
objs.append(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
|
|
26
|
+
objs.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
|
27
|
+
out = b"%PDF-1.4\n"; offs = []
|
|
28
|
+
for i, o in enumerate(objs, 1):
|
|
29
|
+
offs.append(len(out)); out += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
|
|
30
|
+
x = len(out); out += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
|
|
31
|
+
for o in offs:
|
|
32
|
+
out += ("%010d 00000 n \n" % o).encode()
|
|
33
|
+
out += (b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\n"
|
|
34
|
+
b"startxref\n" + str(x).encode() + b"\n%%EOF")
|
|
35
|
+
path.write_bytes(out)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_docx_extraction(tmp_path):
|
|
39
|
+
p = tmp_path / "d.docx"
|
|
40
|
+
_make_docx(p, "The Nimbus service uses the Falcon-Token header.")
|
|
41
|
+
assert extract.extract_document(p) == "The Nimbus service uses the Falcon-Token header."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_pdf_extraction(tmp_path):
|
|
45
|
+
if not extract.pdf_backend_available():
|
|
46
|
+
pytest.skip("no PDF backend (pdftotext/pypdf) available")
|
|
47
|
+
p = tmp_path / "m.pdf"
|
|
48
|
+
_make_pdf(p, "Refunds via the Zephyr gateway.")
|
|
49
|
+
out = extract.extract_document(p)
|
|
50
|
+
assert out and "Zephyr gateway" in out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_unsupported_and_corrupt_return_none(tmp_path):
|
|
54
|
+
assert extract.extract_document(tmp_path / "x.png") is None
|
|
55
|
+
bad = tmp_path / "bad.docx"; bad.write_text("not a zip")
|
|
56
|
+
assert extract.extract_document(bad) is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_graphrag_ingests_docx_and_pdf(tmp_path):
|
|
60
|
+
_make_docx(tmp_path / "spec.docx", "The Nimbus API uses the Falcon-Token header for auth.")
|
|
61
|
+
if extract.pdf_backend_available():
|
|
62
|
+
_make_pdf(tmp_path / "manual.pdf", "Refunds are issued via the Zephyr gateway.")
|
|
63
|
+
store = tmp_path / ".drydock" / "graphrag.json"
|
|
64
|
+
graphrag.build_index(["."], store, cwd=str(tmp_path))
|
|
65
|
+
idx = graphrag.load_index(store)
|
|
66
|
+
srcs = graphrag.sources(idx)
|
|
67
|
+
assert "spec.docx" in srcs
|
|
68
|
+
assert graphrag.query_index(idx, "Falcon-Token header")["chunks"][0]["source"] == "spec.docx"
|
|
69
|
+
if extract.pdf_backend_available():
|
|
70
|
+
assert "manual.pdf" in srcs
|
|
71
|
+
assert graphrag.query_index(idx, "refunds Zephyr gateway")["chunks"][0]["source"] == "manual.pdf"
|
|
@@ -54,3 +54,28 @@ def test_empty_body_skipped(tmp_path):
|
|
|
54
54
|
|
|
55
55
|
def test_no_skills_dir_is_empty(tmp_path):
|
|
56
56
|
assert skills.load_skills(str(tmp_path)) == {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_create_skill_user_scope(tmp_path, monkeypatch):
|
|
60
|
+
monkeypatch.setattr(skills.Path, "home", lambda: tmp_path)
|
|
61
|
+
path = skills.create_skill("review", "Run GitDiff then review for bugs.",
|
|
62
|
+
description="Review the diff")
|
|
63
|
+
assert path.exists()
|
|
64
|
+
loaded = skills.load_skills(str(tmp_path / "proj")) # user-scope visible anywhere
|
|
65
|
+
assert "review" in loaded and "review for bugs" in loaded["review"].body
|
|
66
|
+
assert loaded["review"].description == "Review the diff"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_create_skill_rejects_bad_name_and_empty_body(tmp_path, monkeypatch):
|
|
70
|
+
monkeypatch.setattr(skills.Path, "home", lambda: tmp_path)
|
|
71
|
+
import pytest
|
|
72
|
+
with pytest.raises(ValueError):
|
|
73
|
+
skills.create_skill("bad name!", "body")
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
skills.create_skill("ok", " ")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_create_skill_project_scope(tmp_path):
|
|
79
|
+
path = skills.create_skill("explain", "Explain $ARGS.", scope="project", cwd=str(tmp_path))
|
|
80
|
+
assert str(tmp_path) in str(path)
|
|
81
|
+
assert skills.load_skills(str(tmp_path))["explain"].render("auth.py") == "Explain auth.py."
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The system prompt must teach the model Drydock's own slash commands so it can
|
|
2
|
+
answer 'how do I add documents / make a skill' when a user asks."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from drydock.tuning import system_prompt_for_model
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_prompt_documents_graphrag_and_skills():
|
|
9
|
+
for model in ("gemma4", "some-other-model"):
|
|
10
|
+
p = system_prompt_for_model(model)
|
|
11
|
+
assert "/graphrag build" in p and "/graphrag add" in p
|
|
12
|
+
assert "/skills new" in p
|
|
13
|
+
assert "Knowledge" in p
|
|
14
|
+
# framed so the model explains, not runs, the command
|
|
15
|
+
assert "you do" in p.lower() and "not run" in p.lower()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|