add pi-graphify extension

This commit is contained in:
2026-05-10 15:29:02 +10:00
parent 9e3160a8fa
commit 6a0107aee3
15 changed files with 10167 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.2] - 2026-05-09
### Changed
- Added `.pi/gateway/` to `.gitignore`.
- Clarified README configuration table formatting and `statusbar` option description.
- Synced tracked `graphify-out` artifacts after regeneration.
## [0.1.1] - 2026-05-09
### Added
- `graphify_watch` tool: watch a directory for file changes, auto-rebuild graph on code edits.
- `graphify_cluster` tool: re-run community detection on an existing graph without re-extraction.
- `/graphify watch <path>` command with autocomplete.
- `/graphify cluster` command.
- `/graphify hook <install|uninstall|status>` command to manage git hooks.
- Optional `pi-statusbar` widget integration with auto-refresh on session start and agent start.
- Runner functions for `watch`, `clusterOnly`, `hookAction`, `pushNeo4j`, `saveResult`, `cloneRepo`, `mergeGraphs`, `generateTree`.
- Unit tests for runner module (10 passing).
- Integration tests using @gaodes/pi-test-harness (12 passing): all 8 tools exercised via playbook DSL with mocked bash, plus command-level forwarding coverage.
- `@gaodes/pi-test-harness` as dev dependency.
- Bundled `graphify` skill: full-pipeline orchestration guide (semantic extraction, community labeling, export formats, video transcription, guided exploration). Replaces the standalone skill in `~/.pi/agent/skills/graphify/`.
- Config key standardized to `pi-graphify` with automatic migration from legacy `graphify`.
### Fixed
- npm package metadata now includes `publishConfig.access=public` plus GitHub `bugs`/`homepage` fields for clean scoped-package publishing.
- Added `@biomejs/biome` as a dev dependency so `npm run lint` works in clean environments.
- Relaxed local typecheck strictness that was pulling in `@gaodes/pi-utils-ui` source-level unused-variable diagnostics from `node_modules`.
- Graphify build initialization now auto-manages `.gitignore`: creates the file if missing, removes legacy `graphify-out/` ignore entries, and keeps only `graphify-out/cache/`, `graphify-out/.graphify_python`, and `graphify-out/cost.json` ignored so `graphify-out/` stays tracked.
- Lint script scoped to `src/` instead of `.`.
- Removed leaked `graphify-out/cache` from `src/`.
- Bundled `graphify` skill command snippets now use consistent temporary-file paths (`.graphify_python`, `.graphify_detect.json`, `.graphify_chunk_*.json`, `.graphify_semantic_new.json`).
- Corrected GraphML export snippet in bundled `graphify` skill.
- `/graphify` command now forwards build flags (`--mode deep`, `--no-viz`, `--obsidian`, `--svg`, `--graphml`, `--neo4j`) to `graphify_build` explicitly.
- Removed misleading `--watch` build-flag autocomplete (watching is via `/graphify watch <path>`).
- `--debounce <seconds>` is now parsed for `/graphify watch` and forwarded in the watch tool prompt.
- Added command integration tests to lock behavior for `/graphify` build-flag forwarding and watch debounce parsing.
## [0.1.0] - 2025-05-06
### Added
- Initial release: knowledge graph tools and `/graphify` command for Pi.
- Tools: `graphify_build`, `graphify_query`, `graphify_path`, `graphify_explain`, `graphify_add`, `graphify_update`, `graphify_watch`, `graphify_cluster`.
- Single `/graphify` command with autocomplete for subcommands and flags (build, query, path, explain, add, update, watch, cluster, hook).
[Unreleased]: https://gitlab.elches.dev/agents/primecodex/packages/pi-graphify/-/compare/v0.1.2...main
[0.1.2]: https://gitlab.elches.dev/agents/primecodex/packages/pi-graphify/-/compare/v0.1.1...v0.1.2
[0.1.1]: https://gitlab.elches.dev/agents/primecodex/packages/pi-graphify/-/compare/v0.1.0...v0.1.1
[0.1.0]: https://gitlab.elches.dev/agents/primecodex/packages/pi-graphify/-/tags/v0.1.0

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 El Che
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,97 @@
# @gaodes/pi-graphify
Turn any folder of files (code, docs, papers, images, video) into a queryable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
[**Source**](https://gitlab.elches.dev/agents/primecodex/packages/pi-graphify) · [**npm**](https://www.npmjs.com/package/@gaodes/pi-graphify)
Inspired by [graphify](https://github.com/safishamsi/graphify) — the AI coding assistant skill. This extension wraps graphify's Python CLI for native Pi integration.
It also bundles a `graphify` skill (`skills/graphify/SKILL.md`) for full-pipeline orchestration. Use `/skill:graphify` when you want the guided multi-step workflow; use the tools and `/graphify` command for fast operational calls.
## Tools
| Tool | Description |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| `graphify_build` | Build a knowledge graph from a directory (full pipeline: detect → extract → cluster → visualize) |
| `graphify_query` | Query the graph — BFS for broad context, DFS for tracing specific paths |
| `graphify_path` | Find the shortest path between two concepts in the graph |
| `graphify_explain` | Plain-language explanation of a node — everything connected to it |
| `graphify_add` | Fetch a URL and add it to the corpus, then update the graph |
| `graphify_update` | Incremental update — re-extract only changed files |
| `graphify_watch` | Watch a directory for changes, auto-rebuild graph on code edits |
| `graphify_cluster` | Re-run community detection on an existing graph (no re-extraction) |
## Commands
| Command | Description |
| ----------- | ------------------------------------------------------------------ |
| `/graphify` | Single entry point with autocomplete for all subcommands and flags |
### Subcommands and flags
```
/graphify <path> # build graph (full pipeline)
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental — re-extract only changed files
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --obsidian # generate Obsidian vault
/graphify <path> --svg # export graph.svg
/graphify <path> --graphml # export for Gephi / yEd
/graphify <path> --neo4j # generate cypher.txt for Neo4j
/graphify query "<question>" # BFS traversal — broad context
/graphify query "<question>" --dfs # DFS — trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "ConceptA" "ConceptB" # shortest path between two concepts
/graphify explain "ConceptName" # plain-language explanation of a node
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify update <path> # incremental update
/graphify watch <path> # watch folder, auto-rebuild on changes
/graphify cluster # rerun clustering on existing graph
/graphify hook install # install git hooks for auto-rebuild
/graphify hook uninstall # remove git hooks
/graphify hook status # check hook status
```
## Prerequisites
- Python 3.10+
- `graphifyy` package (auto-installed on first run): `pip install graphifyy` or `uv tool install graphifyy`
## Install
```bash
pi install @gaodes/pi-graphify
```
## Configuration
Key: `pi-graphify` in `prime-settings.json` (legacy `graphify` key auto-migrates on load).
| Setting | Type | Default | Description |
| ------------ | --------- | ----------------- | ------------------------------------- |
| `enabled` | `boolean` | `true` | Enable/disable the extension |
| `pythonPath` | `string` | `"python3"` | Path to Python interpreter |
| `outputDir` | `string` | `"graphify-out"` | Output directory name |
| `statusbar` | `object` | built-in defaults | Optional pi-statusbar widget settings |
## Git tracking policy
On build initialization, the extension ensures `.gitignore` exists and applies Graphify-specific rules:
- keeps `graphify-out/` **tracked**
- ignores only:
- `graphify-out/cache/`
- `graphify-out/.graphify_python`
- `graphify-out/.graphify_root`
- `graphify-out/cost.json`
If a legacy `graphify-out/` ignore entry exists, it is removed automatically.
## Source
- Canonical: `~/agents/primecodex/packages/pi-graphify/`
- GitLab: `agents/primecodex/packages/pi-graphify`
- GitHub: `github.com/gaodes/pi-graphify`

5314
extensions/pi-graphify/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
{
"name": "@gaodes/pi-graphify",
"version": "0.1.2",
"description": "Turn any folder into a queryable knowledge graph — build, query, explore, and update graphs from inside Pi",
"keywords": [
"pi-package",
"pi-extension"
],
"type": "module",
"private": false,
"main": "./src/tools/index.ts",
"files": [
"src/",
"skills/",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "biome check src/",
"lint:fix": "biome check --write src/",
"test": "vitest run",
"test:watch": "vitest"
},
"pi": {
"extensions": [
"./src/tools/index.ts",
"./src/commands/index.ts"
],
"skills": [
"skills"
]
},
"dependencies": {
"@gaodes/pi-utils-ui": "^0.3.0"
},
"devDependencies": {
"@earendil-works/pi-ai": ">=0.69.0",
"@earendil-works/pi-coding-agent": ">=0.69.0",
"@earendil-works/pi-tui": ">=0.69.0",
"@types/node": "^22.0.0",
"typebox": ">=1.0.0",
"typescript": "^5.0.0",
"vitest": "^4.1.5",
"@gaodes/pi-test-harness": ">=0.1.0",
"@biomejs/biome": "^2.4.3"
},
"peerDependencies": {
"@earendil-works/pi-ai": ">=0.69.0",
"@earendil-works/pi-coding-agent": ">=0.69.0",
"@earendil-works/pi-tui": ">=0.69.0",
"typebox": ">=1.0.0"
},
"peerDependenciesMeta": {
"@earendil-works/pi-ai": {
"optional": true
},
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
},
"typebox": {
"optional": true
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/gaodes/pi-graphify.git"
},
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/gaodes/pi-graphify/issues"
},
"homepage": "https://github.com/gaodes/pi-graphify#readme",
"license": "MIT",
"author": "El Che"
}

View File

@@ -0,0 +1,881 @@
---
name: graphify
description: "Full-pipeline knowledge graph orchestration for graphify. Use when the user asks to build a graph from scratch, run a deep extraction, generate specific export formats (Obsidian, SVG, GraphML, Neo4j), transcribe video, or any operation that requires multi-step orchestration beyond the extension's tools. The extension tools (graphify_build, graphify_query, etc.) handle simple operations; this skill handles the full build pipeline."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and multiple outputs: interactive HTML, GraphRAG-ready JSON, Obsidian vault, and a plain-language GRAPH_REPORT.md.
## Extension Tools vs. This Skill
The pi-graphify extension registers 8 tools the LLM can call autonomously:
| Tool | When to use |
|------|-------------|
| `graphify_build` | Quick build with standard settings |
| `graphify_query` | BFS/DFS traversal questions |
| `graphify_path` | Shortest path between concepts |
| `graphify_explain` | Plain-language explanation of a node |
| `graphify_add` | Fetch a URL and add to corpus |
| `graphify_update` | Incremental re-extraction |
| `graphify_cluster` | Re-run community detection only |
| `graphify_watch` | Watch directory for changes |
**Use this skill instead of the tools when:**
- The user runs `/graphify` explicitly (they want the full pipeline)
- The user wants export formats the tools don't produce (Obsidian vault, SVG, GraphML, Neo4j cypher)
- The corpus contains video/audio that needs transcription
- The user wants the guided exploration flow after building
- The user wants to run semantic extraction with subagent dispatch (the tools only call the Python CLI, not multi-step orchestration)
For simple operations (query, explain, path, add, update, cluster), prefer the extension tools — they're faster and the LLM can call them autonomously.
## Usage
### `/graphify` command surface (implemented in this extension)
```
/graphify # full pipeline on current directory
/graphify <path> # full pipeline on specific path
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --obsidian # generate Obsidian vault
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
/graphify update <path> # incremental update (subcommand form)
/graphify watch <path> # watch folder, auto-rebuild on changes
/graphify cluster # rerun clustering on existing graph
/graphify hook <install|uninstall|status> # manage git hooks
```
### Advanced orchestration options (skill-level, not `/graphify` flags)
Use these only when driving the full pipeline from this skill (for example via `/skill:graphify ...`) or when the user asks explicitly:
- `--neo4j-push <uri>`: push graph directly to Neo4j
- `--mcp`: start MCP stdio server for live graph queries
- `--whisper-model <name>`: override transcription model
## What graphify is for
graphify is built around Andrej Karpathy's /raw folder workflow: drop anything into a folder - papers, tweets, screenshots, code, notes - and get a structured knowledge graph that shows you what you didn't know was connected.
Three things it does that your AI assistant alone cannot:
1. **Persistent graph** - relationships are stored in `graphify-out/graph.json` and survive across sessions. Ask questions weeks later without re-reading everything.
2. **Honest audit trail** - every edge is tagged EXTRACTED, INFERRED, or AMBIGUOUS. You know what was found vs invented.
3. **Cross-document surprise** - community detection finds connections between concepts in different files that you would never think to ask about directly.
## What You Must Do When Invoked
If no path was given, use `.` (current directory). Do not ask the user for a path.
### Quick operations → use extension tools
For `query`, `path`, `explain`, `add`, `update`, `cluster`, and `watch` operations, call the corresponding extension tool directly:
| Operation | Tool to call |
|-----------|-------------|
| `query "<question>"` | `graphify_query` with `question` param |
| `query "<question>" --dfs` | `graphify_query` with `question` and `mode: "dfs"` |
| `path "A" "B"` | `graphify_path` with `from` and `to` |
| `explain "Node"` | `graphify_explain` with `concept` |
| `add <url>` | `graphify_add` with `url` |
| `update <path>` or build flag `--update` | `graphify_update` with `path` |
| `cluster` or build flag `--cluster-only` | `graphify_cluster` |
| `watch <path>` | `graphify_watch` with `path` |
### Full pipeline → follow these steps
Follow these steps in order. Do not skip steps.
#### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles pipx, venv, system installs)
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in
*[!a-zA-Z0-9/_.-]*) PYTHON="python3" ;;
esac
else
PYTHON="python3"
fi
"$PYTHON" -c "import graphify" 2>/dev/null || "$PYTHON" -m pip install graphifyy -q 2>/dev/null || "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
mkdir -p graphify-out
# Write interpreter path for all subsequent steps
"$PYTHON" -c "import sys; open('.graphify_python', 'w').write(sys.executable)"
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat .graphify_python)` to use the correct interpreter.**
#### Step 2 - Detect files
```bash
$(cat .graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result))
" > .graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 200: show the warning and the top 5 subdirectories by file count, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
#### Step 2.5 - Transcribe video / audio files (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files.
Video and audio files cannot be read directly. Transcribe them to text first, then treat the transcripts as doc files in Step 3.
**Strategy:** Read the god nodes from the detect output or analysis file. You are already a language model - write a one-sentence domain hint yourself from those labels. Then pass it to Whisper as the initial prompt. No separate API call needed.
**However**, if the corpus has *only* video files and no other docs/code, use the generic fallback prompt: `"Use proper punctuation and paragraph breaks."`
**Step 1 - Write the Whisper prompt yourself.**
Read the top god node labels from detect output or analysis, then compose a short domain hint sentence, for example:
- Labels: `transformer, attention, encoder, decoder` -> `"Machine learning research on transformer architectures and attention mechanisms. Use proper punctuation and paragraph breaks."`
- Labels: `kubernetes, deployment, pod, helm` -> `"DevOps discussion about Kubernetes deployments and Helm charts. Use proper punctuation and paragraph breaks."`
Set it as `GRAPHIFY_WHISPER_PROMPT` in the environment before running the transcription command.
**Step 2 - Transcribe:**
```bash
$(cat .graphify_python) -c "
import json, os
from pathlib import Path
from graphify.transcribe import transcribe_all
detect = json.loads(Path('.graphify_detect.json').read_text())
video_files = detect.get('files', {}).get('video', [])
prompt = os.environ.get('GRAPHIFY_WHISPER_PROMPT', 'Use proper punctuation and paragraph breaks.')
transcript_paths = transcribe_all(video_files, initial_prompt=prompt)
print(json.dumps(transcript_paths))
" > graphify-out/.graphify_transcripts.json
```
After transcription:
- Read the transcript paths from `graphify-out/.graphify_transcripts.json`
- Add them to the docs list before dispatching semantic subagents in Step 3B
- Print how many transcripts were created: `Transcribed N video file(s) -> treating as docs`
- If transcription fails for a file, print a warning and continue with the rest
**Whisper model:** Default is `base`. If the user passed `--whisper-model <name>`, set `GRAPHIFY_WHISPER_MODEL=<name>` in the environment before running the command above.
#### Step 3 - Extract entities and relationships
**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it.
This step has two parts: **structural extraction** (deterministic, free) and **semantic extraction** (your AI model, costs tokens).
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
##### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('.graphify_detect.json').read_text())
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files)
Path('.graphify_ast.json').write_text(json.dumps(result, indent=2))
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}))
print('No code files - skipping AST extraction')
"
```
##### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do.
> **OpenClaw platform:** Multi-agent support is still early on OpenClaw. Extraction runs sequentially — you read and extract each file yourself. This is slower than parallel platforms but fully reliable.
Print: `"Semantic extraction: N files (sequential — OpenClaw)"`
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat .graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('.graphify_detect.json').read_text())
all_files = [f for files in detect['files'].values() for f in files]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files)
if cached_nodes or cached_edges or cached_hyperedges:
Path('.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}))
Path('.graphify_uncached.txt').write_text('\n'.join(uncached))
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `.graphify_uncached.txt`. Split into chunks of 20-25 files each. 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.
**Step B2 - Sequential extraction (OpenClaw)**
Process each file one at a time. For each file:
1. Read the file contents
2. Extract nodes, edges, and hyperedges applying the same rules:
- EXTRACTED: relationship explicit in source (import, call, citation)
- INFERRED: reasonable inference (shared structure, implied dependency)
- AMBIGUOUS: uncertain — flag it, do not omit
- Code files: semantic edges AST cannot find. Do not re-extract imports.
- Doc/paper files: named concepts, entities, citations. Store rationale (WHY decisions were made) as a `rationale` attribute on the relevant node, not as a separate node. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms). Do NOT invent file_types like `concept`. When adding `calls` edges: source is caller, target is callee.
- Image files: use vision — understand what the image IS, not just OCR
- DEEP_MODE (if --mode deep): be aggressive with INFERRED edges
- Semantic similarity: if two concepts solve the same problem without a structural link, add `semantically_similar_to` INFERRED edge (confidence 0.6-0.95). Non-obvious cross-file links only.
- Hyperedges: if 3+ nodes share a concept/flow not captured by pairwise edges, add a hyperedge. Max 3 per file.
- confidence_score REQUIRED on every edge: EXTRACTED=1.0, INFERRED=0.6-0.9 (reason individually), AMBIGUOUS=0.1-0.3
3. Accumulate results across all files
Schema for each file's output:
{"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image|rationale","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0}
After processing all files, write the accumulated result to `.graphify_semantic_new.json`.
**Step B3 - Cache and merge**
For the accumulated result:
If more than half the chunks failed, stop and tell the user.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat .graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text())
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2))
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat .graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('.graphify_semantic_new.json').read_text()) if Path('.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []))
print(f'Cached {saved} files')
"
```
Merge cached + new results into `.graphify_semantic.json`:
```bash
$(cat .graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('.graphify_cached.json').read_text()) if Path('.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('.graphify_semantic_new.json').read_text()) if Path('.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('.graphify_semantic.json').write_text(json.dumps(merged, indent=2))
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_cached.json .graphify_uncached.txt .graphify_semantic_new.json`
##### Part C - Merge AST + semantic into final extraction
```bash
$(cat .graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('.graphify_ast.json').read_text())
sem = json.loads(Path('.graphify_semantic.json').read_text())
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('.graphify_extract.json').write_text(json.dumps(merged, indent=2))
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
#### Step 4 - Build graph, cluster, analyze, generate outputs
```bash
mkdir -p graphify-out
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
detection = json.loads(Path('.graphify_detect.json').read_text())
G = build_from_json(extraction)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report)
to_json(G, communities, 'graphify-out/graph.json')
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('.graphify_analysis.json').write_text(json.dumps(analysis, indent=2))
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
#### Step 5 - Label communities
Read `.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
detection = json.loads(Path('.graphify_detect.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report)
Path('.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}))
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
#### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_obsidian, to_canvas
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
labels_raw = json.loads(Path('.graphify_labels.json').read_text()) if Path('.graphify_labels.json').exists() else {}
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
labels = {int(k): v for k, v in labels_raw.items()}
n = to_obsidian(G, communities, 'graphify-out/obsidian', community_labels=labels or None, cohesion=cohesion)
print(f'Obsidian vault: {n} notes in graphify-out/obsidian/')
to_canvas(G, communities, 'graphify-out/obsidian/graph.canvas', community_labels=labels or None)
print('Canvas: graphify-out/obsidian/graph.canvas - open in Obsidian for structured community layout')
print()
print('Open graphify-out/obsidian/ as a vault in Obsidian.')
print(' Graph view - nodes colored by community (set automatically)')
print(' graph.canvas - structured layout with communities as groups')
print(' _COMMUNITY_* - overview notes with cohesion scores and dataview queries')
"
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_html
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
labels_raw = json.loads(Path('.graphify_labels.json').read_text()) if Path('.graphify_labels.json').exists() else {}
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
labels = {int(k): v for k, v in labels_raw.items()}
if G.number_of_nodes() > 5000:
print(f'Graph has {G.number_of_nodes()} nodes - too large for HTML viz. Use Obsidian vault instead.')
else:
to_html(G, communities, 'graphify-out/graph.html', community_labels=labels or None)
print('graph.html written - open in any browser, no server needed')
"
```
#### Step 7 - Export formats (optional, only if flagged)
##### Neo4j export (only if --neo4j or --neo4j-push flag)
**If `--neo4j`** - generate a Cypher file for manual import:
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_cypher
from pathlib import Path
G = build_from_json(json.loads(Path('.graphify_extract.json').read_text()))
to_cypher(G, 'graphify-out/cypher.txt')
print('cypher.txt written - import with: cypher-shell < graphify-out/cypher.txt')
"
```
**If `--neo4j-push <uri>`** - push directly to a running Neo4j instance. Ask the user for credentials if not provided:
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster
from graphify.export import push_to_neo4j
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
result = push_to_neo4j(G, uri='NEO4J_URI', user='NEO4J_USER', password='NEO4J_PASSWORD', communities=communities)
print(f'Pushed to Neo4j: {result[\"nodes\"]} nodes, {result[\"edges\"]} edges')
"
```
Replace `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD` with actual values. Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates.
##### SVG export (only if --svg flag)
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_svg
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
labels_raw = json.loads(Path('.graphify_labels.json').read_text()) if Path('.graphify_labels.json').exists() else {}
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
labels = {int(k): v for k, v in labels_raw.items()}
to_svg(G, communities, 'graphify-out/graph.svg', community_labels=labels or None)
print('graph.svg written - embeds in Obsidian, Notion, GitHub READMEs')
"
```
##### GraphML export (only if --graphml flag)
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_graphml
from pathlib import Path
extraction = json.loads(Path('.graphify_extract.json').read_text())
analysis = json.loads(Path('.graphify_analysis.json').read_text())
G = build_from_json(extraction)
communities = {int(k): v for k, v in analysis['communities'].items()}
to_graphml(G, communities, 'graphify-out/graph.graphml')
print('graph.graphml written - open in Gephi, yEd, or any GraphML tool')
"
```
##### MCP server (only if --mcp flag)
```bash
python3 -m graphify.serve graphify-out/graph.json
```
This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live.
To configure in Claude Desktop, add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"graphify": {
"command": "python3",
"args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"]
}
}
}
```
#### Step 8 - Token reduction benchmark (only if total_words > 5000)
If `total_words` from `.graphify_detect.json` is greater than 5,000, run:
```bash
$(cat .graphify_python) -c "
import json
from graphify.benchmark import run_benchmark, print_benchmark
from pathlib import Path
detection = json.loads(Path('.graphify_detect.json').read_text())
result = run_benchmark('graphify-out/graph.json', corpus_words=detection['total_words'])
print_benchmark(result)
"
```
Print the output directly in chat. If `total_words <= 5000`, skip silently - the graph value is structural clarity, not token compression, for small corpora.
---
#### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat .graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('.graphify_detect.json').read_text())
save_manifest(detect['files'])
# Update cumulative cost tracker
extract = json.loads(Path('.graphify_extract.json').read_text())
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text())
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2))
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f .graphify_detect.json .graphify_extract.json .graphify_ast.json .graphify_semantic.json .graphify_analysis.json .graphify_labels.json .graphify_chunk_*.json
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, use the `graphify_query` extension tool to answer the question and walk them through it using the graph structure. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## For --update (incremental re-extraction)
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
**If all changed files are code files:** use the `graphify_update` extension tool — it handles code-only updates without LLM semantic extraction.
**If changed files include docs, papers, or images:** follow the full pipeline Steps 19, but replace Step 2's `detect` with `detect_incremental`:
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.detect import detect_incremental, save_manifest
from pathlib import Path
result = detect_incremental(Path('INPUT_PATH'))
new_total = result.get('new_total', 0)
print(json.dumps(result, indent=2))
Path('.graphify_incremental.json').write_text(json.dumps(result))
if new_total == 0:
print('No files changed since last run. Nothing to update.')
raise SystemExit(0)
print(f'{new_total} new/changed file(s) to re-extract.')
"
```
Then check whether all changed files are code files:
```bash
$(cat .graphify_python) -c "
import json
from pathlib import Path
result = json.loads(open('.graphify_incremental.json').read()) if Path('.graphify_incremental.json').exists() else {}
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts'}
new_files = result.get('new_files', {})
all_changed = [f for files in new_files.values() for f in files]
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
print('code_only:', code_only)
"
```
If `code_only` is True: print `[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed)`, run only Step 3A (AST) on the changed files, skip Step 3B entirely (no subagents), then go straight to merge and Steps 48.
If `code_only` is False (any changed file is a doc/paper/image): run the full Steps 3A3C pipeline as normal.
Then:
```bash
$(cat .graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.export import to_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
# Load existing graph
existing_data = json.loads(Path('graphify-out/graph.json').read_text())
G_existing = json_graph.node_link_graph(existing_data, edges='links')
# Load new extraction
new_extraction = json.loads(Path('.graphify_extract.json').read_text())
G_new = build_from_json(new_extraction)
# Merge: new nodes/edges into existing graph
G_existing.update(G_new)
print(f'Merged: {G_existing.number_of_nodes()} nodes, {G_existing.number_of_edges()} edges')
"
```
Then run Steps 48 on the merged graph as normal.
After Step 4, show the graph diff:
```bash
$(cat .graphify_python) -c "
import json
from graphify.analyze import graph_diff
from graphify.build import build_from_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path
# Load old graph (before update) from backup written before merge
old_data = json.loads(Path('.graphify_old.json').read_text()) if Path('.graphify_old.json').exists() else None
new_extract = json.loads(Path('.graphify_extract.json').read_text())
G_new = build_from_json(new_extract)
if old_data:
G_old = json_graph.node_link_graph(old_data, edges='links')
diff = graph_diff(G_old, G_new)
print(diff['summary'])
if diff['new_nodes']:
print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
if diff['new_edges']:
print('New edges:', len(diff['new_edges']))
"
```
Before the merge step, save the old graph: `cp graphify-out/graph.json .graphify_old.json`
Clean up after: `rm -f .graphify_old.json`
---
## For --cluster-only
Use the `graphify_cluster` extension tool.
---
## For query, path, explain, add, watch
Use the corresponding extension tools:
| Subcommand | Tool |
|------------|------|
| `query` | `graphify_query` |
| `path` | `graphify_path` |
| `explain` | `graphify_explain` |
| `add` | `graphify_add` |
| `watch` | `graphify_watch` |
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.

