drydock-cli 3.0.59__tar.gz → 3.0.61__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.59/drydock_cli.egg-info → drydock_cli-3.0.61}/PKG-INFO +47 -1
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/README.md +46 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/cli.py +14 -6
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/graphrag.py +47 -13
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/skills.py +19 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/app.py +65 -20
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tuning.py +25 -1
- {drydock_cli-3.0.59 → drydock_cli-3.0.61/drydock_cli.egg-info}/PKG-INFO +47 -1
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/SOURCES.txt +1 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/pyproject.toml +1 -1
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_graphrag.py +32 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_skills.py +25 -0
- drydock_cli-3.0.61/tests/test_system_prompt_help.py +15 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/LICENSE +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/NOTICE +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/agent.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/config.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/detect.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/guards.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/providers.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock/web.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/setup.cfg +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_config.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.59 → drydock_cli-3.0.61}/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.61
|
|
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
|
|
@@ -71,6 +71,52 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
|
|
|
71
71
|
`~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
|
|
72
72
|
- **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
|
|
73
73
|
|
|
74
|
+
## Slash commands
|
|
75
|
+
|
|
76
|
+
Typed into the prompt. The agent also knows these, so you can just **ask it**
|
|
77
|
+
("how do I add my own docs?") and it'll point you to the right one.
|
|
78
|
+
|
|
79
|
+
| Command | What it does |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
|
|
82
|
+
| `/graphrag add <path>` | Incrementally add more documents to the base |
|
|
83
|
+
| `/graphrag query <q>` | Test what the base returns (no model) |
|
|
84
|
+
| `/graphrag status` · `clear` | List indexed sources · wipe the base |
|
|
85
|
+
| `/skills` | List your skills |
|
|
86
|
+
| `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
|
|
87
|
+
| `/<name>` | Run a skill |
|
|
88
|
+
| `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
|
|
89
|
+
| `/mcp` | List connected MCP servers + their tools |
|
|
90
|
+
| `/model` · `/cwd` | Show/set model & endpoint · working directory |
|
|
91
|
+
| `/undo` · `/back` | Revert the last write · rewind the last turn |
|
|
92
|
+
| `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
|
|
93
|
+
| `/help` · `/quit` | Help · exit |
|
|
94
|
+
|
|
95
|
+
### Knowledge base (GraphRAG) — ingesting your documents
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
/graphrag build ./docs # index a file or a whole folder
|
|
99
|
+
/graphrag add ./more_docs # add more later, incrementally
|
|
100
|
+
/graphrag query "how are refunds handled?" # check retrieval
|
|
101
|
+
/graphrag status # what's indexed
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
|
|
105
|
+
tool) when a question touches your material. Ingests text formats
|
|
106
|
+
(`.md .txt .py .js .json .yaml .sql …`); convert PDFs/Word to text first
|
|
107
|
+
(e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
|
|
108
|
+
`<project>/.drydock/graphrag.json` — clean-room, stdlib-only, no embeddings.
|
|
109
|
+
|
|
110
|
+
### Custom skills
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
/skills new commitmsg Write a concise conventional-commit message for: $ARGS
|
|
114
|
+
/commitmsg the staged auth changes # runs the skill with $ARGS substituted
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
118
|
+
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
119
|
+
|
|
74
120
|
## Install
|
|
75
121
|
|
|
76
122
|
```bash
|
|
@@ -49,6 +49,52 @@ 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 …`); convert PDFs/Word to text first
|
|
85
|
+
(e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
|
|
86
|
+
`<project>/.drydock/graphrag.json` — clean-room, stdlib-only, no embeddings.
|
|
87
|
+
|
|
88
|
+
### Custom skills
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
/skills new commitmsg Write a concise conventional-commit message for: $ARGS
|
|
92
|
+
/commitmsg the staged auth changes # runs the skill with $ARGS substituted
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
96
|
+
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
97
|
+
|
|
52
98
|
## Install
|
|
53
99
|
|
|
54
100
|
```bash
|
|
@@ -198,17 +198,23 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
|
|
|
198
198
|
parts = raw.split() # raw = case-preserved, so paths survive
|
|
199
199
|
sub = parts[1].lower() if len(parts) > 1 else ""
|
|
200
200
|
rest = " ".join(parts[2:]).strip()
|
|
201
|
-
if sub
|
|
202
|
-
print_colored(f" Building knowledge base from {rest} …", "dim")
|
|
201
|
+
if sub in ("build", "add") and rest:
|
|
203
202
|
try:
|
|
204
|
-
|
|
203
|
+
fn = graphrag.build_index if sub == "build" else graphrag.add_to_index
|
|
204
|
+
stats = fn([rest], store, cwd=cwd)
|
|
205
205
|
print_colored(
|
|
206
|
-
f" ✓ {stats['files']} files · {stats['chunks']} chunks · "
|
|
206
|
+
f" ✓ {sub}: +{stats['files']} files · {stats['chunks']} chunks · "
|
|
207
207
|
f"{stats['entities']} entities · {stats['edges']} edges → {store}",
|
|
208
208
|
"green",
|
|
209
209
|
)
|
|
210
210
|
except Exception as e: # noqa: BLE001
|
|
211
|
-
print_colored(f" graphrag
|
|
211
|
+
print_colored(f" graphrag {sub} failed: {e}", "red")
|
|
212
|
+
elif sub == "query" and rest:
|
|
213
|
+
index = graphrag.load_index(store)
|
|
214
|
+
if index is None:
|
|
215
|
+
print_colored(" No knowledge base. Build: /graphrag build <path>", "dim")
|
|
216
|
+
else:
|
|
217
|
+
print(graphrag.format_results(graphrag.query_index(index, rest, k=3), rest))
|
|
212
218
|
elif sub == "clear":
|
|
213
219
|
store.unlink(missing_ok=True)
|
|
214
220
|
print_colored(" Knowledge base cleared.", "green")
|
|
@@ -217,9 +223,11 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
|
|
|
217
223
|
if index is None:
|
|
218
224
|
print_colored(" No knowledge base. Build: /graphrag build <path>", "dim")
|
|
219
225
|
else:
|
|
226
|
+
srcs = graphrag.sources(index)
|
|
220
227
|
print_colored(
|
|
221
228
|
f" Knowledge base: {len(index.get('chunks', []))} chunks · "
|
|
222
|
-
f"{len(index.get('entities', {}))} entities (
|
|
229
|
+
f"{len(index.get('entities', {}))} entities · {len(srcs)} sources "
|
|
230
|
+
f"({store})", "cyan")
|
|
223
231
|
return True
|
|
224
232
|
elif cmd in ("/quit", "/exit"):
|
|
225
233
|
raise KeyboardInterrupt
|
|
@@ -110,26 +110,23 @@ def _iter_text_files(paths: list[str]):
|
|
|
110
110
|
yield fp
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
def
|
|
114
|
-
"""
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"""
|
|
118
|
-
chunks: list[dict] = []
|
|
119
|
-
entity_chunks: dict[str, set[int]] = defaultdict(set)
|
|
120
|
-
edges: dict[str, Counter] = defaultdict(Counter)
|
|
121
|
-
files = 0
|
|
122
|
-
|
|
113
|
+
def _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip_sources):
|
|
114
|
+
"""Chunk + extract + graph every text file under paths into the (mutable)
|
|
115
|
+
accumulators, skipping any source already present. Returns files added."""
|
|
116
|
+
added = 0
|
|
123
117
|
for fp in _iter_text_files([str(Path(cwd) / p) if not os.path.isabs(p) else p
|
|
124
118
|
for p in paths]):
|
|
119
|
+
rel = os.path.relpath(str(fp), cwd)
|
|
120
|
+
if rel in skip_sources:
|
|
121
|
+
continue
|
|
125
122
|
try:
|
|
126
123
|
text = fp.read_text("utf-8", "ignore")
|
|
127
124
|
except OSError:
|
|
128
125
|
continue
|
|
129
126
|
if not text.strip():
|
|
130
127
|
continue
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
added += 1
|
|
129
|
+
skip_sources.add(rel) # don't double-ingest the same file in one call
|
|
133
130
|
for body in _chunk_text(text):
|
|
134
131
|
cid = len(chunks)
|
|
135
132
|
ents = extract_entities(body)
|
|
@@ -140,7 +137,10 @@ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
|
|
|
140
137
|
for b in ents[i + 1:]:
|
|
141
138
|
edges[a][b] += 1
|
|
142
139
|
edges[b][a] += 1
|
|
140
|
+
return added
|
|
141
|
+
|
|
143
142
|
|
|
143
|
+
def _save(store_path, chunks, entity_chunks, edges, files_added):
|
|
144
144
|
index = {
|
|
145
145
|
"version": 1,
|
|
146
146
|
"chunks": chunks,
|
|
@@ -151,13 +151,47 @@ def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") ->
|
|
|
151
151
|
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
152
152
|
sp.write_text(json.dumps(index), encoding="utf-8")
|
|
153
153
|
return {
|
|
154
|
-
"files":
|
|
154
|
+
"files": files_added,
|
|
155
155
|
"chunks": len(chunks),
|
|
156
156
|
"entities": len(entity_chunks),
|
|
157
157
|
"edges": sum(len(v) for v in edges.values()) // 2,
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
|
|
162
|
+
"""Build (or REBUILD from scratch) the knowledge graph from paths; persist to
|
|
163
|
+
store_path. Returns {files, chunks, entities, edges}."""
|
|
164
|
+
chunks: list[dict] = []
|
|
165
|
+
entity_chunks: dict[str, set[int]] = defaultdict(set)
|
|
166
|
+
edges: dict[str, Counter] = defaultdict(Counter)
|
|
167
|
+
added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, set())
|
|
168
|
+
return _save(store_path, chunks, entity_chunks, edges, added)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def add_to_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
|
|
172
|
+
"""Incrementally ADD documents to an existing index (build it if none yet).
|
|
173
|
+
Files already indexed (by relative path) are skipped — clear+build to refresh
|
|
174
|
+
changed files. Returns {files (added), chunks, entities, edges} totals."""
|
|
175
|
+
existing = load_index(store_path)
|
|
176
|
+
if existing is None:
|
|
177
|
+
return build_index(paths, store_path, cwd=cwd)
|
|
178
|
+
chunks: list[dict] = list(existing.get("chunks", []))
|
|
179
|
+
entity_chunks: dict[str, set[int]] = defaultdict(set)
|
|
180
|
+
for e, cids in existing.get("entities", {}).items():
|
|
181
|
+
entity_chunks[e] = set(cids)
|
|
182
|
+
edges: dict[str, Counter] = defaultdict(Counter)
|
|
183
|
+
for a, nbrs in existing.get("edges", {}).items():
|
|
184
|
+
edges[a] = Counter(nbrs)
|
|
185
|
+
skip = {c["source"] for c in chunks}
|
|
186
|
+
added = _ingest_files(paths, cwd, chunks, entity_chunks, edges, skip)
|
|
187
|
+
return _save(store_path, chunks, entity_chunks, edges, added)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def sources(index: dict) -> list[str]:
|
|
191
|
+
"""The distinct source files in an index, sorted."""
|
|
192
|
+
return sorted({c["source"] for c in index.get("chunks", [])})
|
|
193
|
+
|
|
194
|
+
|
|
161
195
|
def load_index(store_path: str | Path) -> dict | None:
|
|
162
196
|
sp = Path(store_path)
|
|
163
197
|
if not sp.exists():
|
|
@@ -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] = {}
|
|
@@ -490,9 +490,9 @@ class DrydockApp(App):
|
|
|
490
490
|
" /stop stop the running turn (or press Esc)\n"
|
|
491
491
|
" /status session model, cwd, turns, tokens\n"
|
|
492
492
|
" /compact shrink old context to free up the window\n"
|
|
493
|
-
" /graphrag
|
|
494
|
-
"
|
|
495
|
-
" /skills list
|
|
493
|
+
" /graphrag ingest docs into a knowledge base the agent can use\n"
|
|
494
|
+
" build <path> · add <path> · query <q> · status · clear\n"
|
|
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:
|
|
@@ -634,33 +657,52 @@ class DrydockApp(App):
|
|
|
634
657
|
sub = parts[0].lower() if parts else ""
|
|
635
658
|
rest = parts[1].strip() if len(parts) > 1 else ""
|
|
636
659
|
|
|
637
|
-
if sub
|
|
660
|
+
if sub in ("build", "add"):
|
|
638
661
|
if not rest:
|
|
639
|
-
self._info("usage: /graphrag
|
|
662
|
+
self._info(f"usage: /graphrag {sub} <path> (a file or directory of docs/code)")
|
|
640
663
|
return
|
|
641
|
-
|
|
664
|
+
verb = "Rebuilding" if sub == "build" else "Ingesting into"
|
|
665
|
+
self._info(f"{verb} knowledge base from {rest} …")
|
|
642
666
|
try:
|
|
643
|
-
|
|
667
|
+
fn = graphrag.build_index if sub == "build" else graphrag.add_to_index
|
|
668
|
+
stats = fn([rest], store, cwd=cwd)
|
|
644
669
|
except Exception as e: # noqa: BLE001 — surface, never crash the TUI
|
|
645
|
-
self._mount(ErrorMessage(f"graphrag
|
|
670
|
+
self._mount(ErrorMessage(f"graphrag {sub} failed: {e}"))
|
|
671
|
+
return
|
|
672
|
+
if sub == "add" and stats["files"] == 0:
|
|
673
|
+
self._info(f"No new documents under {rest} (already indexed, or no text found).")
|
|
646
674
|
return
|
|
647
675
|
if not stats["chunks"]:
|
|
648
676
|
self._info(f"No text found under {rest}. Nothing was indexed.")
|
|
649
677
|
return
|
|
678
|
+
verb2 = "built" if sub == "build" else f"updated (+{stats['files']} new files)"
|
|
650
679
|
self._info(
|
|
651
|
-
f"✓ Knowledge base
|
|
652
|
-
f"{stats['
|
|
653
|
-
f"
|
|
654
|
-
"use the Knowledge tool to draw on it."
|
|
680
|
+
f"✓ Knowledge base {verb2}: {stats['chunks']} chunks · "
|
|
681
|
+
f"{stats['entities']} entities · {stats['edges']} edges.\n"
|
|
682
|
+
f"Stored at {store}. The agent draws on it via the Knowledge tool."
|
|
655
683
|
)
|
|
684
|
+
elif sub == "query":
|
|
685
|
+
if not rest:
|
|
686
|
+
self._info("usage: /graphrag query <question> (test what the KB returns)")
|
|
687
|
+
return
|
|
688
|
+
index = graphrag.load_index(store)
|
|
689
|
+
if index is None:
|
|
690
|
+
self._info("No knowledge base yet. Build one: /graphrag build <path>")
|
|
691
|
+
return
|
|
692
|
+
res = graphrag.query_index(index, rest, k=3)
|
|
693
|
+
self._info(graphrag.format_results(res, rest))
|
|
656
694
|
elif sub in ("", "status"):
|
|
657
695
|
index = graphrag.load_index(store)
|
|
658
696
|
if index is None:
|
|
659
697
|
self._info("No knowledge base yet. Build one: /graphrag build <path>")
|
|
660
698
|
else:
|
|
699
|
+
srcs = graphrag.sources(index)
|
|
700
|
+
shown = "\n".join(f" · {s}" for s in srcs[:20])
|
|
701
|
+
more = f"\n … +{len(srcs) - 20} more" if len(srcs) > 20 else ""
|
|
661
702
|
self._info(
|
|
662
703
|
f"Knowledge base: {len(index.get('chunks', []))} chunks · "
|
|
663
|
-
f"{len(index.get('entities', {}))} entities
|
|
704
|
+
f"{len(index.get('entities', {}))} entities · {len(srcs)} sources "
|
|
705
|
+
f"({store}).\n{shown}{more}"
|
|
664
706
|
)
|
|
665
707
|
elif sub == "clear":
|
|
666
708
|
try:
|
|
@@ -669,7 +711,10 @@ class DrydockApp(App):
|
|
|
669
711
|
except OSError as e:
|
|
670
712
|
self._mount(ErrorMessage(f"could not clear: {e}"))
|
|
671
713
|
else:
|
|
672
|
-
self._info(
|
|
714
|
+
self._info(
|
|
715
|
+
"usage: /graphrag build <path> · add <path> · query <question> "
|
|
716
|
+
"· status · clear"
|
|
717
|
+
)
|
|
673
718
|
|
|
674
719
|
def _persist_config(self) -> None:
|
|
675
720
|
"""Save the persistable settings (model/provider/base_url/… — save_file
|
|
@@ -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/…); PDFs/Word must be converted to text first.\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.61
|
|
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
|
|
@@ -71,6 +71,52 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
|
|
|
71
71
|
`~/.drydock/skills/` (or `<project>/.drydock/skills/`); `$ARGS` substitution.
|
|
72
72
|
- **Loops** — `/loop <count> <prompt>` runs a prompt iteratively (Esc stops).
|
|
73
73
|
|
|
74
|
+
## Slash commands
|
|
75
|
+
|
|
76
|
+
Typed into the prompt. The agent also knows these, so you can just **ask it**
|
|
77
|
+
("how do I add my own docs?") and it'll point you to the right one.
|
|
78
|
+
|
|
79
|
+
| Command | What it does |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `/graphrag build <path>` | Build a knowledge base from a file or folder of docs/code |
|
|
82
|
+
| `/graphrag add <path>` | Incrementally add more documents to the base |
|
|
83
|
+
| `/graphrag query <q>` | Test what the base returns (no model) |
|
|
84
|
+
| `/graphrag status` · `clear` | List indexed sources · wipe the base |
|
|
85
|
+
| `/skills` | List your skills |
|
|
86
|
+
| `/skills new <name> <prompt>` | Create a reusable `/<name>` skill (use `$ARGS` for input) |
|
|
87
|
+
| `/<name>` | Run a skill |
|
|
88
|
+
| `/loop <count> <prompt>` | Repeat a prompt N times (Esc stops) |
|
|
89
|
+
| `/mcp` | List connected MCP servers + their tools |
|
|
90
|
+
| `/model` · `/cwd` | Show/set model & endpoint · working directory |
|
|
91
|
+
| `/undo` · `/back` | Revert the last write · rewind the last turn |
|
|
92
|
+
| `/compact` · `/status` · `/clear` | Shrink context · session stats · reset |
|
|
93
|
+
| `/help` · `/quit` | Help · exit |
|
|
94
|
+
|
|
95
|
+
### Knowledge base (GraphRAG) — ingesting your documents
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
/graphrag build ./docs # index a file or a whole folder
|
|
99
|
+
/graphrag add ./more_docs # add more later, incrementally
|
|
100
|
+
/graphrag query "how are refunds handled?" # check retrieval
|
|
101
|
+
/graphrag status # what's indexed
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Once built, the agent **automatically** retrieves from it (read-only `Knowledge`
|
|
105
|
+
tool) when a question touches your material. Ingests text formats
|
|
106
|
+
(`.md .txt .py .js .json .yaml .sql …`); convert PDFs/Word to text first
|
|
107
|
+
(e.g. `pdftotext file.pdf file.txt`). The index is a single JSON at
|
|
108
|
+
`<project>/.drydock/graphrag.json` — clean-room, stdlib-only, no embeddings.
|
|
109
|
+
|
|
110
|
+
### Custom skills
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
/skills new commitmsg Write a concise conventional-commit message for: $ARGS
|
|
114
|
+
/commitmsg the staged auth changes # runs the skill with $ARGS substituted
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Skills are markdown files in `~/.drydock/skills/` (personal) or
|
|
118
|
+
`<project>/.drydock/skills/` (project); `/skills new` writes one for you.
|
|
119
|
+
|
|
74
120
|
## Install
|
|
75
121
|
|
|
76
122
|
```bash
|
|
@@ -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.61"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -73,3 +73,35 @@ import pytest # noqa: E402
|
|
|
73
73
|
def _make_corpus(tmp_path):
|
|
74
74
|
_corpus(tmp_path)
|
|
75
75
|
yield
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_add_to_index_is_incremental(tmp_path):
|
|
79
|
+
store = tmp_path / ".drydock" / "graphrag.json"
|
|
80
|
+
(tmp_path / "one.md").write_text("The Alpha service uses the Beacon token.")
|
|
81
|
+
s1 = graphrag.build_index(["one.md"], store, cwd=str(tmp_path))
|
|
82
|
+
assert s1["files"] == 1
|
|
83
|
+
# add a second doc — existing chunks preserved, new ones appended
|
|
84
|
+
(tmp_path / "two.md").write_text("The Gamma module writes to the Delta table.")
|
|
85
|
+
s2 = graphrag.add_to_index(["two.md"], store, cwd=str(tmp_path))
|
|
86
|
+
assert s2["files"] == 1 # only the NEW file counted
|
|
87
|
+
assert s2["chunks"] > s1["chunks"] # grew
|
|
88
|
+
idx = graphrag.load_index(store)
|
|
89
|
+
assert set(graphrag.sources(idx)) == {"one.md", "two.md"}
|
|
90
|
+
# both docs are queryable
|
|
91
|
+
assert graphrag.query_index(idx, "Beacon token")["chunks"][0]["source"] == "one.md"
|
|
92
|
+
assert graphrag.query_index(idx, "Delta table")["chunks"][0]["source"] == "two.md"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_add_skips_already_indexed(tmp_path):
|
|
96
|
+
store = tmp_path / ".drydock" / "graphrag.json"
|
|
97
|
+
(tmp_path / "one.md").write_text("Alpha Beacon content here.")
|
|
98
|
+
graphrag.build_index(["one.md"], store, cwd=str(tmp_path))
|
|
99
|
+
s = graphrag.add_to_index(["one.md"], store, cwd=str(tmp_path)) # same file again
|
|
100
|
+
assert s["files"] == 0 # nothing re-ingested
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_add_builds_when_no_index_yet(tmp_path):
|
|
104
|
+
store = tmp_path / ".drydock" / "graphrag.json"
|
|
105
|
+
(tmp_path / "x.md").write_text("Some Echo content.")
|
|
106
|
+
s = graphrag.add_to_index(["x.md"], store, cwd=str(tmp_path))
|
|
107
|
+
assert s["files"] == 1 and graphrag.load_index(store) is not None
|
|
@@ -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
|