Skip to content

Commit 0221e0d

Browse files
authored
Merge pull request #122 from Ar9av/feat/batch-extract
feat(batch): parallel subagent batch planning for large-folder ingestion
2 parents d99df43 + 6069d77 commit 0221e0d

4 files changed

Lines changed: 546 additions & 0 deletions

File tree

.skills/wiki-ingest/SKILL.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,33 @@ This keeps faith with the "immutable raw layer" principle in `llm-wiki/SKILL.md`
9494

9595
## The Ingest Process
9696

97+
### Step 0: Batch Planning for Large Folders
98+
99+
**GUARD: Only run this step when the source is a directory with more than 20 files.** For single files, small folders, or `_raw/` mode, skip directly to Step 1.
100+
101+
When the source is a large directory of docs, plan the parallel dispatch first:
102+
103+
```bash
104+
obsidian-wiki batch-plan "$OBSIDIAN_VAULT_PATH" <source-dir> --pretty
105+
```
106+
107+
This outputs a JSON plan with `batches` (each a list of files + total_bytes + kind counts) and `stats` (total, to_ingest, skipped_unchanged).
108+
109+
**What to do with the plan:**
110+
111+
1. **Check `stats.skipped_unchanged`** — report to the user how many files are being skipped (already ingested, hash unchanged).
112+
2. **If `batch_count == 0`** — all files are unchanged. Tell the user and stop.
113+
3. **If `batch_count == 1`** — proceed with the single batch as a normal Step 1 ingest.
114+
4. **If `batch_count > 1`** — dispatch each batch as a **parallel subagent** (multiple Agent tool calls in a single message). Each subagent receives a message like:
115+
```
116+
Ingest these files into the wiki at $OBSIDIAN_VAULT_PATH using wiki-ingest Step 1 onward:
117+
<list of file paths from this batch>
118+
Skip batch-plan — these files are already partitioned.
119+
```
120+
Wait for all subagents to complete, then run `/cross-linker` once to wire cross-references across all batches.
121+
122+
**Fallback** (if `obsidian-wiki` is not installed): process files sequentially in groups of 15.
123+
97124
### Step 1: Read the Source
98125

99126
Read the source(s) the user wants to ingest. In append mode, skip files the manifest says are already ingested and unchanged. Supported formats:

