Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions graphify/extractors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ def _file_stem(path: Path) -> str:
return path.with_suffix("").as_posix()


def file_id_stem(path: "str | Path", root: "str | Path | None" = None) -> str:
"""Canonical node-id prefix of ``path``: ``make_id(_file_stem(...))`` of its
root-relative form (#4432). Both sides are resolved first, so an absolute path
from detect and a relative root such as ``.`` still line up. "" when no
root-relative form exists, so a checkout location is never baked into an id."""
p = Path(path)
if root is not None:
try:
p = p.resolve().relative_to(Path(root).resolve())
except (OSError, RuntimeError, ValueError):
pass
if p.is_absolute():
return ""
return make_id(_file_stem(p))


def _read_text(node, source: bytes) -> str:
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")

Expand Down
15 changes: 9 additions & 6 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from dataclasses import dataclass, replace
from pathlib import Path

from graphify.extractors.base import _read_source_text
from graphify.extractors.base import _read_source_text, file_id_stem
from graphify.file_slice import (
FileSlice,
bisect_slice,
Expand Down Expand Up @@ -527,8 +527,8 @@ def _thinking_disabled_via_env() -> bool:
attributed to the enclosing file and link to it with a `references` or `cites`
edge. Never invent, shorten or rewrite a path, and never omit source_file.

Node ID format: lowercase, only [a-z0-9_], no dots or slashes.
Format: {stem}_{entity} where stem = full repo-relative path with the extension dropped, every segment joined with _ (e.g. src/auth/session.py -> src_auth_session); entity = symbol name (both normalised). Top-level files use just the filename stem (setup.py -> setup).
Node ID format: lowercase. Letters and digits of any script are kept as written (never transliterated to ASCII); every run of any other character (dots, slashes, spaces, brackets, punctuation) becomes one _.
Format: {stem}_{entity} where stem = full repo-relative path with the extension dropped, every segment joined with _ (e.g. src/auth/session.py -> src_auth_session; docs/отчёт.md -> docs_отчёт); entity = symbol name (both normalised). Top-level files use just the filename stem (setup.py -> setup). Each <untrusted_source> block carries a stem="..." attribute: use it character for character as the id prefix of every node from that file; never respell, shorten or transliterate it.

Edge direction rule — source is always the ACTOR, target is the ACTED-UPON:
- calls: source = the function/method that CONTAINS the call site; target = the function/method BEING CALLED. Never reverse this.
Expand Down Expand Up @@ -613,17 +613,20 @@ def _neutralise_injection_sentinels(text: str) -> str:
return _INJECTION_SENTINELS.sub(lambda m: m.group(0)[0] + "​" + m.group(0)[1:], text)


def _wrap_untrusted(rel: str, content: str) -> str:
def _wrap_untrusted(rel: str, content: str, stem: str = "") -> str:
"""Wrap one file's content in a labelled, hash-stamped untrusted-data block.

The model's system prompt instructs it to treat everything inside
<untrusted_source> as inert data, never as instructions. The sha256 lets a
reviewer correlate a suspicious node back to the exact bytes that produced it.
``stem`` is the file's canonical node-id prefix, computed in code, so the model
copies it instead of inventing its own (#4432).
"""
sha = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
safe = _neutralise_injection_sentinels(content)
stem_attr = f' stem="{stem}"' if stem else ""
return (
f'<untrusted_source path="{rel}" sha256="{sha}">\n'
f'<untrusted_source path="{rel}" sha256="{sha}"{stem_attr}>\n'
f"{safe}\n"
f"</untrusted_source>"
)
Expand Down Expand Up @@ -672,7 +675,7 @@ def _read_files(units: "list[Path | FileSlice]", root: Path) -> str:
f"[graphify] WARNING: file {rel} exceeds {_FILE_CHAR_CAP} characters and was truncated",
file=sys.stderr,
)
parts.append(_wrap_untrusted(rel, content[:_FILE_CHAR_CAP]))
parts.append(_wrap_untrusted(rel, content[:_FILE_CHAR_CAP], file_id_stem(rel)))
return "\n\n".join(parts)


Expand Down
11 changes: 8 additions & 3 deletions graphify/skill-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encodin
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]

cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH', prompt_file='SPEC_PATH')
root = 'INPUT_PATH'
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root=root, prompt_file='SPEC_PATH')

# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
Expand All @@ -251,6 +252,10 @@ if cached_nodes or cached_edges or cached_hyperedges:
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
# One path<TAB>stem line per uncached file, same order: the subagents copy the stem
# instead of guessing it (#4432). Computed here in code, never pasted.
from graphify.extractors.base import file_id_stem
Path('graphify-out/.graphify_uncached_stems.txt').write_text('\n'.join(f'{f}{chr(9)}{file_id_stem(f, root)}' for f in uncached), encoding=\"utf-8\")
# Nothing has been dispatched yet, so any chunk file on disk is a leftover from an
# interrupted run, and Step B3 merges every .graphify_chunk_*.json it finds.
for stale in Path('graphify-out').glob('.graphify_chunk_*.json'):
Expand All @@ -263,7 +268,7 @@ Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt

**Step B1 - Split into chunks**

Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks bounded by BOTH limits: at most 20-25 files AND at most ~6000 total lines per chunk. A text file longer than 2000 lines gets a chunk to itself - that is past the Read tool's single-call cap, so sharing a chunk with other files guarantees it is only partly read. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
Load the lines of `graphify-out/.graphify_uncached_stems.txt` (the same files in the same order as `.graphify_uncached.txt`, each as `path<TAB>stem`; if that file is missing, fall back to `.graphify_uncached.txt`, one bare path per line). A chunk's FILE_LIST is its lines, copied unchanged. Split into chunks bounded by BOTH limits: at most 20-25 files AND at most ~6000 total lines per chunk. A text file longer than 2000 lines gets a chunk to itself - that is past the Read tool's single-call cap, so sharing a chunk with other files guarantees it is only partly read. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.

Both bounds matter. The line bound is not only about the 2000-line Read cap: a subagent holding 25 files splits its attention across all of them and skims the dense ones, and a 590-line plan can come back with zero nodes that way. Bounding content as well as file count is what keeps a chunk's coverage honest (#2225).

Expand Down Expand Up @@ -411,7 +416,7 @@ Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, inden
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_uncached_stems.txt graphify-out/.graphify_semantic_new.json`

#### Part C - Merge AST + semantic into final extraction

Expand Down
11 changes: 8 additions & 3 deletions graphify/skill-amp.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encodin
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]

cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH', prompt_file='SPEC_PATH')
root = 'INPUT_PATH'
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root=root, prompt_file='SPEC_PATH')

# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
Expand All @@ -251,6 +252,10 @@ if cached_nodes or cached_edges or cached_hyperedges:
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
# One path<TAB>stem line per uncached file, same order: the subagents copy the stem
# instead of guessing it (#4432). Computed here in code, never pasted.
from graphify.extractors.base import file_id_stem
Path('graphify-out/.graphify_uncached_stems.txt').write_text('\n'.join(f'{f}{chr(9)}{file_id_stem(f, root)}' for f in uncached), encoding=\"utf-8\")
# Nothing has been dispatched yet, so any chunk file on disk is a leftover from an
# interrupted run, and Step B3 merges every .graphify_chunk_*.json it finds.
for stale in Path('graphify-out').glob('.graphify_chunk_*.json'):
Expand All @@ -263,7 +268,7 @@ Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt

**Step B1 - Split into chunks**

Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks bounded by BOTH limits: at most 20-25 files AND at most ~6000 total lines per chunk. A text file longer than 2000 lines gets a chunk to itself - that is past the Read tool's single-call cap, so sharing a chunk with other files guarantees it is only partly read. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
Load the lines of `graphify-out/.graphify_uncached_stems.txt` (the same files in the same order as `.graphify_uncached.txt`, each as `path<TAB>stem`; if that file is missing, fall back to `.graphify_uncached.txt`, one bare path per line). A chunk's FILE_LIST is its lines, copied unchanged. Split into chunks bounded by BOTH limits: at most 20-25 files AND at most ~6000 total lines per chunk. A text file longer than 2000 lines gets a chunk to itself - that is past the Read tool's single-call cap, so sharing a chunk with other files guarantees it is only partly read. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.

Both bounds matter. The line bound is not only about the 2000-line Read cap: a subagent holding 25 files splits its attention across all of them and skims the dense ones, and a 590-line plan can come back with zero nodes that way. Bounding content as well as file count is what keeps a chunk's coverage honest (#2225).

Expand Down Expand Up @@ -411,7 +416,7 @@ Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, inden
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_uncached_stems.txt graphify-out/.graphify_semantic_new.json`

#### Part C - Merge AST + semantic into final extraction

Expand Down
11 changes: 8 additions & 3 deletions graphify/skill-claw.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encodin
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]

cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH', prompt_file='SPEC_PATH')
root = 'INPUT_PATH'
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root=root, prompt_file='SPEC_PATH')

# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
Expand All @@ -251,6 +252,10 @@ if cached_nodes or cached_edges or cached_hyperedges:
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
# One path<TAB>stem line per uncached file, same order: the subagents copy the stem
# instead of guessing it (#4432). Computed here in code, never pasted.
from graphify.extractors.base import file_id_stem
Path('graphify-out/.graphify_uncached_stems.txt').write_text('\n'.join(f'{f}{chr(9)}{file_id_stem(f, root)}' for f in uncached), encoding=\"utf-8\")
# Nothing has been dispatched yet, so any chunk file on disk is a leftover from an
# interrupted run, and Step B3 merges every .graphify_chunk_*.json it finds.
for stale in Path('graphify-out').glob('.graphify_chunk_*.json'):
Expand All @@ -263,7 +268,7 @@ Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt

**Step B1 - Split into chunks**

Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks bounded by BOTH limits: at most 20-25 files AND at most ~6000 total lines per chunk. A text file longer than 2000 lines gets a chunk to itself - that is past the Read tool's single-call cap, so sharing a chunk with other files guarantees it is only partly read. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
Load the lines of `graphify-out/.graphify_uncached_stems.txt` (the same files in the same order as `.graphify_uncached.txt`, each as `path<TAB>stem`; if that file is missing, fall back to `.graphify_uncached.txt`, one bare path per line). A chunk's FILE_LIST is its lines, copied unchanged. Split into chunks bounded by BOTH limits: at most 20-25 files AND at most ~6000 total lines per chunk. A text file longer than 2000 lines gets a chunk to itself - that is past the Read tool's single-call cap, so sharing a chunk with other files guarantees it is only partly read. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.

Both bounds matter. The line bound is not only about the 2000-line Read cap: a subagent holding 25 files splits its attention across all of them and skims the dense ones, and a 590-line plan can come back with zero nodes that way. Bounding content as well as file count is what keeps a chunk's coverage honest (#2225).

Expand Down Expand Up @@ -414,7 +419,7 @@ Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, inden
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_uncached_stems.txt graphify-out/.graphify_semantic_new.json`

#### Part C - Merge AST + semantic into final extraction

Expand Down
11 changes: 8 additions & 3 deletions graphify/skill-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encodin
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]

cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH', prompt_file='SPEC_PATH')
root = 'INPUT_PATH'
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root=root, prompt_file='SPEC_PATH')

# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
Expand All @@ -251,6 +252,10 @@ if cached_nodes or cached_edges or cached_hyperedges:
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
# One path<TAB>stem line per uncached file, same order: the subagents copy the stem
# instead of guessing it (#4432). Computed here in code, never pasted.
from graphify.extractors.base import file_id_stem
Path('graphify-out/.graphify_uncached_stems.txt').write_text('\n'.join(f'{f}{chr(9)}{file_id_stem(f, root)}' for f in uncached), encoding=\"utf-8\")
# Nothing has been dispatched yet, so any chunk file on disk is a leftover from an
# interrupted run, and Step B3 merges every .graphify_chunk_*.json it finds.
for stale in Path('graphify-out').glob('.graphify_chunk_*.json'):
Expand All @@ -263,7 +268,7 @@ Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt

**Step B1 - Split into chunks**

Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks bounded by BOTH limits: at most 20-25 files AND at most ~6000 total lines per chunk. A text file longer than 2000 lines gets a chunk to itself - that is past the Read tool's single-call cap, so sharing a chunk with other files guarantees it is only partly read. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
Load the lines of `graphify-out/.graphify_uncached_stems.txt` (the same files in the same order as `.graphify_uncached.txt`, each as `path<TAB>stem`; if that file is missing, fall back to `.graphify_uncached.txt`, one bare path per line). A chunk's FILE_LIST is its lines, copied unchanged. Split into chunks bounded by BOTH limits: at most 20-25 files AND at most ~6000 total lines per chunk. A text file longer than 2000 lines gets a chunk to itself - that is past the Read tool's single-call cap, so sharing a chunk with other files guarantees it is only partly read. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.

Both bounds matter. The line bound is not only about the 2000-line Read cap: a subagent holding 25 files splits its attention across all of them and skims the dense ones, and a 590-line plan can come back with zero nodes that way. Bounding content as well as file count is what keeps a chunk's coverage honest (#2225).

Expand Down Expand Up @@ -415,7 +420,7 @@ Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, inden
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_uncached_stems.txt graphify-out/.graphify_semantic_new.json`

#### Part C - Merge AST + semantic into final extraction

Expand Down
Loading
Loading