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 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
+ }
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deckhand = deckhand.cli:main
@@ -0,0 +1 @@
1
+ deckhand