obsidian_wiki/batch.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""Batch planner for parallel wiki-ingest subagent dispatch.
2+
3+
When ingesting a large folder of docs, this module splits the source list into
4+
batches and emits a dispatch plan the skill uses to spawn parallel Claude
5+
subagents — each handling one batch independently, then merging results.
6+
7+
The agent calls `obsidian-wiki batch-plan <vault> <source-dir> [options]`
8+
and gets back a JSON plan:
9+
10+
{
11+
"batches": [
12+
{
13+
"id": 0,
14+
"files": ["path/to/a.md", "path/to/b.pdf"],
15+
"total_bytes": 45000,
16+
"kinds": {"markdown": 1, "pdf": 1}
17+
},
18+
...
19+
],
20+
"stats": {
21+
"total_files": N,
22+
"total_bytes": N,
23+
"batch_count": N,
24+
"skipped_unchanged": N,
25+
"skipped_binary": N
26+
},
27+
"merge_hint": "Run /wiki-ingest on each batch in parallel, then run /cross-linker once all batches are done."
28+
}
29+
30+
The skill dispatches each batch as a parallel subagent call, then runs
31+
cross-linker once all agents report completion.
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import mimetypes
37+
import os
38+
from pathlib import Path
39+
from typing import Any
40+
41+
# ---------------------------------------------------------------------------
42+
# File classification
43+
# ---------------------------------------------------------------------------
44+
45+
# Ingestible text/doc extensions
46+
TEXT_EXTENSIONS = frozenset(
47+
".md .mdx .txt .rst .html .htm .csv .tsv .json .jsonl .yaml .yml .xml".split()
48+
)
49+
PDF_EXTENSIONS = frozenset(".pdf".split())
50+
IMAGE_EXTENSIONS = frozenset(".png .jpg .jpeg .webp .gif .bmp .tiff .svg".split())
51+
OFFICE_EXTENSIONS = frozenset(".docx .xlsx .pptx .odt .ods .odp".split())
52+
CODE_EXTENSIONS = frozenset(
53+
".py .ts .js .jsx .tsx .go .rs .java .kt .rb .c .cpp .h .hpp .swift .sh".split()
54+
)
55+
56+
# Binary / generated — skip entirely
57+
SKIP_EXTENSIONS = frozenset(
58+
".pyc .pyo .pyd .so .dylib .dll .exe .class .jar .war .egg "
59+
".zip .tar .gz .bz2 .whl .lock .mp4 .mov .mp3 .wav .ttf .woff .eot".split()
60+
)
61+
62+
SKIP_DIRS = frozenset(
63+
"node_modules .git __pycache__ .pytest_cache dist build target "
64+
".venv venv env .mypy_cache .ruff_cache coverage .tox .obsidian "
65+
"_raw _archived _staging _archives".split()
66+
)
67+
68+
69+
def _classify(path: Path) -> str:
70+
ext = path.suffix.lower()
71+
if ext in TEXT_EXTENSIONS:
72+
return "text"
73+
if ext in PDF_EXTENSIONS:
74+
return "pdf"
75+
if ext in IMAGE_EXTENSIONS:
76+
return "image"
77+
if ext in OFFICE_EXTENSIONS:
78+
return "office"
79+
if ext in CODE_EXTENSIONS:
80+
return "code"
81+
if ext in SKIP_EXTENSIONS:
82+
return "skip"
83+
# Guess via mime
84+
mime, _ = mimetypes.guess_type(str(path))
85+
if mime and mime.startswith("text/"):
86+
return "text"
87+
return "skip"
88+
89+
90+
def _file_size(path: Path) -> int:
91+
try:
92+
return path.stat().st_size
93+
except OSError:
94+
return 0
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# Source discovery
99+
# ---------------------------------------------------------------------------
100+
101+
def discover_sources(
102+
source_dir: Path,
103+
*,
104+
vault: Path | None = None,
105+
include_code: bool = False,
106+
) -> list[dict]:
107+
"""Walk source_dir and return a list of ingestible file dicts.
108+
109+
Each dict: {path, kind, size_bytes}. Code files are excluded by default
110+
because wiki-ingest Step 1c handles them via ast-extract separately.
111+
"""
112+
files = []
113+
for dirpath, dirnames, filenames in os.walk(source_dir):
114+
dirnames[:] = [
115+
d for d in dirnames
116+
if d not in SKIP_DIRS and not d.startswith(".")
117+
]
118+
for fn in sorted(filenames):
119+
p = Path(dirpath) / fn
120+
kind = _classify(p)
121+
if kind == "skip":
122+
continue
123+
if kind == "code" and not include_code:
124+
continue
125+
files.append({"path": str(p), "kind": kind, "size_bytes": _file_size(p)})
126+
return files
127+
128+
129+
# ---------------------------------------------------------------------------
130+
# Manifest filtering (skip unchanged sources)
131+
# ---------------------------------------------------------------------------
132+
133+
def _filter_unchanged(
134+
files: list[dict],
135+
vault: Path,
136+
) -> tuple[list[dict], int]:
137+
"""Remove files whose hash matches the manifest. Returns (to_ingest, skipped_count)."""
138+
try:
139+
from obsidian_wiki.cache import check_sources, compute_hash
140+
paths = [Path(f["path"]) for f in files]
141+
result = check_sources(vault, paths)
142+
unchanged_set = set(result["unchanged"])
143+
to_ingest = [f for f in files if f["path"] not in unchanged_set]
144+
return to_ingest, len(unchanged_set)
145+
except Exception:
146+
return files, 0
147+
148+
149+
# ---------------------------------------------------------------------------
150+
# Batch splitting
151+
# ---------------------------------------------------------------------------
152+
153+
def _make_batches(
154+
files: list[dict],
155+
*,
156+
max_batch_bytes: int,
157+
max_batch_files: int,
158+
) -> list[list[dict]]:
159+
"""Split files into batches respecting size and file-count limits."""
160+
batches: list[list[dict]] = []
161+
current: list[dict] = []
162+
current_bytes = 0
163+
164+
for f in files:
165+
sz = f["size_bytes"]
166+
if current and (
167+
current_bytes + sz > max_batch_bytes
168+
or len(current) >= max_batch_files
169+
):
170+
batches.append(current)
171+
current = []
172+
current_bytes = 0
173+
current.append(f)
174+
current_bytes += sz
175+
176+
if current:
177+
batches.append(current)
178+
return batches
179+
180+
181+
# ---------------------------------------------------------------------------
182+
# Main entry point
183+
# ---------------------------------------------------------------------------
184+
185+
def plan_batches(
186+
source_dir: Path,
187+
vault: Path,
188+
*,
189+
max_batch_mb: float = 2.0,
190+
max_batch_files: int = 20,
191+
skip_unchanged: bool = True,
192+
include_code: bool = False,
193+
) -> dict[str, Any]:
194+
"""Discover sources, filter unchanged, and split into batches."""
195+
all_files = discover_sources(source_dir, vault=vault, include_code=include_code)
196+
197+
skipped_binary = 0 # files already excluded by _classify
198+
skipped_unchanged = 0
199+
200+
to_ingest = all_files
201+
if skip_unchanged and vault.is_dir():
202+
to_ingest, skipped_unchanged = _filter_unchanged(all_files, vault)
203+
204+
max_batch_bytes = int(max_batch_mb * 1024 * 1024)
205+
batches_raw = _make_batches(
206+
to_ingest,
207+
max_batch_bytes=max_batch_bytes,
208+
max_batch_files=max_batch_files,
209+
)
210+
211+
total_bytes = sum(f["size_bytes"] for f in to_ingest)
212+
213+
batches_out = []
214+
for i, batch in enumerate(batches_raw):
215+
kinds: dict[str, int] = {}
216+
for f in batch:
217+
kinds[f["kind"]] = kinds.get(f["kind"], 0) + 1
218+
batches_out.append({
219+
"id": i,
220+
"files": [f["path"] for f in batch],
221+
"total_bytes": sum(f["size_bytes"] for f in batch),
222+
"kinds": kinds,
223+
})
224+
225+
merge_hint = (
226+
"Dispatch each batch as a parallel subagent with /wiki-ingest on its file list. "
227+
"Once all batches complete, run /cross-linker to wire up cross-references."
228+
)
229+
230+
return {
231+
"batches": batches_out,
232+
"stats": {
233+
"total_files": len(all_files),
234+
"to_ingest": len(to_ingest),
235+
"total_bytes": total_bytes,
236+
"batch_count": len(batches_out),
237+
"skipped_unchanged": skipped_unchanged,
238+
"skipped_binary": skipped_binary,
239+
},
240+
"merge_hint": merge_hint,
241+
}