View File

@@ -0,0 +1,88 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createTestSession, says, type TestSession, when } from "@gaodes/pi-test-harness";
import { afterEach, describe, expect, it } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../..");
const COMMANDS_ENTRY = path.resolve(PROJECT_ROOT, "src/commands/index.ts");
function createBashMock() {
return (params: Record<string, unknown>) => {
const cmd = String(params.command ?? "");
if (cmd.includes("cat graphify-out/.graphify_python")) {
return `$ ${cmd}\n/usr/bin/python3`;
}
if (cmd.includes("import graphify") && cmd.includes("echo $?")) {
return `$ ${cmd}\n0`;
}
return `$ ${cmd}\n`;
};
}
function textOfContent(content: unknown): string {
if (!Array.isArray(content)) return String(content ?? "");
return content
.map((c) => (typeof c === "object" && c !== null && "text" in c ? String(c.text ?? "") : ""))
.join("");
}
describe("/graphify command integration", () => {
let t: TestSession;
afterEach(() => t?.dispose());
async function createSession() {
const session = await createTestSession({
extensions: [COMMANDS_ENTRY],
mockTools: {
bash: createBashMock(),
},
});
// Polyfill setTools for pi-agent-core compatibility
// biome-ignore lint/suspicious/noExplicitAny: compatibility shim accessing untyped internals
const agent = (session.session as any).agent;
if (agent && !agent.setTools) {
agent.setTools = (tools: unknown[]) => {
agent.state.tools = tools;
};
}
return session;
}
it("forwards build flags into graphify_build params contract", async () => {
t = await createSession();
await t.run(
when("/graphify . --mode deep --no-viz --obsidian --svg --graphml --neo4j", [says("ok")]),
);
const userMessages = t.events.messages.filter((m) => m.role === "user");
expect(userMessages.length).toBeGreaterThan(0);
const promptText = textOfContent(userMessages[0].content);
expect(promptText).toContain("Use the graphify_build tool with these exact params");
expect(promptText).toContain('"path":"."');
expect(promptText).toContain('"mode":"deep"');
expect(promptText).toContain('"no_viz":true');
expect(promptText).toContain('"obsidian":true');
expect(promptText).toContain('"svg":true');
expect(promptText).toContain('"graphml":true');
expect(promptText).toContain('"neo4j":true');
});
it("parses --debounce for watch subcommand", async () => {
t = await createSession();
await t.run(when("/graphify watch . --debounce 7", [says("ok")]));
const userMessages = t.events.messages.filter((m) => m.role === "user");
expect(userMessages.length).toBeGreaterThan(0);
const promptText = textOfContent(userMessages[0].content);
expect(promptText).toContain("Use the graphify_watch tool");
expect(promptText).toContain('watch "." for changes with debounce 7s');
});
});

