si-deckhand 0.1.0__py3-none-any.whl
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.
- deckhand/__init__.py +23 -0
- deckhand/agent.py +143 -0
- deckhand/cli.py +98 -0
- deckhand/indexer.py +309 -0
- deckhand/retriever.py +146 -0
- deckhand/task_batcher.py +129 -0
- deckhand/wiki.py +209 -0
- si_deckhand-0.1.0.dist-info/METADATA +106 -0
- si_deckhand-0.1.0.dist-info/RECORD +12 -0
- si_deckhand-0.1.0.dist-info/WHEEL +5 -0
- si_deckhand-0.1.0.dist-info/entry_points.txt +2 -0
- si_deckhand-0.1.0.dist-info/top_level.txt +1 -0
deckhand/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
deckhand — Lightweight local agent that indexes, retrieves, and wikis the workspace.
|
|
3
|
+
|
|
4
|
+
The deckhand is the crew member who knows where everything is.
|
|
5
|
+
File-first (exo-filed). Human-readable. No hidden vector DB.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Indexer",
|
|
13
|
+
"Retriever",
|
|
14
|
+
"WikiGenerator",
|
|
15
|
+
"TaskBatcher",
|
|
16
|
+
"Deckhand",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
from deckhand.agent import Deckhand
|
|
20
|
+
from deckhand.indexer import Indexer
|
|
21
|
+
from deckhand.retriever import Retriever
|
|
22
|
+
from deckhand.wiki import WikiGenerator
|
|
23
|
+
from deckhand.task_batcher import TaskBatcher
|
deckhand/agent.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Agent — the main deckhand loop.
|
|
2
|
+
|
|
3
|
+
Index → Study → Batch → Sleep → Repeat.
|
|
4
|
+
When no tasks are queued, study the workspace and update the wiki.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from deckhand.indexer import Indexer
|
|
14
|
+
from deckhand.retriever import Retriever
|
|
15
|
+
from deckhand.wiki import WikiGenerator
|
|
16
|
+
from deckhand.task_batcher import TaskBatcher
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Deckhand:
|
|
20
|
+
"""The main agent loop."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
workspace_root: str,
|
|
25
|
+
wiki_dir: str | None = None,
|
|
26
|
+
sleep_seconds: float = 300.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.root = workspace_root
|
|
29
|
+
self.wiki_dir = wiki_dir or str(Path(workspace_root) / "deckhand-wiki")
|
|
30
|
+
self.sleep_seconds = sleep_seconds
|
|
31
|
+
self.indexer = Indexer(workspace_root)
|
|
32
|
+
self.retriever: Retriever | None = None
|
|
33
|
+
self.wiki_gen: WikiGenerator | None = None
|
|
34
|
+
self.batcher = TaskBatcher(workspace_root)
|
|
35
|
+
self._running = False
|
|
36
|
+
|
|
37
|
+
def index(self) -> dict[str, Any]:
|
|
38
|
+
"""Run a full index pass."""
|
|
39
|
+
stats = self.indexer.index()
|
|
40
|
+
self.retriever = Retriever(self.indexer.files)
|
|
41
|
+
self.wiki_gen = WikiGenerator(self.indexer)
|
|
42
|
+
return stats
|
|
43
|
+
|
|
44
|
+
def search(self, query: str, top_k: int = 10) -> list[dict[str, Any]]:
|
|
45
|
+
"""Search the workspace."""
|
|
46
|
+
if not self.retriever:
|
|
47
|
+
self.index()
|
|
48
|
+
assert self.retriever is not None
|
|
49
|
+
results = self.retriever.search(query, top_k=top_k)
|
|
50
|
+
return [r.to_dict() for r in results]
|
|
51
|
+
|
|
52
|
+
def generate_wiki(self) -> list[str]:
|
|
53
|
+
"""Generate all wiki pages."""
|
|
54
|
+
if not self.wiki_gen:
|
|
55
|
+
self.index()
|
|
56
|
+
assert self.wiki_gen is not None
|
|
57
|
+
return self.wiki_gen.generate(self.wiki_dir)
|
|
58
|
+
|
|
59
|
+
def batch_tasks(self) -> str:
|
|
60
|
+
"""Collect and batch tasks."""
|
|
61
|
+
batch_path = str(Path(self.wiki_dir) / "BATCH.md")
|
|
62
|
+
return self.batcher.write_batch(batch_path)
|
|
63
|
+
|
|
64
|
+
def study(self) -> str:
|
|
65
|
+
"""Study a random repo and write observations."""
|
|
66
|
+
if not self.indexer.repos:
|
|
67
|
+
self.index()
|
|
68
|
+
|
|
69
|
+
# Pick a repo that hasn't been studied recently
|
|
70
|
+
repos_by_age = sorted(
|
|
71
|
+
self.indexer.repos.values(),
|
|
72
|
+
key=lambda r: r.last_modified,
|
|
73
|
+
)
|
|
74
|
+
if not repos_by_age:
|
|
75
|
+
return "nothing to study"
|
|
76
|
+
|
|
77
|
+
target = repos_by_age[0] # oldest modified
|
|
78
|
+
|
|
79
|
+
log_dir = Path(self.wiki_dir) / "logs"
|
|
80
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
log_path = log_dir / f"{time.strftime('%Y-%m-%d')}.md"
|
|
82
|
+
|
|
83
|
+
lines = [
|
|
84
|
+
f"# Deckhand Study Log — {time.strftime('%Y-%m-%d')}",
|
|
85
|
+
f"",
|
|
86
|
+
f"## Studied: `{target.name}`",
|
|
87
|
+
f"",
|
|
88
|
+
f"- Files: {target.file_count}",
|
|
89
|
+
f"- Health: {target.health}",
|
|
90
|
+
f"- Has tests: {target.has_tests}",
|
|
91
|
+
f"- Has README: {target.has_readme}",
|
|
92
|
+
f"- Languages: {target.languages}",
|
|
93
|
+
f"",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
if target.health == "untested":
|
|
97
|
+
lines.append("⚠️ This repo has code but no tests. Needs a test suite.")
|
|
98
|
+
elif target.health == "no-readme":
|
|
99
|
+
lines.append("❌ This repo has no README. Needs documentation.")
|
|
100
|
+
elif target.health == "stub":
|
|
101
|
+
lines.append("🔩 This repo is a stub (< 3 files). May be abandoned.")
|
|
102
|
+
else:
|
|
103
|
+
lines.append("✅ This repo looks healthy.")
|
|
104
|
+
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
with open(log_path, "a") as fh:
|
|
108
|
+
fh.write("\n".join(lines))
|
|
109
|
+
|
|
110
|
+
return str(log_path)
|
|
111
|
+
|
|
112
|
+
def run_once(self) -> dict[str, Any]:
|
|
113
|
+
"""Run one full cycle: index → wiki → batch → study."""
|
|
114
|
+
stats = self.index()
|
|
115
|
+
wiki_files = self.generate_wiki()
|
|
116
|
+
batch_path = self.batch_tasks()
|
|
117
|
+
study_log = self.study()
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"index": stats,
|
|
121
|
+
"wiki_files": wiki_files,
|
|
122
|
+
"batch": batch_path,
|
|
123
|
+
"study_log": study_log,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def run_forever(self) -> None:
|
|
127
|
+
"""Run the main loop forever."""
|
|
128
|
+
self._running = True
|
|
129
|
+
while self._running:
|
|
130
|
+
try:
|
|
131
|
+
result = self.run_once()
|
|
132
|
+
print(
|
|
133
|
+
f"[deckhand] indexed {result['index']['total_files']} files, "
|
|
134
|
+
f"wrote {len(result['wiki_files'])} wiki pages, "
|
|
135
|
+
f"batch at {result['batch']}"
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
print(f"[deckhand] error: {e}")
|
|
139
|
+
|
|
140
|
+
time.sleep(self.sleep_seconds)
|
|
141
|
+
|
|
142
|
+
def stop(self) -> None:
|
|
143
|
+
self._running = False
|
deckhand/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CLI for deckhand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from deckhand.agent import Deckhand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="deckhand",
|
|
14
|
+
description="🐑 The deckhand — indexes, retrieves, and wikis the workspace.",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
sub = parser.add_subparsers(dest="command")
|
|
18
|
+
|
|
19
|
+
# index
|
|
20
|
+
p_index = sub.add_parser("index", help="Index the workspace")
|
|
21
|
+
p_index.add_argument("workspace", help="Workspace root directory")
|
|
22
|
+
p_index.add_argument("--wiki", default=None, help="Wiki output directory")
|
|
23
|
+
|
|
24
|
+
# search
|
|
25
|
+
p_search = sub.add_parser("search", help="Search the workspace")
|
|
26
|
+
p_search.add_argument("workspace", help="Workspace root directory")
|
|
27
|
+
p_search.add_argument("query", help="Search query")
|
|
28
|
+
p_search.add_argument("--top", type=int, default=10, help="Number of results")
|
|
29
|
+
|
|
30
|
+
# wiki
|
|
31
|
+
p_wiki = sub.add_parser("wiki", help="Generate wiki pages")
|
|
32
|
+
p_wiki.add_argument("workspace", help="Workspace root directory")
|
|
33
|
+
p_wiki.add_argument("--output", default=None, help="Wiki output directory")
|
|
34
|
+
|
|
35
|
+
# batch
|
|
36
|
+
p_batch = sub.add_parser("batch", help="Collect and batch tasks")
|
|
37
|
+
p_batch.add_argument("workspace", help="Workspace root directory")
|
|
38
|
+
|
|
39
|
+
# study
|
|
40
|
+
p_study = sub.add_parser("study", help="Study a repo and write observations")
|
|
41
|
+
p_study.add_argument("workspace", help="Workspace root directory")
|
|
42
|
+
|
|
43
|
+
# run
|
|
44
|
+
p_run = sub.add_parser("run", help="Run the deckhand loop forever")
|
|
45
|
+
p_run.add_argument("workspace", help="Workspace root directory")
|
|
46
|
+
p_run.add_argument("--sleep", type=float, default=300, help="Sleep seconds between cycles")
|
|
47
|
+
|
|
48
|
+
args = parser.parse_args(argv)
|
|
49
|
+
|
|
50
|
+
if not args.command:
|
|
51
|
+
parser.print_help()
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
dh = Deckhand(
|
|
55
|
+
workspace_root=args.workspace,
|
|
56
|
+
wiki_dir=getattr(args, "wiki", None) or getattr(args, "output", None),
|
|
57
|
+
sleep_seconds=getattr(args, "sleep", 300),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if args.command == "index":
|
|
61
|
+
stats = dh.index()
|
|
62
|
+
print(f"Indexed {stats['total_files']} files across {stats['total_repos']} repos in {stats['elapsed_seconds']}s")
|
|
63
|
+
dh.generate_wiki()
|
|
64
|
+
print(f"Wiki written to {dh.wiki_dir}")
|
|
65
|
+
|
|
66
|
+
elif args.command == "search":
|
|
67
|
+
results = dh.search(args.query, top_k=args.top)
|
|
68
|
+
if not results:
|
|
69
|
+
print("No results found.")
|
|
70
|
+
for i, r in enumerate(results, 1):
|
|
71
|
+
print(f"{i}. [{r['score']:.2f}] {r['rel_path']}")
|
|
72
|
+
if r["snippet"]:
|
|
73
|
+
print(f" {r['snippet'][:150]}...")
|
|
74
|
+
|
|
75
|
+
elif args.command == "wiki":
|
|
76
|
+
dh.index()
|
|
77
|
+
files = dh.generate_wiki()
|
|
78
|
+
print(f"Wiki pages written:")
|
|
79
|
+
for f in files:
|
|
80
|
+
print(f" {f}")
|
|
81
|
+
|
|
82
|
+
elif args.command == "batch":
|
|
83
|
+
path = dh.batch_tasks()
|
|
84
|
+
print(f"Batch written to {path}")
|
|
85
|
+
|
|
86
|
+
elif args.command == "study":
|
|
87
|
+
log = dh.study()
|
|
88
|
+
print(f"Study log written to {log}")
|
|
89
|
+
|
|
90
|
+
elif args.command == "run":
|
|
91
|
+
print(f"Deckhand running on {args.workspace} (sleep={args.sleep}s)")
|
|
92
|
+
dh.run_forever()
|
|
93
|
+
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
deckhand/indexer.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Indexer — walks the workspace and builds a file index.
|
|
2
|
+
|
|
3
|
+
The index is exo-filed: stored as JSON + markdown that a human can read.
|
|
4
|
+
No hidden database. No opaque binary format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FileEntry:
|
|
20
|
+
"""One file in the index."""
|
|
21
|
+
path: str
|
|
22
|
+
rel_path: str
|
|
23
|
+
size: int
|
|
24
|
+
lines: int
|
|
25
|
+
suffix: str
|
|
26
|
+
sha256: str
|
|
27
|
+
modified: float
|
|
28
|
+
words: int = 0
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"path": self.path,
|
|
33
|
+
"rel_path": self.rel_path,
|
|
34
|
+
"size": self.size,
|
|
35
|
+
"lines": self.lines,
|
|
36
|
+
"suffix": self.suffix,
|
|
37
|
+
"sha256": self.sha256[:12],
|
|
38
|
+
"modified": self.modified,
|
|
39
|
+
"words": self.words,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RepoEntry:
|
|
45
|
+
"""One repo (or directory) in the index."""
|
|
46
|
+
name: str
|
|
47
|
+
path: str
|
|
48
|
+
has_git: bool
|
|
49
|
+
has_tests: bool
|
|
50
|
+
has_readme: bool
|
|
51
|
+
has_pyproject: bool
|
|
52
|
+
has_package_json: bool
|
|
53
|
+
has_cargo_toml: bool
|
|
54
|
+
file_count: int
|
|
55
|
+
total_size: int
|
|
56
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
57
|
+
last_modified: float = 0.0
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, Any]:
|
|
60
|
+
return {
|
|
61
|
+
"name": self.name,
|
|
62
|
+
"path": self.path,
|
|
63
|
+
"has_git": self.has_git,
|
|
64
|
+
"has_tests": self.has_tests,
|
|
65
|
+
"has_readme": self.has_readme,
|
|
66
|
+
"has_pyproject": self.has_pyproject,
|
|
67
|
+
"has_package_json": self.has_package_json,
|
|
68
|
+
"has_cargo_toml": self.has_cargo_toml,
|
|
69
|
+
"file_count": self.file_count,
|
|
70
|
+
"total_size": self.total_size,
|
|
71
|
+
"languages": self.languages,
|
|
72
|
+
"last_modified": self.last_modified,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def health(self) -> str:
|
|
77
|
+
"""Quick health indicator."""
|
|
78
|
+
if not self.has_readme:
|
|
79
|
+
return "no-readme"
|
|
80
|
+
if self.has_pyproject and not self.has_tests:
|
|
81
|
+
return "untested"
|
|
82
|
+
if self.file_count < 3:
|
|
83
|
+
return "stub"
|
|
84
|
+
return "ok"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Indexer:
|
|
88
|
+
"""Walks a workspace directory and builds an exo-filed index."""
|
|
89
|
+
|
|
90
|
+
SKIP_DIRS = frozenset({
|
|
91
|
+
".git", "__pycache__", "node_modules", ".pytest_cache",
|
|
92
|
+
"dist", "build", ".egg-info", ".venv", "venv", ".tox",
|
|
93
|
+
".wrangler", ".crush", ".swarm",
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
SKIP_FILES = frozenset({
|
|
97
|
+
".DS_Store", "Thumbs.db", ".gitkeep",
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
CODE_SUFFIXES = frozenset({
|
|
101
|
+
".py", ".rs", ".js", ".ts", ".go", ".rb", ".java",
|
|
102
|
+
".c", ".cpp", ".h", ".hpp", ".ex", ".exs", ".zig", ".lua",
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
DOC_SUFFIXES = frozenset({
|
|
106
|
+
".md", ".rst", ".txt", ".org",
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
CONFIG_SUFFIXES = frozenset({
|
|
110
|
+
".toml", ".yaml", ".yml", ".json", ".ini", ".cfg",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
def __init__(self, root: str, max_depth: int = 4) -> None:
|
|
114
|
+
self.root = Path(root).resolve()
|
|
115
|
+
self.max_depth = max_depth
|
|
116
|
+
self.files: list[FileEntry] = []
|
|
117
|
+
self.repos: dict[str, RepoEntry] = {}
|
|
118
|
+
self.indexed_at: float = 0.0
|
|
119
|
+
|
|
120
|
+
def index(self) -> dict[str, Any]:
|
|
121
|
+
"""Walk the workspace and build the index."""
|
|
122
|
+
self.files.clear()
|
|
123
|
+
self.repos.clear()
|
|
124
|
+
start = time.time()
|
|
125
|
+
|
|
126
|
+
for entry in self._walk():
|
|
127
|
+
self.files.append(entry)
|
|
128
|
+
|
|
129
|
+
self._build_repo_index()
|
|
130
|
+
self.indexed_at = time.time()
|
|
131
|
+
elapsed = self.indexed_at - start
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"root": str(self.root),
|
|
135
|
+
"indexed_at": self.indexed_at,
|
|
136
|
+
"elapsed_seconds": round(elapsed, 2),
|
|
137
|
+
"total_files": len(self.files),
|
|
138
|
+
"total_repos": len(self.repos),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def _walk(self) -> list[FileEntry]:
|
|
142
|
+
"""Walk directory tree, yield FileEntry objects."""
|
|
143
|
+
entries: list[FileEntry] = []
|
|
144
|
+
|
|
145
|
+
for dirpath, dirnames, filenames in os.walk(self.root):
|
|
146
|
+
# Prune skip dirs
|
|
147
|
+
dirnames[:] = [d for d in dirnames if d not in self.SKIP_DIRS]
|
|
148
|
+
|
|
149
|
+
# Check depth
|
|
150
|
+
rel = os.path.relpath(dirpath, self.root)
|
|
151
|
+
depth = 0 if rel == "." else rel.count(os.sep) + 1
|
|
152
|
+
if depth > self.max_depth:
|
|
153
|
+
dirnames.clear()
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
for fname in filenames:
|
|
157
|
+
if fname in self.SKIP_FILES:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
fpath = os.path.join(dirpath, fname)
|
|
161
|
+
if not os.path.isfile(fpath):
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
stat = os.stat(fpath)
|
|
166
|
+
except OSError:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
suffix = Path(fname).suffix.lower()
|
|
170
|
+
lines = 0
|
|
171
|
+
words = 0
|
|
172
|
+
|
|
173
|
+
if suffix in self.CODE_SUFFIXES or suffix in self.DOC_SUFFIXES:
|
|
174
|
+
try:
|
|
175
|
+
with open(fpath, "r", errors="ignore") as fh:
|
|
176
|
+
content = fh.read()
|
|
177
|
+
lines = content.count("\n") + 1
|
|
178
|
+
words = len(content.split())
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
sha = self._hash_file(fpath)
|
|
183
|
+
|
|
184
|
+
entries.append(FileEntry(
|
|
185
|
+
path=fpath,
|
|
186
|
+
rel_path=os.path.relpath(fpath, self.root),
|
|
187
|
+
size=stat.st_size,
|
|
188
|
+
lines=lines,
|
|
189
|
+
suffix=suffix,
|
|
190
|
+
sha256=sha,
|
|
191
|
+
modified=stat.st_mtime,
|
|
192
|
+
words=words,
|
|
193
|
+
))
|
|
194
|
+
|
|
195
|
+
return entries
|
|
196
|
+
|
|
197
|
+
def _hash_file(self, path: str, max_bytes: int = 65536) -> str:
|
|
198
|
+
"""Hash first 64KB of a file for identity."""
|
|
199
|
+
try:
|
|
200
|
+
h = hashlib.sha256()
|
|
201
|
+
with open(path, "rb") as fh:
|
|
202
|
+
h.update(fh.read(max_bytes))
|
|
203
|
+
return h.hexdigest()
|
|
204
|
+
except Exception:
|
|
205
|
+
return ""
|
|
206
|
+
|
|
207
|
+
def _build_repo_index(self) -> None:
|
|
208
|
+
"""Group files into repos (top-level directories with .git or source code)."""
|
|
209
|
+
repo_files: dict[str, list[FileEntry]] = {}
|
|
210
|
+
|
|
211
|
+
for f in self.files:
|
|
212
|
+
parts = f.rel_path.split(os.sep)
|
|
213
|
+
if len(parts) < 2:
|
|
214
|
+
continue # root-level file, skip
|
|
215
|
+
repo_name = parts[0]
|
|
216
|
+
repo_files.setdefault(repo_name, []).append(f)
|
|
217
|
+
|
|
218
|
+
for repo_name, files in repo_files.items():
|
|
219
|
+
repo_path = str(self.root / repo_name)
|
|
220
|
+
has_git = os.path.isdir(os.path.join(repo_path, ".git"))
|
|
221
|
+
has_tests = any(
|
|
222
|
+
"test" in f.rel_path.lower() or f.rel_path.endswith("__test__.py")
|
|
223
|
+
for f in files
|
|
224
|
+
)
|
|
225
|
+
has_readme = any(
|
|
226
|
+
os.path.basename(f.rel_path).upper().startswith("README")
|
|
227
|
+
for f in files
|
|
228
|
+
)
|
|
229
|
+
has_pyproject = any(
|
|
230
|
+
f.rel_path == f"{repo_name}/pyproject.toml"
|
|
231
|
+
for f in files
|
|
232
|
+
)
|
|
233
|
+
has_package_json = any(
|
|
234
|
+
f.rel_path == f"{repo_name}/package.json"
|
|
235
|
+
for f in files
|
|
236
|
+
)
|
|
237
|
+
has_cargo = any(
|
|
238
|
+
f.rel_path == f"{repo_name}/Cargo.toml"
|
|
239
|
+
for f in files
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
languages: dict[str, int] = {}
|
|
243
|
+
total_size = 0
|
|
244
|
+
last_mod = 0.0
|
|
245
|
+
|
|
246
|
+
for f in files:
|
|
247
|
+
if f.suffix in self.CODE_SUFFIXES:
|
|
248
|
+
lang = f.suffix.lstrip(".")
|
|
249
|
+
languages[lang] = languages.get(lang, 0) + f.lines
|
|
250
|
+
total_size += f.size
|
|
251
|
+
last_mod = max(last_mod, f.modified)
|
|
252
|
+
|
|
253
|
+
self.repos[repo_name] = RepoEntry(
|
|
254
|
+
name=repo_name,
|
|
255
|
+
path=repo_path,
|
|
256
|
+
has_git=has_git,
|
|
257
|
+
has_tests=has_tests,
|
|
258
|
+
has_readme=has_readme,
|
|
259
|
+
has_pyproject=has_pyproject,
|
|
260
|
+
has_package_json=has_package_json,
|
|
261
|
+
has_cargo_toml=has_cargo,
|
|
262
|
+
file_count=len(files),
|
|
263
|
+
total_size=total_size,
|
|
264
|
+
languages=languages,
|
|
265
|
+
last_modified=last_mod,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def save(self, output_dir: str) -> None:
|
|
269
|
+
"""Save index as exo-filed JSON + markdown."""
|
|
270
|
+
out = Path(output_dir)
|
|
271
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
|
|
273
|
+
# JSON index (machine-readable)
|
|
274
|
+
index_data = {
|
|
275
|
+
"root": str(self.root),
|
|
276
|
+
"indexed_at": self.indexed_at,
|
|
277
|
+
"total_files": len(self.files),
|
|
278
|
+
"total_repos": len(self.repos),
|
|
279
|
+
"files": [f.to_dict() for f in self.files],
|
|
280
|
+
"repos": {n: r.to_dict() for n, r in self.repos.items()},
|
|
281
|
+
}
|
|
282
|
+
with open(out / "index.json", "w") as fh:
|
|
283
|
+
json.dump(index_data, fh, indent=2)
|
|
284
|
+
|
|
285
|
+
# Markdown summary (human-readable)
|
|
286
|
+
lines = [
|
|
287
|
+
f"# Workspace Index",
|
|
288
|
+
f"",
|
|
289
|
+
f"**Root:** `{self.root}`",
|
|
290
|
+
f"**Indexed:** {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime(self.indexed_at))}",
|
|
291
|
+
f"**Files:** {len(self.files):,} | **Repos:** {len(self.repos)}",
|
|
292
|
+
f"",
|
|
293
|
+
f"## Repos",
|
|
294
|
+
f"",
|
|
295
|
+
f"| Repo | Files | Health | Git | Tests | README | Languages |",
|
|
296
|
+
f"|------|-------|--------|-----|-------|--------|-----------|",
|
|
297
|
+
]
|
|
298
|
+
for name in sorted(self.repos):
|
|
299
|
+
r = self.repos[name]
|
|
300
|
+
langs = ", ".join(sorted(r.languages, key=lambda x: -r.languages[x])[:3])
|
|
301
|
+
lines.append(
|
|
302
|
+
f"| `{name}` | {r.file_count} | {r.health} | "
|
|
303
|
+
f"{'✓' if r.has_git else '—'} | "
|
|
304
|
+
f"{'✓' if r.has_tests else '—'} | "
|
|
305
|
+
f"{'✓' if r.has_readme else '—'} | "
|
|
306
|
+
f"{langs} |"
|
|
307
|
+
)
|
|
308
|
+
with open(out / "INDEX.md", "w") as fh:
|
|
309
|
+
fh.write("\n".join(lines))
|
deckhand/retriever.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Retriever — BM25 search over the exo-filed index.
|
|
2
|
+
|
|
3
|
+
Pure Python. No dependencies. No database. Just ranking.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from deckhand.indexer import FileEntry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SearchResult:
|
|
19
|
+
"""A single search result."""
|
|
20
|
+
path: str
|
|
21
|
+
rel_path: str
|
|
22
|
+
score: float
|
|
23
|
+
snippet: str = ""
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"path": self.path,
|
|
28
|
+
"rel_path": self.rel_path,
|
|
29
|
+
"score": round(self.score, 4),
|
|
30
|
+
"snippet": self.snippet[:200],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Retriever:
|
|
35
|
+
"""BM25 search over indexed files."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, files: list[FileEntry], k1: float = 1.5, b: float = 0.75) -> None:
|
|
38
|
+
self.k1 = k1
|
|
39
|
+
self.b = b
|
|
40
|
+
self.files = [f for f in files if f.words > 0]
|
|
41
|
+
self._build_index()
|
|
42
|
+
|
|
43
|
+
def _tokenize(self, text: str) -> list[str]:
|
|
44
|
+
return re.findall(r"\w+", text.lower())
|
|
45
|
+
|
|
46
|
+
def _build_index(self) -> None:
|
|
47
|
+
"""Build BM25 statistics."""
|
|
48
|
+
self.doc_tokens: list[list[str]] = []
|
|
49
|
+
self.doc_freq: Counter[str] = Counter()
|
|
50
|
+
self.doc_len: list[int] = []
|
|
51
|
+
self.total_docs = len(self.files)
|
|
52
|
+
self.avg_len: float = 0.0
|
|
53
|
+
self.term_freqs: list[Counter[str]] = []
|
|
54
|
+
|
|
55
|
+
for f in self.files:
|
|
56
|
+
try:
|
|
57
|
+
with open(f.path, "r", errors="ignore") as fh:
|
|
58
|
+
content = fh.read()
|
|
59
|
+
except Exception:
|
|
60
|
+
content = ""
|
|
61
|
+
|
|
62
|
+
tokens = self._tokenize(content)
|
|
63
|
+
self.doc_tokens.append(tokens)
|
|
64
|
+
tf = Counter(tokens)
|
|
65
|
+
self.term_freqs.append(tf)
|
|
66
|
+
|
|
67
|
+
for term in tf:
|
|
68
|
+
self.doc_freq[term] += 1
|
|
69
|
+
|
|
70
|
+
self.doc_len.append(len(tokens))
|
|
71
|
+
|
|
72
|
+
self.avg_len = sum(self.doc_len) / max(1, self.total_docs)
|
|
73
|
+
|
|
74
|
+
def _idf(self, term: str) -> float:
|
|
75
|
+
df = self.doc_freq.get(term, 0)
|
|
76
|
+
if df == 0:
|
|
77
|
+
return 0.0
|
|
78
|
+
return math.log(1 + (self.total_docs - df + 0.5) / (df + 0.5))
|
|
79
|
+
|
|
80
|
+
def _bm25_score(self, query_terms: list[str], doc_idx: int) -> float:
|
|
81
|
+
score = 0.0
|
|
82
|
+
tf = self.term_freqs[doc_idx]
|
|
83
|
+
dl = self.doc_len[doc_idx]
|
|
84
|
+
|
|
85
|
+
for term in query_terms:
|
|
86
|
+
idf = self._idf(term)
|
|
87
|
+
if idf == 0:
|
|
88
|
+
continue
|
|
89
|
+
f = tf.get(term, 0)
|
|
90
|
+
if f == 0:
|
|
91
|
+
continue
|
|
92
|
+
numerator = f * (self.k1 + 1)
|
|
93
|
+
denominator = f + self.k1 * (1 - self.b + self.b * dl / max(1, self.avg_len))
|
|
94
|
+
score += idf * numerator / denominator
|
|
95
|
+
|
|
96
|
+
return score
|
|
97
|
+
|
|
98
|
+
def search(self, query: str, top_k: int = 10) -> list[SearchResult]:
|
|
99
|
+
"""Search the corpus. Returns ranked results."""
|
|
100
|
+
query_terms = self._tokenize(query)
|
|
101
|
+
if not query_terms:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
scored: list[tuple[int, float]] = []
|
|
105
|
+
for i in range(self.total_docs):
|
|
106
|
+
s = self._bm25_score(query_terms, i)
|
|
107
|
+
if s > 0:
|
|
108
|
+
scored.append((i, s))
|
|
109
|
+
|
|
110
|
+
scored.sort(key=lambda x: -x[1])
|
|
111
|
+
|
|
112
|
+
results: list[SearchResult] = []
|
|
113
|
+
for idx, score in scored[:top_k]:
|
|
114
|
+
f = self.files[idx]
|
|
115
|
+
snippet = self._snippet(idx, query_terms)
|
|
116
|
+
results.append(SearchResult(
|
|
117
|
+
path=f.path,
|
|
118
|
+
rel_path=f.rel_path,
|
|
119
|
+
score=score,
|
|
120
|
+
snippet=snippet,
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
return results
|
|
124
|
+
|
|
125
|
+
def _snippet(self, doc_idx: int, query_terms: list[str], context: int = 50) -> str:
|
|
126
|
+
"""Extract a snippet around the first query term match."""
|
|
127
|
+
tokens = self.doc_tokens[doc_idx]
|
|
128
|
+
query_set = set(query_terms)
|
|
129
|
+
|
|
130
|
+
for i, tok in enumerate(tokens):
|
|
131
|
+
if tok in query_set:
|
|
132
|
+
start = max(0, i - context)
|
|
133
|
+
end = min(len(tokens), i + context)
|
|
134
|
+
snippet = " ".join(tokens[start:end])
|
|
135
|
+
return f"...{snippet}..."
|
|
136
|
+
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
def stats(self) -> dict[str, Any]:
|
|
140
|
+
"""Corpus statistics."""
|
|
141
|
+
return {
|
|
142
|
+
"total_docs": self.total_docs,
|
|
143
|
+
"avg_doc_length": round(self.avg_len, 1),
|
|
144
|
+
"total_terms": sum(self.doc_len),
|
|
145
|
+
"unique_terms": len(self.doc_freq),
|
|
146
|
+
}
|
deckhand/task_batcher.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""TaskBatcher — collects tasks from multiple sources into one batch.
|
|
2
|
+
|
|
3
|
+
Reduces director API calls by batching small tasks into a single list.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskBatcher:
|
|
15
|
+
"""Collects tasks from workspace files into a single batch."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, workspace_root: str) -> None:
|
|
18
|
+
self.root = Path(workspace_root)
|
|
19
|
+
|
|
20
|
+
def collect(self) -> dict[str, Any]:
|
|
21
|
+
"""Collect tasks from all sources."""
|
|
22
|
+
tasks: list[dict[str, str]] = []
|
|
23
|
+
sources: dict[str, int] = {}
|
|
24
|
+
|
|
25
|
+
# From PLANNER_QUEUE.md (checkboxes)
|
|
26
|
+
planner_tasks = self._read_checkboxes("PLANNER_QUEUE.md")
|
|
27
|
+
for t in planner_tasks:
|
|
28
|
+
tasks.append({"source": "PLANNER_QUEUE", "task": t})
|
|
29
|
+
sources["PLANNER_QUEUE"] = len(planner_tasks)
|
|
30
|
+
|
|
31
|
+
# From OPEN_QUESTIONS.md (question bullets)
|
|
32
|
+
questions = self._read_questions("OPEN_QUESTIONS.md")
|
|
33
|
+
for q in questions:
|
|
34
|
+
tasks.append({"source": "OPEN_QUESTION", "task": q})
|
|
35
|
+
sources["OPEN_QUESTIONS"] = len(questions)
|
|
36
|
+
|
|
37
|
+
# From recent git commits (what just changed)
|
|
38
|
+
recent = self._recent_changes()
|
|
39
|
+
for c in recent:
|
|
40
|
+
tasks.append({"source": "RECENT_CHANGE", "task": c})
|
|
41
|
+
sources["RECENT_CHANGES"] = len(recent)
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"collected_at": time.time(),
|
|
45
|
+
"total_tasks": len(tasks),
|
|
46
|
+
"sources": sources,
|
|
47
|
+
"tasks": tasks,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def _read_checkboxes(self, filename: str) -> list[str]:
|
|
51
|
+
"""Read unchecked items from a markdown file."""
|
|
52
|
+
path = self.root / filename
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
items: list[str] = []
|
|
57
|
+
with open(path) as fh:
|
|
58
|
+
for line in fh:
|
|
59
|
+
line = line.strip()
|
|
60
|
+
if line.startswith("- [ ]"):
|
|
61
|
+
items.append(line[5:].strip())
|
|
62
|
+
return items
|
|
63
|
+
|
|
64
|
+
def _read_questions(self, filename: str) -> list[str]:
|
|
65
|
+
"""Read question bullets from OPEN_QUESTIONS.md."""
|
|
66
|
+
path = self.root / filename
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
items: list[str] = []
|
|
71
|
+
with open(path) as fh:
|
|
72
|
+
for line in fh:
|
|
73
|
+
line = line.strip()
|
|
74
|
+
if line.startswith("- [") and "UTC]" in line:
|
|
75
|
+
items.append(line)
|
|
76
|
+
return items
|
|
77
|
+
|
|
78
|
+
def _recent_changes(self, hours: float = 6.0) -> list[str]:
|
|
79
|
+
"""Find files modified in the last N hours across repos."""
|
|
80
|
+
cutoff = time.time() - hours * 3600
|
|
81
|
+
changes: list[str] = []
|
|
82
|
+
|
|
83
|
+
for entry in self.root.iterdir():
|
|
84
|
+
if not entry.is_dir() or entry.name.startswith("."):
|
|
85
|
+
continue
|
|
86
|
+
git_dir = entry / ".git"
|
|
87
|
+
if not git_dir.exists():
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
for dirpath, dirnames, filenames in os.walk(entry):
|
|
91
|
+
dirnames[:] = [d for d in dirnames if d not in {".git", "__pycache__", "node_modules", "dist", "build"}]
|
|
92
|
+
for fname in filenames:
|
|
93
|
+
fpath = os.path.join(dirpath, fname)
|
|
94
|
+
try:
|
|
95
|
+
if os.path.getmtime(fpath) > cutoff:
|
|
96
|
+
rel = os.path.relpath(fpath, self.root)
|
|
97
|
+
changes.append(f"modified: {rel}")
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
return changes[:50] # cap at 50
|
|
102
|
+
|
|
103
|
+
def write_batch(self, output_path: str) -> str:
|
|
104
|
+
"""Write collected tasks as a single BATCH.md file."""
|
|
105
|
+
data = self.collect()
|
|
106
|
+
|
|
107
|
+
lines = [
|
|
108
|
+
"# Task Batch",
|
|
109
|
+
"",
|
|
110
|
+
f"*Collected by deckhand at {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime(data['collected_at']))}*",
|
|
111
|
+
f"",
|
|
112
|
+
f"**Total tasks:** {data['total_tasks']}",
|
|
113
|
+
f"**Sources:** {', '.join(f'{k}({v})' for k,v in data['sources'].items())}",
|
|
114
|
+
f"",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
current_source = None
|
|
118
|
+
for task in data["tasks"]:
|
|
119
|
+
if task["source"] != current_source:
|
|
120
|
+
current_source = task["source"]
|
|
121
|
+
lines.append(f"\n## {current_source}\n")
|
|
122
|
+
lines.append(f"- {task['task']}")
|
|
123
|
+
|
|
124
|
+
lines.append("")
|
|
125
|
+
|
|
126
|
+
with open(output_path, "w") as fh:
|
|
127
|
+
fh.write("\n".join(lines))
|
|
128
|
+
|
|
129
|
+
return output_path
|
deckhand/wiki.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""WikiGenerator — builds human-readable wiki pages from the index.
|
|
2
|
+
|
|
3
|
+
Exo-filed: the wiki IS the interface. A human can cat it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from deckhand.indexer import Indexer, RepoEntry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WikiGenerator:
|
|
17
|
+
"""Generates wiki pages from the index."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, indexer: Indexer) -> None:
|
|
20
|
+
self.indexer = indexer
|
|
21
|
+
|
|
22
|
+
def generate(self, output_dir: str) -> list[str]:
|
|
23
|
+
"""Generate all wiki pages. Returns list of files written."""
|
|
24
|
+
out = Path(output_dir)
|
|
25
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
written: list[str] = []
|
|
28
|
+
written.append(self._write_repos(out))
|
|
29
|
+
written.append(self._write_connections(out))
|
|
30
|
+
written.append(self._write_glossary(out))
|
|
31
|
+
written.append(self._write_status(out))
|
|
32
|
+
|
|
33
|
+
return written
|
|
34
|
+
|
|
35
|
+
def _write_repos(self, out: Path) -> str:
|
|
36
|
+
"""REPOS.md — one-line summary of every repo."""
|
|
37
|
+
lines = [
|
|
38
|
+
"# Fleet Repos",
|
|
39
|
+
"",
|
|
40
|
+
f"*Generated by deckhand at {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())}*",
|
|
41
|
+
"",
|
|
42
|
+
f"**Total repos:** {len(self.indexer.repos)}",
|
|
43
|
+
f"**Total files:** {len(self.indexer.files):,}",
|
|
44
|
+
"",
|
|
45
|
+
"| Repo | Files | Health | Git | Tests | README | Py | JS | Rust | Top Languages |",
|
|
46
|
+
"|------|-------|--------|-----|-------|--------|----|----|------|---------------|",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
for name in sorted(self.indexer.repos):
|
|
50
|
+
r = self.indexer.repos[name]
|
|
51
|
+
langs = sorted(r.languages, key=lambda x: -r.languages[x])[:3]
|
|
52
|
+
lang_str = ", ".join(f"{l}({r.languages[l]}L)" for l in langs) if langs else "—"
|
|
53
|
+
health_emoji = {"ok": "✅", "untested": "⚠️", "stub": "🔩", "no-readme": "❌"}.get(r.health, "?")
|
|
54
|
+
lines.append(
|
|
55
|
+
f"| `{name}` | {r.file_count} | {health_emoji} {r.health} | "
|
|
56
|
+
f"{'✓' if r.has_git else '—'} | "
|
|
57
|
+
f"{'✓' if r.has_tests else '—'} | "
|
|
58
|
+
f"{'✓' if r.has_readme else '—'} | "
|
|
59
|
+
f"{'✓' if r.has_pyproject else '—'} | "
|
|
60
|
+
f"{'✓' if r.has_package_json else '—'} | "
|
|
61
|
+
f"{'✓' if r.has_cargo_toml else '—'} | "
|
|
62
|
+
f"{lang_str} |"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
path = str(out / "REPOS.md")
|
|
66
|
+
with open(path, "w") as fh:
|
|
67
|
+
fh.write("\n".join(lines))
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
def _write_connections(self, out: Path) -> str:
|
|
71
|
+
"""CONNECTIONS.md — cross-reference graph."""
|
|
72
|
+
lines = [
|
|
73
|
+
"# Repo Connections",
|
|
74
|
+
"",
|
|
75
|
+
"*Which repos reference which. Generated by deckhand.*",
|
|
76
|
+
"",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
# Find cross-references by searching for repo names in other repos' files
|
|
80
|
+
repo_names = sorted(self.indexer.repos.keys())
|
|
81
|
+
connections: dict[str, list[str]] = {n: [] for n in repo_names}
|
|
82
|
+
|
|
83
|
+
for f in self.indexer.files:
|
|
84
|
+
parts = f.rel_path.split(os.sep)
|
|
85
|
+
if len(parts) < 2:
|
|
86
|
+
continue
|
|
87
|
+
source_repo = parts[0]
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
with open(f.path, "r", errors="ignore") as fh:
|
|
91
|
+
content = fh.read().lower()
|
|
92
|
+
except Exception:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
for target_repo in repo_names:
|
|
96
|
+
if target_repo == source_repo:
|
|
97
|
+
continue
|
|
98
|
+
if target_repo.lower() in content:
|
|
99
|
+
if target_repo not in connections[source_repo]:
|
|
100
|
+
connections[source_repo].append(target_repo)
|
|
101
|
+
|
|
102
|
+
for repo in sorted(connections):
|
|
103
|
+
targets = connections[repo]
|
|
104
|
+
if targets:
|
|
105
|
+
links = ", ".join(f"`{t}`" for t in sorted(targets))
|
|
106
|
+
lines.append(f"- **`{repo}`** → {links}")
|
|
107
|
+
else:
|
|
108
|
+
lines.append(f"- **`{repo}`** → (isolated)")
|
|
109
|
+
|
|
110
|
+
path = str(out / "CONNECTIONS.md")
|
|
111
|
+
with open(path, "w") as fh:
|
|
112
|
+
fh.write("\n".join(lines))
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
def _write_glossary(self, out: Path) -> str:
|
|
116
|
+
"""GLOSSARY.md — top terms across the workspace."""
|
|
117
|
+
from collections import Counter
|
|
118
|
+
|
|
119
|
+
# Simple word frequency (not TF-IDF — that's the Retriever's job)
|
|
120
|
+
word_freq: Counter[str] = Counter()
|
|
121
|
+
stop = frozenset({
|
|
122
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been",
|
|
123
|
+
"to", "of", "in", "for", "on", "at", "by", "with", "from",
|
|
124
|
+
"and", "or", "not", "but", "if", "then", "else", "when",
|
|
125
|
+
"this", "that", "these", "those", "it", "its", "they", "them",
|
|
126
|
+
"their", "there", "here", "which", "who", "what", "where",
|
|
127
|
+
"import", "from", "def", "class", "return", "self", "none",
|
|
128
|
+
"true", "false", "raise", "try", "except", "with", "as",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
import re
|
|
132
|
+
for f in self.indexer.files:
|
|
133
|
+
if f.words == 0:
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
with open(f.path, "r", errors="ignore") as fh:
|
|
137
|
+
content = fh.read()
|
|
138
|
+
except Exception:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
words = re.findall(r"\b[a-z_]{4,30}\b", content.lower())
|
|
142
|
+
for w in words:
|
|
143
|
+
if w not in stop:
|
|
144
|
+
word_freq[w] += 1
|
|
145
|
+
|
|
146
|
+
lines = [
|
|
147
|
+
"# Workspace Glossary",
|
|
148
|
+
"",
|
|
149
|
+
"*Top 100 terms across the workspace. Generated by deckhand.*",
|
|
150
|
+
"",
|
|
151
|
+
"| Term | Frequency |",
|
|
152
|
+
"|------|-----------|",
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
for term, freq in word_freq.most_common(100):
|
|
156
|
+
lines.append(f"| `{term}` | {freq:,} |")
|
|
157
|
+
|
|
158
|
+
path = str(out / "GLOSSARY.md")
|
|
159
|
+
with open(path, "w") as fh:
|
|
160
|
+
fh.write("\n".join(lines))
|
|
161
|
+
return path
|
|
162
|
+
|
|
163
|
+
def _write_status(self, out: Path) -> str:
|
|
164
|
+
"""STATUS.md — health of each repo."""
|
|
165
|
+
lines = [
|
|
166
|
+
"# Fleet Status",
|
|
167
|
+
"",
|
|
168
|
+
f"*Generated by deckhand at {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())}*",
|
|
169
|
+
"",
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
healthy = [r for r in self.indexer.repos.values() if r.health == "ok"]
|
|
173
|
+
untested = [r for r in self.indexer.repos.values() if r.health == "untested"]
|
|
174
|
+
stubs = [r for r in self.indexer.repos.values() if r.health == "stub"]
|
|
175
|
+
no_readme = [r for r in self.indexer.repos.values() if r.health == "no-readme"]
|
|
176
|
+
|
|
177
|
+
lines.append(f"## Summary")
|
|
178
|
+
lines.append(f"")
|
|
179
|
+
lines.append(f"- ✅ **Healthy:** {len(healthy)}")
|
|
180
|
+
lines.append(f"- ⚠️ **Untested (has code, no tests):** {len(untested)}")
|
|
181
|
+
lines.append(f"- 🔩 **Stub (< 3 files):** {len(stubs)}")
|
|
182
|
+
lines.append(f"- ❌ **No README:** {len(no_readme)}")
|
|
183
|
+
lines.append(f"")
|
|
184
|
+
|
|
185
|
+
if untested:
|
|
186
|
+
lines.append(f"## Needs Tests")
|
|
187
|
+
lines.append(f"")
|
|
188
|
+
for r in sorted(untested, key=lambda x: -x.file_count):
|
|
189
|
+
lines.append(f"- `{r.name}` ({r.file_count} files, {sum(r.languages.values())} LOC)")
|
|
190
|
+
lines.append("")
|
|
191
|
+
|
|
192
|
+
if no_readme:
|
|
193
|
+
lines.append(f"## Needs README")
|
|
194
|
+
lines.append(f"")
|
|
195
|
+
for r in sorted(no_readme, key=lambda x: -x.file_count):
|
|
196
|
+
lines.append(f"- `{r.name}` ({r.file_count} files)")
|
|
197
|
+
lines.append("")
|
|
198
|
+
|
|
199
|
+
if stubs:
|
|
200
|
+
lines.append(f"## Stubs (may be abandoned)")
|
|
201
|
+
lines.append(f"")
|
|
202
|
+
for r in sorted(stubs, key=lambda x: -x.file_count):
|
|
203
|
+
lines.append(f"- `{r.name}` ({r.file_count} files)")
|
|
204
|
+
lines.append("")
|
|
205
|
+
|
|
206
|
+
path = str(out / "STATUS.md")
|
|
207
|
+
with open(path, "w") as fh:
|
|
208
|
+
fh.write("\n".join(lines))
|
|
209
|
+
return path
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: si-deckhand
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight local agent that indexes, retrieves, and wikis the workspace. The crew member who knows where everything is.
|
|
5
|
+
Author: SuperInstance
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: agent,retriever,wiki,index,exo-filed,local
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: System :: Systems Administration
|
|
12
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Provides-Extra: vector
|
|
16
|
+
Requires-Dist: numpy>=1.20; extra == "vector"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# deckhand
|
|
22
|
+
|
|
23
|
+
*The crew member who knows where everything is.*
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
**deckhand** is a lightweight local agent that indexes, retrieves, and wikis your workspace. File-first (exo-filed). Human-readable. No hidden database.
|
|
28
|
+
|
|
29
|
+
## What it does
|
|
30
|
+
|
|
31
|
+
- **Indexes** every file in your workspace (code, docs, configs)
|
|
32
|
+
- **Builds wiki pages** that a human can `cat` — REPOS.md, CONNECTIONS.md, GLOSSARY.md, STATUS.md
|
|
33
|
+
- **Searches** via BM25 (pure Python, no dependencies)
|
|
34
|
+
- **Batches tasks** from PLANNER_QUEUE.md, OPEN_QUESTIONS.md, and recent file changes
|
|
35
|
+
- **Studies** repos off-watch — writes daily observation logs
|
|
36
|
+
- **Runs forever** in a loop: index → wiki → batch → study → sleep → repeat
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install deckhand
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Index the workspace and generate wiki
|
|
48
|
+
deckhand index /path/to/workspace
|
|
49
|
+
|
|
50
|
+
# Search for something
|
|
51
|
+
deckhand search /path/to/workspace "conservation law"
|
|
52
|
+
|
|
53
|
+
# Generate just the wiki
|
|
54
|
+
deckhand wiki /path/to/workspace
|
|
55
|
+
|
|
56
|
+
# Collect tasks into a batch
|
|
57
|
+
deckhand batch /path/to/workspace
|
|
58
|
+
|
|
59
|
+
# Run the deckhand loop forever (index → wiki → batch → study → sleep)
|
|
60
|
+
deckhand run /path/to/workspace --sleep 300
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## The wiki output
|
|
64
|
+
|
|
65
|
+
After indexing, deckhand writes to `deckhand-wiki/`:
|
|
66
|
+
|
|
67
|
+
- **REPOS.md** — table of every repo, one line each. Health indicators (✅ ok, ⚠️ untested, 🔩 stub, ❌ no-readme)
|
|
68
|
+
- **CONNECTIONS.md** — cross-reference graph. Which repos reference which.
|
|
69
|
+
- **GLOSSARY.md** — top 100 terms across the workspace
|
|
70
|
+
- **STATUS.md** — health summary. Lists repos that need tests, need READMEs, or may be abandoned
|
|
71
|
+
- **INDEX.md** — machine-readable JSON index
|
|
72
|
+
- **BATCH.md** — collected tasks from PLANNER_QUEUE + OPEN_QUESTIONS + recent changes
|
|
73
|
+
- **logs/YYYY-MM-DD.md** — daily study observations
|
|
74
|
+
|
|
75
|
+
## Why exo-filed?
|
|
76
|
+
|
|
77
|
+
A vector database is opaque. You can't `cat` it. You can't grep it. You can't diff it in git.
|
|
78
|
+
|
|
79
|
+
deckhand's index is markdown + JSON. A human can open REPOS.md and understand the fleet in 30 seconds. An agent can read CONNECTIONS.md instead of re-reading 4,000 repos.
|
|
80
|
+
|
|
81
|
+
When the corpus gets too large for file-based search (>10K files), build the optional vector twin:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from deckhand.vector_twin import VectorTwin # coming in v0.2
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The vector twin is ALWAYS accompanied by its file-twin. You can always fall back to the human-readable version.
|
|
88
|
+
|
|
89
|
+
## The maritime metaphor
|
|
90
|
+
|
|
91
|
+
On a boat, the deckhand is the crew member who:
|
|
92
|
+
- Knows where every line, every shackle, every tool is stored
|
|
93
|
+
- Prepares tasks for the captain (batches)
|
|
94
|
+
- Maintains the logbook (study logs)
|
|
95
|
+
- Studies charts off-watch (studies repos when idle)
|
|
96
|
+
- Doesn't need to be told what to do — knows the direction of the voyage
|
|
97
|
+
|
|
98
|
+
The deckhand doesn't replace the captain (the director model). The deckhand makes the captain more efficient by pre-processing the workspace into a form the captain can act on in fewer calls.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
*Built by SuperInstance. The deckhand knows where everything is.*
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
deckhand/__init__.py,sha256=0NLBQR2dHquc_dGhZ7KDCYtGLnyqIvW7Kg0AAHp7og4,582
|
|
2
|
+
deckhand/agent.py,sha256=f0KekZ-DJRDKm9artXDD8HZESSv6nzs-LPFxJxAaL1s,4638
|
|
3
|
+
deckhand/cli.py,sha256=uQPmasGuIUKeI0vMu0yAbohqjF_sp0H-46p70Npj--0,3192
|
|
4
|
+
deckhand/indexer.py,sha256=0UOBklFIgB9aVHKToHOa3uWHGidWuQedIV5ATMXzzSU,9936
|
|
5
|
+
deckhand/retriever.py,sha256=MhfRXayt5Y5wQSLGWCD5ICf-nvug6HGQbRKPQtAD4Q0,4344
|
|
6
|
+
deckhand/task_batcher.py,sha256=A7gAJmIXeSqnbJmHxStvtA2mdFI4h5MLPqZs_O-ZPKc,4396
|
|
7
|
+
deckhand/wiki.py,sha256=NibnYfE1t2PkFVggw_3EUAAzGT3SDxPMSODN3zjO2qs,7831
|
|
8
|
+
si_deckhand-0.1.0.dist-info/METADATA,sha256=GGCCdNBPKEnzqBfEtb6VDuKC5KZToNRkiSGyKsaaFhk,3747
|
|
9
|
+
si_deckhand-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
si_deckhand-0.1.0.dist-info/entry_points.txt,sha256=ceZa8_rmihTBe1XOdkxMaiYU42FAdotPdH-f1Nbcozg,47
|
|
11
|
+
si_deckhand-0.1.0.dist-info/top_level.txt,sha256=G4gYaKu5Ipp7G_G-YiBbvZwXB95Jmm42BaXzffzh620,9
|
|
12
|
+
si_deckhand-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
deckhand
|