drydock-cli 3.0.47__tar.gz → 3.0.50__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.47/drydock_cli.egg-info → drydock_cli-3.0.50}/PKG-INFO +1 -1
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/agent.py +4 -2
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/cli.py +46 -5
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/config.py +6 -0
- drydock_cli-3.0.50/drydock/graphrag.py +244 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/tools/__init__.py +131 -3
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/tui/app.py +80 -3
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/tui/messages.py +6 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/tui/widgets.py +24 -1
- drydock_cli-3.0.50/drydock/web.py +98 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock_cli.egg-info/SOURCES.txt +6 -1
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/pyproject.toml +1 -1
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_config.py +4 -1
- drydock_cli-3.0.50/tests/test_context_limit_config.py +44 -0
- drydock_cli-3.0.50/tests/test_graphrag.py +75 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_tuning.py +54 -0
- drydock_cli-3.0.50/tests/test_web_tools.py +72 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/LICENSE +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/NOTICE +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/README.md +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/detect.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/guards.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/providers.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/setup.cfg +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.47 → drydock_cli-3.0.50}/tests/test_vision_input.py +0 -0
|
@@ -19,7 +19,7 @@ def _plan_has_unfinished(config: dict) -> bool:
|
|
|
19
19
|
todo = config.get("_todo")
|
|
20
20
|
return bool(todo) and any(status != "done" for _, status in todo)
|
|
21
21
|
|
|
22
|
-
from drydock.providers import stream, AssistantTurn, TextChunk
|
|
22
|
+
from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk
|
|
23
23
|
from drydock.tool_registry import schemas, execute
|
|
24
24
|
from drydock.tools import register_all
|
|
25
25
|
|
|
@@ -168,7 +168,9 @@ def run(
|
|
|
168
168
|
tool_schemas=filter_tool_schemas(available, turn_config.get("model")),
|
|
169
169
|
config=turn_config,
|
|
170
170
|
):
|
|
171
|
-
if isinstance(event,
|
|
171
|
+
if isinstance(event, ReasoningChunk):
|
|
172
|
+
yield event
|
|
173
|
+
elif isinstance(event, TextChunk):
|
|
172
174
|
yield event
|
|
173
175
|
elif isinstance(event, AssistantTurn):
|
|
174
176
|
assistant_turn = event
|
|
@@ -149,7 +149,8 @@ def run_oneshot(prompt: str, config: dict) -> None:
|
|
|
149
149
|
|
|
150
150
|
def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
|
|
151
151
|
"""Handle slash commands. Returns True if handled."""
|
|
152
|
-
|
|
152
|
+
raw = cmd.strip() # case-preserved (paths/args must not be lowercased)
|
|
153
|
+
cmd = raw.lower()
|
|
153
154
|
if cmd == "/help":
|
|
154
155
|
print_colored("Commands:", "bold")
|
|
155
156
|
print(" /help — show this help")
|
|
@@ -164,7 +165,7 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
|
|
|
164
165
|
print_colored(" Conversation cleared.", "green")
|
|
165
166
|
return True
|
|
166
167
|
elif cmd == "/compact":
|
|
167
|
-
from drydock.compaction import compact, estimate_tokens
|
|
168
|
+
from drydock.compaction import compact, emergency_compact, estimate_tokens
|
|
168
169
|
|
|
169
170
|
if not state.messages:
|
|
170
171
|
print_colored(" Nothing to compact.", "dim")
|
|
@@ -172,6 +173,8 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
|
|
|
172
173
|
before = estimate_tokens(state.messages)
|
|
173
174
|
limit = config.get("context_limit", 65536) or 65536
|
|
174
175
|
state.messages = compact(state.messages, limit)
|
|
176
|
+
if estimate_tokens(state.messages) > limit * 0.5:
|
|
177
|
+
state.messages = emergency_compact(state.messages, limit)
|
|
175
178
|
after = estimate_tokens(state.messages)
|
|
176
179
|
saved = before - after
|
|
177
180
|
if saved > 0:
|
|
@@ -187,6 +190,37 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
|
|
|
187
190
|
print_colored(f" Messages: {len(state.messages)}", "cyan")
|
|
188
191
|
print_colored(f" Tokens: {state.total_input_tokens}in / {state.total_output_tokens}out", "cyan")
|
|
189
192
|
return True
|
|
193
|
+
elif cmd.split()[0] == "/graphrag":
|
|
194
|
+
from drydock import graphrag
|
|
195
|
+
|
|
196
|
+
cwd = config.get("cwd") or os.getcwd()
|
|
197
|
+
store = graphrag.default_store_path(cwd)
|
|
198
|
+
parts = raw.split() # raw = case-preserved, so paths survive
|
|
199
|
+
sub = parts[1].lower() if len(parts) > 1 else ""
|
|
200
|
+
rest = " ".join(parts[2:]).strip()
|
|
201
|
+
if sub == "build" and rest:
|
|
202
|
+
print_colored(f" Building knowledge base from {rest} …", "dim")
|
|
203
|
+
try:
|
|
204
|
+
stats = graphrag.build_index([rest], store, cwd=cwd)
|
|
205
|
+
print_colored(
|
|
206
|
+
f" ✓ {stats['files']} files · {stats['chunks']} chunks · "
|
|
207
|
+
f"{stats['entities']} entities · {stats['edges']} edges → {store}",
|
|
208
|
+
"green",
|
|
209
|
+
)
|
|
210
|
+
except Exception as e: # noqa: BLE001
|
|
211
|
+
print_colored(f" graphrag build failed: {e}", "red")
|
|
212
|
+
elif sub == "clear":
|
|
213
|
+
store.unlink(missing_ok=True)
|
|
214
|
+
print_colored(" Knowledge base cleared.", "green")
|
|
215
|
+
else:
|
|
216
|
+
index = graphrag.load_index(store)
|
|
217
|
+
if index is None:
|
|
218
|
+
print_colored(" No knowledge base. Build: /graphrag build <path>", "dim")
|
|
219
|
+
else:
|
|
220
|
+
print_colored(
|
|
221
|
+
f" Knowledge base: {len(index.get('chunks', []))} chunks · "
|
|
222
|
+
f"{len(index.get('entities', {}))} entities ({store})", "cyan")
|
|
223
|
+
return True
|
|
190
224
|
elif cmd in ("/quit", "/exit"):
|
|
191
225
|
raise KeyboardInterrupt
|
|
192
226
|
return False
|
|
@@ -246,6 +280,11 @@ def main():
|
|
|
246
280
|
parser.add_argument("--provider", help="Provider: vllm, ollama, lmstudio, openai")
|
|
247
281
|
parser.add_argument("--base-url", dest="base_url", help="Override API base URL")
|
|
248
282
|
parser.add_argument("--max-tokens", dest="max_tokens", type=int, help="Max response tokens")
|
|
283
|
+
parser.add_argument(
|
|
284
|
+
"--context-limit", dest="context_limit", type=int,
|
|
285
|
+
help="Model server context window (must match the server's -c / "
|
|
286
|
+
"--max-model-len; drives the ctx gauge + when compaction fires)",
|
|
287
|
+
)
|
|
249
288
|
parser.add_argument("--temperature", type=float)
|
|
250
289
|
parser.add_argument("--max-tool-calls", type=int, default=0, help="Max tool calls (0=unlimited)")
|
|
251
290
|
parser.add_argument("--force-first-tool", action="store_true", help="Force tool_choice=required on first turn")
|
|
@@ -268,6 +307,7 @@ def main():
|
|
|
268
307
|
"base_url": args.base_url,
|
|
269
308
|
"max_tokens": args.max_tokens,
|
|
270
309
|
"temperature": args.temperature,
|
|
310
|
+
"context_limit": args.context_limit,
|
|
271
311
|
}, cfg_path)
|
|
272
312
|
|
|
273
313
|
# First launch: ask the user for their model server URL + model name and
|
|
@@ -288,10 +328,11 @@ def main():
|
|
|
288
328
|
cfgmod.save_file(cfg, cfg_path)
|
|
289
329
|
|
|
290
330
|
config = {
|
|
331
|
+
# context_limit now comes from cfg (DEFAULTS < config.toml < --context-limit)
|
|
332
|
+
# — it drives the ctx gauge AND when compaction fires, so it must match the
|
|
333
|
+
# server's real -c. Previously hardcoded to 65536 here, which clobbered the
|
|
334
|
+
# config value and overflowed servers running a smaller window.
|
|
291
335
|
**cfg,
|
|
292
|
-
# Match the llama.cpp server's -c (gemma4 runs at 64k). Drives both the
|
|
293
|
-
# status-bar context gauge and when compaction kicks in.
|
|
294
|
-
"context_limit": 65536,
|
|
295
336
|
"max_tool_calls": args.max_tool_calls,
|
|
296
337
|
"force_first_tool": args.force_first_tool,
|
|
297
338
|
"_approve_all": args.dangerously_skip_permissions,
|
|
@@ -28,6 +28,12 @@ DEFAULTS: dict[str, object] = {
|
|
|
28
28
|
"base_url": "http://localhost:8000/v1",
|
|
29
29
|
"max_tokens": 8192, # 4096 truncated large file writes mid-JSON (→ _raw fail)
|
|
30
30
|
"temperature": 0.2,
|
|
31
|
+
# The model server's context window (llama.cpp -c / vLLM --max-model-len).
|
|
32
|
+
# Drives the ctx gauge AND when compaction fires (it compacts at ~60% of
|
|
33
|
+
# this). It MUST match your server: if it's too high, drydock overflows the
|
|
34
|
+
# real window BEFORE compacting (the server 400s) — set this to your -c
|
|
35
|
+
# value. Default 65536 matches the bundled gemma4 server (-c 65536).
|
|
36
|
+
"context_limit": 65536,
|
|
31
37
|
"theme": "harbor",
|
|
32
38
|
}
|
|
33
39
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""GraphRAG — a local, dependency-free knowledge graph for retrieval.
|
|
2
|
+
|
|
3
|
+
The user builds an index from their own docs/code; the agent then queries it
|
|
4
|
+
through the `Knowledge` tool while answering or coding, so it can ground its
|
|
5
|
+
work in project-specific information the model was never trained on.
|
|
6
|
+
|
|
7
|
+
Design (clean-room, stdlib only — no embeddings, no third-party deps, works
|
|
8
|
+
against any server):
|
|
9
|
+
• Ingest text files → split into chunks.
|
|
10
|
+
• Extract ENTITIES from each chunk (proper nouns, `code` terms, CamelCase /
|
|
11
|
+
snake_case identifiers, ACRONYMS) → graph NODES.
|
|
12
|
+
• Entities co-occurring in a chunk get an EDGE (weighted by co-occurrence) →
|
|
13
|
+
the graph. Each entity also points at the chunks it appears in.
|
|
14
|
+
• Query: pull the query's entities + keywords, score chunks by entity and
|
|
15
|
+
keyword overlap, then EXPAND one hop along the entity graph so strongly
|
|
16
|
+
related context comes along — the "graph" in GraphRAG. Return the top
|
|
17
|
+
chunks plus the related entities, both fed back to the model.
|
|
18
|
+
|
|
19
|
+
The index is a single JSON file so it's inspectable, portable, and gitignorable.
|
|
20
|
+
|
|
21
|
+
All logic original to Drydock.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
from collections import Counter, defaultdict
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
# Files we ingest as text. Everything else (binaries, images) is skipped.
|
|
32
|
+
_TEXT_EXT = {
|
|
33
|
+
".md", ".txt", ".rst", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".go",
|
|
34
|
+
".rs", ".c", ".h", ".cpp", ".hpp", ".cs", ".rb", ".php", ".sh", ".toml",
|
|
35
|
+
".yaml", ".yml", ".json", ".cfg", ".ini", ".sql", ".html", ".css", ".org",
|
|
36
|
+
".tex", ".csv", ".log", ".",
|
|
37
|
+
}
|
|
38
|
+
_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist",
|
|
39
|
+
"build", ".mypy_cache", ".pytest_cache", ".drydock"}
|
|
40
|
+
|
|
41
|
+
_STOPWORDS = frozenset("""
|
|
42
|
+
the a an and or but if then else for while of to in on at by with from into as
|
|
43
|
+
is are was were be been being do does did this that these those it its their
|
|
44
|
+
your you we they he she his her our not no yes can will would should could may
|
|
45
|
+
""".split())
|
|
46
|
+
|
|
47
|
+
# Entity patterns (kept simple + fast; tuned to surface useful nodes, not noise)
|
|
48
|
+
_RE_BACKTICK = re.compile(r"`([^`\n]{2,60})`")
|
|
49
|
+
_RE_IDENT = re.compile(r"\b([a-zA-Z_][a-zA-Z0-9_]*(?:[_./][a-zA-Z0-9_]+)+)\b")
|
|
50
|
+
_RE_CAMEL = re.compile(r"\b([A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+)\b")
|
|
51
|
+
_RE_PROPER = re.compile(r"\b([A-Z][a-zA-Z0-9]+(?:[ \t]+[A-Z][a-zA-Z0-9]+){0,3})\b")
|
|
52
|
+
_RE_ACRONYM = re.compile(r"\b([A-Z]{2,6})\b")
|
|
53
|
+
_RE_WORD = re.compile(r"[a-zA-Z0-9_]+")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def default_store_path(cwd: str) -> Path:
|
|
57
|
+
"""Project-local index (travels with the project, easy to .gitignore)."""
|
|
58
|
+
return Path(cwd) / ".drydock" / "graphrag.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_entities(text: str) -> list[str]:
|
|
62
|
+
"""Heuristic entity extraction → normalized (lowercased) entity keys."""
|
|
63
|
+
found: set[str] = set()
|
|
64
|
+
for rx in (_RE_BACKTICK, _RE_IDENT, _RE_CAMEL, _RE_PROPER, _RE_ACRONYM):
|
|
65
|
+
for m in rx.findall(text):
|
|
66
|
+
e = m.strip().lower()
|
|
67
|
+
# Drop trivial / stopword-only entities and overlong noise.
|
|
68
|
+
if len(e) < 3 or len(e) > 60:
|
|
69
|
+
continue
|
|
70
|
+
if all(w in _STOPWORDS for w in _RE_WORD.findall(e)):
|
|
71
|
+
continue
|
|
72
|
+
found.add(e)
|
|
73
|
+
return sorted(found)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _chunk_text(text: str, max_chars: int = 1200) -> list[str]:
|
|
77
|
+
"""Split into paragraph-ish chunks, capping size so a chunk stays focused."""
|
|
78
|
+
chunks: list[str] = []
|
|
79
|
+
buf: list[str] = []
|
|
80
|
+
size = 0
|
|
81
|
+
for para in re.split(r"\n\s*\n", text):
|
|
82
|
+
para = para.strip()
|
|
83
|
+
if not para:
|
|
84
|
+
continue
|
|
85
|
+
if size + len(para) > max_chars and buf:
|
|
86
|
+
chunks.append("\n\n".join(buf))
|
|
87
|
+
buf, size = [], 0
|
|
88
|
+
if len(para) > max_chars: # a single huge paragraph → hard-split
|
|
89
|
+
for i in range(0, len(para), max_chars):
|
|
90
|
+
chunks.append(para[i:i + max_chars])
|
|
91
|
+
continue
|
|
92
|
+
buf.append(para)
|
|
93
|
+
size += len(para)
|
|
94
|
+
if buf:
|
|
95
|
+
chunks.append("\n\n".join(buf))
|
|
96
|
+
return chunks
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _iter_text_files(paths: list[str]):
|
|
100
|
+
for p in paths:
|
|
101
|
+
path = Path(p)
|
|
102
|
+
if path.is_file():
|
|
103
|
+
yield path
|
|
104
|
+
elif path.is_dir():
|
|
105
|
+
for root, dirs, files in os.walk(path):
|
|
106
|
+
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
|
|
107
|
+
for f in files:
|
|
108
|
+
fp = Path(root) / f
|
|
109
|
+
if fp.suffix.lower() in _TEXT_EXT or fp.suffix == "":
|
|
110
|
+
yield fp
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_index(paths: list[str], store_path: str | Path, *, cwd: str = ".") -> dict:
|
|
114
|
+
"""Build (or rebuild) the knowledge graph from paths; persist to store_path.
|
|
115
|
+
|
|
116
|
+
Returns a stats dict: {files, chunks, entities, edges}.
|
|
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
|
+
|
|
123
|
+
for fp in _iter_text_files([str(Path(cwd) / p) if not os.path.isabs(p) else p
|
|
124
|
+
for p in paths]):
|
|
125
|
+
try:
|
|
126
|
+
text = fp.read_text("utf-8", "ignore")
|
|
127
|
+
except OSError:
|
|
128
|
+
continue
|
|
129
|
+
if not text.strip():
|
|
130
|
+
continue
|
|
131
|
+
files += 1
|
|
132
|
+
rel = os.path.relpath(str(fp), cwd)
|
|
133
|
+
for body in _chunk_text(text):
|
|
134
|
+
cid = len(chunks)
|
|
135
|
+
ents = extract_entities(body)
|
|
136
|
+
chunks.append({"id": cid, "source": rel, "text": body, "entities": ents})
|
|
137
|
+
for e in ents:
|
|
138
|
+
entity_chunks[e].add(cid)
|
|
139
|
+
for i, a in enumerate(ents): # co-occurrence edges within the chunk
|
|
140
|
+
for b in ents[i + 1:]:
|
|
141
|
+
edges[a][b] += 1
|
|
142
|
+
edges[b][a] += 1
|
|
143
|
+
|
|
144
|
+
index = {
|
|
145
|
+
"version": 1,
|
|
146
|
+
"chunks": chunks,
|
|
147
|
+
"entities": {e: sorted(cids) for e, cids in entity_chunks.items()},
|
|
148
|
+
"edges": {a: dict(nbrs) for a, nbrs in edges.items()},
|
|
149
|
+
}
|
|
150
|
+
sp = Path(store_path)
|
|
151
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
sp.write_text(json.dumps(index), encoding="utf-8")
|
|
153
|
+
return {
|
|
154
|
+
"files": files,
|
|
155
|
+
"chunks": len(chunks),
|
|
156
|
+
"entities": len(entity_chunks),
|
|
157
|
+
"edges": sum(len(v) for v in edges.values()) // 2,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_index(store_path: str | Path) -> dict | None:
|
|
162
|
+
sp = Path(store_path)
|
|
163
|
+
if not sp.exists():
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
return json.loads(sp.read_text("utf-8"))
|
|
167
|
+
except (OSError, ValueError):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def query_index(index: dict, query: str, *, k: int = 5, hops: bool = True) -> dict:
|
|
172
|
+
"""Retrieve the top-k chunks for a query, expanded one hop over the graph.
|
|
173
|
+
|
|
174
|
+
Returns {chunks: [{source, text, score}], related: [entities]}.
|
|
175
|
+
"""
|
|
176
|
+
chunks = index.get("chunks", [])
|
|
177
|
+
entities = index.get("entities", {})
|
|
178
|
+
edges = index.get("edges", {})
|
|
179
|
+
if not chunks:
|
|
180
|
+
return {"chunks": [], "related": []}
|
|
181
|
+
|
|
182
|
+
q_entities = set(extract_entities(query))
|
|
183
|
+
q_words = {w for w in (w.lower() for w in _RE_WORD.findall(query))
|
|
184
|
+
if len(w) > 2 and w not in _STOPWORDS}
|
|
185
|
+
|
|
186
|
+
# Match query entities to graph entities (exact + substring both ways).
|
|
187
|
+
matched: set[str] = set()
|
|
188
|
+
for qe in q_entities | q_words:
|
|
189
|
+
for ge in entities:
|
|
190
|
+
if qe == ge or qe in ge or ge in qe:
|
|
191
|
+
matched.add(ge)
|
|
192
|
+
|
|
193
|
+
# 1-hop graph expansion: pull the strongest neighbors of matched entities.
|
|
194
|
+
related: list[str] = []
|
|
195
|
+
expanded: set[str] = set(matched)
|
|
196
|
+
if hops:
|
|
197
|
+
for e in list(matched):
|
|
198
|
+
nbrs = sorted(edges.get(e, {}).items(), key=lambda kv: -kv[1])[:5]
|
|
199
|
+
for nbr, _w in nbrs:
|
|
200
|
+
expanded.add(nbr)
|
|
201
|
+
if nbr not in matched:
|
|
202
|
+
related.append(nbr)
|
|
203
|
+
|
|
204
|
+
# Score chunks: direct entity hits weigh most, then graph-neighbor hits,
|
|
205
|
+
# then raw keyword overlap (so retrieval still works if entities miss).
|
|
206
|
+
scores: dict[int, float] = defaultdict(float)
|
|
207
|
+
for e in matched:
|
|
208
|
+
for cid in entities.get(e, []):
|
|
209
|
+
scores[cid] += 3.0
|
|
210
|
+
for e in expanded - matched:
|
|
211
|
+
for cid in entities.get(e, []):
|
|
212
|
+
scores[cid] += 1.0
|
|
213
|
+
for c in chunks:
|
|
214
|
+
if q_words:
|
|
215
|
+
overlap = len(q_words & {w.lower() for w in _RE_WORD.findall(c["text"])})
|
|
216
|
+
if overlap:
|
|
217
|
+
scores[c["id"]] += 0.5 * overlap
|
|
218
|
+
|
|
219
|
+
ranked = sorted(scores.items(), key=lambda kv: -kv[1])[:k]
|
|
220
|
+
out_chunks = [
|
|
221
|
+
{"source": chunks[cid]["source"], "text": chunks[cid]["text"], "score": round(sc, 1)}
|
|
222
|
+
for cid, sc in ranked if sc > 0
|
|
223
|
+
]
|
|
224
|
+
# de-dup related, keep order, cap
|
|
225
|
+
seen: set[str] = set()
|
|
226
|
+
rel_unique = [r for r in related if not (r in seen or seen.add(r))][:12]
|
|
227
|
+
return {"chunks": out_chunks, "related": rel_unique}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def format_results(result: dict, query: str) -> str:
|
|
231
|
+
"""Render query results as the tool's string output for the model."""
|
|
232
|
+
chunks = result.get("chunks", [])
|
|
233
|
+
if not chunks:
|
|
234
|
+
return (
|
|
235
|
+
f"No knowledge-base matches for: {query!r}. The index may not cover "
|
|
236
|
+
"this topic — answer from your own knowledge, or ask the user to add "
|
|
237
|
+
"the relevant docs with /graphrag build <path>."
|
|
238
|
+
)
|
|
239
|
+
parts = [f"Knowledge base — {len(chunks)} relevant passage(s) for {query!r}:"]
|
|
240
|
+
for i, c in enumerate(chunks, 1):
|
|
241
|
+
parts.append(f"\n[{i}] (source: {c['source']}, score {c['score']})\n{c['text']}")
|
|
242
|
+
if result.get("related"):
|
|
243
|
+
parts.append("\nRelated entities in the graph: " + ", ".join(result["related"]))
|
|
244
|
+
return "\n".join(parts)
|
|
@@ -161,6 +161,66 @@ SCHEMAS = [
|
|
|
161
161
|
"required": ["prompt"],
|
|
162
162
|
},
|
|
163
163
|
},
|
|
164
|
+
{
|
|
165
|
+
"name": "WebSearch",
|
|
166
|
+
"description": (
|
|
167
|
+
"Search the internet and get back the top results (title, URL, "
|
|
168
|
+
"snippet). Use it for current events, docs, library/API details, "
|
|
169
|
+
"error messages, or anything you're unsure about or that may have "
|
|
170
|
+
"changed since training. Follow up with WebFetch to read a result in "
|
|
171
|
+
"full. Returns a clean message if the machine is offline."
|
|
172
|
+
),
|
|
173
|
+
"input_schema": {
|
|
174
|
+
"type": "object",
|
|
175
|
+
"properties": {
|
|
176
|
+
"query": {"type": "string", "description": "The search query."},
|
|
177
|
+
"k": {"type": "integer", "description": "Max results (default 5)."},
|
|
178
|
+
},
|
|
179
|
+
"required": ["query"],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"name": "WebFetch",
|
|
184
|
+
"description": (
|
|
185
|
+
"Fetch a web page (or any URL) and return its readable text content, "
|
|
186
|
+
"HTML stripped. Use it to read a page found via WebSearch or a URL the "
|
|
187
|
+
"user gave you. Returns a clean message if the machine is offline."
|
|
188
|
+
),
|
|
189
|
+
"input_schema": {
|
|
190
|
+
"type": "object",
|
|
191
|
+
"properties": {
|
|
192
|
+
"url": {"type": "string", "description": "The URL to fetch."},
|
|
193
|
+
"max_chars": {"type": "integer", "description": "Max chars to return (default 6000)."},
|
|
194
|
+
},
|
|
195
|
+
"required": ["url"],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"name": "Knowledge",
|
|
200
|
+
"description": (
|
|
201
|
+
"Search the user's KNOWLEDGE BASE (a GraphRAG index they built from "
|
|
202
|
+
"their own docs/code) for project-specific information you were not "
|
|
203
|
+
"trained on. Returns the most relevant passages plus related entities "
|
|
204
|
+
"from the graph. Use it BEFORE answering or coding when the task may "
|
|
205
|
+
"depend on the user's private/project knowledge (their APIs, specs, "
|
|
206
|
+
"data, conventions). If it returns no matches, the topic isn't in the "
|
|
207
|
+
"base — answer normally."
|
|
208
|
+
),
|
|
209
|
+
"input_schema": {
|
|
210
|
+
"type": "object",
|
|
211
|
+
"properties": {
|
|
212
|
+
"query": {
|
|
213
|
+
"type": "string",
|
|
214
|
+
"description": "What to look up, as a natural-language question or keywords.",
|
|
215
|
+
},
|
|
216
|
+
"k": {
|
|
217
|
+
"type": "integer",
|
|
218
|
+
"description": "Max passages to return (default 5).",
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
"required": ["query"],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
164
224
|
]
|
|
165
225
|
|
|
166
226
|
# ── Tool implementations ──────────────────────────────────────────────────
|
|
@@ -709,6 +769,68 @@ def tool_task(params: dict, config: dict) -> str:
|
|
|
709
769
|
return f"[sub-agent finished {steps} step(s) with no summary]"
|
|
710
770
|
|
|
711
771
|
|
|
772
|
+
def tool_websearch(params: dict, config: dict) -> str:
|
|
773
|
+
"""Search the internet (DuckDuckGo). Read-only; clean message when offline."""
|
|
774
|
+
from drydock import web
|
|
775
|
+
|
|
776
|
+
query = (params.get("query") or "").strip()
|
|
777
|
+
if not query:
|
|
778
|
+
return "Error: `WebSearch` needs a `query`."
|
|
779
|
+
try:
|
|
780
|
+
k = int(params.get("k") or 5)
|
|
781
|
+
except (TypeError, ValueError):
|
|
782
|
+
k = 5
|
|
783
|
+
try:
|
|
784
|
+
results = web.search(query, k=max(1, min(k, 10)))
|
|
785
|
+
except web.WebError as e:
|
|
786
|
+
return f"Web search unavailable: {e}. You appear to be offline — answer from your own knowledge."
|
|
787
|
+
return web.format_search(query, results)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def tool_webfetch(params: dict, config: dict) -> str:
|
|
791
|
+
"""Fetch a URL and return readable text. Read-only; clean message offline."""
|
|
792
|
+
from drydock import web
|
|
793
|
+
|
|
794
|
+
url = (params.get("url") or "").strip()
|
|
795
|
+
if not url:
|
|
796
|
+
return "Error: `WebFetch` needs a `url`."
|
|
797
|
+
try:
|
|
798
|
+
mc = int(params.get("max_chars") or 6000)
|
|
799
|
+
except (TypeError, ValueError):
|
|
800
|
+
mc = 6000
|
|
801
|
+
try:
|
|
802
|
+
text = web.fetch(url, max_chars=max(500, min(mc, 30000)))
|
|
803
|
+
except web.WebError as e:
|
|
804
|
+
return f"Could not fetch the page: {e}."
|
|
805
|
+
return text or f"(no readable text extracted from {url})"
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def tool_knowledge(params: dict, config: dict) -> str:
|
|
809
|
+
"""Query the project's GraphRAG knowledge base (built with /graphrag build).
|
|
810
|
+
Read-only; returns the most relevant passages plus related graph entities.
|
|
811
|
+
If no index exists, says so cleanly rather than erroring."""
|
|
812
|
+
from drydock import graphrag
|
|
813
|
+
|
|
814
|
+
query = (params.get("query") or "").strip()
|
|
815
|
+
if not query:
|
|
816
|
+
return "Error: `Knowledge` needs a `query` describing what to look up."
|
|
817
|
+
cwd = config.get("cwd") or os.getcwd()
|
|
818
|
+
store = config.get("graphrag_store") or graphrag.default_store_path(cwd)
|
|
819
|
+
index = graphrag.load_index(store)
|
|
820
|
+
if index is None:
|
|
821
|
+
return (
|
|
822
|
+
"No knowledge base has been built yet. The user can build one with "
|
|
823
|
+
"'/graphrag build <path>' (a file or directory of docs/code). Until "
|
|
824
|
+
"then, answer from your own knowledge."
|
|
825
|
+
)
|
|
826
|
+
try:
|
|
827
|
+
k = int(params.get("k") or 5)
|
|
828
|
+
except (TypeError, ValueError):
|
|
829
|
+
k = 5
|
|
830
|
+
result = graphrag.query_index(index, query, k=max(1, min(k, 15)))
|
|
831
|
+
return graphrag.format_results(result, query)
|
|
832
|
+
|
|
833
|
+
|
|
712
834
|
# ── Register all tools ────────────────────────────────────────────────────
|
|
713
835
|
|
|
714
836
|
_TOOLS = [
|
|
@@ -720,6 +842,9 @@ _TOOLS = [
|
|
|
720
842
|
("Grep", tool_grep, True),
|
|
721
843
|
("todo", tool_todo, False),
|
|
722
844
|
("task", tool_task, True),
|
|
845
|
+
("Knowledge", tool_knowledge, True),
|
|
846
|
+
("WebSearch", tool_websearch, True),
|
|
847
|
+
("WebFetch", tool_webfetch, True),
|
|
723
848
|
]
|
|
724
849
|
|
|
725
850
|
def register_all():
|
|
@@ -728,10 +853,13 @@ def register_all():
|
|
|
728
853
|
func = {
|
|
729
854
|
"Read": tool_read, "Write": tool_write, "Edit": tool_edit,
|
|
730
855
|
"Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
|
|
731
|
-
"todo": tool_todo, "task": tool_task,
|
|
856
|
+
"todo": tool_todo, "task": tool_task, "Knowledge": tool_knowledge,
|
|
857
|
+
"WebSearch": tool_websearch, "WebFetch": tool_webfetch,
|
|
732
858
|
}[name]
|
|
733
|
-
#
|
|
734
|
-
read_only = name in (
|
|
859
|
+
# These are read-only w.r.t. the parent's files.
|
|
860
|
+
read_only = name in (
|
|
861
|
+
"Read", "Glob", "Grep", "task", "Knowledge", "WebSearch", "WebFetch",
|
|
862
|
+
)
|
|
735
863
|
register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
|
|
736
864
|
|
|
737
865
|
register_all()
|