View File

@@ -0,0 +1,564 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { AutocompleteItem } from "@earendil-works/pi-tui";
import { ensurePrimeSettings, loadConfig, type ResolvedConfig } from "../config";
import type { ExecFn } from "../lib/runner";
import {
clusterOnly,
detectPython,
ensureInstalled,
explainNode,
findPath,
hookAction,
queryGraph,
updateGraph,
} from "../lib/runner";
// ---------------------------------------------------------------------------
// Autocomplete definitions
// ---------------------------------------------------------------------------
const SUBCOMMANDS: AutocompleteItem[] = [
{
value: "",
label: "<path>",
description: "Build graph from directory (full pipeline)",
},
{
value: "query",
label: "query",
description: "Query the graph — BFS for broad context, DFS for tracing paths",
},
{
value: "path",
label: "path",
description: "Find shortest path between two concepts",
},
{
value: "explain",
label: "explain",
description: "Plain-language explanation of a node",
},
{
value: "add",
label: "add",
description: "Fetch a URL and add it to the corpus",
},
{
value: "update",
label: "update",
description: "Incremental update — re-extract only changed files",
},
{
value: "watch",
label: "watch",
description: "Watch directory for changes, auto-rebuild graph",
},
{
value: "cluster",
label: "cluster",
description: "Re-run clustering on existing graph (no re-extraction)",
},
{
value: "hook",
label: "hook",
description: "Manage git hooks (install/uninstall/status)",
},
];
const BUILD_FLAGS: AutocompleteItem[] = [
{
value: "--mode deep",
label: "--mode deep",
description: "More aggressive relationship inference",
},
{ value: "--no-viz", label: "--no-viz", description: "Skip HTML visualization" },
{ value: "--obsidian", label: "--obsidian", description: "Generate Obsidian vault" },
{ value: "--svg", label: "--svg", description: "Export graph.svg" },
{ value: "--graphml", label: "--graphml", description: "Export for Gephi / yEd" },
{ value: "--neo4j", label: "--neo4j", description: "Generate cypher.txt for Neo4j" },
{
value: "--update",
label: "--update",
description: "Incremental — re-extract only changed files",
},
{
value: "--cluster-only",
label: "--cluster-only",
description: "Rerun clustering on existing graph",
},
];
const QUERY_FLAGS: AutocompleteItem[] = [
{ value: "--dfs", label: "--dfs", description: "DFS traversal — trace a specific path" },
{
value: "--budget",
label: "--budget N",
description: "Token budget for the answer (default 2000)",
},
];
function getCompletions(argumentPrefix: string): AutocompleteItem[] {
const parts = argumentPrefix.trim().split(/\s+/);
if (parts.length <= 1) {
const prefix = parts[0] ?? "";
if (prefix.startsWith("--")) {
return BUILD_FLAGS.filter((f) => f.value.startsWith(prefix));
}
return SUBCOMMANDS.filter((s) => s.value === "" || s.value.startsWith(prefix.toLowerCase()));
}
const subcommand = parts[0].toLowerCase();
switch (subcommand) {
case "query": {
if (parts[parts.length - 1]?.startsWith("--")) {
return QUERY_FLAGS.filter((f) => f.value.startsWith(parts[parts.length - 1] ?? ""));
}
return [
{
value: `query "${parts.slice(1).join(" ")}`,
label: `"${parts.slice(1).join(" ")}..."`,
description: "Your question (wrap in quotes)",
},
];
}
case "path": {
return [
{
value: `path ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || '"A" "B"',
description: "Two concept names in quotes",
},
];
}
case "explain": {
return [
{
value: `explain ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || "ConceptName",
description: "Name of the concept to explain",
},
];
}
case "add": {
const addFlags: AutocompleteItem[] = [
{ value: "--author", label: '--author "Name"', description: "Tag who wrote it" },
{
value: "--contributor",
label: '--contributor "Name"',
description: "Tag who added it",
},
];
if (parts[parts.length - 1]?.startsWith("--")) {
return addFlags.filter((f) => f.value.startsWith(parts[parts.length - 1] ?? ""));
}
return [
{
value: `add ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || "<url>",
description: "URL to fetch and add",
},
];
}
case "update": {
return [
{
value: `update ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || ".",
description: "Directory path to update",
},
];
}
case "watch": {
return [
{
value: `watch ${parts.slice(1).join(" ")}`,
label: parts.slice(1).join(" ") || ".",
description: "Directory path to watch",
},
];
}
case "cluster": {
return [{ value: "cluster", label: "cluster", description: "Re-cluster existing graph" }];
}
case "hook": {
const hookActions: AutocompleteItem[] = [
{ value: "hook install", label: "install", description: "Install git hooks" },
{ value: "hook uninstall", label: "uninstall", description: "Remove git hooks" },
{ value: "hook status", label: "status", description: "Check hook status" },
];
const partial = parts.slice(1).join(" ").toLowerCase();
return hookActions.filter((h) => h.label.startsWith(partial || h.label));
}
default: {
if (parts[parts.length - 1]?.startsWith("--")) {
return BUILD_FLAGS.filter((f) => f.value.startsWith(parts[parts.length - 1] ?? ""));
}
return BUILD_FLAGS;
}
}
}
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
interface ParsedArgs {
subcommand:
| "build"
| "query"
| "path"
| "explain"
| "add"
| "update"
| "watch"
| "cluster"
| "hook";
positionals: string[];
flags: Record<string, string | boolean>;
}
function parseArgs(raw: string): ParsedArgs {
const tokens = raw.trim().split(/\s+/).filter(Boolean);
const flags: Record<string, string | boolean> = {};
const positionals: string[] = [];
let i = 0;
let subcommand: ParsedArgs["subcommand"] = "build";
if (tokens.length > 0) {
const first = tokens[0].toLowerCase();
if (
["query", "path", "explain", "add", "update", "watch", "cluster", "hook"].includes(first) &&
!first.startsWith("-")
) {
subcommand = first as ParsedArgs["subcommand"];
i = 1;
}
}
while (i < tokens.length) {
const token = tokens[i];
if (token === "--mode" && tokens[i + 1]) {
flags.mode = tokens[i + 1];
i += 2;
} else if (token === "--budget" && tokens[i + 1]) {
flags.budget = tokens[i + 1];
i += 2;
} else if (token === "--author" && tokens[i + 1]) {
flags.author = tokens[i + 1];
i += 2;
} else if (token === "--contributor" && tokens[i + 1]) {
flags.contributor = tokens[i + 1];
i += 2;
} else if (token === "--debounce" && tokens[i + 1]) {
flags.debounce = tokens[i + 1];
i += 2;
} else if (token.startsWith("--")) {
flags[token.slice(2)] = true;
i++;
} else {
positionals.push(token);
i++;
}
}
return { subcommand, positionals, flags };
}
// ---------------------------------------------------------------------------
// Exec adapter for commands
// ---------------------------------------------------------------------------
function createExec(pi: ExtensionAPI, cwd: string): ExecFn {
return async (command, options) => {
const result = await pi.exec("sh", ["-c", command], {
cwd: options?.cwd ?? cwd,
signal: options?.signal,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.code,
};
};
}
// ---------------------------------------------------------------------------
// Command handlers
// ---------------------------------------------------------------------------
async function handleBuild(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
flags: Record<string, string | boolean>,
) {
const inputPath = positionals[0] ?? ".";
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
if (flags["cluster-only"] === true) {
const result = await pi.exec(
"sh",
[
"-c",
`${python} -c "
import json
from networkx.readwrite import json_graph
from graphify.cluster import cluster, score_all
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
data = json.loads(Path('graphify-out/graph.json').read_text())
G = json_graph.node_link_graph(data, edges='links')
communities = cluster(G)
to_json(G, communities, 'graphify-out/graph.json')
print(f'Re-clustered: {len(communities)} communities')
"`,
],
{ cwd: ctx.cwd },
);
await ctx.ui.notify(result.stdout.trim() || "Re-clustered graph.");
return;
}
if (flags.update === true) {
const result = await updateGraph(exec, python, ctx.cwd, inputPath);
await ctx.ui.notify(
result.newFiles === 0
? "No files changed. Graph is up to date."
: `Updated: ${result.newFiles} files re-extracted. ${result.nodes} nodes, ${result.edges} edges.`,
);
return;
}
const buildArgs = {
path: inputPath,
...(flags.mode === "deep" ? { mode: "deep" } : {}),
...(flags["no-viz"] === true ? { no_viz: true } : {}),
...(flags.obsidian === true ? { obsidian: true } : {}),
...(flags.svg === true ? { svg: true } : {}),
...(flags.graphml === true ? { graphml: true } : {}),
...(flags.neo4j === true ? { neo4j: true } : {}),
};
// Full build — send a message to the agent so it uses the tool with explicit params
pi.sendUserMessage(
`Use the graphify_build tool with these exact params: ${JSON.stringify(buildArgs)}. After the graph is built, read graphify-out/GRAPH_REPORT.md and show me the God Nodes, Surprising Connections, and Suggested Questions.`,
);
}
async function handleQuery(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
flags: Record<string, string | boolean>,
) {
const question = positionals.join(" ").replace(/^["']|["']$/g, "");
if (!question) {
await ctx.ui.notify('Usage: /graphify query "<question>"', "warning");
return;
}
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
const mode = flags.dfs === true ? "dfs" : "bfs";
const budget = typeof flags.budget === "string" ? Number.parseInt(flags.budget, 10) : 2000;
const result = await queryGraph(exec, python, ctx.cwd, { question, mode, budget });
pi.sendUserMessage(
`Based on the graph query result below, answer this question: "${question}"\n\nGraph traversal result:\n${result}`,
);
}
async function handlePath(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
) {
if (positionals.length < 2) {
await ctx.ui.notify('Usage: /graphify path "ConceptA" "ConceptB"', "warning");
return;
}
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
const result = await findPath(exec, python, ctx.cwd, positionals[0], positionals[1]);
pi.sendUserMessage(`Explain this graph path in plain language:\n${result}`);
}
async function handleExplain(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
) {
const concept = positionals.join(" ").replace(/^["']|["']$/g, "");
if (!concept) {
await ctx.ui.notify('Usage: /graphify explain "ConceptName"', "warning");
return;
}
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
const result = await explainNode(exec, python, ctx.cwd, concept);
pi.sendUserMessage(
`Based on the graph data below, provide a plain-language explanation of "${concept}":\n${result}`,
);
}
async function handleAdd(
pi: ExtensionAPI,
positionals: string[],
flags: Record<string, string | boolean>,
) {
const url = positionals[0];
if (!url) {
await pi.sendUserMessage("Usage: /graphify add <url>");
return;
}
const authorStr = typeof flags.author === "string" ? ` (author: ${flags.author})` : "";
const contributorStr =
typeof flags.contributor === "string" ? ` (contributor: ${flags.contributor})` : "";
pi.sendUserMessage(
`Use the graphify_add tool to fetch and add this URL to the corpus: ${url}${authorStr}${contributorStr}. After adding it, run an incremental graph update.`,
);
}
async function handleUpdate(pi: ExtensionAPI, positionals: string[]) {
const inputPath = positionals[0] ?? ".";
pi.sendUserMessage(
`Use the graphify_update tool to incrementally update the knowledge graph for path "${inputPath}".`,
);
}
async function handleWatch(
pi: ExtensionAPI,
_ctx: ExtensionCommandContext,
_config: ResolvedConfig,
positionals: string[],
flags: Record<string, string | boolean>,
) {
const inputPath = positionals[0] ?? ".";
const debounce = typeof flags.debounce === "string" ? flags.debounce : "3";
pi.sendUserMessage(
`Use the graphify_watch tool to watch "${inputPath}" for changes with debounce ${debounce}s. Run it as a background process.`,
);
}
async function handleCluster(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
) {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
try {
const result = await clusterOnly(exec, python, ctx.cwd);
await ctx.ui.notify(`Re-clustered: ${result.communities} communities`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await ctx.ui.notify(`Cluster failed: ${message}`, "error");
}
}
async function handleHook(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
config: ResolvedConfig,
positionals: string[],
) {
const action = positionals[0];
if (!action || !["install", "uninstall", "status"].includes(action)) {
await ctx.ui.notify("Usage: /graphify hook <install|uninstall|status>", "warning");
return;
}
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd);
await ensureInstalled(exec, python, ctx.cwd);
const result = await hookAction(
exec,
python,
ctx.cwd,
action as "install" | "uninstall" | "status",
);
await ctx.ui.notify(result);
}
// ---------------------------------------------------------------------------
// Command registration
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
ensurePrimeSettings();
const config = loadConfig(process.cwd());
if (!config.enabled) return;
pi.registerCommand("graphify", {
description: "Knowledge graph: build, query, explore, and update graphs from directories",
getArgumentCompletions(argumentPrefix: string): AutocompleteItem[] {
return getCompletions(argumentPrefix);
},
async handler(args: string, ctx: ExtensionCommandContext) {
const parsed = parseArgs(args);
try {
switch (parsed.subcommand) {
case "build":
await handleBuild(pi, ctx, config, parsed.positionals, parsed.flags);
break;
case "query":
await handleQuery(pi, ctx, config, parsed.positionals, parsed.flags);
break;
case "path":
await handlePath(pi, ctx, config, parsed.positionals);
break;
case "explain":
await handleExplain(pi, ctx, config, parsed.positionals);
break;
case "add":
await handleAdd(pi, parsed.positionals, parsed.flags);
break;
case "update":
await handleUpdate(pi, parsed.positionals);
break;
case "watch":
await handleWatch(pi, ctx, config, parsed.positionals, parsed.flags);
break;
case "cluster":
await handleCluster(pi, ctx, config);
break;
case "hook":
await handleHook(pi, ctx, config, parsed.positionals);
break;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await ctx.ui.notify(`Graphify error: ${message}`, "error");
}
},
});
}

View File

@@ -0,0 +1,162 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import type { StatusbarConfig } from "./statusbar.js";
export const EXTENSION_ID = "pi-graphify";
export const PRIME_SETTINGS_FILE = "prime-settings.json";
export interface RawConfig {
enabled?: boolean;
pythonPath?: string;
outputDir?: string;
statusbar?: StatusbarConfig;
}
export interface ResolvedConfig {
enabled: boolean;
pythonPath: string;
outputDir: string;
statusbar?: StatusbarConfig;
}
export const DEFAULT_CONFIG: ResolvedConfig = {
enabled: true,
pythonPath: "python3",
outputDir: "graphify-out",
statusbar: {
enabled: true,
icon: "f035b",
icon_color: "accent",
icon_color_uninitialized: "dim",
text_font_color: "dim",
text_font_color_uninitialized: "dim",
show_icon: true,
show_text: true,
placement: { line: 2, side: "left", index: 4 },
separator_before: { icon: "eb8a", icon_color: "dim" },
separator_after: { icon: "eb8a", icon_color: "dim" },
},
};
function readJsonFile(path: string): Record<string, unknown> | undefined {
try {
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
} catch {
return undefined;
}
}
export function resolveConfig(...configs: Array<RawConfig | undefined>): ResolvedConfig {
const resolved: ResolvedConfig = { ...DEFAULT_CONFIG };
for (const raw of configs) {
if (!raw) continue;
if (raw.enabled !== undefined) resolved.enabled = raw.enabled;
if (raw.pythonPath !== undefined) resolved.pythonPath = raw.pythonPath;
if (raw.outputDir !== undefined) resolved.outputDir = raw.outputDir;
if (raw.statusbar !== undefined) resolved.statusbar = raw.statusbar;
}
return resolved;
}
export function loadConfig(cwd: string): ResolvedConfig {
const globalPath = join(getAgentDir(), PRIME_SETTINGS_FILE);
const projectPath = join(cwd, ".pi", PRIME_SETTINGS_FILE);
const globalSettings = existsSync(globalPath) ? readJsonFile(globalPath) : undefined;
const projectSettings = existsSync(projectPath) ? readJsonFile(projectPath) : undefined;
return resolveConfig(
globalSettings?.[EXTENSION_ID] as RawConfig | undefined,
projectSettings?.[EXTENSION_ID] as RawConfig | undefined,
);
}
// ---------------------------------------------------------------------------
// Auto-seed defaults into global prime-settings.json
// ---------------------------------------------------------------------------
const DEFAULT_STATUSBAR_CONFIG: StatusbarConfig = {
enabled: true,
icon: "f035b",
icon_color: "accent",
icon_color_uninitialized: "dim",
text_font_color: "dim",
text_font_color_uninitialized: "dim",
show_icon: true,
show_text: true,
placement: { line: 2, side: "left", index: 4 },
separator_before: { icon: "eb8a", icon_color: "dim" },
separator_after: { icon: "eb8a", icon_color: "dim" },
};
const DEFAULT_EXTENSION_SETTINGS: Record<string, unknown> = {
enabled: true,
pythonPath: "python3",
outputDir: "graphify-out",
statusbar: DEFAULT_STATUSBAR_CONFIG,
};
/**
* Ensure the extension's config exists in prime-settings.json.
*
* - If the key "pi-graphify" is missing, seeds full defaults.
* - If only the legacy key "graphify" exists, migrates it to "pi-graphify".
* - Expands a minimal statusbar config to the full config.
* - Only writes when changes are actually needed.
*/
export function ensurePrimeSettings(): void {
const agentDir = getAgentDir();
const primeSettingsPath = join(agentDir, PRIME_SETTINGS_FILE);
// Only operate when prime-settings.json already exists (real Pi install)
if (!existsSync(primeSettingsPath)) return;
let settings: Record<string, unknown>;
try {
settings = JSON.parse(readFileSync(primeSettingsPath, "utf-8")) as Record<string, unknown>;
} catch {
return;
}
let changed = false;
// Migrate legacy "graphify" key → "pi-graphify"
if ("graphify" in settings && !(EXTENSION_ID in settings)) {
settings[EXTENSION_ID] = settings.graphify;
delete settings.graphify;
changed = true;
}
// Seed defaults if key is missing
if (!(EXTENSION_ID in settings)) {
settings[EXTENSION_ID] = { ...DEFAULT_EXTENSION_SETTINGS };
changed = true;
}
// Seed or expand statusbar sub-key inside existing pi-graphify config
const extensionSettings = settings[EXTENSION_ID] as Record<string, unknown>;
if (!("statusbar" in extensionSettings)) {
extensionSettings.statusbar = { ...DEFAULT_STATUSBAR_CONFIG };
changed = true;
} else {
const sb = extensionSettings.statusbar as Record<string, unknown>;
// Expand a minimal statusbar config to the full config
const needsExpansion =
!("icon" in sb) &&
!("placement" in sb) &&
!("separator_before" in sb) &&
!("separator_after" in sb);
if (needsExpansion) {
extensionSettings.statusbar = { ...DEFAULT_STATUSBAR_CONFIG, ...sb };
changed = true;
}
}
if (!changed) return;
try {
writeFileSync(primeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
} catch (error) {
console.warn("Failed to write prime-settings.json:", error);
}
}

View File

@@ -0,0 +1,226 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { detectFiles, detectPython, ensureGraphifyGitignore, ensureInstalled } from "./runner";
// ---------------------------------------------------------------------------
// Mock exec function
// ---------------------------------------------------------------------------
function createMockExec(
responses: Record<string, { stdout: string; stderr: string; exitCode: number }>,
) {
return vi.fn(async (cmd: string, _opts?: unknown) => {
for (const [pattern, response] of Object.entries(responses)) {
if (cmd.includes(pattern)) return response;
}
return { stdout: "", stderr: "unknown command", exitCode: 1 };
});
}
// ---------------------------------------------------------------------------
// detectPython
// ---------------------------------------------------------------------------
describe("detectPython", () => {
it("returns cached python from graphify-out/.graphify_python", async () => {
const mockExec = createMockExec({
"cat graphify-out/.graphify_python": {
stdout: "/usr/bin/python3\n",
stderr: "",
exitCode: 0,
},
});
const result = await detectPython(mockExec, "python3", "/tmp/test");
expect(result).toBe("/usr/bin/python3");
});
it("detects python from graphify shebang when no cache", async () => {
const mockExec = createMockExec({
"cat graphify-out": { stdout: "", stderr: "", exitCode: 1 },
"which graphify": { stdout: "/usr/local/bin/graphify\n", stderr: "", exitCode: 0 },
"head -1": { stdout: "#!/usr/local/bin/python3.11\n", stderr: "", exitCode: 0 },
});
const result = await detectPython(mockExec, "python3", "/tmp/test");
expect(result).toBe("/usr/local/bin/python3.11");
});
it("falls back to config python when graphify not found", async () => {
const mockExec = createMockExec({
"cat graphify-out": { stdout: "", stderr: "", exitCode: 1 },
"which graphify": { stdout: "", stderr: "", exitCode: 1 },
});
const result = await detectPython(mockExec, "python3.12", "/tmp/test");
expect(result).toBe("python3.12");
});
});
// ---------------------------------------------------------------------------
// ensureInstalled
// ---------------------------------------------------------------------------
describe("ensureInstalled", () => {
it("skips install when graphify is importable", async () => {
const mockExec = vi.fn(async (cmd: string, _opts?: unknown) => {
// All calls return success
if (cmd.includes("import graphify")) {
return { stdout: "0", stderr: "", exitCode: 0 };
}
return { stdout: "", stderr: "", exitCode: 0 };
});
await ensureInstalled(mockExec, "python3", "/tmp/test");
// Only called once (the initial check), no pip install
expect(mockExec).toHaveBeenCalledTimes(1);
expect(mockExec).not.toHaveBeenCalledWith(
expect.stringContaining("pip install"),
expect.anything(),
);
});
it("installs graphifyy when not importable", async () => {
let callIdx = 0;
const mockExec = vi.fn(async (cmd: string, _opts?: unknown) => {
callIdx++;
// First call: check import → fails
if (callIdx === 1 && cmd.includes("import graphify")) {
return { stdout: "1", stderr: "", exitCode: 0 };
}
// Second call: pip install → succeeds
if (cmd.includes("pip install")) {
return { stdout: "", stderr: "", exitCode: 0 };
}
// Third call: verify → succeeds
if (callIdx === 3 && cmd.includes("import graphify")) {
return { stdout: "0", stderr: "", exitCode: 0 };
}
return { stdout: "", stderr: "", exitCode: 0 };
});
await ensureInstalled(mockExec, "python3", "/tmp/test");
expect(mockExec).toHaveBeenCalledWith(
expect.stringContaining("pip install graphifyy"),
expect.anything(),
);
});
});
// ---------------------------------------------------------------------------
// detectFiles
// ---------------------------------------------------------------------------
describe("detectFiles", () => {
it("parses detection output correctly", async () => {
const detectionResult = {
total_files: 5,
total_words: 1200,
files: {
code: ["/tmp/test/main.ts", "/tmp/test/util.ts"],
document: ["/tmp/test/README.md"],
paper: [],
image: [],
video: [],
},
};
const mockExec = createMockExec({
"from graphify.detect import detect": {
stdout: JSON.stringify(detectionResult),
stderr: "",
exitCode: 0,
},
});
const result = await detectFiles(mockExec, "python3", "/tmp/test", "/tmp/test");
expect(result.total_files).toBe(5);
expect(result.total_words).toBe(1200);
expect(result.files.code).toHaveLength(2);
expect(result.files.document).toHaveLength(1);
});
it("throws on detection failure", async () => {
const mockExec = createMockExec({
"from graphify.detect import detect": {
stdout: "",
stderr: "ModuleNotFoundError: No module named 'graphify'",
exitCode: 1,
},
});
await expect(detectFiles(mockExec, "python3", "/tmp/test", "/tmp/test")).rejects.toThrow(
"graphify detect failed",
);
});
});
// ---------------------------------------------------------------------------
// ensureGraphifyGitignore
// ---------------------------------------------------------------------------
describe("ensureGraphifyGitignore", () => {
it("creates .gitignore with graphify cache exclusions when missing", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-graphify-test-"));
try {
const result = await ensureGraphifyGitignore(dir);
expect(result.updated).toBe(true);
const content = await readFile(join(dir, ".gitignore"), "utf-8");
expect(content).toContain("graphify-out/cache/");
expect(content).toContain("graphify-out/.graphify_python");
expect(content).toContain("graphify-out/.graphify_root");
expect(content).toContain("graphify-out/cost.json");
expect(content).not.toContain("graphify-out/\n");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("removes legacy graphify-out/ ignore and preserves other entries", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-graphify-test-"));
try {
await writeFile(
join(dir, ".gitignore"),
"node_modules/\ngraphify-out/\ncustom-file.txt\n",
"utf-8",
);
const result = await ensureGraphifyGitignore(dir);
expect(result.updated).toBe(true);
const content = await readFile(join(dir, ".gitignore"), "utf-8");
expect(content).toContain("node_modules/");
expect(content).toContain("custom-file.txt");
expect(content).toContain("graphify-out/cache/");
expect(content).toContain("graphify-out/.graphify_python");
expect(content).toContain("graphify-out/.graphify_root");
expect(content).toContain("graphify-out/cost.json");
expect(content).not.toContain("\ngraphify-out/\n");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("is idempotent when required entries already exist", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-graphify-test-"));
try {
const expected =
"node_modules/\ngraphify-out/cache/\ngraphify-out/.graphify_python\ngraphify-out/.graphify_root\ngraphify-out/cost.json\n";
await writeFile(join(dir, ".gitignore"), expected, "utf-8");
const result = await ensureGraphifyGitignore(dir);
expect(result.updated).toBe(false);
const content = await readFile(join(dir, ".gitignore"), "utf-8");
expect(content).toBe(expected);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { ResolvedConfig } from "./config.js";
const MODULE_ID = "pi-graphify";
// ── Config Interface ───────────────────────────────────────────────────────
export interface StatusbarConfig {
enabled?: boolean;
placement?: {
line?: number; // 1-based line number
side?: "left" | "right";
index?: number; // Position within side (0 = first)
};
show_icon?: boolean;
show_text?: boolean;
icon?: string; // Hex code
icon_color?: string; // "accent", "dim", "success", "warning", "error"
icon_color_uninitialized?: string;
text_font_color?: string;
text_font_color_uninitialized?: string;
separator_before?: { icon?: string; text?: string; icon_color?: string };
separator_after?: { icon?: string; text?: string; icon_color?: string };
auto_heal?: {
enabled?: boolean;
max_retries?: number;
retry_interval_ms?: number;
};
}
// ── State ──────────────────────────────────────────────────────────────────
export interface StatusbarState {
consecutiveFailures: number;
lastErrorAt: number;
healedAt: number;
isDegraded: boolean;
}
function makeStatusbarState(): StatusbarState {
return { consecutiveFailures: 0, lastErrorAt: 0, healedAt: 0, isDegraded: false };
}
// ── Helpers ────────────────────────────────────────────────────────────────
function emitStatusbarEvent(
pi: ExtensionAPI,
event: string,
data: Record<string, unknown>,
): boolean {
try {
pi.events.emit(event, data);
return true;
} catch {
return false;
}
}
function getDefaultPlacement(): NonNullable<StatusbarConfig["placement"]> {
return { line: 2, side: "left", index: 4 };
}
function resolveStatusbarConfig(config: ResolvedConfig): StatusbarConfig {
const sb = config.statusbar ?? {};
return {
enabled: sb.enabled !== false,
placement: { ...getDefaultPlacement(), ...sb.placement },
show_icon: sb.show_icon !== false,
show_text: sb.show_text !== false,
icon: sb.icon ?? "f035b",
icon_color: sb.icon_color ?? "accent",
icon_color_uninitialized: sb.icon_color_uninitialized ?? "dim",
text_font_color: sb.text_font_color ?? "dim",
text_font_color_uninitialized: sb.text_font_color_uninitialized ?? "dim",
separator_before: sb.separator_before,
separator_after: sb.separator_after,
auto_heal: {
enabled: sb.auto_heal?.enabled !== false,
max_retries: sb.auto_heal?.max_retries ?? 3,
retry_interval_ms: sb.auto_heal?.retry_interval_ms ?? 5000,
},
};
}
function shouldAttemptUpdate(
state: StatusbarState,
autoHeal: StatusbarConfig["auto_heal"],
): boolean {
if (!state.isDegraded) return true;
if (!autoHeal?.enabled) return false;
const now = Date.now();
const retryInterval = autoHeal.retry_interval_ms ?? 5000;
const maxRetries = autoHeal.max_retries ?? 3;
if (state.consecutiveFailures >= maxRetries) {
return now - state.lastErrorAt > retryInterval * 2;
}
return now - state.lastErrorAt > retryInterval;
}
// ── Public API ─────────────────────────────────────────────────────────────
export function registerGraphifyStatusbar(pi: ExtensionAPI, config: ResolvedConfig): void {
const sbConfig = resolveStatusbarConfig(config);
if (!sbConfig.enabled) return;
const contributePayload: Record<string, unknown> = {
id: MODULE_ID,
label: "Graphify",
description: "Knowledge graph status",
default_placement: sbConfig.placement,
default_style: {
show_icon: sbConfig.show_icon,
icon: sbConfig.icon,
icon_color: sbConfig.icon_color,
show_text: sbConfig.show_text,
text_font_color: sbConfig.text_font_color,
text_font_caps: "small",
text_font_style: "regular",
},
priority: 0,
};
if (sbConfig.separator_before) contributePayload.separator_before = sbConfig.separator_before;
if (sbConfig.separator_after) contributePayload.separator_after = sbConfig.separator_after;
emitStatusbarEvent(pi, "statusbar:widget:contribute", contributePayload);
emitStatusbarEvent(pi, "statusbar:module:register", {
id: MODULE_ID,
text: "initializing...",
visible: true,
placement: sbConfig.placement,
style: {
show_icon: sbConfig.show_icon,
icon: sbConfig.icon,
icon_color: sbConfig.icon_color,
show_text: sbConfig.show_text,
text_font_color: sbConfig.text_font_color,
text_font_caps: "small",
text_font_style: "regular",
},
});
}
export function unregisterGraphifyStatusbar(pi: ExtensionAPI): void {
emitStatusbarEvent(pi, "statusbar:module:unregister", { id: MODULE_ID });
}
export async function updateGraphifyStatusbar(
pi: ExtensionAPI,
config: ResolvedConfig,
ctx: ExtensionContext,
state: StatusbarState,
): Promise<void> {
const sbConfig = resolveStatusbarConfig(config);
if (!sbConfig.enabled) return;
if (!shouldAttemptUpdate(state, sbConfig.auto_heal)) return;
try {
const checkResult = await pi.exec(
"sh",
["-c", "test -f graphify-out/graph.json && echo 'yes' || echo 'no'"],
{ cwd: ctx.cwd },
);
let text: string;
let iconColor = sbConfig.icon_color ?? "accent";
let textColor = sbConfig.text_font_color ?? "dim";
if (checkResult.stdout.trim() === "yes") {
const statsResult = await pi.exec(
"sh",
[
"-c",
`python3 -c "
import json
from pathlib import Path
try:
data = json.loads(Path('graphify-out/graph.json').read_text())
nodes = len(data.get('nodes', []))
edges = len(data.get('links', []))
print(f'{nodes}n {edges}e')
except Exception:
print('graph ready')
" 2>/dev/null || echo "graph ready"`,
],
{ cwd: ctx.cwd },
);
text = statsResult.stdout.trim() || "graph ready";
} else {
text = "no graph";
iconColor = sbConfig.icon_color_uninitialized ?? "dim";
textColor = sbConfig.text_font_color_uninitialized ?? "dim";
}
emitStatusbarEvent(pi, "statusbar:module:register", {
id: MODULE_ID,
text,
visible: true,
placement: sbConfig.placement,
style: {
show_icon: sbConfig.show_icon,
icon: sbConfig.icon,
icon_color: iconColor,
show_text: sbConfig.show_text,
text_font_color: textColor,
text_font_caps: "small",
text_font_style: "regular",
},
});
if (state.isDegraded) {
state.healedAt = Date.now();
}
state.consecutiveFailures = 0;
state.isDegraded = false;
} catch (_error) {
state.consecutiveFailures += 1;
state.lastErrorAt = Date.now();
state.isDegraded = true;
emitStatusbarEvent(pi, "statusbar:module:register", {
id: MODULE_ID,
text: "error",
visible: true,
placement: sbConfig.placement,
style: {
show_icon: sbConfig.show_icon,
icon: sbConfig.icon,
icon_color: "error",
show_text: sbConfig.show_text,
text_font_color: "error",
text_font_caps: "small",
text_font_style: "regular",
},
});
}
}
export { makeStatusbarState as createStatusbarState };

View File

@@ -0,0 +1,965 @@
import type {
AgentToolResult,
AgentToolUpdateCallback,
ExtensionAPI,
ExtensionContext,
Theme,
ToolRenderResultOptions,
} from "@earendil-works/pi-coding-agent";
import { defineTool, truncateHead } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { ToolBody, ToolCallHeader, ToolFooter } from "@gaodes/pi-utils-ui";
import { type Static, Type } from "typebox";
import type { ResolvedConfig } from "../config";
import type { ExecFn } from "../lib/runner";
import {
addUrl,
buildGraph,
clusterOnly,
detectPython,
ensureInstalled,
explainNode,
findPath,
queryGraph,
startWatch,
updateGraph,
} from "../lib/runner";
// ---------------------------------------------------------------------------
// Shared exec adapter — wraps pi.exec(command, args[], opts) → ExecFn
// ---------------------------------------------------------------------------
function createExec(pi: ExtensionAPI, cwd: string): ExecFn {
return async (command, options) => {
const result = await pi.exec("sh", ["-c", command], {
cwd: options?.cwd ?? cwd,
signal: options?.signal,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.code,
};
};
}
// ---------------------------------------------------------------------------
// graphify_build
// ---------------------------------------------------------------------------
const buildParameters = Type.Object({
path: Type.String({ description: "Directory path to build graph from" }),
mode: Type.Optional(
Type.Union([Type.Literal("standard"), Type.Literal("deep")], {
description: "Extraction mode: 'deep' for more aggressive relationship inference",
}),
),
no_viz: Type.Optional(Type.Boolean({ description: "Skip HTML visualization" })),
obsidian: Type.Optional(Type.Boolean({ description: "Generate Obsidian vault" })),
svg: Type.Optional(Type.Boolean({ description: "Export graph.svg" })),
graphml: Type.Optional(Type.Boolean({ description: "Export graph.graphml" })),
neo4j: Type.Optional(Type.Boolean({ description: "Generate cypher.txt for Neo4j" })),
});
type BuildParams = Static<typeof buildParameters>;
interface BuildDetails {
path: string;
nodes: number;
edges: number;
communities: number;
outputDir: string;
}
export function createBuildTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_build",
label: "Graphify Build",
description:
"Build a knowledge graph from a directory. Runs the full pipeline: file detection, entity/relationship extraction, community detection, and output generation (HTML, JSON, report).",
parameters: buildParameters,
promptSnippet: "Use graphify_build to create a knowledge graph from any directory of files.",
promptGuidelines: [
"Call graphify_build before graphify_query, graphify_path, or graphify_explain — those tools require an existing graph.",
"Provide the exact directory path. Use '.' for the current directory.",
],
async execute(
_toolCallId: string,
params: BuildParams,
signal: AbortSignal,
onUpdate: AgentToolUpdateCallback<BuildDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<BuildDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const result = await buildGraph(
exec,
python,
ctx.cwd,
{
inputPath: params.path,
mode: params.mode ?? "standard",
noViz: params.no_viz,
obsidian: params.obsidian,
svg: params.svg,
graphml: params.graphml,
neo4j: params.neo4j,
},
signal,
(msg) =>
onUpdate?.({
content: [{ type: "text", text: msg }],
details: {} as BuildDetails,
}),
);
return {
content: [
{
type: "text",
text: `Graph built: ${result.nodes} nodes, ${result.edges} edges, ${result.communities} communities. Output in ${config.outputDir}/`,
},
],
details: {
path: params.path,
nodes: result.nodes,
edges: result.edges,
communities: result.communities,
outputDir: config.outputDir,
},
};
},
renderCall(params: BuildParams, theme: Theme) {
const optionArgs: Array<{ label: string; value: string }> = [];
if (params.mode === "deep") optionArgs.push({ label: "mode", value: "deep" });
if (params.no_viz) optionArgs.push({ label: "no-viz", value: "true" });
if (params.obsidian) optionArgs.push({ label: "obsidian", value: "true" });
if (params.svg) optionArgs.push({ label: "svg", value: "true" });
if (params.graphml) optionArgs.push({ label: "graphml", value: "true" });
if (params.neo4j) optionArgs.push({ label: "neo4j", value: "true" });
return new ToolCallHeader(
{
toolName: "Graphify",
action: "build",
mainArg: params.path,
optionArgs,
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<BuildDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: building graph..."), 0, 0);
}
const details = result.details as BuildDetails | undefined;
if (!details?.nodes) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Build failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
return new ToolBody(
{
fields: [
{
label: "Graph",
value: `${details.nodes} nodes | ${details.edges} edges | ${details.communities} communities`,
showCollapsed: false,
},
{ label: "Path", value: details.path, showCollapsed: true },
{ label: "Output", value: details.outputDir, showCollapsed: true },
],
footer: new ToolFooter(theme, {
items: [{ label: "status", value: "complete" }],
separator: " | ",
}),
includeSpacerBeforeFooter: true,
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_query
// ---------------------------------------------------------------------------
const queryParameters = Type.Object({
question: Type.String({ description: "Natural language question to answer from the graph" }),
mode: Type.Optional(
Type.Union([Type.Literal("bfs"), Type.Literal("dfs")], {
description: "Traversal mode: bfs (broad context) or dfs (trace a specific path)",
}),
),
budget: Type.Optional(
Type.Number({ description: "Token budget for the answer (default 2000)", default: 2000 }),
),
});
type QueryParams = Static<typeof queryParameters>;
interface QueryDetails {
question: string;
mode: string;
result: string;
}
export function createQueryTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_query",
label: "Graphify Query",
description:
"Query the knowledge graph using BFS (broad context) or DFS (trace a path). Requires an existing graph built with graphify_build.",
parameters: queryParameters,
promptSnippet:
"Use graphify_query to answer questions about a codebase using its knowledge graph.",
promptGuidelines: [
"Run graphify_build before graphify_query — the graph must exist first.",
"Use BFS mode for 'what is X connected to?' questions.",
"Use DFS mode for 'how does X reach Y?' questions.",
],
async execute(
_toolCallId: string,
params: QueryParams,
signal: AbortSignal,
_onUpdate: AgentToolUpdateCallback<QueryDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<QueryDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const queryResult = await queryGraph(
exec,
python,
ctx.cwd,
{
question: params.question,
mode: params.mode ?? "bfs",
budget: params.budget,
},
signal,
);
return {
content: [{ type: "text", text: queryResult }],
details: {
question: params.question,
mode: params.mode ?? "bfs",
result: queryResult,
},
};
},
renderCall(params: QueryParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "query",
mainArg: params.question,
optionArgs: [
...(params.mode === "dfs" ? [{ label: "mode", value: "dfs" }] : []),
...(params.budget ? [{ label: "budget", value: String(params.budget) }] : []),
],
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<QueryDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: querying graph..."), 0, 0);
}
const details = result.details as QueryDetails | undefined;
if (!details?.result) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Query failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
const truncated = truncateHead(details.result, {
maxBytes: 50000,
maxLines: 2000,
});
return new ToolBody(
{
fields: [
{ label: "Mode", value: details.mode.toUpperCase(), showCollapsed: true },
{ label: "Question", value: details.question, showCollapsed: true },
{ label: "Result", value: truncated.content, showCollapsed: false },
],
footer: truncated.truncated
? new ToolFooter(theme, {
items: [
{
label: "lines",
value: `${truncated.outputLines}/${truncated.totalLines}`,
},
],
separator: " | ",
})
: undefined,
includeSpacerBeforeFooter: true,
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_path
// ---------------------------------------------------------------------------
const pathParameters = Type.Object({
from: Type.String({ description: "Starting concept name" }),
to: Type.String({ description: "Target concept name" }),
});
type PathParams = Static<typeof pathParameters>;
interface PathDetails {
from: string;
to: string;
result: string;
}
export function createPathTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_path",
label: "Graphify Path",
description:
"Find the shortest path between two concepts in the knowledge graph. Requires an existing graph.",
parameters: pathParameters,
promptSnippet:
"Use graphify_path to trace connections between two concepts in the knowledge graph.",
promptGuidelines: [
"Both concepts must exist as nodes in the graph.",
"Use concept names that appear in graph node labels.",
],
async execute(
_toolCallId: string,
params: PathParams,
signal: AbortSignal,
_onUpdate: AgentToolUpdateCallback<PathDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<PathDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const pathResult = await findPath(exec, python, ctx.cwd, params.from, params.to, signal);
return {
content: [{ type: "text", text: pathResult }],
details: { from: params.from, to: params.to, result: pathResult },
};
},
renderCall(params: PathParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "path",
mainArg: `${params.from}${params.to}`,
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<PathDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: finding path..."), 0, 0);
}
const details = result.details as PathDetails | undefined;
if (!details?.result) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Path not found";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
return new ToolBody(
{
fields: [
{
label: "Path",
value: `${details.from}${details.to}`,
showCollapsed: true,
},
{ label: "Result", value: details.result, showCollapsed: false },
],
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_explain
// ---------------------------------------------------------------------------
const explainParameters = Type.Object({
concept: Type.String({ description: "Name of the concept/node to explain" }),
});
type ExplainParams = Static<typeof explainParameters>;
interface ExplainDetails {
concept: string;
result: string;
}
export function createExplainTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_explain",
label: "Graphify Explain",
description:
"Explain a concept from the knowledge graph — shows everything connected to it. Requires an existing graph.",
parameters: explainParameters,
promptSnippet:
"Use graphify_explain to get a plain-language explanation of a concept and all its connections in the graph.",
promptGuidelines: [
"graphify_explain works best with concept names that appear as node labels in the graph.",
],
async execute(
_toolCallId: string,
params: ExplainParams,
signal: AbortSignal,
_onUpdate: AgentToolUpdateCallback<ExplainDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<ExplainDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const explainResult = await explainNode(exec, python, ctx.cwd, params.concept, signal);
return {
content: [{ type: "text", text: explainResult }],
details: { concept: params.concept, result: explainResult },
};
},
renderCall(params: ExplainParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "explain",
mainArg: params.concept,
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<ExplainDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: explaining node..."), 0, 0);
}
const details = result.details as ExplainDetails | undefined;
if (!details?.result) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Explain failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
return new ToolBody(
{
fields: [
{ label: "Concept", value: details.concept, showCollapsed: true },
{ label: "Details", value: details.result, showCollapsed: false },
],
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_add
// ---------------------------------------------------------------------------
const addParameters = Type.Object({
url: Type.String({ description: "URL to fetch and add to the corpus" }),
author: Type.Optional(Type.String({ description: "Author of the content" })),
contributor: Type.Optional(Type.String({ description: "Who added this to the corpus" })),
});
type AddParams = Static<typeof addParameters>;
interface AddDetails {
url: string;
savedTo: string;
}
export function createAddTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_add",
label: "Graphify Add",
description:
"Fetch a URL (paper, tweet, PDF, image, webpage) and add it to the corpus. Then update the graph.",
parameters: addParameters,
promptSnippet: "Use graphify_add to fetch a URL and incorporate it into the knowledge graph.",
promptGuidelines: [
"graphify_add fetches the content and runs an incremental graph update automatically.",
"Supports arXiv papers, Twitter/X, PDFs, images, and general web pages.",
],
async execute(
_toolCallId: string,
params: AddParams,
signal: AbortSignal,
onUpdate: AgentToolUpdateCallback<AddDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<AddDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
onUpdate?.({
content: [{ type: "text", text: `Fetching ${params.url}...` }],
details: {} as AddDetails,
});
const savedTo = await addUrl(
exec,
python,
ctx.cwd,
{
url: params.url,
author: params.author,
contributor: params.contributor,
},
signal,
);
onUpdate?.({
content: [{ type: "text", text: "Updating graph with new content..." }],
details: {} as AddDetails,
});
await updateGraph(exec, python, ctx.cwd, "./raw", signal);
return {
content: [{ type: "text", text: `Added ${params.url} to corpus and updated graph.` }],
details: { url: params.url, savedTo },
};
},
renderCall(params: AddParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "add",
mainArg: params.url,
optionArgs: [
...(params.author ? [{ label: "author", value: params.author }] : []),
...(params.contributor ? [{ label: "contributor", value: params.contributor }] : []),
],
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<AddDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: adding URL..."), 0, 0);
}
const details = result.details as AddDetails | undefined;
if (!details?.url) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Add failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
return new ToolBody(
{
fields: [
{ label: "URL", value: details.url, showCollapsed: true },
{ label: "Saved", value: details.savedTo, showCollapsed: true },
],
footer: new ToolFooter(theme, {
items: [{ label: "graph", value: "updated" }],
separator: " | ",
}),
includeSpacerBeforeFooter: true,
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_update
// ---------------------------------------------------------------------------
const updateParameters = Type.Object({
path: Type.String({
description: "Directory path to update (re-extract changed files only)",
}),
});
type UpdateParams = Static<typeof updateParameters>;
interface UpdateDetails {
path: string;
newFiles: number;
nodes: number;
edges: number;
}
export function createUpdateTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_update",
label: "Graphify Update",
description:
"Incrementally update the knowledge graph — re-extract only new or changed files. Much faster than a full rebuild.",
parameters: updateParameters,
promptSnippet:
"Use graphify_update for incremental graph updates after files change, instead of rebuilding from scratch.",
promptGuidelines: [
"graphify_update is cheaper and faster than graphify_build — it only processes changed files.",
"Run graphify_update after adding or modifying files in an already-graphed directory.",
],
async execute(
_toolCallId: string,
params: UpdateParams,
signal: AbortSignal,
onUpdate: AgentToolUpdateCallback<UpdateDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<UpdateDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const updateResult = await updateGraph(exec, python, ctx.cwd, params.path, signal, (msg) =>
onUpdate?.({
content: [{ type: "text", text: msg }],
details: {} as UpdateDetails,
}),
);
if (updateResult.newFiles === 0) {
return {
content: [
{
type: "text",
text: "No files changed since last build. Graph is up to date.",
},
],
details: { path: params.path, newFiles: 0, nodes: 0, edges: 0 },
};
}
return {
content: [
{
type: "text",
text: `Updated graph: ${updateResult.newFiles} files re-extracted. Graph now has ${updateResult.nodes} nodes and ${updateResult.edges} edges.`,
},
],
details: {
path: params.path,
newFiles: updateResult.newFiles,
nodes: updateResult.nodes,
edges: updateResult.edges,
},
};
},
renderCall(params: UpdateParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "update",
mainArg: params.path,
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<UpdateDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: updating graph..."), 0, 0);
}
const details = result.details as UpdateDetails | undefined;
if (!details) {
const textBlock = result.content.find((c) => c.type === "text");
const errorMsg = (textBlock?.type === "text" && textBlock.text) || "Update failed";
return new Text(theme.fg("error", errorMsg), 0, 0);
}
if (details.newFiles === 0) {
return new Text(theme.fg("muted", "Graph is up to date — no files changed."), 0, 0);
}
return new ToolBody(
{
fields: [
{
label: "Updated",
value: `${details.newFiles} files re-extracted`,
showCollapsed: false,
},
{
label: "Graph",
value: `${details.nodes} nodes | ${details.edges} edges`,
showCollapsed: true,
},
{ label: "Path", value: details.path, showCollapsed: true },
],
footer: new ToolFooter(theme, {
items: [{ label: "status", value: "updated" }],
separator: " | ",
}),
includeSpacerBeforeFooter: true,
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// graphify_watch
// ---------------------------------------------------------------------------
const watchParameters = Type.Object({
path: Type.String({ description: "Directory path to watch" }),
debounce: Type.Optional(
Type.Number({
description: "Debounce seconds before triggering rebuild (default 3)",
default: 3,
}),
),
});
type WatchParams = Static<typeof watchParameters>;
interface WatchDetails {
path: string;
message: string;
}
export function createWatchTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_watch",
label: "Graphify Watch",
description:
"Watch a directory for file changes and auto-rebuild the graph. Code changes trigger AST rebuild; doc changes flag for manual update.",
parameters: watchParameters,
promptSnippet:
"Use graphify_watch to start a file watcher that auto-updates the knowledge graph when code changes.",
promptGuidelines: [
"graphify_watch runs in the foreground — use the process tool to run it in the background.",
"Code-only changes are rebuilt automatically. Doc/image changes require manual /graphify --update.",
],
async execute(
_toolCallId: string,
params: WatchParams,
signal: AbortSignal,
onUpdate: AgentToolUpdateCallback<WatchDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<WatchDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const message = await startWatch(
exec,
python,
ctx.cwd,
params.path,
params.debounce ?? 3,
signal,
(msg) =>
onUpdate?.({ content: [{ type: "text", text: msg }], details: {} as WatchDetails }),
);
return {
content: [{ type: "text", text: message }],
details: { path: params.path, message },
};
},
renderCall(params: WatchParams, theme: Theme) {
return new ToolCallHeader(
{
toolName: "Graphify",
action: "watch",
mainArg: params.path,
optionArgs: params.debounce
? [{ label: "debounce", value: String(params.debounce) }]
: [],
showColon: true,
},
theme,
);
},
renderResult(
result: AgentToolResult<WatchDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: watching for changes..."), 0, 0);
}
const details = result.details as WatchDetails | undefined;
if (!details?.message) {
return new Text(theme.fg("muted", "Watch ended."), 0, 0);
}
return new Text(theme.fg("muted", details.message), 0, 0);
},
});
}
// ---------------------------------------------------------------------------
// graphify_cluster
// ---------------------------------------------------------------------------
const clusterParameters = Type.Object({});
type ClusterParams = Static<typeof clusterParameters>;
interface ClusterDetails {
communities: number;
}
export function createClusterTool(pi: ExtensionAPI, config: ResolvedConfig) {
return defineTool({
name: "graphify_cluster",
label: "Graphify Cluster",
description:
"Re-run community detection on an existing graph.json and regenerate the report. No re-extraction needed.",
parameters: clusterParameters,
promptSnippet:
"Use graphify_cluster to re-cluster an existing graph without re-extracting files.",
promptGuidelines: [
"graphify_cluster is cheap — it only reruns the clustering algorithm on existing data.",
"Requires an existing graphify-out/graph.json.",
],
async execute(
_toolCallId: string,
_params: ClusterParams,
signal: AbortSignal,
_onUpdate: AgentToolUpdateCallback<ClusterDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<ClusterDetails>> {
const exec = createExec(pi, ctx.cwd);
const python = await detectPython(exec, config.pythonPath, ctx.cwd, signal);
await ensureInstalled(exec, python, ctx.cwd, signal);
const result = await clusterOnly(exec, python, ctx.cwd, signal);
return {
content: [{ type: "text", text: `Re-clustered: ${result.communities} communities` }],
details: { communities: result.communities },
};
},
renderCall(_params: ClusterParams, theme: Theme) {
return new ToolCallHeader(
{ toolName: "Graphify", action: "cluster", mainArg: "re-cluster", showColon: true },
theme,
);
},
renderResult(
result: AgentToolResult<ClusterDetails>,
options: ToolRenderResultOptions,
theme: Theme,
) {
if (options.isPartial) {
return new Text(theme.fg("muted", "Graphify: re-clustering..."), 0, 0);
}
const details = result.details as ClusterDetails | undefined;
if (!details?.communities) {
return new Text(theme.fg("error", "Cluster failed"), 0, 0);
}
return new ToolBody(
{
fields: [
{ label: "Communities", value: String(details.communities), showCollapsed: false },
],
},
options,
theme,
);
},
});
}
// ---------------------------------------------------------------------------
// Re-export all creators
// ---------------------------------------------------------------------------
export function createAllTools(pi: ExtensionAPI, config: ResolvedConfig) {
return [
createBuildTool(pi, config),
createQueryTool(pi, config),
createPathTool(pi, config),
createExplainTool(pi, config),
createAddTool(pi, config),
createUpdateTool(pi, config),
createWatchTool(pi, config),
createClusterTool(pi, config),
];
}

View File

@@ -0,0 +1,390 @@
/**
* Integration tests for pi-graphify extension using @gaodes/pi-test-harness.
*
* These tests exercise the full extension lifecycle:
* - Extension loading and tool registration
* - Tool execution via playbook DSL (when/calls/says)
* - Mock bash responses simulating the graphify Python CLI
*
* The extension's tools call pi.exec("sh", ["-c", ...]) which routes through
* the built-in "bash" tool. We mock that to return deterministic responses.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import { calls, createTestSession, says, type TestSession, when } from "@gaodes/pi-test-harness";
import { afterEach, describe, expect, it } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../..");
const TOOLS_ENTRY = path.resolve(PROJECT_ROOT, "src/tools/index.ts");
// ---------------------------------------------------------------------------
// Mock responses for the graphify Python CLI
// ---------------------------------------------------------------------------
/** Successful detect output */
const MOCK_DETECT_OUTPUT = JSON.stringify({
total_files: 3,
total_words: 500,
files: {
code: ["src/main.ts", "src/util.ts"],
document: ["README.md"],
paper: [],
image: [],
video: [],
},
});
/** Mock python check -- graphify already installed */
const MOCK_PYTHON_CHECK = "0";
/** Mock detect Python from cache */
const MOCK_PYTHON_PATH = "/usr/bin/python3";
/** Successful query output */
const MOCK_QUERY_OUTPUT = `Traversal: BFS | Start: ['Main Module'] | 2 nodes
NODE Main Module [src=src/main.ts loc=]
NODE Util Module [src=src/util.ts loc=]
EDGE Main Module --imports [EXTRACTED]--> Util Module`;
/** Successful explain output */
const MOCK_EXPLAIN_OUTPUT = `NODE: Main Module
source: src/main.ts
type: code
degree: 1
CONNECTIONS:
--imports--> Util Module [EXTRACTED] (src/util.ts)`;
/** Successful ingest output */
const MOCK_INGEST_OUTPUT = "Saved to ./raw/article.md";
// ---------------------------------------------------------------------------
// Bash mock that matches graphify command patterns
// ---------------------------------------------------------------------------
function createGraphifyBashMock(responses?: Record<string, string>) {
const defaultResponses: Record<string, string> = {
".graphify_python": MOCK_PYTHON_PATH,
"import graphify": MOCK_PYTHON_CHECK,
"from graphify.detect import detect": MOCK_DETECT_OUTPUT,
"from graphify.extract import collect_files": JSON.stringify({
nodes: [
{ id: "src_main", label: "Main Module", file_type: "code", source_file: "src/main.ts" },
],
edges: [],
input_tokens: 0,
output_tokens: 0,
}),
graphify_semantic: JSON.stringify({
nodes: [],
edges: [],
hyperedges: [],
input_tokens: 0,
output_tokens: 0,
}),
"from graphify.build import build_from_json": "Graph: 2 nodes, 1 edges, 1 communities",
"Traversal:": MOCK_QUERY_OUTPUT,
"NODE:": MOCK_EXPLAIN_OUTPUT,
"from graphify.ingest import ingest": MOCK_INGEST_OUTPUT,
detect_incremental: JSON.stringify({ new_total: 0, new_files: {} }),
"from graphify.cluster import cluster": "Re-clustered: 3 communities",
"hook status": "Git hooks are not installed.",
mkdir: "",
"cat graphify-out": MOCK_PYTHON_PATH,
save_manifest: "",
benchmark: "Token reduction: 12.5x",
"rm -f": "",
write_text: "",
god_nodes: "[]",
surprising_connections: "[]",
"from graphify.report import generate": "",
to_json: "",
to_html: "graph.html written",
shortest_path: `Shortest path (1 hops):
Main Module --imports--> [EXTRACTED]
Util Module`,
"graphify.watch": "Watching . for changes...",
};
const all = { ...defaultResponses, ...responses };
return (params: Record<string, unknown>) => {
const cmd = String(params.command || "");
// Detect what kind of graphify command this is and respond accordingly
// The mock must match the *purpose* of the command, not just substrings,
// because graphify-out paths appear in many commands.
// Python detection: very first commands
if (cmd.includes("cat graphify-out/.graphify_python")) {
return `$ ${cmd}\n${all[".graphify_python"]}`;
}
if (cmd.includes("import graphify") && cmd.includes("echo $?")) {
return `$ ${cmd}\n${all["import graphify"]}`;
}
if (cmd.includes("pip install graphifyy")) {
return `$ ${cmd}\n`;
}
// mkdir
if (cmd.startsWith("mkdir")) {
return `$ ${cmd}\n${all.mkdir}`;
}
// Cleanup
if (cmd.includes("rm -f ")) {
return `$ ${cmd}\n${all["rm -f"]}`;
}
// Detect files
if (cmd.includes("from graphify.detect import detect")) {
return `$ ${cmd}\n${all["from graphify.detect import detect"]}`;
}
// AST extraction
if (cmd.includes("from graphify.extract import collect_files")) {
return `$ ${cmd}\n${all["from graphify.extract import collect_files"]}`;
}
// Semantic merge
if (cmd.includes("graphify_semantic") || cmd.includes("graphify_cached")) {
return `$ ${cmd}\n${all.graphify_semantic}`;
}
// Build graph
if (cmd.includes("from graphify.build import build_from_json")) {
return `$ ${cmd}\n${all["from graphify.build import build_from_json"]}`;
}
// Query
if (
cmd.includes("Traversal:") ||
cmd.includes("mode = 'bfs'") ||
cmd.includes("mode = 'dfs'")
) {
return `$ ${cmd}\n${all["Traversal:"]}`;
}
// Explain
if (cmd.includes("CONNECTIONS:")) {
return `$ ${cmd}\n${all["NODE:"]}`;
}
// Path
if (cmd.includes("shortest_path")) {
return `$ ${cmd}\n${all.shortest_path}`;
}
// Ingest
if (cmd.includes("from graphify.ingest import ingest")) {
return `$ ${cmd}\n${all["from graphify.ingest import ingest"]}`;
}
// Incremental update
if (cmd.includes("detect_incremental")) {
return `$ ${cmd}\n${all.detect_incremental}`;
}
// Cluster
if (cmd.includes("from graphify.cluster import cluster") && !cmd.includes("build_from_json")) {
return `$ ${cmd}\n${all["from graphify.cluster import cluster"]}`;
}
// Hook
if (cmd.includes("hook status")) {
return `$ ${cmd}\n${all["hook status"]}`;
}
// Watch
if (cmd.includes("graphify.watch")) {
return `$ ${cmd}\n${all["graphify.watch"]}`;
}
// Manifest / benchmark / report / export
if (cmd.includes("save_manifest")) return `$ ${cmd}\n${all.save_manifest}`;
if (cmd.includes("benchmark")) return `$ ${cmd}\n${all.benchmark}`;
if (cmd.includes("from graphify.report import generate"))
return `$ ${cmd}\n${all["from graphify.report import generate"]}`;
if (cmd.includes("to_json")) return `$ ${cmd}\n${all.to_json}`;
if (cmd.includes("to_html")) return `$ ${cmd}\n${all.to_html}`;
if (cmd.includes("write_text")) return `$ ${cmd}\n${all.write_text}`;
// Graph existence check
if (cmd.includes("graphify-out/graph.json") && cmd.includes("exists()")) {
// For explain/path/query — graph exists
return `$ ${cmd}\n${all["NODE:"]}`;
}
return `$ ${cmd}\n`;
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("pi-graphify extension", () => {
let t: TestSession;
afterEach(() => t?.dispose());
/** Helper: create a test session with graphify bash mock */
async function createSession(overrides?: Record<string, string>) {
const session = await createTestSession({
extensions: [TOOLS_ENTRY],
mockTools: {
bash: createGraphifyBashMock(overrides),
},
});
// Polyfill setTools for pi-agent-core compatibility
// (test-harness 1.0.1 calls setTools but pi-coding-agent 0.73 uses state.tools setter)
// biome-ignore lint/suspicious/noExplicitAny: compatibility shim accessing untyped internals
const agent = (session.session as any).agent;
if (agent && !agent.setTools) {
agent.setTools = (tools: unknown[]) => {
agent.state.tools = tools;
};
}
return session;
}
// -- Extension loading --------------------------------------------------
it("loads and registers all 8 tools", async () => {
t = await createSession();
await t.run(when("Build a knowledge graph from .", [calls("graphify_build", { path: "." })]));
const buildCalls = t.events.toolCallsFor("graphify_build");
expect(buildCalls.length).toBeGreaterThanOrEqual(1);
expect(buildCalls[0].blocked).toBe(false);
});
// -- graphify_build -----------------------------------------------------
it("builds a graph from a directory", async () => {
t = await createSession();
await t.run(
when("Build a knowledge graph from the current directory", [
calls("graphify_build", { path: "." }),
says("graph"),
]),
);
const results = t.events.toolResultsFor("graphify_build");
expect(results.length).toBeGreaterThanOrEqual(1);
});
it("respects --no-viz flag", async () => {
t = await createSession();
await t.run(
when("Build graph without visualization", [
calls("graphify_build", { path: ".", noViz: true }),
]),
);
expect(t.events.toolResultsFor("graphify_build").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_query -----------------------------------------------------
it("performs BFS traversal", async () => {
t = await createSession({
"from pathlib import Path": "graphify-out/graph.json",
});
await t.run(
when("Query the graph: what does Main Module connect to?", [
calls("graphify_query", { question: "What does Main Module connect to?" }),
]),
);
expect(t.events.toolResultsFor("graphify_query").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_explain ---------------------------------------------------
it("returns node details", async () => {
t = await createSession({
NODE: MOCK_EXPLAIN_OUTPUT,
});
await t.run(
when("Explain the Main Module concept in the graph", [
calls("graphify_explain", { concept: "Main Module" }),
]),
);
expect(t.events.toolResultsFor("graphify_explain").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_add -------------------------------------------------------
it("fetches a URL and adds to corpus", async () => {
t = await createSession();
await t.run(
when("Add this article to the graph: https://example.com/article", [
calls("graphify_add", { url: "https://example.com/article" }),
]),
);
expect(t.events.toolResultsFor("graphify_add").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_update ----------------------------------------------------
it("checks for changed files", async () => {
t = await createSession({
detect_incremental: JSON.stringify({
new_total: 2,
new_files: { code: ["src/main.ts"] },
}),
});
await t.run(when("Update the graph incrementally", [calls("graphify_update", { path: "." })]));
expect(t.events.toolResultsFor("graphify_update").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_cluster ---------------------------------------------------
it("re-runs community detection", async () => {
t = await createSession();
await t.run(when("Re-cluster the existing graph", [calls("graphify_cluster", {})]));
expect(t.events.toolResultsFor("graphify_cluster").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_watch -----------------------------------------------------
it("starts watching a directory", async () => {
t = await createSession();
await t.run(
when("Watch the current directory for changes", [calls("graphify_watch", { path: "." })]),
);
expect(t.events.toolResultsFor("graphify_watch").length).toBeGreaterThanOrEqual(1);
});
// -- graphify_path ------------------------------------------------------
it("finds shortest path between concepts", async () => {
t = await createSession();
await t.run(
when("Find the path from Main Module to Util Module", [
calls("graphify_path", { from: "Main Module", to: "Util Module" }),
]),
);
expect(t.events.toolResultsFor("graphify_path").length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,46 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { ensurePrimeSettings, loadConfig } from "../config";
import {
createStatusbarState,
registerGraphifyStatusbar,
type StatusbarState,
unregisterGraphifyStatusbar,
updateGraphifyStatusbar,
} from "../statusbar.js";
import { createAllTools } from "./graphify-tools";
type ToolsExtensionState = {
statusbarState: StatusbarState;
};
function createToolsExtensionState(): ToolsExtensionState {
return {
statusbarState: createStatusbarState(),
};
}
export default function (pi: ExtensionAPI) {
ensurePrimeSettings();
const config = loadConfig(process.cwd());
if (!config.enabled) return;
for (const tool of createAllTools(pi, config)) {
pi.registerTool(tool);
}
const state = createToolsExtensionState();
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
registerGraphifyStatusbar(pi, config);
await updateGraphifyStatusbar(pi, config, ctx, state.statusbarState);
});
pi.on("before_agent_start", async (_event: unknown, ctx: ExtensionContext) => {
await updateGraphifyStatusbar(pi, config, ctx, state.statusbarState);
});
pi.on("session_shutdown", async () => {
unregisterGraphifyStatusbar(pi);
});
}