obsidian_wiki/cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,28 @@ def cmd_setup(args: argparse.Namespace) -> int:
349349
return 0
350350

351351

352+
def cmd_batch_plan(args: argparse.Namespace) -> int:
353+
from obsidian_wiki.batch import plan_batches
354+
source_dir = Path(args.source_dir).expanduser().resolve()
355+
vault = Path(args.vault).expanduser().resolve()
356+
if not source_dir.is_dir():
357+
print(f"error: source directory not found: {source_dir}", file=sys.stderr)
358+
return 1
359+
result = plan_batches(
360+
source_dir,
361+
vault,
362+
max_batch_mb=args.max_mb,
363+
max_batch_files=args.max_files,
364+
skip_unchanged=not args.no_cache,
365+
include_code=args.include_code,
366+
)
367+
if args.pretty:
368+
print(json.dumps(result, indent=2))
369+
else:
370+
print(json.dumps(result))
371+
return 0
372+
373+
352374
def cmd_graph_analyse(args: argparse.Namespace) -> int:
353375
from obsidian_wiki.graph_analysis import analyse_vault
354376
vault = Path(args.vault).expanduser().resolve()
@@ -469,6 +491,19 @@ def build_parser() -> argparse.ArgumentParser:
469491
ip = sub.add_parser("info", help="show install paths, version, and config")
470492
ip.set_defaults(func=cmd_info)
471493

494+
bp = sub.add_parser(
495+
"batch-plan",
496+
help="split a source directory into parallel-ingest batches, skipping unchanged files",
497+
)
498+
bp.add_argument("vault", help="path to the Obsidian vault")
499+
bp.add_argument("source_dir", help="directory of source documents to ingest")
500+
bp.add_argument("--max-mb", type=float, default=2.0, help="max MB per batch (default: 2)")
501+
bp.add_argument("--max-files", type=int, default=20, help="max files per batch (default: 20)")
502+
bp.add_argument("--no-cache", action="store_true", help="disable manifest-based skip of unchanged files")
503+
bp.add_argument("--include-code", action="store_true", help="include code files (default: excluded; use ast-extract instead)")
504+
bp.add_argument("--pretty", action="store_true", help="pretty-print JSON output")
505+
bp.set_defaults(func=cmd_batch_plan)
506+
472507
ga = sub.add_parser(
473508
"graph-analyse",
474509
help="analyse the vault's wikilink graph: god nodes, communities, surprising connections",

0 commit comments

Comments
 (0)