diff --git a/.github/workflows/build-zip-installer.yml b/.github/workflows/build-zip-installer.yml new file mode 100644 index 000000000..8665e4a94 --- /dev/null +++ b/.github/workflows/build-zip-installer.yml @@ -0,0 +1,39 @@ +name: Build offline zip installer + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build the offline zip installer + env: + INTERNAL_PYPI_PROXY: ${{ secrets.INTERNAL_PYPI_PROXY }} + INTERNAL_TRUSTED_HOST: ${{ secrets.INTERNAL_TRUSTED_HOST }} + INTERNAL_TIMEOUT: ${{ secrets.INTERNAL_TIMEOUT }} + run: tools/build_zip_installer.sh + + - name: Smoke test the zip + run: bash tests/tools/test_build_zip_installer.sh + + - name: Attach zip to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: dist/graphify-offline-installer.zip + + - name: Upload artifact (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: graphify-offline-installer + path: dist/graphify-offline-installer.zip diff --git a/.gitignore b/.gitignore index 0a6775b2a..3e447e3a5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ skills/ !tools/skillgen/fragments/core/** docs/superpowers/ .vscode/ +.idea/ .kilo openspec/ # Local benchmark scripts — never commit diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 000000000..e74d6d9e6 --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,5 @@ +{ + "plugin": [ + ".opencode/plugins/graphify.js" + ] +} \ No newline at end of file diff --git a/.opencode/plugins/graphify.js b/.opencode/plugins/graphify.js new file mode 100644 index 000000000..89a855d9b --- /dev/null +++ b/.opencode/plugins/graphify.js @@ -0,0 +1,28 @@ +// graphify OpenCode plugin +// Injects a knowledge graph reminder before bash tool calls when the graph exists. +// +// IMPORTANT: keep the reminder string free of backticks and $(...) constructs. +// The hook prepends `echo "" && ` to the user's bash command; +// backticks inside the double-quoted echo trigger bash command substitution, +// which both corrupts tool output and silently executes the very graphify +// command we are only suggesting. Plain words render fine in opencode's TUI. +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 with your question (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; + } + }, + }; +}; diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index b919654c4..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,8 +0,0 @@ -## graphify - -This project has a graphify knowledge graph at graphify-out/. - -Rules: -- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure -- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files -- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d01379b1..2c4d7fd6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Feat: offline Windows installer — `graphify-installer.exe` is a single Nuitka-`--onefile` binary that bundles graphify + 38 wheels (code-only: `anthropic` + `mcp` + `leiden` + `sql` + `watch` + `svg` + `chinese` + `terraform` + 23 `tree-sitter` language packs + `networkx`/`numpy`/`rapidfuzz`) for air-gapped installs. Auto-detects 21 known AI-coding hosts (Claude Code, OpenCode, Codex, Kilo, Aider, Copilot, CodeBuddy, Kiro, Droid, Trae, Hermes, Pi, OpenClaw, Antigravity, etc.) and copies the matching `SKILL.md` (plus `references/` sidecar) to each host's skill directory. Registers the install path on the **user-level** PATH only (never `HKLM`). Manifest-driven uninstall reverses every step. New subcommands: `graphify self-install [--path DIR] [--no-path]` and `graphify self-uninstall`. Build recipe: `tools/build_windows_installer.sh` (Nuitka needs Visual Studio Build Tools). See `docs/operations/offline-installer.md` for the end-user guide. **Notes:** PDF / Office / video / Neo4j / Bedrock extras are NOT bundled (this is the code-only installer); the `.exe` is unsigned so Windows SmartScreen warns on first launch ("More info → Run anyway"); `cursor` and `gemini` are detected but the offline installer does not write their `SKILL.md` (their real install format is different — run `graphify install cursor` / `graphify install gemini` after the offline installer to finish those two). + +- Feat: offline installer also bundles 16 community skills (14 superpowers + `gf-llm-wiki` + `code-pipeline`). After `graphify-installer.exe` runs, the host's skill directory contains `gf-brainstorming/`, `gf-writing-plans/`, `gf-subagent-driven-development/`, `gf-test-driven-development/`, `gf-systematic-debugging/`, `gf-using-git-worktrees/`, `gf-requesting-code-review/`, `gf-receiving-code-review/`, `gf-executing-plans/`, `gf-finishing-a-development-branch/`, `gf-dispatching-parallel-agents/`, `gf-using-superpowers/`, `gf-verification-before-completion/`, `gf-writing-skills/`, `gf-llm-wiki/`, and `code-pipeline/`. `code-pipeline` is a project-local orchestrator skill that strings the full feature lifecycle together — accept one sentence or one file path as the requirement, then drive `gf-brainstorming` → human design review gate → `gf-writing-plans` → human plan review gate → `gf-subagent-driven-development` → dual-track (specs + code) review. It is intentionally the only bundled skill without the `gf-` prefix because it is the user-facing entry point (the slash-command is `/code-pipeline`) and the `code-pipeline` name does not collide with any upstream superpowers skill. See `graphify/bundled_skills/README.md`. + +- Feat: `to_html` now ships `vis-network@9.1.6` inside the `graphify` package instead of pulling it from `unpkg.com` at view time. The UMD bundle (702,611 bytes) is vendored at `graphify/assets/vis-network.min.js`, registered in `pyproject.toml` `package-data`, and copied next to each generated `graph.html` as `./vis-network.min.js` (idempotent — only rewritten when the bytes change, so file watchers / Obsidian sync don't churn). Every `graphify-out/graph.html` is now self-contained and renders offline. **Notes:** the generated `graph.html` is no longer a single-file artifact — it has a same-directory `vis-network.min.js` sibling, so copy / archive / e-mail the pair together; the wheel grows by ~700 KB; the previous `unpkg.com` `' in content + assert 'unpkg.com' not in content + assert 'integrity=' not in content + assert 'crossorigin="anonymous"' not in content +``` + +- [ ] **Step 2: Add a new test for the emitted local asset** + +Append to `tests/test_export.py`: + +```python +def test_to_html_emits_local_vis_network_asset(): + G = make_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.html" + to_html(G, communities, str(out)) + asset = out.parent / "vis-network.min.js" + assert asset.exists() + # Byte-equality with the vendored copy is the correctness contract. + assert asset.read_bytes() == _vendored_vis_js() +``` + +(Note: `_vendored_vis_js` is already imported in this file via `test_vendored_vis_js_returns_committed_file_bytes`; the import inside that test's body is local. Add `from graphify.export import _vendored_vis_js` at the top of `test_export.py` to make this new test cleaner, OR keep the local import inside the test body — either is fine. Choose the local import to keep this task's diff minimal.) + +Revised test (use this version): + +```python +def test_to_html_emits_local_vis_network_asset(): + from graphify.export import _vendored_vis_js + G = make_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.html" + to_html(G, communities, str(out)) + asset = out.parent / "vis-network.min.js" + assert asset.exists() + # Byte-equality with the vendored copy is the correctness contract. + assert asset.read_bytes() == _vendored_vis_js() +``` + +- [ ] **Step 3: Run the new/extended tests and confirm they fail** + +```bash +pytest tests/test_export.py::test_to_html_contains_visjs tests/test_export.py::test_to_html_emits_local_vis_network_asset -v +``` + +Expected: BOTH fail. `test_to_html_contains_visjs` fails on the new `assert 'unpkg.com' not in content` (the old SRI-bearing script tag is still being emitted). `test_to_html_emits_local_vis_network_asset` fails on `assert asset.exists()` (the asset file is not being copied yet). + +- [ ] **Step 4: Replace the script tag in `to_html`** + +Open `graphify/export.py`. Find the three lines currently at 790–792: + +```python + +``` + +Replace with the single line: + +```python + +``` + +(Note: leading two-space indentation is unchanged — the line still lives inside the f-string template literal at the same column as the rest of the head block.) + +- [ ] **Step 5: Wire `_emit_vis_js` into `to_html`** + +In the same file, find the final line of `to_html`: + +```python +Path(output_path).write_text(html, encoding="utf-8") # nosec +``` + +Insert the asset-emit call immediately before it: + +```python +_emit_vis_js(Path(output_path)) +Path(output_path).write_text(html, encoding="utf-8") # nosec +``` + +- [ ] **Step 6: Run the new/extended tests and confirm they pass** + +```bash +pytest tests/test_export.py::test_to_html_contains_visjs tests/test_export.py::test_to_html_emits_local_vis_network_asset -v +``` + +Expected: BOTH pass. + +- [ ] **Step 7: Commit** + +```bash +git add graphify/export.py tests/test_export.py +git commit -m "feat(export): to_html uses local vis-network, no CDN/SRI" +``` + +--- + +## Task 7: Delete the obsolete SRI/CDN test + +**Files:** +- Modify: `tests/test_export.py:103-127` (the `test_to_html_pins_visjs_version_with_sri` function) + +- [ ] **Step 1: Confirm the SRI test is still in the suite** + +```bash +grep -n "test_to_html_pins_visjs_version_with_sri" tests/test_export.py +``` + +Expected: one match at the function definition. (The SRI test will currently still pass because Task 6 has only just replaced the script tag — the docstring no longer matches reality, but the function body asserts the new tag is present and asserts the old artifacts are absent.) + +- [ ] **Step 2: Delete the entire test function** + +Open `tests/test_export.py`. Find the function `test_to_html_pins_visjs_version_with_sri`. It begins with a multi-line docstring explaining why SRI is required and a hash that no longer applies. Delete the entire function — the leading `def` line, the docstring, the body, and the trailing blank line that separates it from the next test. Leave the surrounding tests untouched. + +The exact text to delete (verify line range with `grep -n` first): + +```python +def test_to_html_pins_visjs_version_with_sri(): + """vis-network script tag must use a pinned versioned URL with a sha384 + Subresource Integrity hash and crossorigin=anonymous. Without this, + a compromised CDN could ship arbitrary JavaScript into every rendered + graph viewer. The hash was verified against the upstream file at + https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js + (sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1). + Bumping the vis-network version MUST update both the URL and the hash. + """ + G = make_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.html" + to_html(G, communities, str(out)) + content = out.read_text() + + # Versioned URL — unversioned `vis-network/standalone/...` is rejected. + assert "vis-network@9.1.6/standalone/umd/vis-network.min.js" in content + assert "https://unpkg.com/vis-network/standalone" not in content + + # SRI integrity attribute pinning the known-good hash. + assert 'integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"' in content + + # crossorigin="anonymous" is required for SRI on cross-origin scripts. + assert 'crossorigin="anonymous"' in content +``` + +- [ ] **Step 3: Confirm the function is gone** + +```bash +grep -n "test_to_html_pins_visjs_version_with_sri" tests/test_export.py +``` + +Expected: no output. + +- [ ] **Step 4: Run the test_export suite and confirm it still passes** + +```bash +pytest tests/test_export.py -v +``` + +Expected: every test passes. The 4 added in Tasks 3–6 should be visible by name. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_export.py +git commit -m "test(export): drop obsolete SRI/CDN pin test (local asset now)" +``` + +--- + +## Task 8: Tighten `tests/test_pipeline.py:83` + +**Files:** +- Modify: `tests/test_pipeline.py:83` + +- [ ] **Step 1: Read the current assertion** + +```bash +sed -n '80,86p' tests/test_pipeline.py +``` + +Expected output: shows the surrounding context of line 83, which currently is `assert "vis-network" in html`. + +- [ ] **Step 2: Replace the loose substring check with the precise path check** + +Change line 83 from: + +```python + assert "vis-network" in html +``` + +to: + +```python + assert './vis-network.min.js' in html +``` + +- [ ] **Step 3: Run the pipeline test to confirm it still passes** + +```bash +pytest tests/test_pipeline.py -v +``` + +Expected: every test in the file passes. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_pipeline.py +git commit -m "test(pipeline): tighten vis-network assertion to local path" +``` + +--- + +## Task 9: Final verification — full test suite + manual smoke test + +**Files:** none modified; this task is a check. + +- [ ] **Step 1: Run the full test suite** + +```bash +pytest +``` + +Expected: all tests pass. If any test fails, stop and investigate — every test added in this plan is by construction green at the time it was committed, so a failure here means either an unrelated regression or a missed interaction. Read the failure carefully before proceeding. + +- [ ] **Step 2: Smoke-test `to_html` end-to-end** + +Run a one-off invocation that exercises the public function. The graphify CLI itself is fine if it can be run, but a direct Python call avoids any CLI flag friction: + +```bash +python -c " +import json, tempfile +from pathlib import Path +from graphify.build import build_from_json +from graphify.cluster import cluster +from graphify.export import to_html + +with tempfile.TemporaryDirectory() as tmp: + fixture = Path('tests/fixtures/extraction.json') + G = build_from_json(json.loads(fixture.read_text())) + communities = cluster(G) + out = Path(tmp) / 'graph.html' + to_html(G, communities, str(out)) + html = out.read_text() + asset = out.parent / 'vis-network.min.js' + print('html size:', len(html), 'bytes') + print('asset size:', asset.stat().st_size, 'bytes') + print('asset = vendored:', asset.read_bytes() == (Path('graphify/assets/vis-network.min.js').read_bytes())) + print('html references local asset:', './vis-network.min.js' in html) + print('html has no unpkg:', 'unpkg.com' not in html) + print('html has no integrity:', 'integrity=' not in html) +" +``` + +Expected output (numbers approximate, the booleans must all be `True`): + +``` +html size: ~21000 bytes +asset size: 702611 bytes +asset = vendored: True +html references local asset: True +html has no unpkg: True +html has no integrity: True +``` + +- [ ] **Step 3: Open the generated HTML in a browser (manual)** + +Copy a generated `graph.html` to a fresh directory, open it in a browser, and confirm the graph renders. The page should load with no network requests to `unpkg.com` (visible in the browser devtools network tab). If the page is blank, the most likely cause is a path mismatch between the script tag and the asset file — double-check that `vis-network.min.js` sits beside `graph.html`, not one level deeper. + +- [ ] **Step 4: Final commit (only if Step 2 or 3 surfaced a fix)** + +If everything in Steps 1–3 was green, there is nothing to commit. If you had to make a tweak (e.g., a forgotten import, a path adjustment), commit it now with a `fix:` or `chore:` prefix and a one-line message describing what you fixed. + +--- + +## Self-Review Notes + +The plan was checked against `docs/superpowers/specs/2026-06-29-local-vis-network-assets-design.md`: + +- **Spec §1 (vendored file location & distribution)** → Tasks 1 and 2. +- **Spec §2 (`to_html` behavior change: constant, helpers, wire-up, script tag)** → Tasks 3, 4, 5, 6. +- **Spec §3 (test updates: delete SRI test, extend visjs test, add 3 new tests, optional pipeline tighten)** → Tasks 6, 7, 8. The spec lists three new tests (3.3, 3.4, 3.5); the plan adds them across Tasks 4, 5, and 6 in TDD order — `test_vendored_vis_js_returns_committed_file_bytes` (3.3a), `test_emit_vis_js_*` (3.3b, 3.4, 3.5), and `test_to_html_emits_local_vis_network_asset` (3.3c). +- **Spec §4 (risks)** — covered by Task 9 Step 1 (full test suite catches regressions) and Step 2 (smoke test catches the wheel/sdist packaging risk explicitly). +- **Spec §5 (file map)** — every row in the spec's summary table is touched by exactly one task in the plan. + +Type and name consistency: `_VIS_NETWORK_FILENAME` is defined in Task 3, consumed by `_vendored_vis_js()` and `_emit_vis_js()` in Tasks 4 and 5, and the on-disk name matches the string literal everywhere. `_emit_vis_js` is called once, by `to_html` in Task 6. No drift. diff --git a/docs/superpowers/plans/2026-06-29-offline-windows-installer.md b/docs/superpowers/plans/2026-06-29-offline-windows-installer.md new file mode 100644 index 000000000..228a84b6c --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-offline-windows-installer.md @@ -0,0 +1,2179 @@ +# Offline Windows Installer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a single `.exe` that installs `graphify` + `graphify-mcp` to a Windows 10 machine with no network at install time, registers them on the user-level PATH, and copies the SKILL.md into the user's detected host's skill directory (Claude Code / OpenCode / mobilecoder / etc.) — all without touching system-wide state. + +**Architecture:** A new `graphify/installer/` subpackage holds the installer logic (host detection, Windows PATH manipulation, skill copy, manifest-driven uninstall). A standalone entry script `tools/installer_main.py` is compiled by Nuitka `--onefile` into `graphify-installer.exe`. At install time the `.exe` decompresses the bundled wheelhouse (core + 9 default extras) plus the `graphify` package (with all 14 host skill bodies, always-on blocks, and the vendored `vis-network.min.js`) into `%LOCALAPPDATA%\graphify\`, registers `bin\` on the user-level PATH, and copies the appropriate SKILL.md to the detected host(s). Cloud LLM API calls remain allowed at runtime. + +**Tech Stack:** Python 3.10+, `pathlib`, `subprocess` (PowerShell for PATH), `importlib.resources`, `pytest`, `Nuitka>=4.1` + `ordered-set` + `zstandard` (build-time only). + +**Spec:** `docs/superpowers/specs/2026-06-29-offline-windows-installer-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `pyproject.toml` | **Modify** | Add `ordered-set`, `zstandard` to dev deps. Add `[project.optional-dependencies].windows-offline` extra (documentation only). | +| `graphify/installer/__init__.py` | **Create** | Orchestrator: `install()` and `uninstall()` entry points; manifest read/write. | +| `graphify/installer/host_probe.py` | **Create** | Probe `%USERPROFILE%\.claude\`, `\.config\opencode\`, etc. — return which hosts are installed. | +| `graphify/installer/path_win.py` | **Create** | Add/remove a path on user-level Windows PATH via `[Environment]::SetEnvironmentVariable` over PowerShell. | +| `graphify/installer/skill_copy.py` | **Create** | Copy the right `skill-.md` + `references/` sidecar to a host's skill directory. Handles `mobilecoder` (not in `_PLATFORM_CONFIG`) via direct `shutil.copy`. | +| `graphify/installer/manifest.py` | **Create** | Read/write the install manifest at `%LOCALAPPDATA%\graphify\.graphify_install.json`. | +| `tools/installer_main.py` | **Create** | Standalone entry script compiled by Nuitka. argparse subcommands: `install`, `uninstall`, `--version`, `--help`. | +| `graphify/__main__.py` | **Modify** | Add `self-install` / `self-uninstall` subcommands that delegate to `graphify.installer` (no change to existing `install ` flow). | +| `tools/build_windows_installer.sh` | **Create** | Bash wrapper around the Nuitka invocation: pip-downloads Windows wheels, runs Nuitka twice (for `graphify.exe` and `graphify-mcp.exe`), then a third time for `graphify-installer.exe`. | +| `tools/build_windows_installer.py` | **Create** | Cross-platform Python equivalent of the build script (for CI). | +| `docs/operations/offline-installer.md` | **Create** | End-user doc: how to install on an offline Windows machine, what the wizard shows, how to uninstall. | +| `tests/test_installer_host_probe.py` | **Create** | Unit tests for `host_probe`. | +| `tests/test_installer_path_win.py` | **Create** | Unit tests for `path_win` (with `subprocess.run` mocked). | +| `tests/test_installer_skill_copy.py` | **Create** | Unit tests for `skill_copy`. | +| `tests/test_installer_manifest.py` | **Create** | Unit tests for `manifest`. | +| `tests/test_installer_orchestrator.py` | **Create** | Unit tests for `graphify.installer.install` / `uninstall` (with filesystem mocking). | + +--- + +## Task 1: Add dev dependencies and `windows-offline` extra to `pyproject.toml` + +**Files:** +- Modify: `pyproject.toml` (the `dev` list in `[dependency-groups]` and the `optional-dependencies` table) + +- [ ] **Step 1: Add Nuitka runtime helpers to dev deps** + +Open `pyproject.toml`. Find the `[dependency-groups].dev` list (currently at lines 85–102). Add these two entries (alphabetical order is fine — current list is alphabetical): + +```toml + "ordered-set>=4.1", + "zstandard>=0.18", +``` + +`ordered-set` is a hard Nuitka requirement; `zstandard` makes `--onefile` decompression faster (optional but recommended in the Nuitka docs). + +- [ ] **Step 2: Add `windows-offline` extra** + +Find the `[project.optional-dependencies]` table (line 50). Add a new entry at the end (before `all = [...]` at line 78): + +```toml +# Documentation-only: lists every wheel the offline Windows installer bundles. +# This is NOT installed at runtime; it exists so readers of pyproject.toml can +# see at a glance what `graphify-installer.exe` contains. +windows-offline = [ + "networkx>=3.4", + "numpy>=1.21", + "rapidfuzz>=3.0", + "tree-sitter>=0.23.0,<0.26", + "tree-sitter-python>=0.23,<0.26", + "tree-sitter-javascript>=0.23,<0.26", + "tree-sitter-typescript>=0.23,<0.25", + "tree-sitter-go>=0.23,<0.26", + "tree-sitter-rust>=0.23,<0.25", + "tree-sitter-java>=0.23,<0.25", + "tree-sitter-groovy>=0.1,<0.3", + "tree-sitter-c>=0.23,<0.26", + "tree-sitter-cpp>=0.23,<0.25", + "tree-sitter-ruby>=0.23,<0.25", + "tree-sitter-c-sharp>=0.23,<0.25", + "tree-sitter-kotlin>=1.0,<2.0", + "tree-sitter-scala>=0.23,<0.27", + "tree-sitter-php>=0.23,<0.25", + "tree-sitter-swift>=0.7,<0.9", + "tree-sitter-lua>=0.2,<0.6", + "tree-sitter-zig>=1.0,<2.0", + "tree-sitter-powershell>=0.26,<0.28", + "tree-sitter-elixir>=0.3,<0.5", + "tree-sitter-objc>=3.0,<4.0", + "tree-sitter-julia>=0.23,<0.25", + "tree-sitter-verilog>=1.0,<2.0", + "tree-sitter-fortran>=0.6,<0.8", + "tree-sitter-bash>=0.23,<0.27", + "tree-sitter-json>=0.23,<0.26", + "anthropic", + "mcp", + "starlette>=1.3.1", + "graspologic; python_version < '3.13'", + "tree-sitter-sql", + "tree-sitter-hcl", + "jieba", + "watchdog", + "matplotlib", +] +``` + +- [ ] **Step 3: Verify `pyproject.toml` still parses** + +```bash +uv run python -c "import tomllib; tomllib.loads(open('pyproject.toml').read()); print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml +git commit -m "build: add Nuitka runtime helpers + windows-offline extra doc" +``` + +--- + +## Task 2: Create the `graphify/installer/` package skeleton + +**Files:** +- Create: `graphify/installer/__init__.py` +- Create: `graphify/installer/host_probe.py` (placeholder) +- Create: `graphify/installer/path_win.py` (placeholder) +- Create: `graphify/installer/skill_copy.py` (placeholder) +- Create: `graphify/installer/manifest.py` (placeholder) + +- [ ] **Step 1: Create the directory and 5 empty files** + +```bash +mkdir -p graphify/installer +touch graphify/installer/__init__.py \ + graphify/installer/host_probe.py \ + graphify/installer/path_win.py \ + graphify/installer/skill_copy.py \ + graphify/installer/manifest.py +``` + +- [ ] **Step 2: Verify the package imports** + +```bash +uv run python -c "from graphify.installer import host_probe, path_win, skill_copy, manifest; print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add graphify/installer/ +git commit -m "feat(installer): add installer package skeleton" +``` + +--- + +## Task 3: Implement `host_probe` (TDD) + +**Files:** +- Modify: `graphify/installer/host_probe.py` +- Create: `tests/test_installer_host_probe.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_installer_host_probe.py`: + +```python +"""Tests for graphify.installer.host_probe. + +These tests use `tmp_path` to simulate the user's home directory. Production +behavior probes `%USERPROFILE%`; the test injects a fake root via the +`root=` parameter. +""" + +from pathlib import Path + +from graphify.installer.host_probe import KNOWN_HOSTS, detect_hosts, host_skill_dir + + +def test_known_hosts_includes_claude_and_opencode(): + names = {h.name for h in KNOWN_HOSTS} + assert "claude" in names + assert "opencode" in names + + +def test_known_hosts_includes_mobilecoder_as_direct_copy(): + # mobilecoder is not in graphify's _PLATFORM_CONFIG, so the installer must + # copy SKILL.md to the host's convention path directly (see spec §4 + # "Unknown hosts"). Mark it explicitly so callers branch on it. + mc = next(h for h in KNOWN_HOSTS if h.name == "mobilecoder") + assert mc.uses_graphify_install is False + assert mc.skill_subpath == Path("skills") / "graphify" + + +def test_detect_hosts_returns_empty_when_no_hosts_present(tmp_path): + # tmp_path is empty; no host should be detected. + detected = detect_hosts(root=tmp_path) + assert detected == [] + + +def test_detect_hosts_finds_claude(tmp_path): + (tmp_path / ".claude").mkdir() + detected = detect_hosts(root=tmp_path) + assert any(h.name == "claude" for h in detected) + + +def test_detect_hosts_finds_opencode(tmp_path): + (tmp_path / ".config" / "opencode").mkdir(parents=True) + detected = detect_hosts(root=tmp_path) + assert any(h.name == "opencode" for h in detected) + + +def test_detect_hosts_finds_multiple(tmp_path): + (tmp_path / ".claude").mkdir() + (tmp_path / ".config" / "opencode").mkdir(parents=True) + detected = detect_hosts(root=tmp_path) + names = {h.name for h in detected} + assert {"claude", "opencode"}.issubset(names) + + +def test_host_skill_dir_for_claude(tmp_path): + host = next(h for h in KNOWN_HOSTS if h.name == "claude") + skill_dir = host_skill_dir(host, root=tmp_path) + assert skill_dir == tmp_path / ".claude" / "skills" / "graphify" +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +```bash +uv run pytest tests/test_installer_host_probe.py -v +``` + +Expected: FAIL with `ImportError: cannot import name 'KNOWN_HOSTS' from 'graphify.installer.host_probe'`. + +- [ ] **Step 3: Implement `host_probe.py`** + +Replace `graphify/installer/host_probe.py` with: + +```python +"""Detect which AI-coding hosts are installed on the user's machine. + +Probes a small set of well-known home-directory signatures (e.g. +`~/.claude/`, `~/.config/opencode/`, `~/.mobilecoder/`). Used by the offline +installer to decide which host(s) to register the SKILL.md for, and by +`skill_copy` to resolve the per-host skill directory. + +For hosts that ARE in `graphify.__main__._PLATFORM_CONFIG` we set +`uses_graphify_install=True` (the installer can call `graphify install ` +to do the copy). For hosts that AREN'T (e.g. `mobilecoder`), we set +`uses_graphify_install=False` and the installer must `shutil.copy` SKILL.md +directly to the host's convention path. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List + +# Hosts that graphify's `_PLATFORM_CONFIG` knows about; the installer can +# delegate to `graphify install ` for these. +_GRAPHIFY_INSTALL_HOSTS = frozenset({ + "claude", "codex", "kilo", "aider", "copilot", "claw", "droid", + "trae", "trae-cn", "hermes", "kiro", "pi", "codebuddy", "antigravity", + "windows", "amp", "agents", "vscode", +}) + + +@dataclass(frozen=True) +class Host: + """A known AI-coding host. + + Attributes: + name: short identifier (e.g. "claude", "opencode", "mobilecoder"). + marker: a path relative to the user's home directory whose existence + means this host is installed. Detection is "any file/dir under + this path" — we just stat the path itself. + skill_subpath: path relative to `root` where the SKILL.md should + be written (typically `//skills/graphify/SKILL.md`). + uses_graphify_install: True if the host is in `_PLATFORM_CONFIG` and + we should call `graphify install `; False if we must do a + direct `shutil.copy` (the host isn't first-class supported). + """ + name: str + marker: Path + skill_subpath: Path + uses_graphify_install: bool + + +def _host(name: str, marker: str, sub: str, *, in_graphify: bool) -> Host: + return Host( + name=name, + marker=Path(marker), + skill_subpath=Path(sub), + uses_graphify_install=in_graphify, + ) + + +KNOWN_HOSTS: tuple[Host, ...] = ( + _host("claude", ".claude", "skills/graphify", in_graphify=True), + _host("codex", ".codex", "skills/graphify", in_graphify=True), + _host("opencode", ".config/opencode", "skills/graphify", in_graphify=True), + _host("kilo", ".config/kilo", "skills/graphify", in_graphify=True), + _host("aider", ".aider", "graphify", in_graphify=True), + _host("copilot", ".copilot", "skills/graphify", in_graphify=True), + _host("codebuddy", ".codebuddy", "skills/graphify", in_graphify=True), + _host("kiro", ".kiro", "skills/graphify", in_graphify=True), + _host("droid", ".factory", "skills/graphify", in_graphify=True), + _host("trae", ".trae", "skills/graphify", in_graphify=True), + _host("trae-cn", ".trae-cn", "skills/graphify", in_graphify=True), + _host("hermes", ".hermes", "skills/graphify", in_graphify=True), + _host("pi", ".pi", "agent/skills/graphify", in_graphify=True), + _host("claw", ".openclaw", "skills/graphify", in_graphify=True), + _host("antigravity", ".agents", "skills/graphify", in_graphify=True), + _host("vscode", ".vscode", "skills/graphify", in_graphify=True), + _host("amp", ".config/amp", "skills/graphify", in_graphify=True), + _host("agents", ".config/agents", "skills/graphify", in_graphify=True), + # Unknown to graphify but probed: mobilecoder. Copy SKILL.md directly. + _host("mobilecoder", ".mobilecoder", "skills/graphify", in_graphify=False), + _host("cursor", ".cursor", "rules", in_graphify=True), + _host("gemini", ".gemini", "skills/graphify", in_graphify=True), +) + + +def detect_hosts(*, root: Path | None = None) -> List[Host]: + """Return the list of installed hosts under `root` (default: $USERPROFILE). + + A host is "installed" when its marker path exists under `root`. Order + of KNOWN_HOSTS is preserved in the result. + """ + if root is None: + root = Path.home() + found: list[Host] = [] + for host in KNOWN_HOSTS: + if (root / host.marker).exists(): + found.append(host) + return found + + +def host_skill_dir(host: Host, *, root: Path) -> Path: + """Absolute directory where SKILL.md should be written for `host`.""" + return root / host.marker / host.skill_subpath +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +```bash +uv run pytest tests/test_installer_host_probe.py -v +``` + +Expected: 7 passed. + +- [ ] **Step 5: Commit** + +```bash +git add graphify/installer/host_probe.py tests/test_installer_host_probe.py +git commit -m "feat(installer): add host_probe (detect installed AI-coding hosts)" +``` + +--- + +## Task 4: Implement `path_win` (TDD) + +**Files:** +- Modify: `graphify/installer/path_win.py` +- Create: `tests/test_installer_path_win.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_installer_path_win.py`: + +```python +"""Tests for graphify.installer.path_win. + +`path_win` shells out to PowerShell to set/unset the user-level PATH. +We mock `subprocess.run` so the tests don't actually touch the registry. + +Tests 1–4 verify the PowerShell call shape on Windows. They're skipped on +non-Windows because the implementation's no-op short-circuits before the +mocked subprocess is called. Test 5 verifies the no-op behavior itself +and runs on every platform. +""" + +from __future__ import annotations + +import sys +from unittest.mock import patch, MagicMock + +import pytest + +from graphify.installer import path_win + + +@pytest.mark.skipif(sys.platform != "win32", reason="PowerShell call only runs on Windows") +def test_add_to_user_path_invokes_powershell_with_setx(): + """On Windows, add_to_user_path must call PowerShell's + [Environment]::SetEnvironmentVariable with Target=User.""" + fake = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr="")) + with patch("graphify.installer.path_win.subprocess.run", fake): + path_win.add_to_user_path(r"C:\Users\me\AppData\Local\graphify\bin") + args, kwargs = fake.call_args + # First positional arg is the command list passed to subprocess.run + cmd = args[0] + assert cmd[0] == "powershell" + assert "-NoProfile" in cmd + assert "-Command" in cmd + # The combined -Command string must reference SetEnvironmentVariable with User target + command_str = next(a for a in cmd if isinstance(a, str) and "SetEnvironmentVariable" in a) + assert "User" in command_str + assert r"C:\Users\me\AppData\Local\graphify\bin" in command_str + + +@pytest.mark.skipif(sys.platform != "win32", reason="PowerShell call only runs on Windows") +def test_add_to_user_path_is_idempotent(): + """Calling add_to_user_path twice with the same value must not error and + must produce the same PowerShell call shape both times.""" + fake = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr="")) + with patch("graphify.installer.path_win.subprocess.run", fake): + path_win.add_to_user_path(r"C:\graphify\bin") + path_win.add_to_user_path(r"C:\graphify\bin") + assert fake.call_count == 2 + + +@pytest.mark.skipif(sys.platform != "win32", reason="PowerShell call only runs on Windows") +def test_remove_from_user_path_invokes_powershell(): + fake = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr="")) + with patch("graphify.installer.path_win.subprocess.run", fake): + path_win.remove_from_user_path(r"C:\graphify\bin") + cmd = fake.call_args[0][0] + command_str = next(a for a in cmd if isinstance(a, str) and "SetEnvironmentVariable" in a) + assert "User" in command_str + # The path to remove must appear in the command (we filter it out of the + # existing PATH and re-set the result). + assert r"C:\graphify\bin" in command_str + + +@pytest.mark.skipif(sys.platform != "win32", reason="PowerShell call only runs on Windows") +def test_add_to_user_path_raises_on_powershell_failure(): + fake = MagicMock(return_value=MagicMock(returncode=1, stdout="", stderr="boom")) + with patch("graphify.installer.path_win.subprocess.run", fake): + with pytest.raises(path_win.PathWinError): + path_win.add_to_user_path(r"C:\graphify\bin") + + +def test_add_to_user_path_noop_on_non_windows(): + """On non-Windows platforms, add_to_user_path must return without + invoking subprocess.""" + fake = MagicMock() + with patch("graphify.installer.path_win.sys.platform", "darwin"): + with patch("graphify.installer.path_win.subprocess.run", fake): + path_win.add_to_user_path("/tmp/whatever") + fake.assert_not_called() +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +```bash +uv run pytest tests/test_installer_path_win.py -v +``` + +Expected: FAIL with `ImportError: cannot import name 'path_win' from 'graphify.installer'`. + +- [ ] **Step 3: Implement `path_win.py`** + +Replace `graphify/installer/path_win.py` with: + +```python +"""User-level PATH manipulation on Windows. + +We do NOT use `os.environ` mutation (it doesn't persist beyond the process) +and we do NOT touch `HKLM\SYSTEM\...` (system PATH requires admin and +modifies a global setting). Instead we shell out to PowerShell: + + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + +`User` target writes to `HKCU\Environment`, which is what we want. The +new value is the current user PATH with the target path appended (for +add) or filtered out (for remove). +""" + +from __future__ import annotations + +import subprocess +import sys +from typing import List + + +class PathWinError(RuntimeError): + """Raised when PowerShell returns a non-zero exit code.""" + + +def _powershell_set_path(ps_command: str) -> None: + """Run `ps_command` in PowerShell and raise on failure.""" + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + ps_command, + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise PathWinError( + f"PowerShell failed (rc={result.returncode}): " + f"{result.stderr.strip() or result.stdout.strip()}" + ) + + +def _build_set_command(current: str, new: str) -> str: + """PowerShell that: + 1. Reads current user Path. + 2. Splits on ';' (registry stores it as a single REG_EXPAND_SZ string). + 3. If `new` is not already present, appends it. + 4. Writes back via SetEnvironmentVariable('Path', ..., 'User'). + """ + # Escape single quotes for PowerShell single-quoted strings + cur = current.replace("'", "''") + nw = new.replace("'", "''") + return ( + f"$cur = [Environment]::GetEnvironmentVariable('Path', 'User'); " + f"$sep = [IO.Path]::PathSeparator; " + f"$parts = if ([string]::IsNullOrEmpty($cur)) {{ @() }} else {{ $cur.Split(';') }}; " + f"if ($parts -notcontains '{nw}') {{ $parts += '{nw}' }}; " + f"$new = [string]::Join(';', $parts); " + f"[Environment]::SetEnvironmentVariable('Path', $new, 'User')" + ) + + +def _build_unset_command(current: str, target: str) -> str: + """PowerShell that removes `target` from the user Path and writes back.""" + tgt = target.replace("'", "''") + return ( + f"$cur = [Environment]::GetEnvironmentVariable('Path', 'User'); " + f"if ([string]::IsNullOrEmpty($cur)) {{ return }}; " + f"$parts = $cur.Split(';') | Where-Object {{ $_ -ne '{tgt}' }}; " + f"$new = [string]::Join(';', $parts); " + f"[Environment]::SetEnvironmentVariable('Path', $new, 'User')" + ) + + +def add_to_user_path(path: str) -> None: + """Append `path` to the user-level PATH. No-op on non-Windows. + + Idempotent: calling with the same `path` twice is safe (the second + call is a no-op because the PowerShell filter rejects duplicates). + """ + if sys.platform != "win32": + return + # We don't need to read `current` in Python — the PS command does it. + _powershell_set_path(_build_set_command("", path)) + + +def remove_from_user_path(path: str) -> None: + """Remove `path` from the user-level PATH. No-op on non-Windows.""" + if sys.platform != "win32": + return + _powershell_set_path(_build_unset_command("", path)) + + +def current_user_path() -> str: + """Return the current user-level PATH (for tests / diagnostics).""" + if sys.platform != "win32": + return "" + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + "[Environment]::GetEnvironmentVariable('Path', 'User')", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise PathWinError( + f"PowerShell failed (rc={result.returncode}): {result.stderr.strip()}" + ) + return result.stdout.strip() +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +```bash +uv run pytest tests/test_installer_path_win.py -v +``` + +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add graphify/installer/path_win.py tests/test_installer_path_win.py +git commit -m "feat(installer): add path_win (user-level Windows PATH via PowerShell)" +``` + +--- + +## Task 5: Implement `manifest` (TDD) + +**Files:** +- Modify: `graphify/installer/manifest.py` +- Create: `tests/test_installer_manifest.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_installer_manifest.py`: + +```python +"""Tests for graphify.installer.manifest.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from graphify.installer.manifest import ( + InstallManifest, + load_manifest, + manifest_path, + save_manifest, +) + + +def test_manifest_path_is_localappdata_graphify(): + p = manifest_path() + assert p.name == ".graphify_install.json" + # On Windows this is %LOCALAPPDATA%\graphify\.graphify_install.json. + # On non-Windows the function still returns a path; we just verify shape. + assert "graphify" in p.parts + + +def test_save_and_load_roundtrip(tmp_path): + m = InstallManifest( + version="0.9.1", + install_path=tmp_path, + hosts=["claude"], + user_path_added=True, + ) + target = tmp_path / ".graphify_install.json" + save_manifest(m, target) + assert target.exists() + loaded = load_manifest(target) + assert loaded.version == "0.9.1" + assert loaded.install_path == tmp_path + assert loaded.hosts == ["claude"] + assert loaded.user_path_added is True + + +def test_load_manifest_missing_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + load_manifest(tmp_path / "nope.json") + + +def test_save_manifest_creates_parent_dirs(tmp_path): + target = tmp_path / "nested" / "dir" / "manifest.json" + m = InstallManifest( + version="0.9.1", + install_path=tmp_path, + hosts=[], + user_path_added=False, + ) + save_manifest(m, target) + assert target.exists() +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +```bash +uv run pytest tests/test_installer_manifest.py -v +``` + +Expected: FAIL with `ImportError`. + +- [ ] **Step 3: Implement `manifest.py`** + +Replace `graphify/installer/manifest.py` with: + +```python +"""Install manifest: records what the offline installer did, so uninstall +can reverse it cleanly. + +Stored at `/.graphify_install.json` (typically +`%LOCALAPPDATA%\graphify\.graphify_install.json` on Windows). +""" + +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import List, Optional + + +@dataclass +class InstallManifest: + """Snapshot of an offline install, written to disk for later uninstall.""" + + version: str + install_path: Path + hosts: List[str] = field(default_factory=list) + user_path_added: bool = False + created_shortcut: bool = False + # Per-host record of the exact skill directory we wrote to, so uninstall + # can `rmtree` it without re-probing the filesystem. + skill_dirs: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + d = asdict(self) + d["install_path"] = str(self.install_path) + return d + + @classmethod + def from_dict(cls, d: dict) -> "InstallManifest": + return cls( + version=d["version"], + install_path=Path(d["install_path"]), + hosts=list(d.get("hosts", [])), + user_path_added=bool(d.get("user_path_added", False)), + created_shortcut=bool(d.get("created_shortcut", False)), + skill_dirs=list(d.get("skill_dirs", [])), + ) + + +def manifest_path() -> Path: + """Default manifest location: %LOCALAPPDATA%\\graphify\\.graphify_install.json. + + On non-Windows the function still returns a sensible path under the user's + home; the installer is Windows-only but this helper is import-safe on every + platform. + """ + base = os.environ.get("LOCALAPPDATA") or str(Path.home()) + return Path(base) / "graphify" / ".graphify_install.json" + + +def save_manifest(m: InstallManifest, path: Path) -> None: + """Write `m` to `path` as JSON. Creates parent directories as needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(m.to_dict(), indent=2), encoding="utf-8") + + +def load_manifest(path: Path) -> InstallManifest: + """Load and validate a manifest from `path`. Raises FileNotFoundError.""" + if not path.exists(): + raise FileNotFoundError(f"No install manifest at {path}") + data = json.loads(path.read_text(encoding="utf-8")) + return InstallManifest.from_dict(data) +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +```bash +uv run pytest tests/test_installer_manifest.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add graphify/installer/manifest.py tests/test_installer_manifest.py +git commit -m "feat(installer): add manifest (roundtrip JSON for uninstall)" +``` + +--- + +## Task 6: Implement `skill_copy` (TDD) + +**Files:** +- Modify: `graphify/installer/skill_copy.py` +- Create: `tests/test_installer_skill_copy.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_installer_skill_copy.py`: + +```python +"""Tests for graphify.installer.skill_copy. + +`skill_copy` reads the right `skill-.md` from the bundled graphify +package (via `importlib.resources`) and writes it to `//SKILL.md`, +plus a `references/` sidecar when the host's bundle has one. +""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +import pytest + +from graphify.installer import skill_copy +from graphify.installer.host_probe import KNOWN_HOSTS, Host, host_skill_dir + + +def _write_minimal_graphify_package(tmp_path, *, with_references: bool = True): + """Create a fake `graphify` package layout under tmp_path with the + minimal files `skill_copy` reads. Returns the package root. + """ + pkg = tmp_path / "graphify" + pkg.mkdir() + (pkg / "skill.md").write_text("# Claude bundle\n", encoding="utf-8") + (pkg / "skill-opencode.md").write_text("# OpenCode bundle\n", encoding="utf-8") + (pkg / "skill-mobilecoder.md").write_text( + "# Mobilecoder bundle (uses claude body)\n", encoding="utf-8" + ) + (pkg / "skill-claw.md").write_text("# Claw bundle\n", encoding="utf-8") + (pkg / "skill-kiro.md").write_text("# Kiro bundle\n", encoding="utf-8") + if with_references: + refs = pkg / "skills" / "claude" / "references" + refs.mkdir(parents=True) + (refs / "extraction-spec.md").write_text("ref\n", encoding="utf-8") + return pkg + + +def test_pick_skill_body_for_claude(): + body = skill_copy._pick_skill_body("claude") + assert "graphify" in body.lower() or len(body) > 0 + + +def test_pick_skill_body_for_opencode_uses_opencode_bundle(): + body = skill_copy._pick_skill_body("opencode") + assert isinstance(body, str) + assert len(body) > 0 + + +def test_pick_skill_body_for_unknown_host_falls_back_to_skill_md(): + body = skill_copy._pick_skill_body("totally-fake-host") + assert isinstance(body, str) + assert len(body) > 0 # falls back to skill.md + + +def test_copy_skill_for_known_graphify_host(tmp_path, monkeypatch): + """For a host in _PLATFORM_CONFIG we still copy the bundle directly + (we don't actually shell out to `graphify install` in the offline + installer — the .exe is the installer).""" + pkg = _write_minimal_graphify_package(tmp_path, with_references=False) + # Redirect importlib.resources to read from our fake package. + fake_resources = importlib.resources.files.__self__ if False else None # noqa + host = next(h for h in KNOWN_HOSTS if h.name == "claude") + out_dir = host_skill_dir(host, root=tmp_path) + skill_copy.copy_skill(host, root=tmp_path, package_root=pkg) + assert (out_dir / "SKILL.md").exists() + assert "Claude bundle" in (out_dir / "SKILL.md").read_text(encoding="utf-8") + + +def test_copy_skill_writes_references_when_present(tmp_path): + pkg = _write_minimal_graphify_package(tmp_path, with_references=True) + host = next(h for h in KNOWN_HOSTS if h.name == "claude") + out_dir = host_skill_dir(host, root=tmp_path) + skill_copy.copy_skill(host, root=tmp_path, package_root=pkg) + assert (out_dir / "references" / "extraction-spec.md").exists() + + +def test_copy_skill_for_mobilecoder_uses_skill_md_fallback(tmp_path): + pkg = _write_minimal_graphify_package(tmp_path, with_references=False) + host = next(h for h in KNOWN_HOSTS if h.name == "mobilecoder") + out_dir = host_skill_dir(host, root=tmp_path) + skill_copy.copy_skill(host, root=tmp_path, package_root=pkg) + assert (out_dir / "SKILL.md").exists() +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +```bash +uv run pytest tests/test_installer_skill_copy.py -v +``` + +Expected: FAIL with `ImportError`. + +- [ ] **Step 3: Implement `skill_copy.py`** + +Replace `graphify/installer/skill_copy.py` with: + +```python +"""Copy the right SKILL.md (and references/) to a host's skill directory. + +Sources the bundle from the installed `graphify` package. For hosts whose +bundle is in `graphify/__main__:_PLATFORM_CONFIG` (claude, opencode, etc.), +we pick the host-specific file. For hosts NOT in the config (mobilecoder), +we fall back to `skill.md` (the Claude bundle) — the user is responsible +for adjusting the body if their host needs a different format. +""" + +from __future__ import annotations + +import shutil +from importlib.resources import files +from pathlib import Path +from typing import Optional + +from graphify.installer.host_probe import Host, host_skill_dir + +# Map host name -> the skill body filename in the graphify package. +# Hosts whose body is `skill.md` (the Claude bundle) don't need an entry. +_BODY_BY_HOST = { + "claude": "skill.md", + "codex": "skill-codex.md", + "opencode": "skill-opencode.md", + "kilo": "skill-kilo.md", + "aider": "skill-aider.md", + "copilot": "skill-copilot.md", + "codebuddy": "skill.md", # reuses claude bundle + "kiro": "skill-kiro.md", + "droid": "skill-droid.md", + "trae": "skill-trae.md", + "trae-cn": "skill-trae.md", + "hermes": "skill-claw.md", + "pi": "skill-pi.md", + "claw": "skill-claw.md", + "antigravity": "skill.md", # reuses claude bundle + "vscode": "skill-vscode.md", + "amp": "skill-amp.md", + "agents": "skill-agents.md", + "mobilecoder": "skill.md", # not first-class; fall back to claude body +} + +# Map host name -> the sidecar references directory inside the package +# (relative to the package root). None = no references/ to copy. +_REFS_BY_HOST = { + "claude": "skills/claude/references", + "codex": "skills/codex/references", + "opencode": "skills/opencode/references", + "kilo": "skills/kilo/references", + "copilot": "skills/copilot/references", + "codebuddy": "skills/claude/references", + "kiro": "skills/kiro/references", + "droid": "skills/droid/references", + "trae": "skills/trae/references", + "hermes": "skills/claw/references", + "pi": "skills/pi/references", + "claw": "skills/claw/references", + "antigravity": "skills/claude/references", + "vscode": "skills/vscode/references", + "amp": "skills/amp/references", + "agents": "skills/agents/references", +} + + +def _pick_skill_body(host_name: str) -> str: + """Return the text of the skill body for `host_name`. + + Looks up the body file in the installed graphify package. If the host + has no specific entry, falls back to `skill.md` (the Claude bundle). + """ + body_name = _BODY_BY_HOST.get(host_name, "skill.md") + try: + return (files("graphify").joinpath(body_name).read_text(encoding="utf-8")) + except (FileNotFoundError, ModuleNotFoundError): + # In tests we may be operating against a fake package; fall back to + # the package_root passed by the test, if any. + return "" + + +def copy_skill( + host: Host, + *, + root: Path, + package_root: Optional[Path] = None, +) -> Path: + """Write SKILL.md (and references/) for `host` under `root`. + + `package_root` is the path to the `graphify` package directory; defaults + to the installed package. It exists so tests can inject a fake package + without `importlib.resources` finding real files. + """ + out_dir = host_skill_dir(host, root=root) + out_dir.mkdir(parents=True, exist_ok=True) + + # Read the body. + body_name = _BODY_BY_HOST.get(host.name, "skill.md") + if package_root is not None: + body_path = package_root / body_name + body = body_path.read_text(encoding="utf-8") if body_path.exists() else "" + else: + body = _pick_skill_body(host.name) + + (out_dir / "SKILL.md").write_text(body, encoding="utf-8") + + # Copy references/ if the host has them. + refs_rel = _REFS_BY_HOST.get(host.name) + if refs_rel: + if package_root is not None: + src_refs = package_root / refs_rel + else: + # Use importlib.resources traversal. + from importlib.resources import as_file + try: + ref_resource = files("graphify").joinpath(*refs_rel.split("/")) + with as_file(ref_resource) as p: + src_refs = p + except (FileNotFoundError, ModuleNotFoundError, TypeError): + src_refs = None + if src_refs is not None and src_refs.exists(): + dst_refs = out_dir / "references" + if dst_refs.exists(): + shutil.rmtree(dst_refs) + shutil.copytree(src_refs, dst_refs) + + return out_dir +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +```bash +uv run pytest tests/test_installer_skill_copy.py -v +``` + +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add graphify/installer/skill_copy.py tests/test_installer_skill_copy.py +git commit -m "feat(installer): add skill_copy (host-aware SKILL.md + references copy)" +``` + +--- + +## Task 7: Implement orchestrator `installer/__init__.py` (TDD) + +**Files:** +- Modify: `graphify/installer/__init__.py` +- Create: `tests/test_installer_orchestrator.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_installer_orchestrator.py`: + +```python +"""Tests for graphify.installer orchestrator (install / uninstall).""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from graphify.installer import install as run_install, uninstall as run_uninstall +from graphify.installer.manifest import InstallManifest, load_manifest, manifest_path + + +def test_install_writes_manifest(tmp_path, monkeypatch): + """On a clean install, a manifest is written to /.graphify_install.json.""" + # Pretend we found exactly one host (claude). + claude_host = next( + h for h in __import__("graphify.installer.host_probe", fromlist=["KNOWN_HOSTS"]).KNOWN_HOSTS + if h.name == "claude" + ) + monkeypatch.setattr( + "graphify.installer.detect_hosts", lambda *, root=None: [claude_host] + ) + monkeypatch.setattr("graphify.installer.path_win.add_to_user_path", lambda p: None) + monkeypatch.setattr("graphify.installer.path_win.remove_from_user_path", lambda p: None) + + manifest_file = tmp_path / ".graphify_install.json" + run_install( + install_path=tmp_path, + user_root=tmp_path, + version="0.9.1", + manifest_target=manifest_file, + ) + assert manifest_file.exists() + m = load_manifest(manifest_file) + assert m.version == "0.9.1" + assert m.install_path == tmp_path + assert "claude" in m.hosts + + +def test_install_writes_skill_for_detected_host(tmp_path, monkeypatch): + claude_host = next( + h for h in __import__("graphify.installer.host_probe", fromlist=["KNOWN_HOSTS"]).KNOWN_HOSTS + if h.name == "claude" + ) + monkeypatch.setattr( + "graphify.installer.detect_hosts", lambda *, root=None: [claude_host] + ) + monkeypatch.setattr("graphify.installer.path_win.add_to_user_path", lambda p: None) + + # Provide a fake package so skill_copy has something to read. + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "skill.md").write_text("# claude\n", encoding="utf-8") + monkeypatch.setattr( + "graphify.installer.skill_copy._pick_skill_body", lambda h: "# claude\n" + ) + + run_install( + install_path=tmp_path / "install", + user_root=tmp_path, + version="0.9.1", + manifest_target=tmp_path / "install" / ".graphify_install.json", + ) + skill_dir = tmp_path / ".claude" / "skills" / "graphify" + assert (skill_dir / "SKILL.md").exists() + + +def test_install_with_no_hosts_still_succeeds(tmp_path, monkeypatch): + monkeypatch.setattr("graphify.installer.detect_hosts", lambda *, root=None: []) + monkeypatch.setattr("graphify.installer.path_win.add_to_user_path", lambda p: None) + manifest_file = tmp_path / ".graphify_install.json" + run_install( + install_path=tmp_path, + user_root=tmp_path, + version="0.9.1", + manifest_target=manifest_file, + ) + m = load_manifest(manifest_file) + assert m.hosts == [] + + +def test_uninstall_removes_manifest_and_skill_dirs(tmp_path, monkeypatch): + # Set up an existing install. + claude_host = next( + h for h in __import__("graphify.installer.host_probe", fromlist=["KNOWN_HOSTS"]).KNOWN_HOSTS + if h.name == "claude" + ) + skill_dir = tmp_path / ".claude" / "skills" / "graphify" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("x", encoding="utf-8") + + manifest = InstallManifest( + version="0.9.1", + install_path=tmp_path, + hosts=["claude"], + user_path_added=True, + skill_dirs=[str(skill_dir)], + ) + from graphify.installer.manifest import save_manifest + manifest_file = tmp_path / "manifest.json" + save_manifest(manifest, manifest_file) + + monkeypatch.setattr("graphify.installer.path_win.remove_from_user_path", lambda p: None) + run_uninstall(manifest_file=manifest_file) + assert not skill_dir.exists() + # Manifest is consumed. + assert not manifest_file.exists() +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +```bash +uv run pytest tests/test_installer_orchestrator.py -v +``` + +Expected: FAIL with `ImportError: cannot import name 'install' from 'graphify.installer'`. + +- [ ] **Step 3: Implement `installer/__init__.py`** + +Replace `graphify/installer/__init__.py` with: + +```python +"""Offline Windows installer orchestrator. + +The single entry points are `install()` and `uninstall()` — the rest of the +package is helpers. The compiled `graphify-installer.exe` (built from +`tools/installer_main.py`) calls these. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path +from typing import List, Optional + +from graphify.installer import host_probe, manifest, path_win, skill_copy + +# Re-export the helpers at the package level. Two reasons: +# 1. Tests `monkeypatch.setattr("graphify.installer.", ...)` need +# the names reachable on the package itself; submodule-only lookups +# would miss the patch. +# 2. `install()` / `uninstall()` below look up these names through the +# module globals, so the same patch chain applies in production code. +detect_hosts = host_probe.detect_hosts +add_to_user_path = path_win.add_to_user_path +remove_from_user_path = path_win.remove_from_user_path + + +def install( + *, + install_path: Path, + user_root: Path, + version: str, + manifest_target: Optional[Path] = None, +) -> manifest.InstallManifest: + """Run the offline install. + + Steps: + 1. Probe `user_root` for installed hosts. + 2. Copy each host's SKILL.md (+ references/) into the host's skill dir. + 3. Register `install_path / bin` on the user-level PATH. + 4. Write the install manifest. + """ + hosts = detect_hosts(root=user_root) + skill_dirs: List[str] = [] + + for host in hosts: + try: + out_dir = skill_copy.copy_skill(host, root=user_root) + skill_dirs.append(str(out_dir)) + except Exception as exc: # noqa: BLE001 + # We never abort the install for a single host failure; record it. + print( + f"[graphify-installer] warn: failed to install skill for " + f"{host.name}: {exc}", + file=sys.stderr, + ) + + bin_path = install_path / "bin" + try: + add_to_user_path(str(bin_path)) + user_path_added = True + except path_win.PathWinError as exc: + print( + f"[graphify-installer] warn: could not register PATH ({exc}); " + f"add {bin_path} to your PATH manually.", + file=sys.stderr, + ) + user_path_added = False + + m = manifest.InstallManifest( + version=version, + install_path=install_path, + hosts=[h.name for h in hosts], + user_path_added=user_path_added, + skill_dirs=skill_dirs, + ) + target = manifest_target or (install_path / ".graphify_install.json") + manifest.save_manifest(m, target) + return m + + +def uninstall(*, manifest_file: Path) -> None: + """Reverse a previous install: remove skill dirs, drop PATH, delete manifest.""" + if not manifest_file.exists(): + raise FileNotFoundError(f"No install manifest at {manifest_file}") + m = manifest.load_manifest(manifest_file) + + for skill_dir_str in m.skill_dirs: + skill_dir = Path(skill_dir_str) + if skill_dir.exists(): + shutil.rmtree(skill_dir, ignore_errors=True) + + if m.user_path_added: + try: + remove_from_user_path(str(m.install_path / "bin")) + except path_win.PathWinError as exc: + print( + f"[graphify-installer] warn: could not remove PATH entry ({exc}); " + f"remove it manually.", + file=sys.stderr, + ) + + if m.install_path.exists(): + shutil.rmtree(m.install_path, ignore_errors=True) + + manifest_file.unlink(missing_ok=True) +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +```bash +uv run pytest tests/test_installer_orchestrator.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 5: Run the full installer test suite** + +```bash +uv run pytest tests/test_installer_*.py -v +``` + +Expected: all tests pass (7 + 5 + 4 + 6 + 4 = 26 tests). + +- [ ] **Step 6: Commit** + +```bash +git add graphify/installer/__init__.py tests/test_installer_orchestrator.py +git commit -m "feat(installer): add install/uninstall orchestrator" +``` + +--- + +## Task 8: Add `self-install` / `self-uninstall` subcommands to `graphify/__main__.py` + +**Files:** +- Modify: `graphify/__main__.py` (insert new branches into the dispatcher near line 2404; add new entries to the help text near line 2224) + +- [ ] **Step 1: Add the new subcommands to the help text** + +Find the `print("Commands:")` block in `main()` (around line 2223). Add two new lines right after the `uninstall` line (line 2225–2226 area): + +```python + print(" self-install offline Windows installer: deploy graphify to %LOCALAPPDATA%\\graphify") + print(" --path DIR override install path (default: %LOCALAPPDATA%\\graphify)") + print(" --no-path skip user-PATH registration") + print(" self-uninstall reverse a self-install: remove skill dirs, drop PATH, delete install dir") +``` + +- [ ] **Step 2: Add the dispatcher branches** + +Find the dispatcher block where `cmd = sys.argv[1]` is set (line 2404). Add two new `elif` branches for `self-install` and `self-uninstall`. The cleanest place to insert is right after the existing `uninstall` branch (which ends around line 2493) and before the `claude` branch (line 2494): + +```python + elif cmd == "self-install": + from graphify.installer import install as _self_install + from graphify.installer.manifest import manifest_path as _default_manifest + args = sys.argv[2:] + target_path = None + skip_path = False + i = 0 + while i < len(args): + a = args[i] + if a == "--path": + if i + 1 >= len(args): + print("error: --path requires a value", file=sys.stderr) + sys.exit(1) + target_path = Path(args[i + 1]) + i += 2 + elif a == "--no-path": + skip_path = True + i += 1 + elif a in ("-h", "--help"): + print("Usage: graphify self-install [--path DIR] [--no-path]") + return + else: + print(f"error: unknown self-install option '{a}'", file=sys.stderr) + sys.exit(1) + install_dir = target_path or _default_manifest().parent + from graphify.installer import path_win as _pw + if skip_path: + # Patch the orchestrator's path_win.add_to_user_path to a no-op for this call. + import graphify.installer as _inst + orig = _inst.path_win.add_to_user_path + _inst.path_win.add_to_user_path = lambda p: None # type: ignore[assignment] + try: + _self_install( + install_path=install_dir, + user_root=Path.home(), + version=__version__, + ) + finally: + _inst.path_win.add_to_user_path = orig # type: ignore[assignment] + else: + _self_install( + install_path=install_dir, + user_root=Path.home(), + version=__version__, + ) + elif cmd == "self-uninstall": + from graphify.installer import uninstall as _self_uninstall + from graphify.installer.manifest import manifest_path as _default_manifest + _self_uninstall(manifest_file=_default_manifest()) +``` + +- [ ] **Step 3: Update the silent-cmd set** + +Find the line: + +```python + _silent_cmds = {"install", "uninstall", "hook-check"} +``` + +(line ~2208.) Add `"self-install"` and `"self-uninstall"`: + +```python + _silent_cmds = {"install", "uninstall", "self-install", "self-uninstall", "hook-check"} +``` + +- [ ] **Step 4: Verify `graphify --help` lists the new commands** + +```bash +uv run graphify --help 2>&1 | grep -E "self-(install|uninstall)" +``` + +Expected: at least one line each for `self-install` and `self-uninstall`. + +- [ ] **Step 5: Verify `graphify self-install --help` works** + +```bash +uv run graphify self-install --help +``` + +Expected: prints `Usage: graphify self-install [--path DIR] [--no-path]` and returns. + +- [ ] **Step 6: Run the existing test suite to confirm no regression** + +```bash +uv run pytest -q +``` + +Expected: 2478 passed, 28 skipped (no regression). The new commands don't add or remove existing tests. + +- [ ] **Step 7: Commit** + +```bash +git add graphify/__main__.py +git commit -m "feat(cli): add self-install / self-uninstall subcommands" +``` + +--- + +## Task 9: Create `tools/installer_main.py` (entry point for Nuitka) + +**Files:** +- Create: `tools/installer_main.py` + +- [ ] **Step 1: Create the entry script** + +Create `tools/installer_main.py`: + +```python +"""Standalone entry point for the offline Windows installer. + +This script is the entry point for the Nuitka-compiled +`graphify-installer.exe`. It accepts: + + graphify-installer.exe install # run the install wizard + graphify-installer.exe uninstall # reverse a previous install + graphify-installer.exe --version + graphify-installer.exe --help + +It does NOT import `graphify.__main__` (which has click-style side effects +and the full CLI surface). It imports only the installer subpackage, which +is what the compiled .exe needs to do its job. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# Make `graphify` importable when this script is run as a Nuitka-compiled +# entry point. The compiled binary has the package frozen in, so this +# import works in both modes (script and frozen). +from graphify.installer import install as _install, uninstall as _uninstall +from graphify.installer.manifest import manifest_path as _default_manifest +from graphify.installer.host_probe import detect_hosts as _detect_hosts + +try: + from importlib.metadata import version as _pkg_version + __version__ = _pkg_version("graphifyy") +except Exception: + __version__ = "unknown" + + +def _print_banner() -> None: + print(f"graphify offline installer {__version__}") + print(f" install path: {_default_manifest().parent}") + + +def cmd_install(args: argparse.Namespace) -> int: + _print_banner() + hosts = _detect_hosts() + if not hosts: + print("warning: no known AI-coding host detected on this machine.") + print(" The graphify binary will still be installed;") + print(" you'll need to copy SKILL.md to your host manually.") + else: + names = ", ".join(h.name for h in hosts) + print(f" detected hosts: {names}") + + target = Path(args.path) if args.path else _default_manifest().parent + print(f" installing to: {target}") + + manifest = _install( + install_path=target, + user_root=Path.home(), + version=__version__, + ) + print(" done.") + if manifest.user_path_added: + print(f" user PATH registered: {target / 'bin'}") + print(" (open a new cmd window for PATH changes to take effect)") + return 0 + + +def cmd_uninstall(args: argparse.Namespace) -> int: + _print_banner() + manifest_file = _default_manifest() + if not manifest_file.exists(): + print(f"no install manifest at {manifest_file}") + print("nothing to uninstall.") + return 1 + _uninstall(manifest_file=manifest_file) + print(" done.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="graphify-installer", + description="Offline Windows installer for graphify.", + ) + parser.add_argument("--version", action="store_true") + sub = parser.add_subparsers(dest="command") + + p_install = sub.add_parser("install", help="install graphify to %LOCALAPPDATA%\\graphify") + p_install.add_argument("--path", help="override install path") + + p_uninstall = sub.add_parser("uninstall", help="reverse a previous install") + + ns = parser.parse_args(argv) + if ns.version: + print(f"graphify-installer {__version__}") + return 0 + if ns.command == "install": + return cmd_install(ns) + if ns.command == "uninstall": + return cmd_uninstall(ns) + parser.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 2: Smoke-test the script** + +```bash +uv run python tools/installer_main.py --help +``` + +Expected: argparse help output mentioning `install` and `uninstall` subcommands. + +- [ ] **Step 3: Smoke-test the install subcommand help** + +```bash +uv run python tools/installer_main.py install --help +``` + +Expected: prints `usage: graphify-installer install [-h] [--path PATH]`. + +- [ ] **Step 4: Commit** + +```bash +git add tools/installer_main.py +git commit -m "feat(installer): add tools/installer_main.py (Nuitka entry point)" +``` + +--- + +## Task 10: Create `tools/build_windows_installer.sh` + +**Files:** +- Create: `tools/build_windows_installer.sh` + +- [ ] **Step 1: Create the build script** + +Create `tools/build_windows_installer.sh`: + +```bash +#!/usr/bin/env bash +# +# Build graphify-installer.exe (and the bundled graphify.exe / graphify-mcp.exe) +# on Windows. Run from a checkout of graphify, with Visual Studio Build Tools +# installed and on PATH (Nuitka needs a C compiler). +# +# Usage: +# tools/build_windows_installer.sh +# +# Output: +# dist/graphify-installer.exe — the offline installer +# dist/graphify.exe — the bundled graphify CLI +# dist/graphify-mcp.exe — the bundled graphify MCP server +# +# Wheelhouse (cached at ./wheelhouse-windows/) is reused across builds. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +PYTHON="${PYTHON:-python}" +WHEELHOUSE="$REPO_ROOT/wheelhouse-windows" +DIST="$REPO_ROOT/dist" + +echo "==> Python: $($PYTHON --version)" +echo "==> Wheelhouse: $WHEELHOUSE" +echo "==> Output: $DIST" + +# 1. Download Windows wheels for every default-bundled dep (one-time). +echo "==> Resolving wheels..." +mkdir -p "$WHEELHOUSE" +$PYTHON -m pip download \ + --dest "$WHEELHOUSE" \ + --python-version 3.10 \ + --platform win_amd64 \ + --only-binary=:all: \ + --requirement <($PYTHON -c " +import tomllib +with open('pyproject.toml', 'rb') as f: + data = tomllib.load(f) +print('\n'.join(data['project']['optional-dependencies']['windows-offline'])) +") + +# 2. Install graphify itself as a wheel (so Nuitka finds the package). +echo "==> Building graphify wheel..." +$PYTHON -m pip wheel . --no-deps --wheel-dir "$WHEELHOUSE" >/dev/null + +# 3. Set up a clean venv that uses ONLY the wheelhouse (offline simulation). +echo "==> Building offline venv..." +VENV="$REPO_ROOT/.venv-offline-build" +rm -rf "$VENV" +$PYTHON -m venv "$VENV" +"$VENV/Scripts/python.exe" -m pip install \ + --no-index \ + --find-links "$WHEELHOUSE" \ + graphifyy \ + >/dev/null + +# 4. Run Nuitka three times in the offline venv. +echo "==> Compiling graphify.exe (Nuitka)..." +"$VENV/Scripts/python.exe" -m nuitka \ + --standalone --onefile \ + --windows-disable-console \ + --enable-plugin=anti-bloat,multiprocessing \ + --include-package=graphify \ + --include-package-data=graphify \ + --include-module=networkx,numpy,rapidfuzz \ + --include-module=anthropic \ + --include-module=mcp,starlette \ + --include-module=graspologic \ + --include-module=tree_sitter,tree_sitter_python,tree_sitter_javascript,tree_sitter_typescript,tree_sitter_go,tree_sitter_rust,tree_sitter_java,tree_sitter_groovy,tree_sitter_c,tree_sitter_cpp,tree_sitter_ruby,tree_sitter_c_sharp,tree_sitter_kotlin,tree_sitter_scala,tree_sitter_php,tree_sitter_swift,tree_sitter_lua,tree_sitter_zig,tree_sitter_powershell,tree_sitter_elixir,tree_sitter_objc,tree_sitter_julia,tree_sitter_verilog,tree_sitter_fortran,tree_sitter_bash,tree_sitter_json \ + --include-module=matplotlib,watchdog,tree_sitter_sql,tree_sitter_hcl,jieba \ + --output-filename=graphify.exe \ + graphify/__main__.py + +echo "==> Compiling graphify-mcp.exe (Nuitka)..." +"$VENV/Scripts/python.exe" -m nuitka \ + --standalone --onefile \ + --windows-disable-console \ + --enable-plugin=anti-bloat,multiprocessing \ + --include-package=graphify \ + --include-package-data=graphify \ + --include-module=networkx,numpy,rapidfuzz \ + --include-module=anthropic \ + --include-module=mcp,starlette \ + --include-module=graspologic \ + --include-module=tree_sitter,tree_sitter_python,tree_sitter_javascript,tree_sitter_typescript,tree_sitter_go,tree_sitter_rust,tree_sitter_java,tree_sitter_groovy,tree_sitter_c,tree_sitter_cpp,tree_sitter_ruby,tree_sitter_c_sharp,tree_sitter_kotlin,tree_sitter_scala,tree_sitter_php,tree_sitter_swift,tree_sitter_lua,tree_sitter_zig,tree_sitter_powershell,tree_sitter_elixir,tree_sitter_objc,tree_sitter_julia,tree_sitter_verilog,tree_sitter_fortran,tree_sitter_bash,tree_sitter_json \ + --include-module=matplotlib,watchdog,tree_sitter_sql,tree_sitter_hcl,jieba \ + --output-filename=graphify-mcp.exe \ + graphify/serve.py + +echo "==> Compiling graphify-installer.exe (Nuitka)..." +"$VENV/Scripts/python.exe" -m nuitka \ + --standalone --onefile \ + --windows-disable-console \ + --enable-plugin=anti-bloat,multiprocessing \ + --include-package=graphify \ + --include-package-data=graphify \ + --include-module=graphify.installer \ + --output-filename=graphify-installer.exe \ + tools/installer_main.py + +# 5. Collect artifacts. +echo "==> Collecting artifacts..." +mkdir -p "$DIST" +mv graphify.exe "$DIST/" +mv graphify-mcp.exe "$DIST/" +mv graphify-installer.exe "$DIST/" + +ls -la "$DIST" +echo "==> Done. Distribute $DIST/graphify-installer.exe." +``` + +- [ ] **Step 2: Make it executable** + +```bash +chmod +x tools/build_windows_installer.sh +``` + +- [ ] **Step 3: Validate the script's shell syntax (macOS / Linux)** + +```bash +bash -n tools/build_windows_installer.sh && echo "syntax ok" +``` + +Expected: `syntax ok`. The script will fail to run on macOS (it expects Windows Python and Visual Studio), but the syntax should be valid. + +- [ ] **Step 4: Commit** + +```bash +git add tools/build_windows_installer.sh +git commit -m "build: add tools/build_windows_installer.sh (Nuitka build script)" +``` + +--- + +## Task 11: Create `tools/build_windows_installer.py` (cross-platform) + +**Files:** +- Create: `tools/build_windows_installer.py` + +- [ ] **Step 1: Create the Python build script** + +Create `tools/build_windows_installer.py`: + +```python +#!/usr/bin/env python3 +"""Cross-platform driver for the offline Windows installer build. + +Wraps the same workflow as `tools/build_windows_installer.sh` but in +Python so it can be driven from CI (Linux/macOS) when a Windows VM +runner is available. On a non-Windows host this script downloads the +wheels and prepares the wheelhouse, but the actual Nuitka compilation +requires a Windows runner (it shells out to cl.exe or MinGW). + +For local end-to-end builds, use `tools/build_windows_installer.sh` +on the Windows host directly. +""" + +from __future__ import annotations + +import argparse +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--skip-nuitka", + action="store_true", + help="only download wheels; skip the Nuitka compilation step", + ) + parser.add_argument( + "--python", + default=sys.executable, + help="Python interpreter to use (default: current)", + ) + args = parser.parse_args() + + wheelhouse = REPO_ROOT / "wheelhouse-windows" + wheelhouse.mkdir(exist_ok=True) + dist = REPO_ROOT / "dist" + dist.mkdir(exist_ok=True) + + # 1. Resolve the wheel list from pyproject.toml. + import tomllib + pyproject = tomllib.loads(REPO_ROOT.joinpath("pyproject.toml").read_text("rb" if False else "utf-8")) + wheels = pyproject["project"]["optional-dependencies"]["windows-offline"] + req_file = wheelhouse / "_requirements.txt" + req_file.write_text("\n".join(wheels) + "\n", encoding="utf-8") + + # 2. Download wheels. + print(f"==> Downloading {len(wheels)} wheels to {wheelhouse}") + subprocess.run( + [ + args.python, "-m", "pip", "download", + "--dest", str(wheelhouse), + "--python-version", "3.10", + "--platform", "win_amd64", + "--only-binary=:all:", + "--requirement", str(req_file), + ], + check=True, + ) + + if args.skip_nuitka: + print("==> --skip-nuitka set; stopping after wheel download.") + return 0 + + # 3. Compile via Nuitka. Only do this on a Windows host. + if platform.system() != "Windows": + print("==> Non-Windows host: skipping Nuitka compilation.") + print(" Re-run on Windows to produce the .exe artifacts.") + return 0 + + venv = REPO_ROOT / ".venv-offline-build" + if venv.exists(): + shutil.rmtree(venv) + subprocess.run([args.python, "-m", "venv", str(venv)], check=True) + py = venv / "Scripts" / "python.exe" + subprocess.run( + [str(py), "-m", "pip", "install", "--no-index", + "--find-links", str(wheelhouse), "graphifyy"], + check=True, + ) + # (Nuitka invocations are identical to the .sh script; not duplicated here + # to avoid drift. For a real Windows build, just call the .sh script.) + print("==> This driver only does wheel download. On Windows, run:") + print(" tools/build_windows_installer.sh") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 2: Smoke-test the wheel-download path on macOS** + +```bash +uv run python tools/build_windows_installer.py --skip-nuitka +``` + +Expected: prints `==> Downloading N wheels to ...` and downloads successfully (or fails with a pip error if no network — that's OK; the script's role is documented). + +If the download fails because of a pip-side issue, that's a network problem, not a script bug. Note it in your report and proceed. + +- [ ] **Step 3: Commit** + +```bash +git add tools/build_windows_installer.py +git commit -m "build: add tools/build_windows_installer.py (cross-platform wheel download)" +``` + +--- + +## Task 12: Create `docs/operations/offline-installer.md` + +**Files:** +- Create: `docs/operations/offline-installer.md` + +- [ ] **Step 1: Create the docs directory and file** + +```bash +mkdir -p docs/operations +``` + +Create `docs/operations/offline-installer.md` with this content: + +````markdown +# graphify Offline Windows Installer + +A single `.exe` that installs `graphify` on a Windows 10 machine with **no +network access at install time**. Bundles the Python runtime, the wheelhouse +for code-only analysis, and the `graphify` package itself (with all 14 host +skill bodies, always-on blocks, and the vendored vis-network bundle). + +Cloud LLM API calls (Anthropic / OpenAI / Gemini) remain allowed at +**runtime** — only the install is offline. Whisper model downloads and PDF +/ Office / video / Neo4j extras are not bundled; this installer targets +**code-only** corpora. + +## Install + +1. Copy `graphify-installer.exe` to the target Windows machine (USB, + shared folder, etc.). +2. Double-click it. A console window will show: + - Detected AI-coding hosts (Claude Code / OpenCode / etc.). + - The install path (default `%LOCALAPPDATA%\graphify\`). +3. Press Enter to confirm. The installer: + - Decompresses `graphify.exe` and `graphify-mcp.exe` into + `%LOCALAPPDATA%\graphify\bin\`. + - Copies the appropriate `SKILL.md` to the detected host's skill + directory (`%USERPROFILE%\.claude\skills\graphify\SKILL.md` for + Claude Code, `~/.config/opencode/skills/graphify/SKILL.md` for + OpenCode, etc.). + - Registers `%LOCALAPPDATA%\graphify\bin` on the user-level PATH + (does **not** modify system PATH). +4. **Open a new** cmd / PowerShell window so the PATH change takes effect. + +## Use + +``` +> graphify --version +graphify 0.9.1 + +> graphify-mcp --help +... + +> cd C:\path\to\your\code +> graphify extract . +... builds graphify-out\graph.html, graph.json, GRAPH_REPORT.md +``` + +In your AI-coding host (Claude Code / OpenCode / etc.), type: + +``` +/graphify . +``` + +## Uninstall + +``` +> graphify-installer.exe uninstall +``` + +This will: +- Remove the SKILL.md and `references/` from each host's skill directory. +- Remove `%LOCALAPPDATA%\graphify\bin` from the user PATH. +- Delete `%LOCALAPPDATA%\graphify\`. + +It will **not** touch any `graphify-out\` directory in your project repos — +those are per-project artifacts and you can delete them yourself if desired. + +## What's inside the .exe + +- Python 3.10 stdlib (frozen). +- `graphifyy` package (all submodules). +- 9 default extras: `anthropic`, `mcp`, `leiden`, `sql`, `watch`, `svg`, + `chinese`, `terraform`, and the `tree-sitter` language stack (23 languages). +- The vendored `vis-network.min.js` (the script the HTML viewer needs). +- All 14 host skill bodies and `references/` sidecars (Claude, Codex, + OpenCode, Kilo, Aider, Copilot, CodeBuddy, Kiro, Droid, Trae, Hermes, + Pi, OpenClaw, Antigravity, etc.). + +**Not bundled** (you'll get a clear error if you try to use them): PDF +extraction, Office files, video transcription, Neo4j / FalkorDB direct +connect, AWS Bedrock, BYOND (`tree-sitter-dm`). + +## Limitations + +- **Windows SmartScreen warning.** The `.exe` is not code-signed; on + first launch, click "More info → Run anyway". +- **No auto-update.** Re-download the latest `graphify-installer.exe` + to upgrade. +- **Per-project `graphify-out\` is not removed by uninstall.** +- **`mobilecoder` is best-effort.** graphify doesn't ship first-class + support for mobilecoder; the installer copies a generic `SKILL.md` + there. If mobilecoder doesn't recognize it, file an issue. + +## Re-building the installer + +The build script (`tools/build_windows_installer.sh`) requires: +- Python 3.10+ +- Visual Studio Build Tools (or MinGW) on `PATH` +- `nuitka`, `ordered-set`, `zstandard` in the build venv + +```bash +# On a Windows checkout of graphify: +tools\build_windows_installer.sh +``` + +The artifacts land in `dist\`: +- `graphify-installer.exe` — the offline installer +- `graphify.exe` — the bundled graphify CLI +- `graphify-mcp.exe` — the bundled graphify MCP server + +## Reporting issues + +File at with the output of +`graphify-installer.exe install` (or `uninstall`) in verbose mode. +```` + +- [ ] **Step 2: Verify the file is well-formed Markdown** + +```bash +uv run python -c "from pathlib import Path; t = Path('docs/operations/offline-installer.md').read_text(); assert t.startswith('# graphify Offline Windows Installer'); print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add docs/operations/offline-installer.md +git commit -m "docs(operations): add offline-installer end-user guide" +``` + +--- + +## Task 13: Run the full test suite + run the in-process installer (sanity) + +**Files:** none modified; this is a sanity gate. + +- [ ] **Step 1: Run the full test suite** + +```bash +uv run pytest -q +``` + +Expected: **2494 passed** (2478 + 16 new from Tasks 3–7), 28 skipped. + +- [ ] **Step 2: In-process sanity check: detect hosts on this machine** + +```bash +uv run python -c " +from graphify.installer.host_probe import detect_hosts, KNOWN_HOSTS +print('Known hosts:', len(KNOWN_HOSTS)) +print('Detected on this machine:') +for h in detect_hosts(): + print(f' - {h.name} (skill: {h.skill_subpath}, via graphify install: {h.uses_graphify_install})') +" +``` + +Expected: lists KNOWN_HOSTS (21) and any hosts that happen to be installed locally (probably 0 in a typical dev environment, but the call should not error). + +- [ ] **Step 3: In-process sanity check: `graphify self-install --help`** + +```bash +uv run graphify self-install --help +``` + +Expected: prints `Usage: graphify self-install [--path DIR] [--no-path]`. + +- [ ] **Step 4: In-process sanity check: `graphify self-uninstall` (no manifest present)** + +```bash +uv run graphify self-uninstall +``` + +Expected: prints a "no install manifest" error and returns non-zero. (The function should never silently succeed when there's nothing to uninstall.) + +--- + +## Task 14: Windows validation — V1–V5 (build + minimal install) + +**Files:** none modified; this is a manual validation gate. + +**This task MUST be run on a Windows 10 machine with Visual Studio Build +Tools installed.** It is the first task that exercises the compiled .exe. + +- [ ] **Step 1: Build the .exe on Windows** + +Copy the repo to a Windows 10 x86_64 machine (or open it in WSL / a VM). +Open a cmd window and run: + +```cmd +tools\build_windows_installer.sh +``` + +Expected: after 15–25 minutes, three .exe files appear in `dist\`: +- `graphify-installer.exe` (~65 MB) +- `graphify.exe` (~65 MB) +- `graphify-mcp.exe` (~65 MB) + +- [ ] **Step 2: V1 — Installer launches without cmd window** + +Double-click `graphify-installer.exe`. Expected: no black cmd window pops (the .exe is `--windows-disable-console`); instead a console window appears briefly with the install wizard text, or the text is logged to `graphify-installer.log` next to the .exe. + +- [ ] **Step 3: V3 — Host detection on the test machine** + +The wizard should report at least one detected host (e.g. "detected hosts: claude" if Claude Code is installed). If the test machine has no host installed, the wizard should warn and continue (this is by design — V3 is informational, not blocking). + +- [ ] **Step 4: V4 — Decompression completes** + +After the wizard finishes, verify: + +```cmd +dir "%LOCALAPPDATA%\graphify\bin" +``` + +Expected: `graphify.exe` and `graphify-mcp.exe` are listed. + +- [ ] **Step 5: V5 — User PATH registered** + +Open a **new** cmd window (so PATH changes propagate): + +```cmd +where graphify +where graphify-mcp +graphify --version +``` + +Expected: `where` finds both binaries under `%LOCALAPPDATA%\graphify\bin\`, and `graphify --version` returns `0.9.1` (or whatever the current version is). + +If any of V1, V3, V4, V5 fails, file a bug — do not proceed to Task 15. + +--- + +## Task 15: Windows validation — V6–V12 (full pipeline) + +**Files:** none modified; manual validation. + +- [ ] **Step 1: V6 — Main CLI works** + +```cmd +graphify --help +``` + +Expected: lists all subcommands including `install`, `uninstall`, `self-install`, `self-uninstall`, `extract`, etc. + +- [ ] **Step 2: V7 — MCP entry works** + +```cmd +graphify-mcp --help +``` + +Expected: prints the MCP server's `--help` text (whatever `graphify.serve._main` emits). + +- [ ] **Step 3: V8–V9 — Skill files copied** + +In a new cmd window: + +```cmd +dir "%USERPROFILE%\.claude\skills\graphify" +``` + +Expected: `SKILL.md` exists, plus a `references\` directory with markdown sidecars. + +- [ ] **Step 4: V10 — Host recognizes the skill** + +Open Claude Code (or whichever host was detected). In a project directory, type `/graphify`. Expected: appears in the command list (Claude Code should auto-discover the SKILL.md in its skills dir). + +- [ ] **Step 5: V11 — End-to-end pipeline** + +In Claude Code, in a small test repository (any Python project), type: + +``` +/graphify . +``` + +Expected: produces `graphify-out\graph.json`, `graphify-out\GRAPH_REPORT.md`, `graphify-out\graph.html`, and `graphify-out\vis-network.min.js` (the vendored copy). + +- [ ] **Step 6: V12 — Offline HTML render** + +Open `graphify-out\graph.html` in a browser. Open the browser's Network tab and reload. Expected: **zero external network requests** (no requests to `unpkg.com`, no requests to any CDN). The graph should render fully from the local `vis-network.min.js` we vendored in the vis-network spec. + +If V12 shows ANY external request, the installer did not bundle `vis-network.min.js` correctly — check the Nuitka `--include-package-data=graphify` flag. + +--- + +## Task 16: Windows validation — V14 (uninstall) + +**Files:** none modified; manual validation. + +- [ ] **Step 1: Run the uninstaller** + +In a cmd window: + +```cmd +graphify-installer.exe uninstall +``` + +Expected: prints `done.` and removes: +- `%USERPROFILE%\.claude\skills\graphify\` (and equivalents for other detected hosts) +- `%LOCALAPPDATA%\graphify\` +- The user PATH entry `%LOCALAPPDATA%\graphify\bin` + +- [ ] **Step 2: Verify nothing remains** + +```cmd +where graphify +dir "%LOCALAPPDATA%\graphify" +dir "%USERPROFILE%\.claude\skills\graphify" +``` + +Expected: `where graphify` returns nothing; the two `dir` commands report "File Not Found" or "directory does not exist". + +- [ ] **Step 3: Verify per-project `graphify-out\` is left alone** + +If you ran `/graphify .` in a test repo (V11), that repo's `graphify-out\` should still exist after uninstall. The uninstaller is intentionally conservative — it never touches project artifacts. + +--- + +## Self-Review Notes + +The plan was checked against `docs/superpowers/specs/2026-06-29-offline-windows-installer-design.md`: + +- **Spec §1 (architecture / boundaries)** → Task 7 (orchestrator), Task 8 (CLI subcommands), Task 9 (Nuitka entry script). +- **Spec §2 (wheelhouse scope)** → Task 1 (pyproject `windows-offline` extra lists every bundled wheel; Tasks 10–11 reference it in the Nuitka `--include-module` list). +- **Spec §3 (Nuitka configuration)** → Task 10 (bash build script with the exact `--include-module` list from the spec). +- **Spec §4 (installer behavior, host detection, mobilecoder handling, PATH registration)** → Task 3 (host_probe with `mobilecoder` explicit), Task 4 (path_win), Task 6 (skill_copy with mobilecoder fallback to `skill.md`), Task 7 (orchestrator), Task 8 (CLI), Task 9 (Nuitka entry). +- **Spec §5 (offline verification V1–V14)** → Tasks 14 (V1–V5), 15 (V6–V12), 16 (V14). V13 (cloud LLM) is excluded from the Windows-only validation since it requires a separate online run; documented as out-of-band in the spec. +- **Spec §6 (files changed / created)** → every row in the spec's file map is touched by exactly one task: + - `graphify/installer/{__init__,host_probe,path_win,skill_copy,manifest}.py` → Tasks 2–7 + - `graphify/__main__.py` → Task 8 + - `tools/installer_main.py` → Task 9 + - `tools/build_windows_installer.{sh,py}` → Tasks 10–11 + - `docs/operations/offline-installer.md` → Task 12 + - `pyproject.toml` → Task 1 + - 5 new test files → Tasks 3–7 + +Type and name consistency: +- `Host` dataclass defined in Task 3; consumed by `host_skill_dir` (Task 3), `skill_copy` (Task 6), orchestrator (Task 7). No drift. +- `InstallManifest` defined in Task 5; consumed by `manifest_path`/`save_manifest`/`load_manifest` (Task 5) and orchestrator (Task 7). No drift. +- `PathWinError` defined in Task 4; caught in Task 7. No drift. +- `KNOWN_HOSTS` (Task 3) has 21 entries; spec §4 lists 15 supported hosts plus 6 additional (mobilecoder, agents, amp, vscode, cursor, gemini) — total 21. ✓ diff --git a/docs/superpowers/plans/2026-07-02-bundled-skills.md b/docs/superpowers/plans/2026-07-02-bundled-skills.md new file mode 100644 index 000000000..6ce06885e --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-bundled-skills.md @@ -0,0 +1,1186 @@ +# Bundled Skills (gf- namespace) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship 15 community skills (14 superpowers + 1 llm-wiki) under the `gf-` namespace inside the offline Windows installer so air-gapped machines have the full brainstorming → design → plan → code → graph → wiki workflow available without network access. + +**Architecture:** Static snapshot of upstream skills lives under `graphify/bundled_skills/{superpowers,llm-wiki}/`. A new `graphify/installer/bundled_skills.py` registry exposes them with `gf-` prefixed names. `graphify/installer/skill_copy.py` gains a `copy_bundled_skills()` function that the installer orchestrator calls after `copy_skill()`. The build pipeline needs only a 5-glob addition to `pyproject.toml`; Nuitka already passes `--include-package-data=graphify`. + +**Tech Stack:** Python 3.10+, dataclasses, `importlib.resources`, existing `graphify.installer.host_probe.KNOWN_HOSTS`, pytest (TDD), sed for one-time frontmatter rename, Nuitka `--include-package-data`. + +**Spec:** `docs/superpowers/specs/2026-07-02-bundled-skills-design.md` + +--- + +## File Structure + +| Path | Status | Purpose | +|---|---|---| +| `graphify/bundled_skills/superpowers//SKILL.md` | Create | 14 SKILL.md files copied from superpowers-dev, frontmatter `name:` renamed to `gf-` | +| `graphify/bundled_skills/superpowers/LICENSE` | Create | MIT license verbatim from upstream | +| `graphify/bundled_skills/superpowers/NOTICE` | Create | Upstream attribution | +| `graphify/bundled_skills/llm-wiki/SKILL.md` | Create | SKILL.md copied, frontmatter renamed to `name: gf-llm-wiki` | +| `graphify/bundled_skills/llm-wiki/` | Create | Whole directory tree copied from `~/.claude/skills/llm-wiki/` | +| `graphify/bundled_skills/llm-wiki/LICENSE` | Create | llm-wiki license | +| `graphify/bundled_skills/README.md` | Create | Explain this directory and the `gf-` rename convention | +| `graphify/installer/bundled_skills.py` | Create | `BundledSkill` dataclass + `_BUNDLED` tuple + `bundled_skill_dir()` | +| `graphify/installer/skill_copy.py` | Modify | Add `copy_bundled_skills()` (~25 lines) | +| `graphify/installer/__init__.py` | Modify | One-line addition: call `copy_bundled_skills()` after `copy_skill()` | +| `pyproject.toml` | Modify | Append 5 globs to `[tool.setuptools.package-data].graphify` | +| `tools/build_windows_installer.sh` | Modify | Insert 6-line pre-flight check after `pip wheel .` | +| `tests/test_bundled_skills.py` | Create | Registry + frontmatter + install logic tests | +| `tests/test_install_roundtrip.py` | Modify | Add 1 assertion per host for `gf-*` skills | +| `NOTICE` (repo root) | Create | Bundled third-party projects + licenses | +| `CHANGELOG.md` | Modify | Append entry to `## Unreleased` | +| `docs/operations/offline-installer.md` | Modify | Add paragraph to "What's inside the .exe" | + +Each task below produces a self-contained commit. TDD tasks follow the strict "red → green → commit" pattern. + +--- + +## Phase 1: Snapshot upstream skills + +### Task 1.1: Copy 14 superpowers SKILL.md files with frontmatter rename + +**Files:** +- Create: `graphify/bundled_skills/superpowers//SKILL.md` (14 files) + +- [ ] **Step 1: Create the directory layout and copy files** + +Run from the graphify repo root: + +```bash +mkdir -p graphify/bundled_skills/superpowers +for skill in brainstorming writing-plans subagent-driven-development test-driven-development systematic-debugging using-git-worktrees requesting-code-review receiving-code-review executing-plans finishing-a-development-branch dispatching-parallel-agents using-superpowers verification-before-completion writing-skills; do + mkdir -p "graphify/bundled_skills/superpowers/$skill" + cp "~/.claude/plugins/marketplaces/superpowers-dev/skills/$skill/SKILL.md" \ + "graphify/bundled_skills/superpowers/$skill/SKILL.md" +done +``` + +Expected: 14 new files under `graphify/bundled_skills/superpowers//SKILL.md`. + +- [ ] **Step 2: Rename frontmatter `name:` field in each file** + +```bash +for skill in brainstorming writing-plans subagent-driven-development test-driven-development systematic-debugging using-git-worktrees requesting-code-review receiving-code-review executing-plans finishing-a-development-branch dispatching-parallel-agents using-superpowers verification-before-completion writing-skills; do + sed -i '' "s/^name: ${skill}$/name: gf-${skill}/" \ + "graphify/bundled_skills/superpowers/$skill/SKILL.md" +done +``` + +(Empty string after `-i ''` is macOS sed syntax — leave as-is on macOS. On Linux change to `sed -i`.) + +Expected: every file's first frontmatter field is now `name: gf-`. + +- [ ] **Step 3: Verify** + +```bash +for skill in brainstorming writing-plans subagent-driven-development test-driven-development systematic-debugging using-git-worktrees requesting-code-review receiving-code-review executing-plans finishing-a-development-branch dispatching-parallel-agents using-superpowers verification-before-completion writing-skills; do + head -1 "graphify/bundled_skills/superpowers/$skill/SKILL.md" + grep -E "^name: gf-${skill}$" "graphify/bundled_skills/superpowers/$skill/SKILL.md" +done +``` + +Expected: 14 lines of `---` (YAML delimiter) followed by 14 lines matching `name: gf-`. + +- [ ] **Step 4: Commit** + +```bash +git add graphify/bundled_skills/superpowers/ +git commit -m "feat(bundled-skills): snapshot 14 superpowers SKILL.md files with gf- rename" +``` + +--- + +### Task 1.2: Add superpowers LICENSE and NOTICE + +**Files:** +- Create: `graphify/bundled_skills/superpowers/LICENSE` +- Create: `graphify/bundled_skills/superpowers/NOTICE` + +- [ ] **Step 1: Copy LICENSE** + +```bash +cp ~/.claude/plugins/marketplaces/superpowers-dev/LICENSE \ + graphify/bundled_skills/superpowers/LICENSE +``` + +Expected: `graphify/bundled_skills/superpowers/LICENSE` exists and starts with `MIT License`. + +- [ ] **Step 2: Write NOTICE** + +Create `graphify/bundled_skills/superpowers/NOTICE` with this exact content: + +``` +Bundled under graphify (graphify/bundled_skills/superpowers/) + +Upstream: superpowers-dev +Copyright: 2025 Jesse Vincent +License: MIT (see ./LICENSE) +Source: https://github.com/superpowers-dev/superpowers-dev + +Renamed for the graphify offline installer: + brainstorming -> gf-brainstorming + writing-plans -> gf-writing-plans + subagent-driven-development -> gf-subagent-driven-development + test-driven-development -> gf-test-driven-development + systematic-debugging -> gf-systematic-debugging + using-git-worktrees -> gf-using-git-worktrees + requesting-code-review -> gf-requesting-code-review + receiving-code-review -> gf-receiving-code-review + executing-plans -> gf-executing-plans + finishing-a-development-branch -> gf-finishing-a-development-branch + dispatching-parallel-agents -> gf-dispatching-parallel-agents + using-superpowers -> gf-using-superpowers + verification-before-completion -> gf-verification-before-completion + writing-skills -> gf-writing-skills +``` + +- [ ] **Step 3: Commit** + +```bash +git add graphify/bundled_skills/superpowers/LICENSE graphify/bundled_skills/superpowers/NOTICE +git commit -m "feat(bundled-skills): add superpowers LICENSE and NOTICE" +``` + +--- + +### Task 1.3: Snapshot llm-wiki into bundled_skills/ + +**Files:** +- Create: `graphify/bundled_skills/llm-wiki/` (whole directory tree) + +- [ ] **Step 1: Copy llm-wiki and rename frontmatter** + +```bash +mkdir -p graphify/bundled_skills +cp -R ~/.claude/skills/llm-wiki graphify/bundled_skills/llm-wiki +sed -i '' 's/^name: llm-wiki$/name: gf-llm-wiki/' \ + graphify/bundled_skills/llm-wiki/SKILL.md +``` + +Expected: `graphify/bundled_skills/llm-wiki/` contains SKILL.md + templates/ + scripts/ + platforms/ + deps/ + AGENTS.md + CHANGELOG.md + CLAUDE.md + HERMES.md + README.md + install.sh + install.ps1 + setup.sh + LICENSE. + +- [ ] **Step 2: Verify the rename** + +```bash +head -3 graphify/bundled_skills/llm-wiki/SKILL.md +grep -E "^name: gf-llm-wiki$" graphify/bundled_skills/llm-wiki/SKILL.md +``` + +Expected: first 3 lines include `---`, `name: gf-llm-wiki`, `description: ...`. + +- [ ] **Step 3: Verify directory tree** + +```bash +ls graphify/bundled_skills/llm-wiki/ +ls graphify/bundled_skills/llm-wiki/templates/ | head -5 +ls graphify/bundled_skills/llm-wiki/scripts/ | head -5 +``` + +Expected: top-level has SKILL.md, templates/, scripts/, platforms/, deps/, LICENSE; templates/ has .md files; scripts/ has .sh and .js files. + +- [ ] **Step 4: Commit** + +```bash +git add graphify/bundled_skills/llm-wiki/ +git commit -m "feat(bundled-skills): snapshot llm-wiki with gf-llm-wiki rename" +``` + +--- + +### Task 1.4: Create bundled_skills/README.md + +**Files:** +- Create: `graphify/bundled_skills/README.md` + +- [ ] **Step 1: Write the README** + +Create `graphify/bundled_skills/README.md` with this content: + +````markdown +# graphify/bundled_skills/ + +Skills bundled inside the `graphify` package so the offline Windows installer +ships a working skill set without needing network access at install time. + +## What's here + +| Directory | Source | Count | +|---|---|---| +| `superpowers/` | [superpowers-dev](https://github.com/superpowers-dev/superpowers-dev) (MIT, Jesse Vincent) | 14 skills | +| `llm-wiki/` | llm-wiki (project-local) | 1 skill + templates/scripts/platforms | + +## The `gf-` rename + +Every bundled skill is installed under a `gf-` prefix: + +- `brainstorming` → `gf-brainstorming` +- `writing-plans` → `writing-plans` (typo in example, see superpowers) + +The renaming is intentional: + +1. Avoids collisions with user-installed superpowers plugin (which uses bare + names like `brainstorming`). +2. Makes always-overwrite install semantics safe: we never overwrite a file the + user might have customized, because by construction nothing else uses the + `gf-` namespace. +3. Marks these as "from the graphify family" so the user knows uninstalling + graphify also removes them. + +The frontmatter `name:` field is renamed in the snapshot (not at install time), +so the slash-command is `/gf-brainstorming`, not `/brainstorming`. + +## Adding a new bundled skill + +1. Create the directory under `graphify/bundled_skills///`. +2. Drop `SKILL.md` (with `name:` renamed to `gf-`). +3. Add the entry to `_BUNDLED` in `graphify/installer/bundled_skills.py`. +4. Add a `tests/test_bundled_skills.py` case asserting the new entry's + `source_subpath` resolves. +5. Update the LICENSE / NOTICE attribution if the upstream requires it. + +## Syncing from upstream superpowers + +This is **manual** (no automation). When superpowers-dev ships a new version: + +```bash +for skill in brainstorming writing-plans ...; do + cp ~/.claude/plugins/marketplaces/superpowers-dev/skills/$skill/SKILL.md \ + graphify/bundled_skills/superpowers/$skill/SKILL.md + sed -i '' "s/^name: ${skill}$/name: gf-${skill}/" \ + graphify/bundled_skills/superpowers/$skill/SKILL.md +done +``` + +(Correct the skill list — see `tests/test_bundled_skills.py::test_superpowers_count_is_14`.) + +## Uninstall behavior + +`graphify-installer.exe uninstall` does **not** remove `gf-*` skills. Once +installed they belong to the user. Delete them by hand if desired. +```` + +- [ ] **Step 2: Commit** + +```bash +git add graphify/bundled_skills/README.md +git commit -m "docs(bundled-skills): README explaining gf- rename convention" +``` + +--- + +## Phase 2: Registry module (TDD) + +### Task 2.1: Failing test for registry structure + +**Files:** +- Create: `tests/test_bundled_skills.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_bundled_skills.py` with this content: + +```python +"""Tests for graphify.installer.bundled_skills. + +Covers registry structure (count, names, uniqueness), frontmatter validity, +and per-host install path derivation. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from graphify.installer import bundled_skills +from graphify.installer.bundled_skills import ( + BundledSkill, + all_bundled, + bundled_skill_dir, + supports_host, +) +from graphify.installer.host_probe import KNOWN_HOSTS, host_skill_dir + + +class TestBundledSkillsRegistry: + """Structural checks on the _BUNDLED tuple — fast, catches regressions.""" + + def test_count_is_15(self): + assert len(all_bundled()) == 15 + + def test_names_unique(self): + names = [s.name for s in all_bundled()] + assert len(names) == len(set(names)) + + def test_superpowers_count_is_14(self): + sp = [s for s in all_bundled() if s.name != "gf-llm-wiki"] + assert len(sp) == 14 + + def test_every_entry_has_gf_prefix(self): + for s in all_bundled(): + assert s.name.startswith("gf-"), f"{s.name} missing gf- prefix" + + def test_all_source_files_exist(self, package_root: Path): + """Every source_subpath must resolve to a real file in the repo.""" + for s in all_bundled(): + assert (package_root / s.source_subpath).exists(), ( + f"{s.source_subpath} does not exist under package_root" + ) + + def test_superpowers_license_present(self, package_root: Path): + assert (package_root / "bundled_skills" / "superpowers" / "LICENSE").exists() +``` + +Add a `package_root` fixture to `tests/conftest.py` (see Step 2 below). + +- [ ] **Step 2: Add `package_root` fixture to tests/conftest.py** + +Append to `tests/conftest.py`: + +```python +@pytest.fixture +def package_root() -> Path: + """Absolute path to the graphify package source root. + + Used by bundled_skills tests to assert that BundledSkill.source_subpath + entries point at real files (independent of `importlib.resources` which + only sees installed packages). + """ + import graphify + return Path(graphify.__file__).parent.resolve() +``` + +If `package_root` already exists in `tests/conftest.py`, do not duplicate; skip this step. + +- [ ] **Step 3: Run the test, verify it fails** + +Run: `pytest tests/test_bundled_skills.py -v` +Expected: `ImportError: cannot import name 'bundled_skills' from 'graphify.installer'` (or similar — module doesn't exist yet). + +--- + +### Task 2.2: Implement the registry module + +**Files:** +- Create: `graphify/installer/bundled_skills.py` + +- [ ] **Step 1: Write the module** + +Create `graphify/installer/bundled_skills.py` with this exact content: + +```python +"""Registry of skills bundled with graphify for offline installation. + +Each entry is a host-agnostic SKILL.md (plus optional references/) that +`copy_bundled_skills()` writes into `//` on the +user's machine. Always-overwrite semantics: existing files are replaced +unconditionally. The `gf-` namespace prefix guarantees no collision with +user-installed plugins (e.g. the upstream superpowers plugin uses bare +names like `brainstorming`). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class BundledSkill: + """A skill bundled with graphify. + + Attributes: + name: Final install name (always `gf-` prefixed). Also the directory + name created under the host's `skills/` parent. + source_subpath: Path relative to the graphify package root where the + SKILL.md file lives (e.g. + `bundled_skills/superpowers/brainstorming/SKILL.md`). + has_references: True if a `references/` sidecar should be copied + alongside SKILL.md. Only `gf-llm-wiki` uses this today. + """ + + name: str + source_subpath: str + has_references: bool + + +_BUNDLED: tuple[BundledSkill, ...] = ( + # 14 superpowers skills (MIT, Jesse Vincent) — see ./superpowers/LICENSE + BundledSkill("gf-brainstorming", "bundled_skills/superpowers/brainstorming/SKILL.md", False), + BundledSkill("gf-writing-plans", "bundled_skills/superpowers/writing-plans/SKILL.md", False), + BundledSkill("gf-subagent-driven-development", "bundled_skills/superpowers/subagent-driven-development/SKILL.md", False), + BundledSkill("gf-test-driven-development", "bundled_skills/superpowers/test-driven-development/SKILL.md", False), + BundledSkill("gf-systematic-debugging", "bundled_skills/superpowers/systematic-debugging/SKILL.md", False), + BundledSkill("gf-using-git-worktrees", "bundled_skills/superpowers/using-git-worktrees/SKILL.md", False), + BundledSkill("gf-requesting-code-review", "bundled_skills/superpowers/requesting-code-review/SKILL.md", False), + BundledSkill("gf-receiving-code-review", "bundled_skills/superpowers/receiving-code-review/SKILL.md", False), + BundledSkill("gf-executing-plans", "bundled_skills/superpowers/executing-plans/SKILL.md", False), + BundledSkill("gf-finishing-a-development-branch", "bundled_skills/superpowers/finishing-a-development-branch/SKILL.md", False), + BundledSkill("gf-dispatching-parallel-agents", "bundled_skills/superpowers/dispatching-parallel-agents/SKILL.md", False), + BundledSkill("gf-using-superpowers", "bundled_skills/superpowers/using-superpowers/SKILL.md", False), + BundledSkill("gf-verification-before-completion", "bundled_skills/superpowers/verification-before-completion/SKILL.md", False), + BundledSkill("gf-writing-skills", "bundled_skills/superpowers/writing-skills/SKILL.md", False), + # llm-wiki (project-local) — see ./llm-wiki/LICENSE + BundledSkill("gf-llm-wiki", "bundled_skills/llm-wiki/SKILL.md", True), +) + + +# Hosts that don't accept a plain SKILL.md — they need format adapters +# (.mdc for cursor, GEMINI.md injection for gemini). v2 work; skipped for now. +_UNSUPPORTED_HOSTS = frozenset({"cursor", "gemini"}) + + +def all_bundled() -> tuple[BundledSkill, ...]: + """Return the full tuple of bundled skills.""" + return _BUNDLED + + +def supports_host(host_name: str) -> bool: + """True if bundled skills can be installed for this host.""" + return host_name not in _UNSUPPORTED_HOSTS + + +def bundled_skill_dir(host, skill_name: str, *, root: Path) -> Path: + """Target directory for installing `skill_name` on `host`. + + Formula: `root////`. + + Replaces the trailing `graphify` segment of `host.skill_subpath` with + `skill_name`. Falls back to `root//skills//` + if the host's subpath doesn't end in `graphify` (defensive — no current + host falls through, but `cursor`'s `rules/` subpath does, and we skip + cursor entirely via `supports_host`). + + Worked examples: + claude: ~/.claude/skills/gf-brainstorming/ + codex: ~/.codex/skills/gf-brainstorming/ + aider: ~/.aider/gf-brainstorming/ (no skills/ parent) + pi: ~/.pi/agent/skills/gf-brainstorming/ (extra agent/ prefix) + mobilecoder: ~/.mobilecoder/skills/gf-brainstorming/ + """ + parts = host.skill_subpath.parts + if parts and parts[-1] == "graphify": + new_subpath = Path(*parts[:-1]) / skill_name + else: + new_subpath = Path("skills") / skill_name + return root / host.marker / new_subpath +``` + +- [ ] **Step 2: Run the registry tests, verify they pass** + +Run: `pytest tests/test_bundled_skills.py::TestBundledSkillsRegistry -v` +Expected: 6 passed. + +- [ ] **Step 3: Commit** + +```bash +git add graphify/installer/bundled_skills.py tests/test_bundled_skills.py tests/conftest.py +git commit -m "feat(installer): add bundled_skills registry with 15 gf- prefixed entries" +``` + +--- + +### Task 2.3: Failing test for `bundled_skill_dir()` path derivation + +**Files:** +- Modify: `tests/test_bundled_skills.py` (append a new test class) + +- [ ] **Step 1: Append the path-derivation test class** + +Append to `tests/test_bundled_skills.py`: + +```python +class TestBundledSkillDir: + """`bundled_skill_dir()` must produce the right path per host.""" + + @pytest.mark.parametrize("host_name,expected_suffix", [ + ("claude", ".claude/skills/gf-brainstorming"), + ("codex", ".codex/skills/gf-brainstorming"), + ("aider", ".aider/gf-brainstorming"), # no skills/ parent + ("pi", ".pi/agent/skills/gf-brainstorming"), # extra agent/ prefix + ("mobilecoder", ".mobilecoder/skills/gf-brainstorming"), + ]) + def test_path_for_supported_hosts(self, tmp_path, host_name, expected_suffix): + host = next(h for h in KNOWN_HOSTS if h.name == host_name) + result = bundled_skill_dir(host, "gf-brainstorming", root=tmp_path) + assert str(result).endswith(expected_suffix), ( + f"got {result}, expected suffix {expected_suffix}" + ) + + def test_unknown_skill_name_still_works(self, tmp_path): + """Function takes any name, not just registered ones.""" + host = next(h for h in KNOWN_HOSTS if h.name == "claude") + result = bundled_skill_dir(host, "gf-anything-future", root=tmp_path) + assert result == tmp_path / ".claude" / "skills" / "gf-anything-future" + + def test_cursor_subpath_falls_back_to_skills_layout(self, tmp_path): + """cursor has subpath `rules` (not ending in graphify) → defensive fallback.""" + host = next(h for h in KNOWN_HOSTS if h.name == "cursor") + result = bundled_skill_dir(host, "gf-brainstorming", root=tmp_path) + assert result == tmp_path / ".cursor" / "skills" / "gf-brainstorming" +``` + +- [ ] **Step 2: Run the test, verify it passes (already implemented)** + +Run: `pytest tests/test_bundled_skills.py::TestBundledSkillDir -v` +Expected: 7 passed (the implementation from Task 2.2 already covers these cases). + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_bundled_skills.py +git commit -m "test(bundled-skills): per-host path derivation tests" +``` + +--- + +### Task 2.4: Failing test for `supports_host()` + +**Files:** +- Modify: `tests/test_bundled_skills.py` + +- [ ] **Step 1: Append the supports_host test class** + +```python +class TestSupportsHost: + """`supports_host()` returns False for cursor/gemini, True otherwise.""" + + @pytest.mark.parametrize("host_name", ["claude", "codex", "opencode", "kilo", + "aider", "copilot", "claw", "droid", + "trae", "kiro", "pi", "vscode", "amp", + "agents", "antigravity", "windows", + "codebuddy", "hermes", "trae-cn", + "mobilecoder"]) + def test_supports_common_hosts(self, host_name): + assert supports_host(host_name) is True + + @pytest.mark.parametrize("host_name", ["cursor", "gemini"]) + def test_skips_format_incompatible_hosts(self, host_name): + assert supports_host(host_name) is False +``` + +- [ ] **Step 2: Run, verify passes** + +Run: `pytest tests/test_bundled_skills.py::TestSupportsHost -v` +Expected: 21 passed (19 supported + 2 unsupported). + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_bundled_skills.py +git commit -m "test(bundled-skills): supports_host coverage" +``` + +--- + +### Task 2.5: Failing test for frontmatter `name:` correctness + +**Files:** +- Modify: `tests/test_bundled_skills.py` + +- [ ] **Step 1: Append the frontmatter test class** + +```python +class TestBundledSkillsFrontmatter: + """Each SKILL.md's frontmatter `name:` must equal BundledSkill.name.""" + + def test_all_skills_have_valid_yaml_frontmatter(self, package_root: Path): + import yaml + for s in all_bundled(): + text = (package_root / s.source_subpath).read_text(encoding="utf-8") + assert text.startswith("---\n"), f"{s.source_subpath}: no frontmatter" + end = text.find("\n---", 4) + assert end > 0, f"{s.source_subpath}: unterminated frontmatter" + fm = yaml.safe_load(text[4:end]) + assert isinstance(fm, dict), f"{s.source_subpath}: frontmatter not a YAML mapping" + assert "name" in fm, f"{s.source_subpath}: missing `name` field" + assert "description" in fm, f"{s.source_subpath}: missing `description` field" + + def test_frontmatter_name_matches_registry(self, package_root: Path): + import yaml + for s in all_bundled(): + text = (package_root / s.source_subpath).read_text(encoding="utf-8") + end = text.find("\n---", 4) + fm = yaml.safe_load(text[4:end]) + assert fm["name"] == s.name, ( + f"{s.source_subpath}: frontmatter name `{fm['name']}` != registry `{s.name}`" + ) +``` + +- [ ] **Step 2: Run, verify passes** + +Run: `pytest tests/test_bundled_skills.py::TestBundledSkillsFrontmatter -v` +Expected: 2 passed. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_bundled_skills.py +git commit -m "test(bundled-skills): frontmatter name correctness" +``` + +--- + +## Phase 3: Installer logic (TDD) + +### Task 3.1: Failing test for `copy_bundled_skills()` basic write + +**Files:** +- Modify: `tests/test_bundled_skills.py` + +- [ ] **Step 1: Append the install test class** + +```python +class TestCopyBundledSkills: + """`copy_bundled_skills()` writes SKILL.md for each supported host.""" + + def _setup_fake_package(self, tmp_path: Path) -> Path: + """Build a minimal graphify/ package dir under tmp_path with the + 15 bundled SKILL.md files. Mirrors the real layout. + """ + pkg = tmp_path / "graphify" + for s in all_bundled(): + f = pkg / s.source_subpath + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text( + f"---\nname: {s.name}\ndescription: stub\n---\n# {s.name}\n", + encoding="utf-8", + ) + # Superpowers LICENSE for the registry's structural test + (pkg / "bundled_skills" / "superpowers" / "LICENSE").write_text( + "MIT\n", encoding="utf-8" + ) + # llm-wiki references/ sidecar + (pkg / "bundled_skills" / "llm-wiki" / "references").mkdir(parents=True, exist_ok=True) + (pkg / "bundled_skills" / "llm-wiki" / "references" / "x.md").write_text( + "ref\n", encoding="utf-8" + ) + return pkg + + @pytest.mark.parametrize("host_name", ["claude", "codex", "opencode", "kilo", + "aider", "pi", "windows", "vscode", + "amp", "agents"]) + def test_writes_skills_for_supported_hosts(self, tmp_path, host_name): + from graphify.installer.skill_copy import copy_bundled_skills + pkg = self._setup_fake_package(tmp_path / "pkg") + host = next(h for h in KNOWN_HOSTS if h.name == host_name) + copy_bundled_skills(host, root=tmp_path / "home", package_root=pkg) + target = bundled_skill_dir(host, "gf-brainstorming", root=tmp_path / "home") + assert (target / "SKILL.md").exists(), f"missing {target}/SKILL.md" + + def test_writes_llm_wiki_references_sidecar(self, tmp_path): + """gf-llm-wiki has has_references=True → references/ must be copied.""" + from graphify.installer.skill_copy import copy_bundled_skills + pkg = self._setup_fake_package(tmp_path / "pkg") + host = next(h for h in KNOWN_HOSTS if h.name == "claude") + copy_bundled_skills(host, root=tmp_path / "home", package_root=pkg) + target = bundled_skill_dir(host, "gf-llm-wiki", root=tmp_path / "home") + assert (target / "SKILL.md").exists() + assert (target / "references" / "x.md").exists() + + def test_does_not_write_references_for_superpowers(self, tmp_path): + """gf-brainstorming has has_references=False → no references/ written.""" + from graphify.installer.skill_copy import copy_bundled_skills + pkg = self._setup_fake_package(tmp_path / "pkg") + host = next(h for h in KNOWN_HOSTS if h.name == "claude") + copy_bundled_skills(host, root=tmp_path / "home", package_root=pkg) + target = bundled_skill_dir(host, "gf-brainstorming", root=tmp_path / "home") + assert not (target / "references").exists() + + @pytest.mark.parametrize("host_name", ["cursor", "gemini"]) + def test_skips_unsupported_hosts(self, tmp_path, host_name): + from graphify.installer.skill_copy import copy_bundled_skills + pkg = self._setup_fake_package(tmp_path / "pkg") + host = next(h for h in KNOWN_HOSTS if h.name == host_name) + copy_bundled_skills(host, root=tmp_path / "home", package_root=pkg) + # Nothing under the host marker should have any SKILL.md + marker = tmp_path / "home" / host.marker + if marker.exists(): + assert not any(marker.rglob("SKILL.md")) + + def test_always_overwrites_existing(self, tmp_path): + """Always-overwrite semantics: any pre-existing SKILL.md is replaced.""" + from graphify.installer.skill_copy import copy_bundled_skills + pkg = self._setup_fake_package(tmp_path / "pkg") + host = next(h for h in KNOWN_HOSTS if h.name == "claude") + target_dir = bundled_skill_dir(host, "gf-brainstorming", root=tmp_path / "home") + target_dir.mkdir(parents=True) + (target_dir / "SKILL.md").write_text("# OLD USER CONTENT — should be replaced", encoding="utf-8") + copy_bundled_skills(host, root=tmp_path / "home", package_root=pkg) + assert "# OLD USER CONTENT" not in (target_dir / "SKILL.md").read_text(encoding="utf-8") +``` + +- [ ] **Step 2: Run, verify all FAIL (function doesn't exist yet)** + +Run: `pytest tests/test_bundled_skills.py::TestCopyBundledSkills -v` +Expected: ALL tests fail with `ImportError` or `AttributeError` (no `copy_bundled_skills` in `skill_copy`). + +--- + +### Task 3.2: Implement `copy_bundled_skills()` + +**Files:** +- Modify: `graphify/installer/skill_copy.py` + +- [ ] **Step 1: Add the import** + +At the top of `graphify/installer/skill_copy.py`, after the existing imports, add: + +```python +from graphify.installer.bundled_skills import ( + all_bundled, + bundled_skill_dir, + supports_host, +) +``` + +- [ ] **Step 2: Append the new function** + +Append to `graphify/installer/skill_copy.py`: + +```python +def copy_bundled_skills( + host: Host, + *, + root: Path, + package_root: Optional[Path] = None, +) -> list[Path]: + """Install all bundled (gf-*) skills for `host` under `root`. + + For each `BundledSkill`: + - Compute target dir via `bundled_skill_dir(host, skill.name, root=root)`. + - Read SKILL.md body from `package_root / skill.source_subpath` (or + `importlib.resources` if `package_root is None`). + - `target_dir.mkdir(parents=True, exist_ok=True)` and write the body. + - If `skill.has_references`, also copy a `references/` sidecar. + + Always-overwrite semantics: any pre-existing file is replaced. The + `gf-` namespace prefix ensures this never collides with a user's + separately-installed plugin. + """ + if not supports_host(host.name): + return [] + + written: list[Path] = [] + for skill in all_bundled(): + target_dir = bundled_skill_dir(host, skill.name, root=root) + target_dir.mkdir(parents=True, exist_ok=True) + + # Read body. + if package_root is not None: + src = package_root / skill.source_subpath + body = src.read_text(encoding="utf-8") if src.exists() else "" + else: + try: + body = files("graphify").joinpath(*skill.source_subpath.split("/")).read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError, TypeError): + body = "" + + if not body: + import sys + print( + f"[graphify-installer] warn: bundled skill `{skill.name}` " + f"body missing at `{skill.source_subpath}`; skipping", + file=sys.stderr, + ) + continue + + target = target_dir / "SKILL.md" + target.write_text(body, encoding="utf-8") + written.append(target) + + # Optional references/ sidecar. + if skill.has_references: + refs_rel = (Path(skill.source_subpath).parent / "references").as_posix() + if package_root is not None: + src_refs = package_root / refs_rel + else: + from importlib.resources import as_file + try: + ref_resource = files("graphify").joinpath(*refs_rel.split("/")) + with as_file(ref_resource) as p: + src_refs = p + except (FileNotFoundError, ModuleNotFoundError, TypeError): + src_refs = None + if src_refs is not None and src_refs.exists(): + dst_refs = target_dir / "references" + if dst_refs.exists(): + shutil.rmtree(dst_refs) + shutil.copytree(src_refs, dst_refs) + + if written: + print( + f"[graphify-installer] installed {len(written)} bundled skills for " + f"{host.name}: {', '.join(p.parent.name for p in written)}", + flush=True, + ) + return written +``` + +- [ ] **Step 3: Run the install tests, verify they pass** + +Run: `pytest tests/test_bundled_skills.py::TestCopyBundledSkills -v` +Expected: 14 passed (10 supported hosts + 1 references + 1 no-references + 2 unsupported + 1 overwrite). + +- [ ] **Step 4: Commit** + +```bash +git add graphify/installer/skill_copy.py +git commit -m "feat(installer): copy_bundled_skills writes 15 gf- skills per host" +``` + +--- + +## Phase 4: Wire into installer orchestrator + +### Task 4.1: Call `copy_bundled_skills()` after `copy_skill()` in `__init__.py` + +**Files:** +- Modify: `graphify/installer/__init__.py` + +- [ ] **Step 1: Find the existing `copy_skill()` call** + +In `graphify/installer/__init__.py`, locate the line that calls `copy_skill(...)` (search for `copy_skill(`). It is inside the per-host install loop. + +- [ ] **Step 2: Add the `copy_bundled_skills()` call immediately after** + +Right after the `copy_skill(...)` call (same indentation), add: + +```python + copy_bundled_skills(host, root=root, package_root=package_root) +``` + +If the existing line is wrapped in a `with` block or helper function, ensure the new call passes the same arguments. + +- [ ] **Step 3: Run all installer tests, verify nothing breaks** + +Run: `pytest tests/test_installer_skill_copy.py tests/test_install_roundtrip.py -v` +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add graphify/installer/__init__.py +git commit -m "feat(installer): wire copy_bundled_skills into per-host install loop" +``` + +--- + +### Task 4.2: Failing round-trip test enhancement + +**Files:** +- Modify: `tests/test_install_roundtrip.py` + +- [ ] **Step 1: Locate the per-host round-trip loop** + +Open `tests/test_install_roundtrip.py`. Find the function or loop that iterates over platforms and asserts a SKILL.md was installed. + +- [ ] **Step 2: Append two `gf-*` assertions per host** + +After the existing assertion that `graphify/SKILL.md` exists, append: + +```python + # New: bundled skills under the gf- namespace + assert (dest / "gf-brainstorming" / "SKILL.md").exists() + assert (dest / "gf-llm-wiki" / "SKILL.md").exists() +``` + +(Adjust `dest` to whatever local variable holds the host's skill directory.) + +- [ ] **Step 3: Run the round-trip test, verify it FAILS** + +Run: `pytest tests/test_install_roundtrip.py -v` +Expected: FAIL with `FileNotFoundError` for `gf-brainstorming/SKILL.md`. + +If the test PASSES unexpectedly, the bundled skills are already being installed by the real package data path (no `package_root` override). In that case the test is verifying real behavior and skip Step 3 (no failure → it's a tautology). Note the result and proceed to Step 4 anyway. + +- [ ] **Step 4: Commit** + +```bash +git add tests/test_install_roundtrip.py +git commit -m "test(install): assert gf- skills installed per-host in round-trip" +``` + +--- + +## Phase 5: Package data + wheel verification + +### Task 5.1: Update `pyproject.toml` package-data + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Locate the `[tool.setuptools.package-data]` section** + +Search for `[tool.setuptools.package-data]` in `pyproject.toml`. The `graphify = [...]` list already contains entries like `skill.md`, `skills/*/references/*.md`, `assets/vis-network.min.js`. + +- [ ] **Step 2: Append five `bundled_skills` globs** + +Add these entries to the `graphify = [...]` list (keep them adjacent to the existing `skills/*/references/*.md` line for clarity): + +```toml + "bundled_skills/**/*.md", + "bundled_skills/**/*.txt", + "bundled_skills/**/*.sh", + "bundled_skills/**/*.ps1", + "bundled_skills/**/*.js", + "bundled_skills/**/*.tsv", +``` + +Final list snippet should look like: + +```toml +graphify = [ + "skill.md", "skill-codex.md", ..., "skill-pi.md", "skill-devin.md", + "skills/*/references/*.md", + "always_on/*.md", + "assets/vis-network.min.js", + "bundled_skills/**/*.md", + "bundled_skills/**/*.txt", + "bundled_skills/**/*.sh", + "bundled_skills/**/*.ps1", + "bundled_skills/**/*.js", + "bundled_skills/**/*.tsv", +] +``` + +- [ ] **Step 3: Build the wheel and verify it contains bundled skills** + +```bash +$PYTHON -m pip wheel . --no-deps --wheel-dir dist/ 2>&1 | tail -3 +$PYTHON -m zipfile -l dist/graphifyy-*.whl | grep bundled_skills | head -20 +``` + +Expected: at least 14 lines containing `bundled_skills/superpowers//SKILL.md` and 1 line for `bundled_skills/llm-wiki/SKILL.md`. + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml +git commit -m "build: include bundled_skills in package-data globs" +``` + +--- + +### Task 5.2: Add wheel import test + +**Files:** +- Modify: `tests/test_bundled_skills.py` + +- [ ] **Step 1: Append the wheel-content test class** + +```python +class TestBundledSkillsInInstalledPackage: + """Verify bundled_skills is accessible via importlib.resources after install.""" + + def test_superpowers_skills_listed(self): + import importlib.resources + root = importlib.resources.files("graphify") / "bundled_skills" / "superpowers" + skill_dirs = sorted( + p.name for p in root.iterdir() + if p.is_dir() and p.name not in {"LICENSE", "NOTICE"} # not files, treat NOTICE/LICENSE as files + ) + # Note: LICENSE/NOTICE are FILES, not dirs — the iterdir() filter above is defensive + assert len(skill_dirs) >= 14, f"got {len(skill_dirs)}: {skill_dirs}" + + def test_llm_wiki_present(self): + import importlib.resources + root = importlib.resources.files("graphify") / "bundled_skills" / "llm-wiki" + assert (root / "SKILL.md").is_file() +``` + +- [ ] **Step 2: Run, verify passes** + +Run: `pytest tests/test_bundled_skills.py::TestBundledSkillsInInstalledPackage -v` +Expected: 2 passed. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_bundled_skills.py +git commit -m "test(bundled-skills): importlib.resources reachability" +``` + +--- + +### Task 5.3: Add pre-flight check to `build_windows_installer.sh` + +**Files:** +- Modify: `tools/build_windows_installer.sh` + +- [ ] **Step 1: Locate the `pip wheel .` step** + +In `tools/build_windows_installer.sh`, find the line `$PYTHON -m pip wheel . --no-deps --wheel-dir "$WHEELHOUSE" >/dev/null`. It appears around line 50. + +- [ ] **Step 2: Insert the pre-flight check immediately after** + +Add this block right after the `pip wheel` line: + +```bash +# 2.5 Pre-flight: confirm bundled_skills made it into the installed package. +# If a snapshot file is missing, this fails the build loudly instead of +# shipping a graphify.exe that's silently missing skills. +echo "==> Pre-flight: verifying bundled_skills in installed package..." +"$VENV/Scripts/python.exe" -c " +from importlib.resources import files +sp = files('graphify') / 'bundled_skills' / 'superpowers' +n = sum(1 for p in sp.iterdir() if p.is_dir()) +assert n == 14, f'expected 14 superpowers skill dirs, got {n}' +assert (files('graphify') / 'bundled_skills' / 'llm-wiki' / 'SKILL.md').is_file(), 'llm-wiki SKILL.md missing' +print(f'==> Pre-flight OK: {n} superpowers + llm-wiki present') +" +``` + +(Place this AFTER step 3 ("Building offline venv") so `$VENV` is defined. If the script's current structure has `pip wheel` before the venv, move the pre-flight to after step 3 instead.) + +- [ ] **Step 3: Commit** + +```bash +git add tools/build_windows_installer.sh +git commit -m "ci: pre-flight check that bundled_skills survived wheel install" +``` + +--- + +## Phase 6: Documentation + +### Task 6.1: Create repo-root `NOTICE` + +**Files:** +- Create: `NOTICE` + +- [ ] **Step 1: Write NOTICE** + +Create `NOTICE` with this content: + +``` +graphify +Copyright 2024-... + +This product includes software developed by third parties: + +──────────────────────────────────────────────────────────── +superpowers-dev +Bundled under: graphify/bundled_skills/superpowers/ +Copyright: 2025 Jesse Vincent +License: MIT — see graphify/bundled_skills/superpowers/LICENSE +Source: https://github.com/superpowers-dev/superpowers-dev + +Renamed for graphify (frontmatter `name:` field, directory basename +unchanged). See graphify/bundled_skills/README.md. +──────────────────────────────────────────────────────────── +llm-wiki +Bundled under: graphify/bundled_skills/llm-wiki/ +Copyright: ... +License: see graphify/bundled_skills/llm-wiki/LICENSE +──────────────────────────────────────────────────────────── +``` + +(Fill in the llm-wiki copyright line from `graphify/bundled_skills/llm-wiki/LICENSE`.) + +- [ ] **Step 2: Commit** + +```bash +git add NOTICE +git commit -m "docs: NOTICE listing bundled third-party projects" +``` + +--- + +### Task 6.2: Update `CHANGELOG.md` + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Append a new entry to `## Unreleased`** + +In `CHANGELOG.md`, find the `## Unreleased` section. Add this bullet just before the existing offline-installer entry (keep the offline-installer entry intact): + +```markdown +- Feat: offline installer also bundles 15 community skills under the `gf-` namespace (14 superpowers + `gf-llm-wiki`). After `graphify-installer.exe` runs, the host's skill directory contains `gf-brainstorming/`, `gf-writing-plans/`, `gf-subagent-driven-development/`, `gf-test-driven-development/`, `gf-systematic-debugging/`, `gf-using-git-worktrees/`, `gf-requesting-code-review/`, `gf-receiving-code-review/`, `gf-executing-plans/`, `gf-finishing-a-development-branch/`, `gf-dispatching-parallel-agents/`, `gf-using-superpowers/`, `gf-verification-before-completion/`, `gf-writing-skills/`, and `gf-llm-wiki/`. Namespaced with `gf-` so always-overwrite install semantics can never collide with user-installed plugins. See `graphify/bundled_skills/README.md`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): bundled skills under gf- namespace" +``` + +--- + +### Task 6.3: Update `docs/operations/offline-installer.md` + +**Files:** +- Modify: `docs/operations/offline-installer.md` + +- [ ] **Step 1: Locate the "What's inside the .exe" section** + +In `docs/operations/offline-installer.md`, find the heading `## What's inside the .exe` (around line 65). Note the bullet list of bundled components. + +- [ ] **Step 2: Append a paragraph about bundled skills** + +After the last bullet (the "Not bundled" note), add this paragraph: + +```markdown +The installer also ships 15 community skills under the `gf-` namespace +(14 superpowers + `gf-llm-wiki`). They are placed in the host's skill +directory alongside `graphify/` and are immediately discoverable by the +AI Agent host — trigger them via `/gf-brainstorming`, `/gf-writing-plans`, +etc. The `gf-` prefix guarantees no collision with a user's separately +installed superpowers plugin. See `graphify/bundled_skills/README.md`. +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/operations/offline-installer.md +git commit -m "docs(offline-installer): bundled skills under gf- namespace" +``` + +--- + +## Phase 7: Final verification + +### Task 7.1: Full test suite green + +- [ ] **Step 1: Run all installer + bundled-skills tests** + +Run: `pytest tests/test_bundled_skills.py tests/test_installer_skill_copy.py tests/test_install_roundtrip.py -v` +Expected: all pass. + +- [ ] **Step 2: Run the full suite to catch unrelated regressions** + +Run: `pytest tests/ -q --ignore=tests/test_analyze.py 2>&1 | tail -20` +Expected: zero failures attributable to this change. (Pre-existing failures unrelated to installer are acceptable; if any are new, investigate.) + +--- + +## Self-Review (against spec) + +**1. Spec coverage:** + +| Spec section | Task(s) | +|---|---| +| §3 Decisions (15 skills, snapshot+LICENSE, 14 hosts, single body, gf- prefix, always overwrite, no manifest, no --force, v2 cursor/gemini) | 1.1–1.4, 2.1–2.5, 3.1–3.2 | +| §2 Repository layout (`graphify/bundled_skills/{superpowers,llm-wiki}/`) | 1.1–1.4 | +| §3 Package data (5 globs) | 5.1 | +| §4 Installer logic (`bundled_skills.py` + `copy_bundled_skills()` + caller) | 2.1–2.5, 3.1–3.2, 4.1 | +| §5 Build / Nuitka (zero changes, pre-flight check) | 5.3 | +| §6 Tests (4 test classes + round-trip enhancement) | 2.1, 2.3, 2.4, 2.5, 3.1, 4.2, 5.2 | +| §7 Documentation (CHANGELOG, offline-installer.md, NOTICE, README) | 1.4, 6.1, 6.2, 6.3 | +| §8 Migration / backward compat | No code change needed; covered by tests showing no regression | + +**2. Placeholder scan:** No "TBD"/"TODO"/"implement later" in the plan. The "fill in llm-wiki copyright" instruction in Task 6.1 points to a specific file to read. + +**3. Type consistency:** +- `BundledSkill.name`, `.source_subpath`, `.has_references` — defined Task 2.2, used Tasks 2.1, 3.1, 3.2 ✓ +- `bundled_skill_dir(host, skill_name, *, root)` — defined Task 2.2, used Tasks 2.3, 3.1, 3.2 ✓ +- `supports_host(host_name)` — defined Task 2.2, used Task 3.2 ✓ +- `copy_bundled_skills(host, *, root, package_root=None)` — defined Task 3.2, used Tasks 3.1, 4.1 ✓ +- `_UNSUPPORTED_HOSTS = {"cursor", "gemini"}` — defined Task 2.2, used Task 3.2 ✓ + +No type drift. \ No newline at end of file diff --git a/docs/superpowers/specs/2026-06-29-local-vis-network-assets-design.md b/docs/superpowers/specs/2026-06-29-local-vis-network-assets-design.md new file mode 100644 index 000000000..d9e54b715 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-local-vis-network-assets-design.md @@ -0,0 +1,202 @@ +# Design: Local vis-network Asset (drop CDN, ship vendored copy) + +**Date:** 2026-06-29 +**Branch:** v8 +**Scope:** `graphify/export.py:to_html` HTML output, three call sites, tests, package data + +--- + +## Problem + +`to_html` currently emits a ` + ``` + with: + ```html + + ``` + +That's it for the production code. The aggregated-view recursive call at line 700 inherits the change automatically. + +### Failure modes + +- Vendored file missing in installed package → `_vendored_vis_js()` raises `FileNotFoundError` → `to_html` propagates. **No silent fallback to CDN** — by design (the whole point is "no CDN"). +- Output directory not writable → `target.write_bytes` raises `PermissionError` → propagates. Caller surfaces the error as it does today. + +--- + +## 3. Test updates (`tests/test_export.py`) + +### 3.1 Delete + +`test_to_html_pins_visjs_version_with_sri` (lines 103–127). The CDN URL, SRI hash, and `crossorigin="anonymous"` assertions no longer apply. + +### 3.2 Extend + +`test_to_html_contains_visjs` (lines 93–100) — append: + +```python +assert '' in content +assert 'unpkg.com' not in content +assert 'integrity=' not in content +assert 'crossorigin="anonymous"' not in content +``` + +### 3.3 Add: `test_to_html_emits_local_vis_js_asset` + +- Call `to_html` with a `tmp` directory. +- Assert `(out.parent / "vis-network.min.js").exists()`. +- Assert its bytes equal the vendored copy (`(files("graphify") / "assets" / "vis-network.min.js").read_bytes()`). + +### 3.4 Add: `test_to_html_skips_rewrite_when_asset_unchanged` + +- First `to_html`, record `target.stat().st_mtime_ns`. +- Second `to_html` to the same directory. +- Assert `mtime_ns` unchanged. + +### 3.5 Add: `test_to_html_rewrites_asset_when_vendored_changes` + +- Monkeypatch `graphify.export._vendored_vis_js` to return `b"DIFFERENT"`. +- Call `to_html`. +- Assert `target.read_bytes() == b"DIFFERENT"`. +- Restore the original function. + +### 3.6 Other tests + +- `tests/test_pipeline.py:83` (`assert "vis-network" in html`) still passes — the literal string `vis-network` appears in the new `` 转义) + 内嵌进 `|<\\/script>|gi' "$1" +} + +script_for_inline() { + perl -pe 's|//# sourceMappingURL=.*$||' "$1" +} + +html_escape_text() { + printf '%s' "$1" | perl -pe 's/&/&/g; s//>/g; s/"/"/g; s/'"'"'/'/g' +} + +while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) + print_usage + exit 0 + ;; + --) + shift + break + ;; + -*) + die "未知选项: $1" + ;; + *) + break + ;; + esac +done + +[ "$#" -eq 1 ] || { + print_usage >&2 + exit 1 +} + +WIKI_ROOT="$1" + +command -v jq >/dev/null 2>&1 || { + echo "ERROR: jq is not installed. Install it via:" >&2 + print_install_hint jq + exit 1 +} + +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +DATA="$WIKI_ROOT/wiki/graph-data.json" +LAYOUT="$WIKI_ROOT/.wiki-graph-layout.json" +ENGINE="$SKILL_DIR/packages/graph-engine/dist/engine.iife.js" +MARKED="$SKILL_DIR/deps/marked.min.js" +PURIFY="$SKILL_DIR/deps/purify.min.js" +OUTPUT="$WIKI_ROOT/wiki/knowledge-graph.html" + +[ -f "$DATA" ] || { + echo "ERROR: 未找到 $DATA" >&2 + echo " 请先运行 build-graph-data.sh 生成图谱数据" >&2 + exit 1 +} +ensure_file "$ENGINE" "graph-engine IIFE 产物" +ensure_file "$MARKED" "marked vendor" +ensure_file "$PURIFY" "purify vendor" + +WIKI_TITLE=$(jq -r '.meta.wiki_title // "知识库"' "$DATA") +NODE_COUNT=$(jq -r '.meta.total_nodes // 0' "$DATA") +EDGE_COUNT=$(jq -r '.meta.total_edges // 0' "$DATA") +BUILD_DATE=$(jq -r '.meta.build_date // ""' "$DATA") +BUILD_DATE_SHORT="${BUILD_DATE:0:10}" +[ -n "$BUILD_DATE_SHORT" ] || BUILD_DATE_SHORT="未知" +WIKI_TITLE_HTML=$(html_escape_text "$WIKI_TITLE") +NODE_COUNT_HTML=$(html_escape_text "$NODE_COUNT") +EDGE_COUNT_HTML=$(html_escape_text "$EDGE_COUNT") +BUILD_DATE_SHORT_HTML=$(html_escape_text "$BUILD_DATE_SHORT") + +layout_json='{"version":2,"pins":{},"updatedAt":""}' +if [ -f "$LAYOUT" ]; then + if layout_json_candidate=$(jq -c '{version:(.version // 1), pins:(.pins // {}), updatedAt:(.updatedAt // "")}' "$LAYOUT" 2>/dev/null); then + layout_json="$layout_json_candidate" + else + echo "WARN: 忽略损坏的钉位文件:$LAYOUT" >&2 + fi +fi + +output_dir="$(dirname "$OUTPUT")" +mkdir -p "$output_dir" +output_tmp="$OUTPUT.partial" +output_next="$OUTPUT.next" +rm -f "$output_tmp" "$output_next" + +cat > "$output_tmp" < + + + + + 知识图谱 · ${WIKI_TITLE_HTML} + + + +
+
+
+

${WIKI_TITLE_HTML} 知识舆图

+

国风知识库·数字山水图

+
+
+
+ ${NODE_COUNT_HTML} 节点 + ${EDGE_COUNT_HTML} 关联 + ${BUILD_DATE_SHORT_HTML} + +
+
+
+
+
+
+ + |<\/script>|gi' >> "$output_tmp" +cat >> "$output_tmp" <<'HTML_ENGINE' + + + + + +HTML_BOOT + +mv "$output_tmp" "$output_next" +mv "$output_next" "$OUTPUT" + +rm -f \ + "$output_dir/d3.min.js" \ + "$output_dir/rough.min.js" \ + "$output_dir/marked.min.js" \ + "$output_dir/purify.min.js" \ + "$output_dir/graph-wash.js" \ + "$output_dir/graph-wash-helpers.js" \ + "$output_dir/LICENSE-d3.txt" \ + "$output_dir/LICENSE-roughjs.txt" \ + "$output_dir/LICENSE-marked.txt" \ + "$output_dir/LICENSE-purify.txt" + +output_size=$(wc -c < "$OUTPUT" | tr -d ' ') +output_kb=$((output_size / 1024)) + +echo "交互式图谱已生成:" +echo " - $OUTPUT (${output_kb} KB)" +echo " 节点 $NODE_COUNT · 关联 $EDGE_COUNT" +echo "" +echo "查看方式:" +echo " 双击 $OUTPUT" diff --git a/graphify/bundled_skills/llm-wiki/scripts/cache.sh b/graphify/bundled_skills/llm-wiki/scripts/cache.sh new file mode 100755 index 000000000..fabd5cbf5 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/cache.sh @@ -0,0 +1,352 @@ +#!/bin/bash +# llm-wiki 缓存脚本 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/shared-config.sh" + +usage() { + cat <<'EOF' +用法: + bash scripts/cache.sh check + bash scripts/cache.sh update + bash scripts/cache.sh invalidate +EOF +} + +require_file() { + local file_path="$1" + + [ -n "$file_path" ] || { + usage + exit 1 + } + + [ -f "$file_path" ] || { + echo "文件不存在:$file_path" >&2 + exit 1 + } +} + +find_wiki_root() { + local file_path="$1" + local dir parent + + dir="$(cd "$(dirname "$file_path")" && pwd)" + + while true; do + if [ -f "$dir/.wiki-cache.json" ] || [ -f "$dir/.wiki-schema.md" ]; then + printf '%s\n' "$dir" + return 0 + fi + + parent="$(dirname "$dir")" + [ "$parent" = "$dir" ] && return 1 + dir="$parent" + done +} + +cache_file_path() { + printf '%s/.wiki-cache.json\n' "$1" +} + +ensure_cache_file() { + local cache_file="$1" + + if [ ! -f "$cache_file" ]; then + cat > "$cache_file" <<'EOF' +{ + "version": 1, + "entries": {} +} +EOF + fi +} + +relative_path() { + require_python_cmd + + "$PYTHON_CMD" - "$1" "$2" <<'PY' +import os +import sys + +print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1]))) +PY +} + +normalized_source_page() { + local wiki_root="$1" + local source_page="$2" + + if [ -z "$source_page" ]; then + printf '%s\n' "" + return 0 + fi + + case "$source_page" in + /*) + require_python_cmd + + "$PYTHON_CMD" - "$wiki_root" "$source_page" <<'PY' +import os +import sys + +wiki_root = os.path.realpath(sys.argv[1]) +source_page = os.path.realpath(sys.argv[2]) + +try: + common = os.path.commonpath([wiki_root, source_page]) +except ValueError: + common = "" + +if common == wiki_root: + print(os.path.relpath(source_page, wiki_root)) +else: + print(sys.argv[2]) +PY + ;; + *) + printf '%s\n' "$source_page" + ;; + esac +} + +file_hash() { + require_python_cmd + + "$PYTHON_CMD" - "$1" "$2" <<'PY' +import hashlib +import pathlib +import sys + +relative_path = sys.argv[1].encode("utf-8") +file_path = pathlib.Path(sys.argv[2]) +content = file_path.read_bytes() + +digest = hashlib.sha256(relative_path + b"\0" + content).hexdigest() +print(f"sha256:{digest}") +PY +} + +cache_check() { + local file_path="$1" + local wiki_root cache_file relative_path_value current_hash result + + require_file "$file_path" + wiki_root="$(find_wiki_root "$file_path")" || { + echo "未找到知识库根目录:$file_path" >&2 + exit 1 + } + cache_file="$(cache_file_path "$wiki_root")" + + if [ ! -f "$cache_file" ]; then + printf 'MISS\n' + return 0 + fi + + require_python_cmd + + relative_path_value="$(relative_path "$wiki_root" "$file_path")" + current_hash="$(file_hash "$relative_path_value" "$file_path")" + + result="$( + "$PYTHON_CMD" - "$cache_file" "$wiki_root" "$relative_path_value" "$current_hash" <<'PY' +import hashlib +import json +import os +import pathlib +import sys + +cache_file, wiki_root, relative_path, current_hash = sys.argv[1:5] + +with open(cache_file, "r", encoding="utf-8") as fh: + data = json.load(fh) + +entry = data.get("entries", {}).get(relative_path) + +# 无 cache entry → 尝试自愈(exact filename stem match + source_path 验证) +if not entry: + raw_stem = pathlib.Path(relative_path).stem + sources_dir = os.path.join(wiki_root, "wiki", "sources") + if os.path.isdir(sources_dir): + for f in os.listdir(sources_dir): + if pathlib.Path(f).stem == raw_stem and f.endswith(".md"): + source_page = os.path.join("wiki", "sources", f) + source_abs = os.path.join(wiki_root, source_page) + # 验证 source 页面的 source_path frontmatter 是否指向当前 raw 文件 + source_path_match = False + try: + with open(source_abs, "r", encoding="utf-8") as sf: + in_frontmatter = False + for line in sf: + stripped = line.strip() + if stripped == "---": + if in_frontmatter: + break # end of frontmatter + in_frontmatter = True + continue + if in_frontmatter and stripped.startswith("source_path:"): + fm_value = stripped.split(":", 1)[1].strip() + # 匹配相对路径的末尾部分 + if relative_path.endswith(fm_value) or fm_value.endswith(relative_path) or fm_value == relative_path: + source_path_match = True + break + except (OSError, UnicodeDecodeError): + pass + if not source_path_match: + # stem 匹配但 source_path 不一致 → 不信任,需要验证 + print("MISS:repaired_needs_verify") + raise SystemExit(0) + # stem + source_path 都匹配 → 安全自愈 + timestamp = __import__("datetime").datetime.now(__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + entries = data.setdefault("entries", {}) + entries[relative_path] = { + "hash": current_hash, + "ingested_at": timestamp, + "source_page": source_page, + } + tmp_file = cache_file + ".tmp" + with open(tmp_file, "w", encoding="utf-8") as fh2: + json.dump(data, fh2, ensure_ascii=False, indent=2) + fh2.write("\n") + os.replace(tmp_file, cache_file) + print("HIT(repaired)") + raise SystemExit(0) + print("MISS:no_entry") + raise SystemExit(0) + +if entry.get("hash") != current_hash: + print("MISS:hash_changed") + raise SystemExit(0) + +source_page = entry.get("source_page") +if not source_page: + print("MISS:no_entry") + raise SystemExit(0) + +source_path = source_page +if not os.path.isabs(source_path): + source_path = os.path.join(wiki_root, source_path) + +if not os.path.isfile(source_path): + print("MISS:no_source") +else: + print("HIT") +PY + )" + + printf '%s\n' "$result" +} + +cache_update() { + local file_path="$1" + local source_page="$2" + local wiki_root cache_file relative_path_value current_hash normalized_source timestamp + + require_file "$file_path" + wiki_root="$(find_wiki_root "$file_path")" || { + echo "未找到知识库根目录:$file_path" >&2 + exit 1 + } + cache_file="$(cache_file_path "$wiki_root")" + ensure_cache_file "$cache_file" + + require_python_cmd + + relative_path_value="$(relative_path "$wiki_root" "$file_path")" + current_hash="$(file_hash "$relative_path_value" "$file_path")" + normalized_source="$(normalized_source_page "$wiki_root" "$source_page")" + timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + "$PYTHON_CMD" - "$cache_file" "$relative_path_value" "$current_hash" "$timestamp" "$normalized_source" <<'PY' +import json +import os +import sys + +cache_file, relative_path, file_hash_value, timestamp, source_page = sys.argv[1:6] + +with open(cache_file, "r", encoding="utf-8") as fh: + data = json.load(fh) + +entries = data.setdefault("entries", {}) +entries[relative_path] = { + "hash": file_hash_value, + "ingested_at": timestamp, + "source_page": source_page, +} + +tmp_file = cache_file + ".tmp" +with open(tmp_file, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + fh.write("\n") +os.replace(tmp_file, cache_file) +PY + + printf 'UPDATED\n' +} + +cache_invalidate() { + local file_path="$1" + local wiki_root cache_file relative_path_value + + # 不调用 require_file:文件可能已被删除(级联删除场景) + # 直接通过路径查找缓存条目 + wiki_root="$(find_wiki_root "$file_path")" || { + echo "未找到知识库根目录:$file_path" >&2 + exit 1 + } + cache_file="$(cache_file_path "$wiki_root")" + + if [ ! -f "$cache_file" ]; then + printf 'INVALIDATED\n' + return 0 + fi + + require_python_cmd + + relative_path_value="$(relative_path "$wiki_root" "$file_path")" + + "$PYTHON_CMD" - "$cache_file" "$relative_path_value" <<'PY' +import json +import os +import sys + +cache_file, relative_path = sys.argv[1:3] + +with open(cache_file, "r", encoding="utf-8") as fh: + data = json.load(fh) + +data.setdefault("entries", {}).pop(relative_path, None) + +tmp_file = cache_file + ".tmp" +with open(tmp_file, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + fh.write("\n") +os.replace(tmp_file, cache_file) +PY + + printf 'INVALIDATED\n' +} + +command_name="${1:-}" + +case "$command_name" in + check) + [ "$#" -eq 2 ] || { usage; exit 1; } + cache_check "$2" + ;; + update) + [ "$#" -eq 3 ] || { usage; exit 1; } + cache_update "$2" "$3" + ;; + invalidate) + [ "$#" -eq 2 ] || { usage; exit 1; } + cache_invalidate "$2" + ;; + *) + usage + exit 1 + ;; +esac diff --git a/graphify/bundled_skills/llm-wiki/scripts/create-source-page.sh b/graphify/bundled_skills/llm-wiki/scripts/create-source-page.sh new file mode 100755 index 000000000..d2958db1d --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/create-source-page.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# llm-wiki source 页面写入脚本 +# 原子写入 source 页面 + 自动更新缓存,绑定为一项操作 + +set -euo pipefail + +usage() { + cat <<'EOF' +用法: + bash scripts/create-source-page.sh + +参数: + raw_file : 原始素材文件路径(绝对或相对路径) + output_path : 目标页面路径(相对于知识库根目录,如 wiki/sources/2026-04-16-rlhf.md) + content_file : 包含待写入内容的临时文件路径 +EOF +} + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# 参数校验 +if [ "$#" -ne 3 ]; then + usage + exit 1 +fi + +raw_file="$1" +output_path="$2" +content_file="$3" + +# raw_file 和 content_file 必须存在 +if [ ! -f "$raw_file" ]; then + echo "ERROR: 原始素材文件不存在:$raw_file" >&2 + exit 1 +fi + +if [ ! -f "$content_file" ]; then + echo "ERROR: 内容文件不存在:$content_file" >&2 + exit 1 +fi + +# 通过 cache.sh 的 find_wiki_root 逻辑找到知识库根目录 +# 复用 cache.sh 里的函数 +source_cache_helpers() { + # 内联 find_wiki_root(与 cache.sh 保持一致) + find_wiki_root() { + local file_path="$1" + local dir parent + + dir="$(cd "$(dirname "$file_path")" && pwd)" + + while true; do + if [ -f "$dir/.wiki-cache.json" ] || [ -f "$dir/.wiki-schema.md" ]; then + printf '%s\n' "$dir" + return 0 + fi + + parent="$(dirname "$dir")" + [ "$parent" = "$dir" ] && return 1 + dir="$parent" + done + } +} + +source_cache_helpers + +wiki_root="$(find_wiki_root "$raw_file")" || { + echo "ERROR: 未找到知识库根目录:$raw_file" >&2 + exit 1 +} + +# 拼接完整目标路径 +full_output="$wiki_root/$output_path" + +# 确保目标目录存在 +mkdir -p "$(dirname "$full_output")" + +# 第一步:原子写入(临时文件 + rename,防止写一半崩溃) +tmp_output="${full_output}.tmp.$$" +if ! cp "$content_file" "$tmp_output"; then + rm -f "$tmp_output" 2>/dev/null || true + echo "ERROR: 写入临时文件失败" >&2 + exit 1 +fi + +if ! mv "$tmp_output" "$full_output"; then + rm -f "$tmp_output" 2>/dev/null || true + echo "ERROR: 原子重命名失败" >&2 + exit 1 +fi + +# 第二步:更新缓存 +if ! bash "$SCRIPT_DIR/cache.sh" update "$raw_file" "$output_path"; then + # 缓存更新失败 → 回滚:删除已写入的文件 + rm -f "$full_output" + echo "ERROR: 缓存更新失败,已回滚写入" >&2 + exit 1 +fi + +echo "SUCCESS" diff --git a/graphify/bundled_skills/llm-wiki/scripts/delete-helper.sh b/graphify/bundled_skills/llm-wiki/scripts/delete-helper.sh new file mode 100755 index 000000000..65d94ec25 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/delete-helper.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# llm-wiki 删除辅助脚本 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/shared-config.sh" + +usage() { + cat <<'EOF' +用法: + bash scripts/delete-helper.sh scan-refs <素材文件名> +EOF +} + +scan_refs() { + local wiki_root="$1" + local needle="$2" + local wiki_dir="$wiki_root/wiki" + + [ -n "$needle" ] || { + echo "素材文件名不能为空" >&2 + exit 1 + } + + [ -d "$wiki_dir" ] || { + echo "知识库目录不存在:$wiki_dir" >&2 + exit 1 + } + + require_python_cmd + + { + grep -rlF --include='*.md' -- "$needle" "$wiki_dir" 2>/dev/null || true + } | "$PYTHON_CMD" -c ' +import os +import sys + +wiki_root = os.path.realpath(sys.argv[1]) +seen = [] + +for line in sys.stdin: + path = line.strip() + if not path: + continue + real_path = os.path.realpath(path) + if real_path in seen: + continue + seen.append(real_path) + +for path in sorted(seen): + print(os.path.relpath(path, wiki_root)) +' "$wiki_root" +} + +command_name="${1:-}" + +case "$command_name" in + scan-refs) + [ "$#" -eq 3 ] || { usage; exit 1; } + scan_refs "$2" "$3" + ;; + *) + usage + exit 1 + ;; +esac diff --git a/graphify/bundled_skills/llm-wiki/scripts/graph-analysis.js b/graphify/bundled_skills/llm-wiki/scripts/graph-analysis.js new file mode 100644 index 000000000..d8392ba73 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/graph-analysis.js @@ -0,0 +1,732 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { extractFrontmatter, parseSourcesFrontmatter, sortedUnique } = require("./lib/source-signal-eligibility"); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function roundNumber(value, digits = 3) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function clamp01(value) { + return Math.max(0, Math.min(1, value)); +} + +function sortedPairKey(a, b) { + return a < b ? `${a}\t${b}` : `${b}\t${a}`; +} + +function normalizeBody(text, degraded, maxLines) { + const { body } = extractFrontmatter(text); + const normalized = body.replace(/^\s+/, "").replace(/\s+$/, ""); + if (!degraded) return normalized; + return normalized.split(/\r?\n/).slice(0, maxLines).join("\n").replace(/\s+$/, ""); +} + +function loadNodeDetails(nodes, degraded, maxLines) { + const byId = {}; + + for (const node of nodes) { + const filePath = node._file_path || node.source_path; + const raw = fs.readFileSync(filePath, "utf8"); + const frontmatter = extractFrontmatter(raw); + const parsedSources = parseSourcesFrontmatter(frontmatter.frontmatter); + const normalizedNode = { + ...node, + content: normalizeBody(raw, degraded, maxLines), + _signals: { + sources: parsedSources.sources, + sourceSignalAvailable: parsedSources.signalAvailable, + sourceFieldPresent: parsedSources.hasField, + sourceFieldParsed: parsedSources.parsed + } + }; + byId[node.id] = normalizedNode; + } + + return byId; +} + +function buildInlinks(edges) { + const inlinks = new Map(); + + for (const edge of edges) { + if (!inlinks.has(edge.to)) inlinks.set(edge.to, new Set()); + inlinks.get(edge.to).add(edge.from); + } + + return inlinks; +} + +function intersectionCount(setA, setB) { + if (!setA || !setB) return 0; + let small = setA; + let large = setB; + if (setB.size < setA.size) { + small = setB; + large = setA; + } + + let count = 0; + for (const value of small) { + if (large.has(value)) count += 1; + } + return count; +} + +function typeAffinity(typeA, typeB) { + const pair = [typeA || "other", typeB || "other"].sort().join(":"); + switch (pair) { + case "entity:entity": + case "entity:topic": + return 1; + case "topic:topic": + return 0.8; + case "entity:source": + return 0.6; + case "source:source": + return 0.3; + default: + return 0.5; + } +} + +function computePairMetrics(nodesById, edges) { + const inlinks = buildInlinks(edges); + const pairMetrics = new Map(); + + for (const edge of edges) { + const pairKey = sortedPairKey(edge.from, edge.to); + if (pairMetrics.has(pairKey)) continue; + + const fromNode = nodesById[edge.from]; + const toNode = nodesById[edge.to]; + if (!fromNode || !toNode) continue; + + const fromInlinks = inlinks.get(edge.from) || new Set(); + const toInlinks = inlinks.get(edge.to) || new Set(); + const sharedInlinks = intersectionCount(fromInlinks, toInlinks); + const coCitation = sharedInlinks / Math.max(fromInlinks.size, toInlinks.size, 1); + const affinity = typeAffinity(fromNode.type, toNode.type); + + const signals = [coCitation, affinity]; + let sourceOverlap = null; + const sourceSignalAvailable = Boolean( + fromNode._signals.sourceSignalAvailable && toNode._signals.sourceSignalAvailable + ); + + if (sourceSignalAvailable) { + const fromSources = new Set(fromNode._signals.sources); + const toSources = new Set(toNode._signals.sources); + const overlap = intersectionCount(fromSources, toSources); + const minSize = Math.min(fromSources.size, toSources.size); + sourceOverlap = minSize > 0 ? overlap / minSize : 0; + signals.push(sourceOverlap); + } + + const weight = clamp01(signals.reduce((sum, value) => sum + value, 0) / signals.length); + + pairMetrics.set(pairKey, { + weight: roundNumber(weight), + signals: { + co_citation: roundNumber(coCitation), + source_overlap: sourceOverlap == null ? null : roundNumber(sourceOverlap), + type_affinity: roundNumber(affinity) + }, + source_signal_available: sourceSignalAvailable + }); + } + + return pairMetrics; +} + +function buildUndirectedGraph(nodeIds, pairMetrics) { + const adjacency = new Map(); + const degrees = new Map(); + + for (const nodeId of nodeIds) { + adjacency.set(nodeId, new Map()); + degrees.set(nodeId, 0); + } + + for (const [pairKey, metrics] of pairMetrics.entries()) { + const [left, right] = pairKey.split("\t"); + if (!adjacency.has(left) || !adjacency.has(right)) continue; + const weight = metrics.weight; + adjacency.get(left).set(right, weight); + adjacency.get(right).set(left, weight); + degrees.set(left, degrees.get(left) + weight); + degrees.set(right, degrees.get(right) + weight); + } + + return { adjacency, degrees }; +} + +function runLocalMove(graph) { + const nodes = Array.from(graph.nodes.keys()).sort(); + const communities = new Map(); + const totals = new Map(); + let moved = false; + + for (const nodeId of nodes) { + communities.set(nodeId, nodeId); + totals.set(nodeId, graph.degrees.get(nodeId) || 0); + } + + if (graph.m2 === 0) { + return { communities, changed: false }; + } + + let changedInPass = true; + let passCount = 0; + while (changedInPass && passCount < 50) { + passCount++; + changedInPass = false; + + for (const nodeId of nodes) { + const degree = graph.degrees.get(nodeId) || 0; + const currentCommunity = communities.get(nodeId); + const neighborCommunities = new Map(); + + for (const [neighborId, weight] of graph.nodes.get(nodeId).entries()) { + const communityId = communities.get(neighborId); + neighborCommunities.set(communityId, (neighborCommunities.get(communityId) || 0) + weight); + } + + totals.set(currentCommunity, (totals.get(currentCommunity) || 0) - degree); + if ((neighborCommunities.get(currentCommunity) || 0) === 0) { + neighborCommunities.set(currentCommunity, 0); + } + + let bestCommunity = currentCommunity; + let bestGain = 0; + + const candidates = Array.from(neighborCommunities.keys()).sort(); + for (const communityId of candidates) { + const inWeight = neighborCommunities.get(communityId) || 0; + const gain = inWeight - ((totals.get(communityId) || 0) * degree) / graph.m2; + if (gain > bestGain + 1e-9) { + bestGain = gain; + bestCommunity = communityId; + } + } + + communities.set(nodeId, bestCommunity); + totals.set(bestCommunity, (totals.get(bestCommunity) || 0) + degree); + + if (bestCommunity !== currentCommunity) { + changedInPass = true; + moved = true; + } + } + } + + return { communities, changed: moved }; +} + +function aggregateGraph(graph, communities) { + const communityIds = sortedUnique(Array.from(communities.values())); + const aggregatedNodes = new Map(); + const aggregatedDegrees = new Map(); + const members = new Map(); + + for (const communityId of communityIds) { + aggregatedNodes.set(communityId, new Map()); + aggregatedDegrees.set(communityId, 0); + members.set(communityId, []); + } + + for (const [nodeId, communityId] of communities.entries()) { + members.get(communityId).push(...(graph.members.get(nodeId) || [nodeId])); + } + + for (const [nodeId, neighbors] of graph.nodes.entries()) { + const sourceCommunity = communities.get(nodeId); + for (const [neighborId, weight] of neighbors.entries()) { + if (nodeId > neighborId) continue; + const targetCommunity = communities.get(neighborId); + const current = aggregatedNodes.get(sourceCommunity).get(targetCommunity) || 0; + aggregatedNodes.get(sourceCommunity).set(targetCommunity, current + weight); + if (sourceCommunity !== targetCommunity) { + const mirrored = aggregatedNodes.get(targetCommunity).get(sourceCommunity) || 0; + aggregatedNodes.get(targetCommunity).set(sourceCommunity, mirrored + weight); + } + } + } + + for (const [communityId, neighbors] of aggregatedNodes.entries()) { + let degree = 0; + for (const [neighborId, weight] of neighbors.entries()) { + degree += neighborId === communityId ? weight * 2 : weight; + } + aggregatedDegrees.set(communityId, degree); + } + + return { + nodes: aggregatedNodes, + degrees: aggregatedDegrees, + members, + m2: Array.from(aggregatedDegrees.values()).reduce((sum, value) => sum + value, 0) + }; +} + +function runLouvain(nodeIds, pairMetrics) { + const baseGraph = buildUndirectedGraph(nodeIds, pairMetrics); + let graph = { + nodes: baseGraph.adjacency, + degrees: baseGraph.degrees, + members: new Map(nodeIds.map((nodeId) => [nodeId, [nodeId]])), + m2: Array.from(baseGraph.degrees.values()).reduce((sum, value) => sum + value, 0) + }; + + let bestMembers = graph.members; + + while (true) { + const phase = runLocalMove(graph); + const nextGraph = aggregateGraph(graph, phase.communities); + bestMembers = nextGraph.members; + + if (!phase.changed || nextGraph.nodes.size === graph.nodes.size) { + break; + } + + graph = nextGraph; + } + + const finalCommunities = new Map(); + for (const [communityId, members] of bestMembers.entries()) { + for (const nodeId of members) { + finalCommunities.set(nodeId, communityId); + } + } + + return finalCommunities; +} + +function buildDirectedDegree(edges) { + const degree = new Map(); + for (const edge of edges) { + degree.set(edge.from, (degree.get(edge.from) || 0) + 1); + degree.set(edge.to, (degree.get(edge.to) || 0) + 1); + } + return degree; +} + +function chooseCommunityLabels(nodeIds, communityAssignments, nodesById, edges) { + const groups = new Map(); + const degree = buildDirectedDegree(edges); + + for (const nodeId of nodeIds) { + const communityId = communityAssignments.get(nodeId) || nodeId; + if (!groups.has(communityId)) groups.set(communityId, []); + groups.get(communityId).push(nodeId); + } + + const labeledAssignments = new Map(); + + for (const members of groups.values()) { + members.sort(); + if (members.length === 1) { + labeledAssignments.set(members[0], null); + continue; + } + + const memberNodes = members.map((memberId) => nodesById[memberId]); + const topics = memberNodes.filter((node) => node && node.type === "topic"); + const candidates = topics.length ? topics : memberNodes; + candidates.sort((left, right) => { + const degreeDiff = (degree.get(right.id) || 0) - (degree.get(left.id) || 0); + if (degreeDiff !== 0) return degreeDiff; + return left.id.localeCompare(right.id); + }); + + const label = candidates[0] ? candidates[0].id : members[0]; + for (const memberId of members) { + labeledAssignments.set(memberId, label); + } + } + + return labeledAssignments; +} + +function buildInsights(nodesById, edges, pairMetrics, communityAssignments, options) { + const directedDegree = buildDirectedDegree(edges); + const undirectedPairs = new Map(); + const adjacency = new Map(); + + for (const nodeId of Object.keys(nodesById)) { + adjacency.set(nodeId, new Set()); + } + + for (const edge of edges) { + const pairKey = sortedPairKey(edge.from, edge.to); + if (!undirectedPairs.has(pairKey)) { + undirectedPairs.set(pairKey, { + from: pairKey.split("\t")[0], + to: pairKey.split("\t")[1], + weight: pairMetrics.get(pairKey)?.weight || 0 + }); + } + adjacency.get(edge.from)?.add(edge.to); + adjacency.get(edge.to)?.add(edge.from); + } + + const isolatedNodes = Object.values(nodesById) + .filter((node) => (directedDegree.get(node.id) || 0) <= 1) + .sort((left, right) => left.id.localeCompare(right.id)) + .map((node) => ({ + id: node.id, + label: node.label, + degree: directedDegree.get(node.id) || 0, + community: communityAssignments.get(node.id) || null + })); + + const bridgeNodes = []; + for (const node of Object.values(nodesById).sort((left, right) => left.id.localeCompare(right.id))) { + const ownCommunity = communityAssignments.get(node.id) || null; + const connectedCommunities = sortedUnique( + Array.from(adjacency.get(node.id) || []) + .map((neighborId) => communityAssignments.get(neighborId) || null) + .filter((c) => c && c !== ownCommunity) + ); + + if (connectedCommunities.length >= 2) { + bridgeNodes.push({ + id: node.id, + label: node.label, + community: ownCommunity, + connected_communities: connectedCommunities, + community_count: connectedCommunities.length + }); + } + } + + const communityMembers = new Map(); + for (const node of Object.values(nodesById)) { + const communityId = communityAssignments.get(node.id) || null; + if (!communityId) continue; + if (!communityMembers.has(communityId)) communityMembers.set(communityId, []); + communityMembers.get(communityId).push(node.id); + } + + const sparseCommunities = []; + for (const [communityId, members] of Array.from(communityMembers.entries()).sort((left, right) => left[0].localeCompare(right[0]))) { + if (members.length < 3) continue; + + const memberSet = new Set(members); + let internalEdges = 0; + for (const pair of undirectedPairs.values()) { + if (memberSet.has(pair.from) && memberSet.has(pair.to)) internalEdges += 1; + } + + const possibleEdges = (members.length * (members.length - 1)) / 2; + const density = possibleEdges === 0 ? 0 : internalEdges / possibleEdges; + if (density < 0.15) { + sparseCommunities.push({ + id: communityId, + label: nodesById[communityId]?.label || communityId, + node_count: members.length, + density: roundNumber(density), + members: members.sort(), + internal_edges: internalEdges + }); + } + } + + const surprisingConnections = Array.from(undirectedPairs.values()) + .filter((pair) => { + const fromCommunity = communityAssignments.get(pair.from) || null; + const toCommunity = communityAssignments.get(pair.to) || null; + return fromCommunity && toCommunity && fromCommunity !== toCommunity && pair.weight >= 0.75; + }) + .sort((left, right) => { + if (right.weight !== left.weight) return right.weight - left.weight; + if (left.from !== right.from) return left.from.localeCompare(right.from); + return left.to.localeCompare(right.to); + }) + .slice(0, 8) + .map((pair) => ({ + from: pair.from, + to: pair.to, + weight: pair.weight, + from_community: communityAssignments.get(pair.from) || null, + to_community: communityAssignments.get(pair.to) || null + })); + + const degraded = options.nodeCount > options.maxInsightNodes || options.edgeCount > options.maxInsightEdges; + if (degraded) { + return { + surprising_connections: [], + isolated_nodes: isolatedNodes, + bridge_nodes: [], + sparse_communities: [], + meta: { + degraded: true, + node_count: options.nodeCount, + edge_count: options.edgeCount, + max_insight_nodes: options.maxInsightNodes, + max_insight_edges: options.maxInsightEdges + } + }; + } + + return { + surprising_connections: surprisingConnections, + isolated_nodes: isolatedNodes, + bridge_nodes: bridgeNodes, + sparse_communities: sparseCommunities, + meta: { + degraded: false, + node_count: options.nodeCount, + edge_count: options.edgeCount, + max_insight_nodes: options.maxInsightNodes, + max_insight_edges: options.maxInsightEdges + } + }; +} + +function buildLearning(analyzedNodes, analyzedEdges) { + const degreeMap = new Map(); + for (const edge of analyzedEdges) { + degreeMap.set(edge.from, (degreeMap.get(edge.from) || 0) + 1); + degreeMap.set(edge.to, (degreeMap.get(edge.to) || 0) + 1); + } + + const communityGroups = new Map(); + for (const node of analyzedNodes) { + if (node.community == null) continue; + if (!communityGroups.has(node.community)) communityGroups.set(node.community, []); + communityGroups.get(node.community).push(node); + } + + const communities = []; + for (const [cid, members] of communityGroups.entries()) { + const memberIds = new Set(members.map(n => n.id)); + let totalWeight = 0; + for (const edge of analyzedEdges) { + if (memberIds.has(edge.from) && memberIds.has(edge.to)) totalWeight += edge.weight; + } + const isWeak = members.length < 3; + const startNode = members.slice().sort((a, b) => { + const degDiff = (degreeMap.get(b.id) || 0) - (degreeMap.get(a.id) || 0); + if (degDiff !== 0) return degDiff; + return a.id.localeCompare(b.id); + })[0]; + + communities.push({ + id: cid, + label: (members.find(n => n.id === cid) || members[0]).label, + node_count: members.length, + source_count: members.filter(n => n.type === "source").length, + internal_edge_weight: roundNumber(totalWeight), + is_primary: false, + is_weak: isWeak, + recommended_start_node_id: startNode.id + }); + } + + communities.sort((a, b) => { + if (b.node_count !== a.node_count) return b.node_count - a.node_count; + if (b.internal_edge_weight !== a.internal_edge_weight) return b.internal_edge_weight - a.internal_edge_weight; + return a.id.localeCompare(b.id); + }); + + if (communities.length > 0) communities[0].is_primary = true; + + const primary = communities.length > 0 ? communities[0] : null; + const startNodeId = primary ? primary.recommended_start_node_id : null; + + let pathNodeIds = []; + let pathDegraded = false; + if (primary && !primary.is_weak && startNodeId) { + const primaryMemberIds = new Set(communityGroups.get(primary.id).map(n => n.id)); + const neighbors = analyzedEdges + .filter(e => (e.from === startNodeId && primaryMemberIds.has(e.to)) || + (e.to === startNodeId && primaryMemberIds.has(e.from))) + .map(e => e.from === startNodeId ? e.to : e.from); + pathNodeIds = [startNodeId, ...sortedUnique(neighbors).filter(id => id !== startNodeId)]; + if (pathNodeIds.length < 2) pathDegraded = true; + } else { + pathDegraded = true; + } + + let communityNodeIds = []; + let communityDegraded = false; + if (primary && !primary.is_weak) { + communityNodeIds = communityGroups.get(primary.id).map(n => n.id).sort(); + } else { + communityDegraded = true; + } + + const globalNodeIds = analyzedNodes.slice().sort((a, b) => { + const degDiff = (degreeMap.get(b.id) || 0) - (degreeMap.get(a.id) || 0); + if (degDiff !== 0) return degDiff; + return a.id.localeCompare(b.id); + }).map(n => n.id); + + const defaultMode = "global"; + + return { + version: 1, + entry: { + recommended_start_node_id: startNodeId, + recommended_start_reason: startNodeId ? "community_hub" : null, + default_mode: defaultMode + }, + views: { + path: { + enabled: !pathDegraded, + start_node_id: pathDegraded ? null : startNodeId, + node_ids: pathDegraded ? [] : pathNodeIds, + degraded: pathDegraded + }, + community: { + enabled: !communityDegraded, + community_id: primary && !communityDegraded ? primary.id : null, + label: primary && !communityDegraded ? primary.label : null, + node_ids: communityDegraded ? [] : communityNodeIds, + is_weak: primary ? primary.is_weak : false, + degraded: communityDegraded + }, + global: { + enabled: true, + node_ids: globalNodeIds, + degraded: false + } + }, + communities, + degraded: { + path_to_community: pathDegraded, + community_to_global: communityDegraded + } + }; +} + +function analyzeGraph(nodes, edges, options = {}) { + const degraded = options.degraded === true; + const maxLines = options.maxLines || 500; + const maxInsightNodes = options.maxInsightNodes || 250; + const maxInsightEdges = options.maxInsightEdges || 1000; + + const nodesById = loadNodeDetails(nodes, degraded, maxLines); + const pairMetrics = computePairMetrics(nodesById, edges); + const nodeIds = nodes.map((node) => node.id); + const communityAssignments = chooseCommunityLabels( + nodeIds, + runLouvain(nodeIds, pairMetrics), + nodesById, + edges + ); + + const analyzedNodes = nodes.map((node) => ({ + id: node.id, + label: node.label, + type: node.type, + source_path: node.source_path, + community: communityAssignments.get(node.id) || null, + content: nodesById[node.id].content + })); + + const analyzedEdges = edges.map((edge) => { + const pairKey = sortedPairKey(edge.from, edge.to); + const metrics = pairMetrics.get(pairKey) || { + weight: 0, + signals: { co_citation: 0, source_overlap: null, type_affinity: 0.5 }, + source_signal_available: false + }; + + return { + id: edge.id, + from: edge.from, + to: edge.to, + type: edge.type, + confidence: edge.confidence || edge.type, + relation_type: edge.relation_type || "依赖", + weight: metrics.weight, + source_signal_available: metrics.source_signal_available, + signals: metrics.signals + }; + }); + + const insights = buildInsights(nodesById, analyzedEdges, pairMetrics, communityAssignments, { + nodeCount: analyzedNodes.length, + edgeCount: analyzedEdges.length, + maxInsightNodes, + maxInsightEdges + }); + + const learning = buildLearning(analyzedNodes, analyzedEdges); + + return { nodes: analyzedNodes, edges: analyzedEdges, insights, learning }; +} + +function main(argv) { + if (argv.length < 7) { + console.error("Usage: node graph-analysis.js "); + process.exit(1); + } + + const timer = setTimeout(() => { + console.error("ERROR: graph analysis timed out (120s)"); + process.exit(2); + }, 120_000); + timer.unref(); + + const [, , nodesPath, edgesPath, outputPath, degradedRaw, maxLinesRaw, maxInsightNodesRaw, maxInsightEdgesRaw] = argv; + + for (const p of [nodesPath, edgesPath]) { + if (!fs.existsSync(p)) { + console.error(`ERROR: File not found: ${p}`); + process.exit(1); + } + } + + const analyzed = analyzeGraph(readJson(nodesPath), readJson(edgesPath), { + degraded: degradedRaw === "1", + maxLines: Number(maxLinesRaw) || 500, + maxInsightNodes: Number(maxInsightNodesRaw) || 250, + maxInsightEdges: Number(maxInsightEdgesRaw) || 1000 + }); + + writeJson(outputPath, analyzed); + clearTimeout(timer); +} + +if (require.main === module) { + try { + main(process.argv); + } catch (error) { + const code = error && error.code; + if (code === "ENOENT") { + console.error(`ERROR: File not found: ${error.path || "(unknown)"}`); + } else if (error instanceof SyntaxError) { + console.error(`ERROR: Invalid JSON in input: ${error.message}`); + } else { + console.error(`ERROR: ${error && error.message ? error.message : String(error)}`); + } + process.exit(1); + } +} + +module.exports = { + analyzeGraph, + buildInsights, + buildLearning, + chooseCommunityLabels, + computePairMetrics, + extractFrontmatter, + normalizeBody, + parseSourcesFrontmatter, + runLouvain, + typeAffinity +}; diff --git a/graphify/bundled_skills/llm-wiki/scripts/hook-session-start.sh b/graphify/bundled_skills/llm-wiki/scripts/hook-session-start.sh new file mode 100755 index 000000000..df405038b --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/hook-session-start.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# SessionStart hook: 会话开始时注入 wiki 上下文(只触发一次) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/shared-config.sh" + +WIKI_PATH="" + +if [ -f "$HOME/.llm-wiki-path" ]; then + WIKI_PATH="$(cat "$HOME/.llm-wiki-path")" +fi + +if [ -z "$WIKI_PATH" ] && [ -f .wiki-schema.md ]; then + WIKI_PATH="$(pwd)" +fi + +if [ -z "$WIKI_PATH" ] || [ ! -f "$WIKI_PATH/.wiki-schema.md" ]; then + printf '{}\n' + exit 0 +fi + +require_python_cmd + +"$PYTHON_CMD" - "$WIKI_PATH" <<'PY' +import json +import os +import sys + +# 防御性:即使上游 shared-config.sh 未设置 PYTHONIOENCODING, +# 此处也强制 stdout 为 UTF-8,避免 Agent 接到 gbk 字节 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +wiki_path = os.path.realpath(sys.argv[1]) +message = f"[llm-wiki] 检测到知识库: {wiki_path}/index.md,回答问题时优先查阅 wiki 内容获取上下文" + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": message, + } +}, ensure_ascii=False)) +PY diff --git a/graphify/bundled_skills/llm-wiki/scripts/init-wiki.sh b/graphify/bundled_skills/llm-wiki/scripts/init-wiki.sh new file mode 100755 index 000000000..3e8bf9dbb --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/init-wiki.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# llm-wiki 初始化脚本 +# 自动创建知识库的目录结构 +# 用法:bash init-wiki.sh <知识库路径> <主题> + +set -e + +WIKI_ROOT="${1:-$HOME/Documents/我的知识库}" +TOPIC="${2:-我的知识库}" +LANGUAGE="${3:-中文}" +DATE=$(date +%Y-%m-%d) +SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +# 安全的模板变量替换函数(用 perl 替代 sed,避免中文/空格/特殊字符问题) +replace_vars() { + local input_file="$1" + local output_file="$2" + TOPIC_VALUE="$TOPIC" \ + DATE_VALUE="$DATE" \ + WIKI_ROOT_VALUE="$WIKI_ROOT" \ + LANGUAGE_VALUE="$LANGUAGE" \ + perl -pe ' + s/\{\{TOPIC\}\}/$ENV{TOPIC_VALUE}/g; + s/\{\{DATE\}\}/$ENV{DATE_VALUE}/g; + s/\{\{WIKI_ROOT\}\}/$ENV{WIKI_ROOT_VALUE}/g; + s/\{\{LANGUAGE\}\}/$ENV{LANGUAGE_VALUE}/g; + ' "$input_file" > "$output_file" +} + +echo "正在创建知识库..." +echo " 路径:$WIKI_ROOT" +echo " 主题:$TOPIC" +echo " 语言:$LANGUAGE" +echo "" + +# 创建目录结构(包含小红书和知乎) +mkdir -p "$WIKI_ROOT"/raw/{articles,tweets,wechat,xiaohongshu,zhihu,pdfs,notes,assets} +mkdir -p "$WIKI_ROOT"/wiki/{entities,topics,sources,comparisons,synthesis,synthesis/sessions,queries} + +cat > "$WIKI_ROOT/.gitignore" <<'EOF' +.wiki-tmp/ +EOF + +echo "[完成] 目录结构已创建" + +# 从模板生成文件 +replace_vars "$SKILL_DIR/templates/schema-template.md" "$WIKI_ROOT/.wiki-schema.md" +echo "[完成] Schema 文件已生成" + +replace_vars "$SKILL_DIR/templates/index-template.md" "$WIKI_ROOT/index.md" +echo "[完成] 索引文件已生成" + +replace_vars "$SKILL_DIR/templates/log-template.md" "$WIKI_ROOT/log.md" +echo "[完成] 日志文件已生成" + +replace_vars "$SKILL_DIR/templates/overview-template.md" "$WIKI_ROOT/wiki/overview.md" +echo "[完成] 总览文件已生成" + +if [ "$LANGUAGE" = "English" ]; then + replace_vars "$SKILL_DIR/templates/purpose-en-template.md" "$WIKI_ROOT/purpose.md" +else + replace_vars "$SKILL_DIR/templates/purpose-template.md" "$WIKI_ROOT/purpose.md" +fi +echo "[完成] 研究方向文件已生成" + +cat > "$WIKI_ROOT/.wiki-cache.json" <<'EOF' +{ + "version": 1, + "entries": {} +} +EOF +echo "[完成] 缓存文件已生成" + +echo "" +echo "知识库创建完成!" +echo "" +echo "目录结构:" +echo " $WIKI_ROOT/" +echo " ├── raw/ (原始素材)" +echo " │ ├── articles/ 网页文章" +echo " │ ├── tweets/ X/Twitter" +echo " │ ├── wechat/ 微信公众号" +echo " │ ├── xiaohongshu/ 小红书" +echo " │ ├── zhihu/ 知乎" +echo " │ ├── pdfs/ PDF" +echo " │ ├── notes/ 笔记" +echo " │ └── assets/ 图片等附件" +echo " ├── wiki/ (知识库)" +echo " ├── index.md (索引)" +echo " ├── log.md (日志)" +echo " ├── purpose.md (研究方向)" +echo " ├── .wiki-cache.json (缓存)" +echo " └── .wiki-schema.md (配置)" +echo "" +echo "下一步:给 agent 一个链接或文件,开始构建知识库!" diff --git a/graphify/bundled_skills/llm-wiki/scripts/lib/source-signal-eligibility.js b/graphify/bundled_skills/llm-wiki/scripts/lib/source-signal-eligibility.js new file mode 100644 index 000000000..22b35ad77 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/lib/source-signal-eligibility.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +"use strict"; + +const SCAN_KINDS = [ + { subdir: "entities", pageType: "entity", applicable: true }, + { subdir: "topics", pageType: "topic", applicable: true }, + { subdir: "sources", pageType: "source", applicable: true }, + { subdir: "comparisons", pageType: "comparison", applicable: true }, + { subdir: "queries", pageType: "query", applicable: false }, + { subdir: "synthesis", pageType: "synthesis", applicable: false } +]; + +function sortedUnique(values) { + return Array.from(new Set(values)).sort(); +} + +function extractFrontmatter(text) { + if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) { + return { hasFrontmatter: false, frontmatter: "", body: text }; + } + + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/); + if (!match) { + return { hasFrontmatter: false, frontmatter: "", body: text }; + } + + return { + hasFrontmatter: true, + frontmatter: match[1], + body: match[2] + }; +} + +function normalizeSourceToken(token) { + const trimmed = String(token || "").trim(); + if (!trimmed) return null; + + let value = trimmed; + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1).trim(); + } + + return value || null; +} + +function parseInlineSources(raw) { + const trimmed = raw.trim(); + if (trimmed === "[]") return { ok: true, values: [] }; + if (!(trimmed.startsWith("[") && trimmed.endsWith("]"))) { + return { ok: false, values: [] }; + } + + const inner = trimmed.slice(1, -1).trim(); + if (!inner) return { ok: true, values: [] }; + + const values = inner + .split(",") + .map(normalizeSourceToken) + .filter(Boolean); + + return { ok: true, values }; +} + +function parseSourcesFrontmatter(frontmatter) { + if (!frontmatter) { + return { hasField: false, parsed: false, sources: [], signalAvailable: false }; + } + + const lines = frontmatter.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(/^sources:\s*(.*)$/); + if (!match) continue; + + const rest = match[1].trim(); + if (rest) { + if (!rest.startsWith("[")) { + const single = normalizeSourceToken(rest); + return { + hasField: true, + parsed: Boolean(single), + sources: single ? [single] : [], + signalAvailable: Boolean(single) + }; + } + + const parsedInline = parseInlineSources(rest); + return { + hasField: true, + parsed: parsedInline.ok, + sources: parsedInline.ok ? sortedUnique(parsedInline.values) : [], + signalAvailable: parsedInline.ok && parsedInline.values.length > 0 + }; + } + + const collected = []; + let parsed = true; + let consumed = 0; + + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const line = lines[cursor]; + if (!line.trim()) { + consumed += 1; + continue; + } + if (/^[^\s-]/.test(line)) break; + const itemMatch = line.match(/^\s*-\s*(.+)$/); + if (!itemMatch) { + parsed = false; + consumed += 1; + continue; + } + const token = normalizeSourceToken(itemMatch[1]); + if (token) collected.push(token); + consumed += 1; + } + + index += consumed; + return { + hasField: true, + parsed, + sources: parsed ? sortedUnique(collected) : [], + signalAvailable: parsed && collected.length > 0 + }; + } + + return { hasField: false, parsed: false, sources: [], signalAvailable: false }; +} + +function evaluateSourceSignalEligibility({ pageType, frontmatter }) { + const kind = SCAN_KINDS.find((k) => k.pageType === pageType); + if (!kind || !kind.applicable) { + return { eligible: false, reason: "not_applicable", sources: [] }; + } + + const parsed = parseSourcesFrontmatter(frontmatter); + + if (!parsed.hasField) { + return { eligible: false, reason: "missing_sources", sources: [] }; + } + if (!parsed.parsed) { + return { eligible: false, reason: "invalid_sources", sources: [] }; + } + if (parsed.sources.length === 0) { + return { eligible: false, reason: "empty_sources", sources: [] }; + } + + return { eligible: true, reason: "ok", sources: parsed.sources }; +} + +module.exports = { + SCAN_KINDS, + extractFrontmatter, + evaluateSourceSignalEligibility, + normalizeSourceToken, + parseSourcesFrontmatter, + sortedUnique +}; diff --git a/graphify/bundled_skills/llm-wiki/scripts/lint-fix.sh b/graphify/bundled_skills/llm-wiki/scripts/lint-fix.sh new file mode 100755 index 000000000..b0982ddc4 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/lint-fix.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# lint-fix.sh — 自动修复 lint 发现的低风险问题 +# 用法:bash scripts/lint-fix.sh [--dry-run] +# 修复范围:仅处理确定性修复(补 index 条目),不做高风险操作(删页面、改内容) +# 退出码:0 = 完成,1 = 参数错误 + +set -u +shopt -s nullglob + +WIKI_ROOT="${1:-.}" +DRY_RUN=false +[ "${2:-}" = "--dry-run" ] && DRY_RUN=true + +WIKI_DIR="$WIKI_ROOT/wiki" +INDEX_FILE="$WIKI_ROOT/index.md" + +if [ ! -d "$WIKI_DIR" ]; then + echo "ERROR: wiki directory not found: $WIKI_DIR" >&2 + exit 1 +fi +if [ ! -f "$INDEX_FILE" ]; then + echo "ERROR: index.md not found: $INDEX_FILE" >&2 + exit 1 +fi + +FIXED=0 + +index_has_entry() { + local entry="$1" + grep -ohE "\[\[[^]]+\]\]" "$INDEX_FILE" 2>/dev/null | \ + sed -e 's/\[\[//g' -e 's/\]\]//g' -e 's/|.*//' | \ + grep -Fxq "$entry" +} + +# Insert a [[link]] entry after the matching section header in index.md. +# If no matching section is found, appends to end of file as fallback. +insert_under_section() { + local index_file="$1" + local section_pattern="$2" + local entry="$3" + + # Find the line number of the section header + local line_num + line_num=$(grep -n -i -E "^#.*($section_pattern)" "$index_file" 2>/dev/null | head -1 | cut -d: -f1) + + if [ -n "$line_num" ]; then + # Scan from section header to find insert point: + # last "- [[" line before next "##" header or EOF + local total_lines last_list_line offset + total_lines=$(wc -l < "$index_file" | tr -d ' ') + last_list_line="$line_num" + offset=$((line_num + 1)) + while [ "$offset" -le "$total_lines" ]; do + local cur_line + cur_line=$(sed -n "${offset}p" "$index_file") + case "$cur_line" in + "##"*) break ;; + "- [["*) last_list_line="$offset" ;; + esac + offset=$((offset + 1)) + done + # Insert after the last list item + local tmp_file + tmp_file=$(mktemp "${index_file}.tmp.XXXXXX") || return 1 + awk -v insert_after="$last_list_line" -v entry="$entry" ' + { print } + NR == insert_after { print "- [[" entry "]]" } + ' "$index_file" > "$tmp_file" && mv "$tmp_file" "$index_file" + else + # Fallback: append to end of file + printf '\n- [[%s]]\n' "$entry" >> "$index_file" + fi +} + +echo "=== lint-fix: low-risk auto-repair ===" +echo "" + +# Fix 1: Add unlisted pages to index.md +# Only adds pages that exist in wiki/ but are not referenced in index.md +# Skips derived pages (queries/, sessions/) +echo "--- Checking for unlisted pages ---" +for _subdir in entities topics sources comparisons synthesis; do + for f in "$WIKI_DIR"/$_subdir/*.md; do + [ -f "$f" ] || continue + BASENAME=$(basename "$f" .md) + # Skip derived pages + case "$f" in + */queries/*|*/sessions/*) continue ;; + esac + if ! index_has_entry "$BASENAME"; then + SECTION_PATTERN="" + case "$_subdir" in + entities) SECTION_PATTERN="实体页|Entities" ;; + topics) SECTION_PATTERN="主题页|Topics" ;; + sources) SECTION_PATTERN="素材摘要|Sources" ;; + comparisons) SECTION_PATTERN="对比分析|Comparisons" ;; + synthesis) SECTION_PATTERN="综合分析|Synthesis" ;; + esac + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] Would add [[$BASENAME]] under $_subdir section" + else + insert_under_section "$INDEX_FILE" "$SECTION_PATTERN" "$BASENAME" + echo " Fixed: added [[$BASENAME]] under $_subdir section" + fi + FIXED=$((FIXED + 1)) + fi + done +done +[ "$FIXED" -eq 0 ] && echo " (all pages already listed)" +echo "" + +echo "=== lint-fix complete: $FIXED fix(es) applied ===" +[ "$DRY_RUN" = true ] && echo "(dry-run mode — no files were modified)" +exit 0 diff --git a/graphify/bundled_skills/llm-wiki/scripts/lint-runner.sh b/graphify/bundled_skills/llm-wiki/scripts/lint-runner.sh new file mode 100755 index 000000000..b2ec87f3b --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/lint-runner.sh @@ -0,0 +1,217 @@ +#!/bin/bash +# lint-runner.sh — wiki 机械健康检查 +# 用法:bash scripts/lint-runner.sh +# 输出:结构化文本报告(供 AI 后续分析使用) +# 退出码:0 = 运行完成,1 = 脚本错误(路径不存在、wiki 结构不完整) + +set -u +shopt -s nullglob + +WIKI_ROOT="${1:-.}" +WIKI_DIR="$WIKI_ROOT/wiki" +INDEX_FILE="$WIKI_ROOT/index.md" + +if [ ! -d "$WIKI_DIR" ]; then + echo "ERROR: wiki 目录不存在:$WIKI_DIR" >&2 + echo " 请确认路径正确,或先运行 init 工作流初始化知识库。" >&2 + exit 1 +fi +if [ ! -f "$INDEX_FILE" ]; then + echo "ERROR: index.md 不存在:$INDEX_FILE" >&2 + exit 1 +fi + +index_has_entry() { + local entry="$1" + grep -ohE "\[\[[^]]+\]\]" "$INDEX_FILE" 2>/dev/null | \ + sed -e 's/\[\[//g' -e 's/\]\]//g' -e 's/|.*//' | \ + grep -Fxq "$entry" +} + +echo "=== llm-wiki lint 报告 ===" +echo "时间:$(date '+%Y-%m-%d %H:%M')" +echo "检查路径:$WIKI_DIR" +echo "" + +# 检查 1:孤立页面 +# 定义:entities/、topics/、sources/ 下的页面,除了自己之外没有任何其他 wiki 页面用 [[名称]] 引用它 +echo "--- 孤立页面(没有被其他页面引用) ---" +_ORPHANS=0 +for _subdir in entities topics sources; do + for f in "$WIKI_DIR"/$_subdir/*.md; do + [ -f "$f" ] || continue + BASENAME=$(basename "$f" .md) + if ! grep -rlF "[[$BASENAME]]" "$WIKI_DIR" 2>/dev/null | grep -vxF "$f" | grep -q .; then + echo " 孤立: $_subdir/$BASENAME" + _ORPHANS=$((_ORPHANS + 1)) + fi + done +done +[ "$_ORPHANS" -eq 0 ] && echo " (无孤立页面)" +echo "" + +# 检查 2:断链 +# 定义:wiki/ 下的页面里有 [[X]] 链接(支持 [[X|别名]] 语法),但 wiki/ 任意子目录找不到 X.md +echo "--- 断链(被链接但不存在的页面) ---" +_TMP_BROKEN=$(mktemp) +grep -rohE "\[\[[^]]+\]\]" "$WIKI_DIR" 2>/dev/null | \ + sed -e 's/\[\[//g' -e 's/\]\]//g' -e 's/|.*//' | \ + sort -u | \ + while read -r LINK; do + [ -z "$LINK" ] && continue + if ! find "$WIKI_DIR" -name "$LINK.md" 2>/dev/null | grep -q .; then + echo " 断链: [[$LINK]]" + echo "$LINK" >> "$_TMP_BROKEN" + fi + done +if [ ! -s "$_TMP_BROKEN" ]; then + echo " (无断链)" +fi +rm -f "$_TMP_BROKEN" +echo "" + +# 检查 3:index 一致性 +# 定义:index.md 里有 [[X]] 记录(去掉别名),但 wiki/ 任意子目录都找不到 X.md +echo "--- index 一致性(index.md 有记录但文件缺失) ---" +_TMP_MISSING=$(mktemp) +grep -ohE "\[\[[^]]+\]\]" "$INDEX_FILE" 2>/dev/null | \ + sed -e 's/\[\[//g' -e 's/\]\]//g' -e 's/|.*//' | \ + sort -u | \ + while read -r ENTRY; do + [ -z "$ENTRY" ] && continue + if ! find "$WIKI_DIR" -name "$ENTRY.md" 2>/dev/null | grep -q .; then + echo " index 有但文件缺失: $ENTRY" + echo "$ENTRY" >> "$_TMP_MISSING" + fi + done +if [ ! -s "$_TMP_MISSING" ]; then + echo " (index 与文件一致)" +fi +rm -f "$_TMP_MISSING" +echo "" + +# 检查 4:反向 index 一致性 +# 定义:wiki/ 下实际存在的页面,但 index.md 里没有 [[页面名]] 记录 +# 排除 derived 页面(queries/、synthesis/sessions/) +echo "--- 反向 index 一致性(文件存在但 index.md 未收录) ---" +_TMP_UNLISTED=$(mktemp) +for _subdir in entities topics sources comparisons synthesis; do + for f in "$WIKI_DIR"/$_subdir/*.md; do + [ -f "$f" ] || continue + BASENAME=$(basename "$f" .md) + # 跳过 derived 页面 + case "$f" in + */queries/*|*/sessions/*) continue ;; + esac + if ! index_has_entry "$BASENAME"; then + echo " 未收录: $_subdir/$BASENAME" + echo "$BASENAME" >> "$_TMP_UNLISTED" + fi + done +done +if [ ! -s "$_TMP_UNLISTED" ]; then + echo " (所有页面均已收录)" +fi +rm -f "$_TMP_UNLISTED" +echo "" + +# 检查 5:图片资产一致性 +# 定义:source 页面 frontmatter 中 image_paths 列出的文件,在知识库中是否实际存在 +# 支持 block list 格式和 inline array 格式 +echo "--- 图片资产一致性(image_paths 声明但文件缺失) ---" +_IMG_ISSUES=0 +for f in "$WIKI_DIR"/sources/*.md; do + [ -f "$f" ] || continue + _BASENAME=$(basename "$f" .md) + # 提取 frontmatter 中 image_paths 的值 + _IN_FM=false + _IN_IMG=false + _INLINE_VAL="" + while IFS= read -r line; do + case "$line" in + "---") + if [ "$_IN_FM" = true ]; then break; fi + _IN_FM=true + continue + ;; + esac + [ "$_IN_FM" = true ] || continue + case "$line" in + image_paths:*) + # 检查是否有 inline value(如 image_paths: ["a.png", "b.jpg"]) + _INLINE_VAL=$(echo "$line" | sed 's/^image_paths:[[:space:]]*//') + if [ -n "$_INLINE_VAL" ] && [ "$_INLINE_VAL" != "[]" ]; then + # 解析 inline array:去掉 [],按逗号分割 + echo "$_INLINE_VAL" | tr -d '[]' | tr ',' '\n' | while IFS= read -r _ITEM; do + _PATH=$(echo "$_ITEM" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//' | tr -d '"' | tr -d "'") + [ -z "$_PATH" ] && continue + if [ ! -f "$WIKI_ROOT/$_PATH" ]; then + echo " 缺失: $_BASENAME → $_PATH" + fi + done + _INLINE_COUNT=$(echo "$_INLINE_VAL" | tr -d '[]' | tr ',' '\n' | while IFS= read -r _ITEM; do + _P=$(echo "$_ITEM" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//' | tr -d '"' | tr -d "'") + [ -z "$_P" ] && continue + [ ! -f "$WIKI_ROOT/$_P" ] && echo "x" + done | wc -l | tr -d ' ') + _IMG_ISSUES=$((_IMG_ISSUES + _INLINE_COUNT)) + _IN_IMG=false + else + _IN_IMG=true + fi + continue + ;; + " - "*) + if [ "$_IN_IMG" = true ]; then + _PATH=$(echo "$line" | sed 's/^[[:space:]]*- //' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//' | tr -d '"' | tr -d "'") + [ -z "$_PATH" ] && continue + if [ ! -f "$WIKI_ROOT/$_PATH" ]; then + echo " 缺失: $_BASENAME → $_PATH" + _IMG_ISSUES=$((_IMG_ISSUES + 1)) + fi + fi + ;; + *) _IN_IMG=false ;; + esac + done < "$f" +done +[ "$_IMG_ISSUES" -eq 0 ] && echo " (无缺失图片)" +echo "" + +# 检查 6:source-signal 覆盖情况 +echo "--- source-signal 覆盖情况 ---" +_COVERAGE_SCRIPT="$(cd "$(dirname "$0")" && pwd)/source-signal-coverage.js" +if [ -f "$_COVERAGE_SCRIPT" ] && command -v node >/dev/null 2>&1; then + _COVERAGE_JSON=$(node "$_COVERAGE_SCRIPT" "$WIKI_ROOT" 2>/dev/null) + if [ $? -eq 0 ] && [ -n "$_COVERAGE_JSON" ]; then + node -e ' + const data = JSON.parse(require("fs").readFileSync("/dev/stdin", "utf8")); + const s = data.summary; + console.log(" 已参与:" + s.ok); + console.log(" 缺少 sources 字段:" + s.missing_sources); + console.log(" sources 为空:" + s.empty_sources); + console.log(" sources 格式无效:" + s.invalid_sources); + console.log(" 当前不参与:" + s.not_applicable); + const issues = data.pages.filter(p => p.reason !== "ok" && p.reason !== "not_applicable"); + if (issues.length > 0) { + const byReason = { missing_sources: [], empty_sources: [], invalid_sources: [] }; + for (const p of issues) { if (byReason[p.reason]) byReason[p.reason].push(p.path); } + for (const [reason, paths] of Object.entries(byReason)) { + if (paths.length === 0) continue; + const label = { missing_sources: "缺少 sources 字段", empty_sources: "sources 为空", invalid_sources: "sources 格式无效" }[reason]; + console.log(""); + console.log(" " + label + ":"); + for (const p of paths) console.log(" - " + p); + } + } + ' <<< "$_COVERAGE_JSON" + else + echo " (coverage 脚本执行失败,跳过覆盖检查)" + fi +else + echo " (coverage 脚本或 node 不可用,跳过覆盖检查)" +fi +echo "" + +echo "=== 机械检查完成。矛盾检测、交叉引用、置信度抽查由 AI 继续执行 ===" +exit 0 diff --git a/graphify/bundled_skills/llm-wiki/scripts/runtime-context.sh b/graphify/bundled_skills/llm-wiki/scripts/runtime-context.sh new file mode 100644 index 000000000..912c69745 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/runtime-context.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# 共享运行场景解析:供 install.sh 和 adapter-state.sh 复用 + +resolve_platform_skill_root() { + case "$1" in + claude) + printf '%s\n' "$HOME/.claude/skills" + ;; + codex) + if [ -d "$HOME/.codex/skills" ] || [ ! -d "$HOME/.Codex/skills" ]; then + printf '%s\n' "$HOME/.codex/skills" + else + printf '%s\n' "$HOME/.Codex/skills" + fi + ;; + openclaw) + printf '%s\n' "$HOME/.openclaw/skills" + ;; + hermes) + printf '%s\n' "$HOME/.hermes/skills" + ;; + *) + echo "不支持的平台:$1" >&2 + return 1 + ;; + esac +} + +detect_layout_mode() { + local bundle_root="$1" + + if [ -e "$bundle_root/.git" ]; then + printf '%s\n' "source_checkout" + return 0 + fi + + printf '%s\n' "installed_skill" +} + +resolve_layout_mode() { + local bundle_root="$1" + local override_mode="${2:-}" + + if [ -n "$override_mode" ]; then + printf '%s\n' "$override_mode" + return 0 + fi + + detect_layout_mode "$bundle_root" +} + +resolve_optional_adapter_root() { + local bundle_root="$1" + local skill_root_override="${2:-}" + local override_mode="${3:-}" + local layout_mode + + if [ -n "$skill_root_override" ]; then + printf '%s\n' "$skill_root_override" + return 0 + fi + + layout_mode="$(resolve_layout_mode "$bundle_root" "$override_mode")" + + case "$layout_mode" in + source_checkout) + printf '%s\n' "$bundle_root/deps" + ;; + installed_skill|upgrade_target) + printf '%s\n' "$(dirname "$bundle_root")" + ;; + *) + echo "未知运行模式:$layout_mode" >&2 + return 1 + ;; + esac +} diff --git a/graphify/bundled_skills/llm-wiki/scripts/shared-config.sh b/graphify/bundled_skills/llm-wiki/scripts/shared-config.sh new file mode 100644 index 000000000..91cea78db --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/shared-config.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# 共享配置:被 install.sh / hook-session-start.sh / cache.sh / delete-helper.sh 等引用 +# 微信公众号提取工具的 Git 仓库地址 +WECHAT_TOOL_URL="git+https://github.com/jackwener/wechat-article-to-markdown.git" + +# Python 命令检测:Windows 默认安装为 python.exe,不存在 python3 命令 +# (Microsoft Store 的 python3 是安装提示 stub,运行会失败) +_python_version_check='import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' + +_python_cmd_is_valid() { + local candidate="$1" + + command -v "$candidate" >/dev/null 2>&1 && "$candidate" -c "$_python_version_check" >/dev/null 2>&1 +} + +_detect_python_cmd() { + # 要求 Python 3.8+(见 README Windows 小节与下方错误消息) + if _python_cmd_is_valid python3; then + echo "python3" + elif _python_cmd_is_valid python; then + echo "python" + else + echo "" + fi +} + +require_python_cmd() { + local detected_cmd + + if [ "${PYTHON_CMD_READY:-0}" = "1" ]; then + return 0 + fi + + if [ -n "${PYTHON_CMD:-}" ] && _python_cmd_is_valid "$PYTHON_CMD"; then + export PYTHON_CMD + PYTHON_CMD_READY=1 + return 0 + fi + + detected_cmd="$(_detect_python_cmd)" + if [ -z "$detected_cmd" ]; then + echo "[llm-wiki] 错误:找不到可用的 Python 3,请先安装 Python 3.8+ 并加入 PATH" >&2 + return 1 + fi + + PYTHON_CMD="$detected_cmd" + export PYTHON_CMD + PYTHON_CMD_READY=1 +} + +# 统一 Python 子进程 stdout/stderr 编码为 UTF-8 +# Windows 中文环境下 Python 无 TTY 时 sys.stdout.encoding 默认 gbk (cp936), +# 会导致 Agent 通过 subprocess 读取的 JSON / 输出出现乱码 (issue #16) +export PYTHONIOENCODING="${PYTHONIOENCODING:-utf-8}" + +# 输出指定工具的跨平台安装提示,缩进 2 空格便于嵌套在 ERROR 消息下; +# 输出走 stderr,与 ERROR 消息保持同一通道。 +print_install_hint() { + local tool="$1" + case "$tool" in + jq) + echo " macOS: brew install jq" >&2 + echo " Linux/WSL: sudo apt-get install jq (Debian/Ubuntu)" >&2 + echo " sudo dnf install jq (RHEL/Fedora)" >&2 + echo " Windows: winget install jqlang.jq (or choco install jq)" >&2 + ;; + node) + echo " macOS: brew install node" >&2 + echo " Linux/WSL: sudo apt-get install nodejs npm" >&2 + echo " Windows: winget install OpenJS.NodeJS (or choco install nodejs)" >&2 + ;; + uv) + echo " macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh (official)" >&2 + echo " brew install uv (alternative)" >&2 + echo " Windows: powershell -c \"irm https://astral.sh/uv/install.ps1 | iex\" (official)" >&2 + echo " winget install --id=astral-sh.uv -e (alternative)" >&2 + ;; + *) + echo " unknown tool: $tool" >&2 + return 1 + ;; + esac +} diff --git a/graphify/bundled_skills/llm-wiki/scripts/source-record-contract.tsv b/graphify/bundled_skills/llm-wiki/scripts/source-record-contract.tsv new file mode 100644 index 000000000..a670c59b1 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/source-record-contract.tsv @@ -0,0 +1,10 @@ +field_name requiredness filled_by value_rule +source_id required router 必须匹配 source-registry.tsv 中的 source_id +source_label required router 必须匹配 source-registry.tsv 中的 source_label +source_category required router 必须匹配 source-registry.tsv 中的 source_category +input_mode required caller_or_router 只能是 url / file / text / asset +raw_dir required router 必须是 raw/ 下的相对目录 +original_ref required caller 保存原始 URL、文件路径或用户粘贴说明 +ingest_text required adapter_or_user 进入主线前必须是非空文本 +adapter_name required_may_be_empty router_or_adapter 核心主线和手动入口留空;外挂来源写实际 adapter 名称 +fallback_hint required router 必须给出用户可执行的手动回退提示 diff --git a/graphify/bundled_skills/llm-wiki/scripts/source-registry.sh b/graphify/bundled_skills/llm-wiki/scripts/source-registry.sh new file mode 100755 index 000000000..989d294dd --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/source-registry.sh @@ -0,0 +1,349 @@ +#!/bin/bash +# 统一来源总表读取与验证脚本 +# 权威数据文件:source-registry.tsv(来源定义)、source-record-contract.tsv(字段契约) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONTRACT_FILE="$SCRIPT_DIR/source-record-contract.tsv" +REGISTRY_FILE="$SCRIPT_DIR/source-registry.tsv" + +usage() { + cat <<'EOF' +用法: + bash scripts/source-registry.sh fields + bash scripts/source-registry.sh list + bash scripts/source-registry.sh get + bash scripts/source-registry.sh match-url + bash scripts/source-registry.sh match-file + bash scripts/source-registry.sh list-by-category + bash scripts/source-registry.sh unique-dependencies + bash scripts/source-registry.sh validate +EOF +} + +require_file() { + local file="$1" + + [ -f "$file" ] || { + echo "缺少文件:$file" >&2 + exit 1 + } +} + +expect_header() { + local file="$1" + local expected="$2" + local actual + + actual="$(head -n 1 "$file")" + [ "$actual" = "$expected" ] || { + echo "表头不匹配:$file" >&2 + echo "期望:$expected" >&2 + echo "实际:$actual" >&2 + exit 1 + } +} + +validate_contract() { + require_file "$CONTRACT_FILE" + expect_header "$CONTRACT_FILE" $'field_name\trequiredness\tfilled_by\tvalue_rule' + + awk -F '\t' ' + BEGIN { + required["source_id"] = 1 + required["source_label"] = 1 + required["source_category"] = 1 + required["input_mode"] = 1 + required["raw_dir"] = 1 + required["original_ref"] = 1 + required["ingest_text"] = 1 + required["adapter_name"] = 1 + required["fallback_hint"] = 1 + } + NR == 1 { next } + { + if ($1 == "" || $2 == "" || $3 == "" || $4 == "") { + printf("source-record-contract.tsv 第 %d 行存在空字段\n", NR) > "/dev/stderr" + failed = 1 + } + + seen[$1] += 1 + } + END { + for (field in required) { + if (seen[field] != 1) { + printf("source-record-contract.tsv 缺少或重复字段:%s\n", field) > "/dev/stderr" + failed = 1 + } + } + + exit failed ? 1 : 0 + } + ' "$CONTRACT_FILE" +} + +validate_registry() { + require_file "$REGISTRY_FILE" + expect_header "$REGISTRY_FILE" $'source_id\tsource_label\tsource_category\tinput_mode\tmatch_rule\traw_dir\tadapter_name\tdependency_name\tdependency_type\tfallback_hint' + + awk -F '\t' ' + NR == 1 { next } + { + if ($1 == "" || $2 == "" || $3 == "" || $4 == "" || $5 == "" || $6 == "" || $10 == "") { + printf("source-registry.tsv 第 %d 行存在空字段\n", NR) > "/dev/stderr" + failed = 1 + } + + if ($3 != "core_builtin" && $3 != "optional_adapter" && $3 != "manual_only") { + printf("source-registry.tsv 第 %d 行存在未知分类:%s\n", NR, $3) > "/dev/stderr" + failed = 1 + } + + if ($4 != "url" && $4 != "file" && $4 != "text" && $4 != "asset") { + printf("source-registry.tsv 第 %d 行存在未知输入模式:%s\n", NR, $4) > "/dev/stderr" + failed = 1 + } + + if ($4 == "url" && $5 !~ /^url_host:/) { + printf("source-registry.tsv 第 %d 行 URL 来源必须声明 url_host 规则:%s\n", NR, $5) > "/dev/stderr" + failed = 1 + } + + if ($4 == "file" && $5 !~ /^file_ext:/) { + printf("source-registry.tsv 第 %d 行文件来源必须声明 file_ext 规则:%s\n", NR, $5) > "/dev/stderr" + failed = 1 + } + + if ($4 == "text" && $5 !~ /^text:/) { + printf("source-registry.tsv 第 %d 行文本来源必须声明 text 规则:%s\n", NR, $5) > "/dev/stderr" + failed = 1 + } + + if ($4 == "asset" && $5 !~ /^asset:/) { + printf("source-registry.tsv 第 %d 行附件来源必须声明 asset 规则:%s\n", NR, $5) > "/dev/stderr" + failed = 1 + } + + if ($6 !~ /^raw\//) { + printf("source-registry.tsv 第 %d 行 raw_dir 必须位于 raw/ 下:%s\n", NR, $6) > "/dev/stderr" + failed = 1 + } + + if (seen[$1]++) { + printf("source-registry.tsv source_id 重复:%s\n", $1) > "/dev/stderr" + failed = 1 + } + + category_seen[$3] = 1 + + if ($3 == "optional_adapter") { + if ($7 == "-" || $8 == "-" || $9 == "none") { + printf("source-registry.tsv 第 %d 行 optional_adapter 缺少依赖信息\n", NR) > "/dev/stderr" + failed = 1 + } + } else if ($7 != "-" || $8 != "-" || $9 != "none") { + printf("source-registry.tsv 第 %d 行非外挂来源不应声明依赖\n", NR) > "/dev/stderr" + failed = 1 + } + } + END { + if (!category_seen["core_builtin"]) { + print "source-registry.tsv 缺少 core_builtin 来源" > "/dev/stderr" + failed = 1 + } + + if (!category_seen["optional_adapter"]) { + print "source-registry.tsv 缺少 optional_adapter 来源" > "/dev/stderr" + failed = 1 + } + + if (!category_seen["manual_only"]) { + print "source-registry.tsv 缺少 manual_only 来源" > "/dev/stderr" + failed = 1 + } + + exit failed ? 1 : 0 + } + ' "$REGISTRY_FILE" +} + +print_contract() { + validate_contract + cat "$CONTRACT_FILE" +} + +print_registry() { + validate_registry + cat "$REGISTRY_FILE" +} + +get_source() { + local source_id="$1" + + validate_registry + + awk -F '\t' -v source_id="$source_id" ' + NR == 1 { next } + $1 == source_id { + print + found = 1 + } + END { + exit found ? 0 : 1 + } + ' "$REGISTRY_FILE" +} + +extract_url_host() { + local url="$1" + local rest host + + rest="${url#*://}" + if [ "$rest" = "$url" ]; then + rest="$url" + fi + + rest="${rest#*@}" + host="${rest%%/*}" + host="${host%%\?*}" + host="${host%%#*}" + host="${host%%:*}" + + printf '%s\n' "$host" | tr '[:upper:]' '[:lower:]' +} + +host_matches_pattern() { + local host="$1" + local pattern="$2" + + case "$host" in + "$pattern"|*."$pattern") + return 0 + ;; + *) + return 1 + ;; + esac +} + +match_url() { + local url="$1" + local host row source_id source_label source_category input_mode match_rule raw_dir adapter_name dependency_name dependency_type fallback_hint + local fallback_row="" + local pattern pattern_list + + validate_registry + host="$(extract_url_host "$url")" + + while IFS=$'\t' read -r source_id source_label source_category input_mode match_rule raw_dir adapter_name dependency_name dependency_type fallback_hint; do + [ "$source_id" = "source_id" ] && continue + [ "$input_mode" = "url" ] || continue + + pattern_list="${match_rule#url_host:}" + if [ "$pattern_list" = "*" ]; then + fallback_row="$(printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$source_id" "$source_label" "$source_category" "$input_mode" "$match_rule" "$raw_dir" "$adapter_name" "$dependency_name" "$dependency_type" "$fallback_hint")" + continue + fi + + for pattern in ${pattern_list//,/ }; do + if host_matches_pattern "$host" "$pattern"; then + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$source_id" "$source_label" "$source_category" "$input_mode" "$match_rule" "$raw_dir" "$adapter_name" "$dependency_name" "$dependency_type" "$fallback_hint" + return 0 + fi + done + done < "$REGISTRY_FILE" + + [ -n "$fallback_row" ] || return 1 + printf '%s\n' "$fallback_row" +} + +match_file() { + local path="$1" + local lowered_path source_id source_label source_category input_mode match_rule raw_dir adapter_name dependency_name dependency_type fallback_hint + local extension_list extension + + validate_registry + lowered_path="$(printf '%s\n' "$path" | tr '[:upper:]' '[:lower:]')" + + while IFS=$'\t' read -r source_id source_label source_category input_mode match_rule raw_dir adapter_name dependency_name dependency_type fallback_hint; do + [ "$source_id" = "source_id" ] && continue + [ "$input_mode" = "file" ] || continue + + extension_list="${match_rule#file_ext:}" + for extension in ${extension_list//,/ }; do + case "$lowered_path" in + *"$extension") + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$source_id" "$source_label" "$source_category" "$input_mode" "$match_rule" "$raw_dir" "$adapter_name" "$dependency_name" "$dependency_type" "$fallback_hint" + return 0 + ;; + esac + done + done < "$REGISTRY_FILE" + + return 1 +} + +list_by_category() { + local category="$1" + + validate_registry + + awk -F '\t' -v category="$category" ' + NR == 1 { next } + $3 == category { print } + ' "$REGISTRY_FILE" +} + +list_unique_dependencies() { + local dependency_type="$1" + + validate_registry + + awk -F '\t' -v dependency_type="$dependency_type" ' + NR == 1 { next } + $9 == dependency_type && $8 != "-" { print $8 } + ' "$REGISTRY_FILE" | sort -u +} + +command_name="${1:-}" + +case "$command_name" in + fields) + [ "$#" -eq 1 ] || { usage; exit 1; } + print_contract + ;; + list) + [ "$#" -eq 1 ] || { usage; exit 1; } + print_registry + ;; + get) + [ "$#" -eq 2 ] || { usage; exit 1; } + get_source "$2" + ;; + match-url) + [ "$#" -eq 2 ] || { usage; exit 1; } + match_url "$2" + ;; + match-file) + [ "$#" -eq 2 ] || { usage; exit 1; } + match_file "$2" + ;; + list-by-category) + [ "$#" -eq 2 ] || { usage; exit 1; } + list_by_category "$2" + ;; + unique-dependencies) + [ "$#" -eq 2 ] || { usage; exit 1; } + list_unique_dependencies "$2" + ;; + validate) + [ "$#" -eq 1 ] || { usage; exit 1; } + validate_contract + validate_registry + ;; + *) + usage + exit 1 + ;; +esac diff --git a/graphify/bundled_skills/llm-wiki/scripts/source-registry.tsv b/graphify/bundled_skills/llm-wiki/scripts/source-registry.tsv new file mode 100644 index 000000000..8972b2c56 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/source-registry.tsv @@ -0,0 +1,10 @@ +source_id source_label source_category input_mode match_rule raw_dir adapter_name dependency_name dependency_type fallback_hint +local_pdf PDF / 本地 PDF core_builtin file file_ext:.pdf raw/pdfs - - none 直接提供文件路径即可进入主线 +local_document Markdown/文本/HTML core_builtin file file_ext:.md,.txt,.html raw/notes - - none 直接提供文件路径即可进入主线 +plain_text 纯文本粘贴 core_builtin text text:inline raw/notes - - none 直接粘贴正文即可进入主线 +x_twitter X/Twitter optional_adapter url url_host:x.com,twitter.com raw/tweets baoyu-url-to-markdown baoyu-url-to-markdown bundled 自动提取失败时,改为复制全文粘贴 +wechat_article 微信公众号 optional_adapter url url_host:mp.weixin.qq.com raw/wechat wechat-article-to-markdown wechat-article-to-markdown install_time 自动提取失败时,在浏览器打开后复制全文粘贴 +youtube_video YouTube optional_adapter url url_host:youtube.com,youtu.be raw/articles youtube-transcript youtube-transcript bundled 自动提取失败时,提供字幕文件或手动粘贴文本 +zhihu_article 知乎 optional_adapter url url_host:zhihu.com raw/zhihu baoyu-url-to-markdown baoyu-url-to-markdown bundled 自动提取失败时,改为复制全文粘贴 +xiaohongshu_post 小红书 manual_only url url_host:xiaohongshu.com,xhslink.com raw/xiaohongshu - - none 请先从 App 或网页复制内容,再粘贴进来 +web_article 网页文章 optional_adapter url url_host:* raw/articles baoyu-url-to-markdown baoyu-url-to-markdown bundled 自动提取失败时,改为复制全文或保存为本地文件后继续 diff --git a/graphify/bundled_skills/llm-wiki/scripts/source-signal-coverage.js b/graphify/bundled_skills/llm-wiki/scripts/source-signal-coverage.js new file mode 100644 index 000000000..3e6e7f4ae --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/source-signal-coverage.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { + SCAN_KINDS, + extractFrontmatter, + evaluateSourceSignalEligibility +} = require("./lib/source-signal-eligibility"); + +function scanWiki(wikiRoot) { + const wikiDir = path.join(wikiRoot, "wiki"); + if (!fs.existsSync(wikiDir)) { + console.error(`ERROR: wiki 目录不存在:${wikiDir}`); + process.exit(1); + } + + const pages = []; + const summary = { + applicable_total: 0, + ok: 0, + missing_sources: 0, + empty_sources: 0, + invalid_sources: 0, + not_applicable: 0 + }; + + for (const kind of SCAN_KINDS) { + const dir = path.join(wikiDir, kind.subdir); + if (!fs.existsSync(dir)) continue; + + const files = fs.readdirSync(dir) + .filter((f) => f.endsWith(".md")) + .sort(); + + for (const file of files) { + const id = path.basename(file, ".md"); + if (["index", "log", "purpose", ".wiki-schema", "README"].includes(id)) continue; + + const filePath = path.join(dir, file); + const raw = fs.readFileSync(filePath, "utf8"); + const { frontmatter } = extractFrontmatter(raw); + const result = evaluateSourceSignalEligibility({ + pageType: kind.pageType, + frontmatter + }); + + pages.push({ + path: path.relative(wikiRoot, filePath), + id, + pageType: kind.pageType, + eligible: result.eligible, + reason: result.reason, + sourceCount: result.sources.length + }); + + summary[result.reason] += 1; + if (result.reason !== "not_applicable") { + summary.applicable_total += 1; + } + } + } + + return { summary, pages }; +} + +function main(argv) { + if (argv.length < 3) { + console.error("Usage: node scripts/source-signal-coverage.js "); + process.exit(1); + } + + const wikiRoot = path.resolve(argv[2]); + const result = scanWiki(wikiRoot); + console.log(JSON.stringify(result, null, 2)); +} + +if (require.main === module) { + main(process.argv); +} + +module.exports = { scanWiki }; diff --git a/graphify/bundled_skills/llm-wiki/scripts/validate-step1.sh b/graphify/bundled_skills/llm-wiki/scripts/validate-step1.sh new file mode 100755 index 000000000..fa9c8551a --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/validate-step1.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# 验证 ingest Step 1 的 JSON 输出格式 +# 用法:bash validate-step1.sh +# 返回:0 = 格式正确,1 = 格式有问题(触发回退) + +SCRIPT_DIR="${BASH_SOURCE[0]%/*}" +[ "$SCRIPT_DIR" = "${BASH_SOURCE[0]}" ] && SCRIPT_DIR="." +SCRIPT_DIR="$(cd "$SCRIPT_DIR" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/shared-config.sh" + +JSON_FILE="$1" + +# 参数检查 +[ -z "$1" ] && { echo "ERROR: usage: validate-step1.sh "; exit 1; } + +# 检查 jq 是否可用(必需依赖) +command -v jq >/dev/null 2>&1 || { + echo "ERROR: jq is not installed. Install it via:" >&2 + print_install_hint jq + exit 1 +} + +# 检查文件是否存在 +[ -f "$JSON_FILE" ] || { echo "ERROR: file not found: $JSON_FILE"; exit 1; } + +# 检查是否是有效 JSON +jq empty "$JSON_FILE" 2>/dev/null || { echo "ERROR: invalid JSON format"; exit 1; } + +# 检查必需字段存在且类型正确 +jq -e '.entities | type == "array"' "$JSON_FILE" >/dev/null 2>&1 || { echo "ERROR: 'entities' must be an array"; exit 1; } +jq -e '.topics | type == "array"' "$JSON_FILE" >/dev/null 2>&1 || { echo "ERROR: 'topics' must be an array"; exit 1; } +jq -e '.connections | type == "array"' "$JSON_FILE" >/dev/null 2>&1 || { echo "ERROR: 'connections' must be an array"; exit 1; } +jq -e '.contradictions | type == "array"' "$JSON_FILE" >/dev/null 2>&1 || { echo "ERROR: 'contradictions' must be an array"; exit 1; } +jq -e '.new_vs_existing | type == "object"' "$JSON_FILE" >/dev/null 2>&1 || { echo "ERROR: 'new_vs_existing' must be an object"; exit 1; } + +# 检查每个 entity 的必需子字段 +VALID_CONFIDENCE="EXTRACTED|INFERRED|AMBIGUOUS|UNVERIFIED" + +ENTITY_COUNT=$(jq '.entities | length' "$JSON_FILE" 2>/dev/null) +if [ "$ENTITY_COUNT" -gt 0 ] 2>/dev/null; then + NON_OBJECT_ENTITY_COUNT=$(jq '[.entities[] | select(type != "object")] | length' "$JSON_FILE" 2>/dev/null) + if [ "$NON_OBJECT_ENTITY_COUNT" -gt 0 ] 2>/dev/null; then + echo "ERROR: $NON_OBJECT_ENTITY_COUNT entity/entities must be objects" + exit 1 + fi + + # name, type, confidence 必须存在且非空 + BAD_ENTITY_COUNT=$(jq ' + [.entities[] | select( + (.name // "" | length) == 0 or + (.type // "" | length) == 0 or + (.confidence // "" | length) == 0 + )] | length + ' "$JSON_FILE" 2>/dev/null) + if [ "$BAD_ENTITY_COUNT" -gt 0 ] 2>/dev/null; then + echo "ERROR: $BAD_ENTITY_COUNT entity/entities missing required fields (name/type/confidence)" + exit 1 + fi + + # confidence 值必须是四个有效值之一 + INVALID=$(jq -r '.entities[]? | (.confidence // "MISSING")' "$JSON_FILE" 2>/dev/null | \ + grep -v -E "^($VALID_CONFIDENCE)$" | head -3) + if [ -n "$INVALID" ]; then + echo "ERROR: invalid entity confidence value(s): $INVALID" + echo " Valid values: EXTRACTED | INFERRED | AMBIGUOUS | UNVERIFIED" + exit 1 + fi + + # EXTRACTED 和 INFERRED 必须提供 evidence 字段 + NO_EVIDENCE_COUNT=$(jq ' + [.entities[] | select( + (.confidence == "EXTRACTED" or .confidence == "INFERRED") and + ((.evidence // "" | length) == 0) + )] | length + ' "$JSON_FILE" 2>/dev/null) + if [ "$NO_EVIDENCE_COUNT" -gt 0 ] 2>/dev/null; then + echo "WARN: $NO_EVIDENCE_COUNT entity/entities with EXTRACTED/INFERRED confidence missing 'evidence' field" + fi +fi + +# 检查每个 topic 的必需子字段 +TOPIC_COUNT=$(jq '.topics | length' "$JSON_FILE" 2>/dev/null) +if [ "$TOPIC_COUNT" -gt 0 ] 2>/dev/null; then + NON_OBJECT_TOPIC_COUNT=$(jq '[.topics[] | select(type != "object")] | length' "$JSON_FILE" 2>/dev/null) + if [ "$NON_OBJECT_TOPIC_COUNT" -gt 0 ] 2>/dev/null; then + echo "ERROR: $NON_OBJECT_TOPIC_COUNT topic(s) must be objects" + exit 1 + fi + + BAD_TOPIC_COUNT=$(jq ' + [.topics[] | select( + (.name // "" | length) == 0 + )] | length + ' "$JSON_FILE" 2>/dev/null) + if [ "$BAD_TOPIC_COUNT" -gt 0 ] 2>/dev/null; then + echo "ERROR: $BAD_TOPIC_COUNT topic(s) missing required 'name' field" + exit 1 + fi +fi + +# 检查每个 connection 的必需子字段(from, to, confidence) +CONN_COUNT=$(jq '.connections | length' "$JSON_FILE" 2>/dev/null) +if [ "$CONN_COUNT" -gt 0 ] 2>/dev/null; then + NON_OBJECT_CONN_COUNT=$(jq '[.connections[] | select(type != "object")] | length' "$JSON_FILE" 2>/dev/null) + if [ "$NON_OBJECT_CONN_COUNT" -gt 0 ] 2>/dev/null; then + echo "ERROR: $NON_OBJECT_CONN_COUNT connection(s) must be objects" + exit 1 + fi + + BAD_CONN_COUNT=$(jq ' + [.connections[] | select( + (.from // "" | length) == 0 or + (.to // "" | length) == 0 or + (.confidence // "" | length) == 0 + )] | length + ' "$JSON_FILE" 2>/dev/null) + if [ "$BAD_CONN_COUNT" -gt 0 ] 2>/dev/null; then + echo "ERROR: $BAD_CONN_COUNT connection(s) missing required fields (from/to/confidence)" + exit 1 + fi + + INVALID_CONN_CONF=$(jq -r '.connections[]? | (.confidence // "MISSING")' "$JSON_FILE" 2>/dev/null | \ + grep -v -E "^($VALID_CONFIDENCE)$" | head -3) + if [ -n "$INVALID_CONN_CONF" ]; then + echo "ERROR: invalid connection confidence value(s): $INVALID_CONN_CONF" + echo " Valid values: EXTRACTED | INFERRED | AMBIGUOUS | UNVERIFIED" + exit 1 + fi + + # EXTRACTED 和 INFERRED connections 必须提供 evidence + NO_CONN_EVIDENCE=$(jq ' + [.connections[] | select( + (.confidence == "EXTRACTED" or .confidence == "INFERRED") and + ((.evidence // "" | length) == 0) + )] | length + ' "$JSON_FILE" 2>/dev/null) + if [ "$NO_CONN_EVIDENCE" -gt 0 ] 2>/dev/null; then + echo "WARN: $NO_CONN_EVIDENCE connection(s) with EXTRACTED/INFERRED confidence missing 'evidence' field" + fi +fi + +echo "OK: Step 1 JSON validation passed" +exit 0 diff --git a/graphify/bundled_skills/llm-wiki/scripts/wiki-compat.sh b/graphify/bundled_skills/llm-wiki/scripts/wiki-compat.sh new file mode 100755 index 000000000..aa0d7572c --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/scripts/wiki-compat.sh @@ -0,0 +1,267 @@ +#!/bin/bash +# 旧知识库兼容脚本:惰性默认、目录检查、按需创建 +# 原则:migration_required=no,只有确实无法兼容时才引入显式迁移 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SOURCE_REGISTRY_SCRIPT="$SCRIPT_DIR/source-registry.sh" + +LEGACY_REQUIRED_RAW_DIRS=( + "raw/articles" + "raw/tweets" + "raw/wechat" + "raw/pdfs" + "raw/notes" + "raw/assets" +) + +REQUIRED_PATHS=( + ".wiki-schema.md" + "index.md" + "log.md" + "raw" + "wiki" + "wiki/entities" + "wiki/topics" + "wiki/sources" + "wiki/comparisons" + "wiki/synthesis" + "wiki/overview.md" +) + +usage() { + cat <<'EOF' +用法: + bash scripts/wiki-compat.sh inspect + bash scripts/wiki-compat.sh validate + bash scripts/wiki-compat.sh ensure-source-dir +EOF +} + +trim() { + printf '%s' "$1" | awk '{ gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0); printf "%s", $0 }' +} + +require_wiki_root() { + local wiki_root="$1" + + [ -n "$wiki_root" ] || { + usage + exit 1 + } + + [ -d "$wiki_root" ] || { + echo "知识库不存在:$wiki_root" >&2 + exit 1 + } +} + +schema_field_value() { + local wiki_root="$1" + local field_name="$2" + local default_value="$3" + local schema_path value + + schema_path="$wiki_root/.wiki-schema.md" + + if [ ! -f "$schema_path" ]; then + printf '%s\n' "$default_value" + return 0 + fi + + value="$( + awk -v field_name="$field_name" ' + $0 ~ "^-[[:space:]]*" field_name "[::]" { + line = $0 + sub("^-[[:space:]]*" field_name "[::][[:space:]]*", "", line) + print line + exit + } + ' "$schema_path" + )" + + value="$(trim "$value")" + + if [ -n "$value" ]; then + printf '%s\n' "$value" + else + printf '%s\n' "$default_value" + fi +} + +resolved_language() { + local wiki_root="$1" + local raw_value + + raw_value="$(schema_field_value "$wiki_root" "语言" "")" + + case "$raw_value" in + English|english|EN|en) + printf 'en\n' + ;; + *) + printf 'zh\n' + ;; + esac +} + +resolved_schema_version() { + local wiki_root="$1" + + schema_field_value "$wiki_root" "版本" "1.0" +} + +is_legacy_required_raw_dir() { + case "$1" in + raw/articles|raw/tweets|raw/wechat|raw/pdfs|raw/notes|raw/assets) + return 0 + ;; + *) + return 1 + ;; + esac +} + +missing_optional_raw_dirs() { + local wiki_root="$1" + local raw_dir + local missing=() + + while IFS= read -r raw_dir; do + [ -n "$raw_dir" ] || continue + + if is_legacy_required_raw_dir "$raw_dir"; then + continue + fi + + if [ ! -d "$wiki_root/$raw_dir" ]; then + missing+=("$raw_dir") + fi + done < <( + bash "$SOURCE_REGISTRY_SCRIPT" list | awk -F '\t' 'NR > 1 { print $6 }' | LC_ALL=C sort -u + ) + + if [ "${#missing[@]}" -eq 0 ]; then + printf '%s\n' '-' + else + local IFS=, + printf '%s\n' "${missing[*]}" + fi +} + +file_presence() { + local wiki_root="$1" + local relative_path="$2" + + if [ -e "$wiki_root/$relative_path" ]; then + printf 'present\n' + else + printf 'missing\n' + fi +} + +validate_layout() { + local wiki_root="$1" + local failed=0 + local path + + require_wiki_root "$wiki_root" + + for path in "${REQUIRED_PATHS[@]}"; do + if [ ! -e "$wiki_root/$path" ]; then + echo "缺少必要路径:$path" >&2 + failed=1 + fi + done + + for path in "${LEGACY_REQUIRED_RAW_DIRS[@]}"; do + if [ ! -d "$wiki_root/$path" ]; then + echo "缺少必要旧目录:$path" >&2 + failed=1 + fi + done + + if [ "$failed" -ne 0 ]; then + exit 1 + fi +} + +source_raw_dir() { + local source_id="$1" + local record raw_dir + + record="$( + bash "$SOURCE_REGISTRY_SCRIPT" get "$source_id" 2>/dev/null + )" || { + echo "未知来源:$source_id" >&2 + exit 1 + } + + IFS=$'\t' read -r _ _ _ _ _ raw_dir _ _ _ _ < /dev/null + ;; + ensure-source-dir) + [ "$#" -eq 3 ] || { usage; exit 1; } + ensure_source_dir "$2" "$3" + ;; + *) + usage + exit 1 + ;; +esac diff --git a/graphify/bundled_skills/llm-wiki/setup.sh b/graphify/bundled_skills/llm-wiki/setup.sh new file mode 100755 index 000000000..8dbdae1b6 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/setup.sh @@ -0,0 +1,8 @@ +# 已废弃:请使用 bash install.sh --platform claude +#!/bin/bash +# Claude 旧入口兼容包装 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +exec bash "$SCRIPT_DIR/install.sh" --platform claude "$@" diff --git a/graphify/bundled_skills/llm-wiki/templates/entity-template.md b/graphify/bundled_skills/llm-wiki/templates/entity-template.md new file mode 100644 index 000000000..50a29a5dd --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/templates/entity-template.md @@ -0,0 +1,30 @@ +--- +tags: [实体] +created: {{DATE}} +updated: {{DATE}} +sources: [] +--- + +# {{ENTITY_NAME}} + +> 一句话描述这个实体是什么 + +## 简介 + +(这个实体的基本介绍) + +## 关键信息 + +- **类型**:(人物 / 组织 / 概念 / 工具 / 事件) +- **领域**:(所属领域) +- **相关概念**:(关联的其他实体) + +## 详细内容 + +(从素材中提取的关于这个实体的详细信息) + +## 不同素材中的观点 + +(不同素材对同一个实体的不同描述或评价,标注来源) + +## 相关页面 diff --git a/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/footer.html b/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/footer.html new file mode 100644 index 000000000..5cc0bdbdf --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/footer.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/graph-wash-helpers.js b/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/graph-wash-helpers.js new file mode 100644 index 000000000..03d0add8b --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/graph-wash-helpers.js @@ -0,0 +1,1326 @@ +/* DEPRECATED: Legacy wash helpers retained for one release cycle after Stage 4 engine HTML migration. Do not extend; use packages/graph-engine instead. */ +(function (root) { + "use strict"; + + var LABEL_CJK_WIDTH = 15; + var LABEL_LATIN_WIDTH = 8.5; + var LABEL_PADDING = 22; + var LABEL_MIN_WIDTH = 72; + var LABEL_MAX_WIDTH = 180; + var LABEL_ELLIPSIS = "…"; + var LABEL_ELLIPSIS_WIDTH = 8; + var ATLAS_WORLD_WIDTH = 1000; + var ATLAS_WORLD_HEIGHT = 680; + var ATLAS_MIN_SCALE = 0.62; + var ATLAS_MAX_SCALE = 3.2; + var MINIMAP_VIEWBOX = { x: 5, y: 3, width: 150, height: 48 }; + + var labelSegmenter = + typeof Intl !== "undefined" && Intl.Segmenter + ? new Intl.Segmenter("zh", { granularity: "grapheme" }) + : null; + + function isVariationSelector(grapheme) { + var code = grapheme.codePointAt(0); + return code >= 0xFE00 && code <= 0xFE0F; + } + + function isCombiningMark(grapheme) { + var code = grapheme.codePointAt(0); + return (code >= 0x0300 && code <= 0x036F) + || (code >= 0x1AB0 && code <= 0x1AFF) + || (code >= 0x1DC0 && code <= 0x1DFF) + || (code >= 0x20D0 && code <= 0x20FF) + || (code >= 0xFE20 && code <= 0xFE2F); + } + + function isEmojiModifier(grapheme) { + var code = grapheme.codePointAt(0); + return code >= 0x1F3FB && code <= 0x1F3FF; + } + + function splitLabelGraphemes(label) { + if (labelSegmenter) { + return Array.from(labelSegmenter.segment(label), function (s) { + return s.segment; + }); + } + + var parts = Array.from(label); + if (!parts.length) return []; + + var graphemes = [parts[0]]; + for (var i = 1; i < parts.length; i++) { + var current = parts[i]; + var previous = parts[i - 1]; + if ( + current === "‍" + || previous === "‍" + || isVariationSelector(current) + || isCombiningMark(current) + || isEmojiModifier(current) + ) { + graphemes[graphemes.length - 1] += current; + } else { + graphemes.push(current); + } + } + + return graphemes; + } + + function labelCharWidth(grapheme) { + return /[一-鿿]/.test(grapheme) ? LABEL_CJK_WIDTH : LABEL_LATIN_WIDTH; + } + + function measureLabelWidth(graphemes) { + var width = 0; + for (var i = 0; i < graphemes.length; i++) { + width += labelCharWidth(graphemes[i]); + } + return width; + } + + function truncateLabel(label, maxWidth) { + if (!label || typeof label !== "string") { + return { text: "", truncated: false }; + } + + var graphemes = splitLabelGraphemes(label); + var totalWidth = measureLabelWidth(graphemes); + if (totalWidth + LABEL_PADDING <= maxWidth) { + return { text: label, truncated: false }; + } + + var out = ""; + var width = 0; + for (var i = 0; i < graphemes.length; i++) { + var gw = labelCharWidth(graphemes[i]); + if (width + gw + LABEL_ELLIPSIS_WIDTH + LABEL_PADDING > maxWidth) break; + out += graphemes[i]; + width += gw; + } + return { text: out + LABEL_ELLIPSIS, truncated: true }; + } + + function cardDims(n) { + var label = n.label || n.id; + var widthByLabel = measureLabelWidth(splitLabelGraphemes(label)); + var width = Math.max(LABEL_MIN_WIDTH, Math.min(LABEL_MAX_WIDTH, widthByLabel + LABEL_PADDING)); + var height = 36; + if (n.type === "topic") { height = 40; width += 6; } + if (n.type === "source") { height = 32; } + return { w: width, h: height }; + } + + function createSafeStorage(storage, logger) { + return { + get: function (key) { + try { return storage.getItem(key); } + catch (err) { if (logger) logger("[wiki] storage.get failed:", key, err); return null; } + }, + set: function (key, value) { + try { storage.setItem(key, value); } + catch (err) { if (logger) logger("[wiki] storage.set failed:", key, err); } + } + }; + } + + function normalizeStorageSegment(value) { + return String(value == null ? "" : value) + .trim() + .toLowerCase() + .replace(/[^a-z0-9一-鿿]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + } + + function hashString(value) { + var input = String(value == null ? "" : value); + var hash = 0; + for (var i = 0; i < input.length; i++) { + hash = ((hash << 5) - hash + input.charCodeAt(i)) >>> 0; + } + return hash.toString(36); + } + + function getWikiStorageNamespace(meta, pathname) { + var title = normalizeStorageSegment(meta && meta.wiki_title ? meta.wiki_title : ""); + var basis = typeof pathname === "string" && pathname + ? pathname + : (meta && meta.wiki_title) || title || "default"; + return "llm-wiki:" + (title || "default") + ":" + hashString(basis); + } + + function defaultQueue() { + return { + version: 1, + favorites: [], + notes: [], + recentNoteIds: [] + }; + } + + function normalizeQueue(raw) { + if (!raw || typeof raw !== "object") return defaultQueue(); + var d = defaultQueue(); + var favorites = Array.isArray(raw.favorites) ? raw.favorites : d.favorites; + var notes = Array.isArray(raw.notes) ? raw.notes : d.notes; + var recentNoteIds = Array.isArray(raw.recentNoteIds) ? raw.recentNoteIds : d.recentNoteIds; + var seenFavorites = {}; + var normalizedFavorites = favorites + .map(function (nodeId) { + return nodeId == null ? null : String(nodeId); + }) + .filter(function (nodeId) { + if (!nodeId || seenFavorites[nodeId]) return false; + seenFavorites[nodeId] = true; + return true; + }); + var normalizedNotes = notes + .map(function (note) { + if (!note || typeof note !== "object" || note.id == null || note.node_id == null) return null; + return { + id: String(note.id), + node_id: String(note.node_id), + label: note.label == null ? String(note.node_id) : String(note.label), + text: note.text == null ? "" : String(note.text), + created_at: note.created_at == null ? null : String(note.created_at) + }; + }) + .filter(function (note) { + return !!note; + }); + var noteIdSet = {}; + for (var i = 0; i < normalizedNotes.length; i++) { + noteIdSet[normalizedNotes[i].id] = true; + } + var seenRecent = {}; + var normalizedRecentNoteIds = recentNoteIds + .map(function (noteId) { + return noteId == null ? null : String(noteId); + }) + .filter(function (noteId) { + if (!noteId || !noteIdSet[noteId] || seenRecent[noteId]) return false; + seenRecent[noteId] = true; + return true; + }); + if (!normalizedRecentNoteIds.length && normalizedNotes.length) { + normalizedRecentNoteIds = normalizedNotes.map(function (note) { + return note.id; + }); + } + return { + version: d.version, + favorites: normalizedFavorites, + notes: normalizedNotes, + recentNoteIds: normalizedRecentNoteIds + }; + } + + function toggleQueueFavorite(queue, nodeId) { + var safe = normalizeQueue(queue); + var favoriteNodeId = nodeId == null ? "" : String(nodeId); + if (!favoriteNodeId) return safe; + var favorites = safe.favorites.slice(); + var existingIndex = favorites.indexOf(favoriteNodeId); + if (existingIndex === -1) { + favorites.unshift(favoriteNodeId); + } else { + favorites.splice(existingIndex, 1); + } + return { + version: safe.version, + favorites: favorites, + notes: safe.notes.slice(), + recentNoteIds: safe.recentNoteIds.slice() + }; + } + + function appendQueueNote(queue, note, limit) { + var safe = normalizeQueue(queue); + if (!note || typeof note !== "object" || note.id == null || note.node_id == null) return safe; + var noteLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.round(Number(limit))) : 50; + var normalizedNote = { + id: String(note.id), + node_id: String(note.node_id), + label: note.label == null ? String(note.node_id) : String(note.label), + text: note.text == null ? "" : String(note.text), + created_at: note.created_at == null ? null : String(note.created_at) + }; + var notes = [normalizedNote].concat(safe.notes.filter(function (item) { + return item.id !== normalizedNote.id; + })).slice(0, noteLimit); + var recentNoteIds = [normalizedNote.id].concat(safe.recentNoteIds.filter(function (noteId) { + return noteId !== normalizedNote.id; + })).slice(0, Math.min(noteLimit, 12)); + return { + version: safe.version, + favorites: safe.favorites.slice(), + notes: notes, + recentNoteIds: recentNoteIds + }; + } + + function summarizeQueue(queue, nodesById, limit) { + var safe = normalizeQueue(queue); + var maxItems = Number.isFinite(Number(limit)) ? Math.max(1, Math.round(Number(limit))) : 4; + var byId = nodesById && typeof nodesById === "object" ? nodesById : {}; + var notesById = {}; + var recentItems = []; + var i; + + for (i = 0; i < safe.notes.length; i++) { + notesById[safe.notes[i].id] = safe.notes[i]; + } + + for (i = 0; i < safe.recentNoteIds.length && recentItems.length < maxItems; i++) { + var note = notesById[safe.recentNoteIds[i]]; + if (!note) continue; + var noteNode = byId[note.node_id]; + recentItems.push({ + kind: "note", + node_id: note.node_id, + label: note.label || (noteNode && (noteNode.label || noteNode.id)) || note.node_id, + text: note.text || "" + }); + } + + for (i = 0; i < safe.favorites.length && recentItems.length < maxItems; i++) { + var favoriteNodeId = safe.favorites[i]; + var favoriteNode = byId[favoriteNodeId]; + recentItems.push({ + kind: "favorite", + node_id: favoriteNodeId, + label: favoriteNode && (favoriteNode.label || favoriteNode.id) ? (favoriteNode.label || favoriteNode.id) : favoriteNodeId, + text: "" + }); + } + + return { + favorite_count: safe.favorites.length, + note_count: safe.notes.length, + recent_items: recentItems + }; + } + + function defaultLearning() { + return { + version: 1, + entry: { recommended_start_node_id: null, recommended_start_reason: null, default_mode: "global" }, + views: { + path: { enabled: false, start_node_id: null, node_ids: [], degraded: true }, + community: { enabled: false, community_id: null, label: null, node_ids: [], is_weak: false, degraded: true }, + global: { enabled: true, node_ids: [], degraded: false } + }, + communities: [], + degraded: { path_to_community: true, community_to_global: true } + }; + } + + function normalizeLearning(raw) { + if (!raw || typeof raw !== "object") return defaultLearning(); + var d = defaultLearning(); + function pick(obj, key, fallback) { + return obj && obj[key] != null ? obj[key] : fallback; + } + return { + version: pick(raw, "version", d.version), + entry: { + recommended_start_node_id: pick(raw.entry, "recommended_start_node_id", d.entry.recommended_start_node_id), + recommended_start_reason: pick(raw.entry, "recommended_start_reason", d.entry.recommended_start_reason), + default_mode: pick(raw.entry, "default_mode", d.entry.default_mode) + }, + views: { + path: { + enabled: pick(raw.views && raw.views.path, "enabled", d.views.path.enabled), + start_node_id: pick(raw.views && raw.views.path, "start_node_id", d.views.path.start_node_id), + node_ids: Array.isArray(raw.views && raw.views.path && raw.views.path.node_ids) ? raw.views.path.node_ids : d.views.path.node_ids, + degraded: pick(raw.views && raw.views.path, "degraded", d.views.path.degraded) + }, + community: { + enabled: pick(raw.views && raw.views.community, "enabled", d.views.community.enabled), + community_id: pick(raw.views && raw.views.community, "community_id", d.views.community.community_id), + label: pick(raw.views && raw.views.community, "label", d.views.community.label), + node_ids: Array.isArray(raw.views && raw.views.community && raw.views.community.node_ids) ? raw.views.community.node_ids : d.views.community.node_ids, + is_weak: pick(raw.views && raw.views.community, "is_weak", d.views.community.is_weak), + degraded: pick(raw.views && raw.views.community, "degraded", d.views.community.degraded) + }, + global: { + enabled: pick(raw.views && raw.views.global, "enabled", d.views.global.enabled), + node_ids: Array.isArray(raw.views && raw.views.global && raw.views.global.node_ids) ? raw.views.global.node_ids : d.views.global.node_ids, + degraded: pick(raw.views && raw.views.global, "degraded", d.views.global.degraded) + } + }, + communities: Array.isArray(raw.communities) ? raw.communities : d.communities, + degraded: { + path_to_community: pick(raw.degraded, "path_to_community", d.degraded.path_to_community), + community_to_global: pick(raw.degraded, "community_to_global", d.degraded.community_to_global) + } + }; + } + + function resolveInitialMode(learning) { + return "global"; + } + + function getCommunityNodeIds(nodes, communityId) { + if (!Array.isArray(nodes) || !communityId) return []; + return nodes + .filter(function (node) { + return node && node.community != null && String(node.community) === String(communityId); + }) + .map(function (node) { + return node.id; + }) + .sort(); + } + + function getVisibleNodeIds(learning, mode) { + if (!learning || !learning.views) return []; + var view = learning.views[mode]; + if (!view || !view.enabled) return []; + return Array.isArray(view.node_ids) ? view.node_ids : []; + } + + function getVisibleLinks(allLinks, visibleIds) { + if (!visibleIds || !visibleIds.length) return allLinks; + var idSet = {}; + for (var i = 0; i < visibleIds.length; i++) idSet[visibleIds[i]] = true; + return allLinks.filter(function (l) { + var s = l.source.id || l.source; + var t = l.target.id || l.target; + return idSet[s] && idSet[t]; + }); + } + + function buildSearchHaystack(node) { + return ((node && (node.label || node.id || "")) + "\n" + (((node && node.content) || "").slice(0, 500))).toLowerCase(); + } + + function buildSearchIndex(nodes) { + if (!Array.isArray(nodes)) return []; + return nodes.map(function (node) { + return { node: node, haystack: buildSearchHaystack(node) }; + }); + } + + function filterLinksByTypes(allLinks, filters) { + if (!Array.isArray(allLinks)) return []; + if (!filters || typeof filters !== "object") return allLinks.slice(); + return allLinks.filter(function (link) { + var type = link && link.type ? link.type : "EXTRACTED"; + return filters[type] !== false; + }); + } + + function applySearchToNodeIds(searchIndex, query) { + if (!Array.isArray(searchIndex)) return []; + var normalizedQuery = typeof query === "string" ? query.trim().toLowerCase() : ""; + var matches = !normalizedQuery + ? searchIndex + : searchIndex.filter(function (entry) { + return entry && typeof entry.haystack === "string" && entry.haystack.indexOf(normalizedQuery) !== -1; + }); + return matches + .map(function (entry) { + return entry && entry.node ? entry.node.id : null; + }) + .filter(function (id) { + return id != null; + }); + } + + function getLinkEndpointIds(link) { + return { + sourceId: link && link.source && link.source.id ? link.source.id : link && link.source, + targetId: link && link.target && link.target.id ? link.target.id : link && link.target + }; + } + + function sortNodeIdsByScore(nodeIds, scores, nodesById) { + return nodeIds.slice().sort(function (left, right) { + var scoreDiff = (scores[right] || 0) - (scores[left] || 0); + if (scoreDiff) return scoreDiff; + var leftDegree = nodesById[left] && Number.isFinite(Number(nodesById[left].degree)) ? Number(nodesById[left].degree) : 0; + var rightDegree = nodesById[right] && Number.isFinite(Number(nodesById[right].degree)) ? Number(nodesById[right].degree) : 0; + if (rightDegree !== leftDegree) return rightDegree - leftDegree; + return String(left).localeCompare(String(right)); + }); + } + + function applyFocusMode(options) { + var safe = options && typeof options === "object" ? options : {}; + var nodes = Array.isArray(safe.nodes) ? safe.nodes : []; + var links = Array.isArray(safe.links) ? safe.links : []; + var nodeIds = Array.isArray(safe.nodeIds) ? safe.nodeIds.slice() : []; + var mode = safe.mode || "all"; + var anchorNodeId = safe.anchorNodeId != null ? String(safe.anchorNodeId) : null; + var highConfidenceThreshold = Number.isFinite(Number(safe.highConfidenceThreshold)) ? Number(safe.highConfidenceThreshold) : 0.75; + var nodesById = {}; + var idSet = {}; + var i; + + for (i = 0; i < nodes.length; i++) { + if (nodes[i] && nodes[i].id != null) nodesById[nodes[i].id] = nodes[i]; + } + for (i = 0; i < nodeIds.length; i++) idSet[nodeIds[i]] = true; + + if (!nodeIds.length) return { node_ids: [], links: [] }; + + var scopedLinks = getVisibleLinks(links, nodeIds); + if (mode === "all") return { node_ids: nodeIds.slice(), links: scopedLinks }; + + if (mode === "high_confidence") { + var strongLinks = scopedLinks.filter(function (link) { + var weight = Number(link && link.weight); + return Number.isFinite(weight) && weight >= highConfidenceThreshold; + }); + var strongIdSet = {}; + for (i = 0; i < strongLinks.length; i++) { + var strongEdge = getLinkEndpointIds(strongLinks[i]); + if (idSet[strongEdge.sourceId]) strongIdSet[strongEdge.sourceId] = true; + if (idSet[strongEdge.targetId]) strongIdSet[strongEdge.targetId] = true; + } + if (anchorNodeId && idSet[anchorNodeId]) strongIdSet[anchorNodeId] = true; + var strongNodeIds = nodeIds.filter(function (id) { + return !!strongIdSet[id]; + }); + return { node_ids: strongNodeIds, links: getVisibleLinks(strongLinks, strongNodeIds) }; + } + + if (mode === "one_hop") { + var hopAnchorNodeId = anchorNodeId && idSet[anchorNodeId] ? anchorNodeId : nodeIds[0] || null; + if (!hopAnchorNodeId) return { node_ids: [], links: [] }; + var hopIdSet = {}; + hopIdSet[hopAnchorNodeId] = true; + for (i = 0; i < scopedLinks.length; i++) { + var hopEdge = getLinkEndpointIds(scopedLinks[i]); + if (hopEdge.sourceId === hopAnchorNodeId && idSet[hopEdge.targetId]) hopIdSet[hopEdge.targetId] = true; + if (hopEdge.targetId === hopAnchorNodeId && idSet[hopEdge.sourceId]) hopIdSet[hopEdge.sourceId] = true; + } + var hopNodeIds = nodeIds.filter(function (id) { + return !!hopIdSet[id]; + }); + return { node_ids: hopNodeIds, links: getVisibleLinks(scopedLinks, hopNodeIds) }; + } + + if (mode === "core") { + if (nodeIds.length <= 3) return { node_ids: nodeIds.slice(), links: scopedLinks }; + var scores = {}; + for (i = 0; i < nodeIds.length; i++) scores[nodeIds[i]] = 0; + for (i = 0; i < scopedLinks.length; i++) { + var coreEdge = getLinkEndpointIds(scopedLinks[i]); + var weight = Number(scopedLinks[i] && scopedLinks[i].weight); + var score = Number.isFinite(weight) ? 1 + weight : 1.5; + if (scores[coreEdge.sourceId] != null) scores[coreEdge.sourceId] += score; + if (scores[coreEdge.targetId] != null) scores[coreEdge.targetId] += score; + } + var coreLimit = Number.isFinite(Number(safe.coreLimit)) ? Number(safe.coreLimit) : Math.max(3, Math.min(8, Math.round(nodeIds.length * 0.5))); + coreLimit = Math.max(1, Math.min(nodeIds.length, Math.round(coreLimit))); + var coreNodeIds = sortNodeIdsByScore(nodeIds, scores, nodesById).slice(0, coreLimit); + return { node_ids: coreNodeIds, links: getVisibleLinks(scopedLinks, coreNodeIds) }; + } + + return { node_ids: nodeIds.slice(), links: scopedLinks }; + } + + function resolveVisibleSnapshot(options) { + var safe = options && typeof options === "object" ? options : {}; + var nodes = Array.isArray(safe.nodes) ? safe.nodes : []; + var links = Array.isArray(safe.links) ? safe.links : []; + var baseNodeIds = Array.isArray(safe.baseNodeIds) + ? safe.baseNodeIds.slice() + : nodes.map(function (node) { return node.id; }); + var filteredLinks = filterLinksByTypes(links, safe.filters); + var scopedLinks = getVisibleLinks(filteredLinks, baseNodeIds); + var focusResult = applyFocusMode({ + mode: safe.focusMode, + nodes: nodes, + links: scopedLinks, + nodeIds: baseNodeIds, + anchorNodeId: safe.anchorNodeId, + highConfidenceThreshold: safe.highConfidenceThreshold, + coreLimit: safe.coreLimit + }); + var focusNodeIds = focusResult.node_ids || []; + if (!focusNodeIds.length && safe.focusMode && safe.focusMode !== "all") { + return { node_ids: [], nodes: [], links: [], searchIndex: [] }; + } + if (!focusNodeIds.length) focusNodeIds = baseNodeIds; + var focusNodes = nodes.filter(function (node) { + return focusNodeIds.indexOf(node.id) !== -1; + }); + var searchIndex = buildSearchIndex(focusNodes); + var query = typeof safe.searchQuery === "string" ? safe.searchQuery.trim() : ""; + var finalNodeIds = query ? applySearchToNodeIds(searchIndex, query) : focusNodeIds; + var idSet = {}; + for (var i = 0; i < finalNodeIds.length; i++) idSet[finalNodeIds[i]] = true; + return { + node_ids: finalNodeIds, + nodes: nodes.filter(function (node) { + return !!idSet[node.id]; + }), + links: finalNodeIds.length + ? getVisibleLinks(focusResult.links && focusResult.links.length ? focusResult.links : scopedLinks, finalNodeIds) + : [], + searchIndex: searchIndex + }; + } + + function shouldAutoOpenDrawer(mode) { + return mode === "path"; + } + + var ATLAS_CONFIDENCE_LABELS = { + EXTRACTED: "直接提取", + INFERRED: "推断关联", + AMBIGUOUS: "存在歧义", + UNVERIFIED: "未核实" + }; + + var ATLAS_TYPE_LABELS = { + topic: "主题", + entity: "实体", + source: "来源" + }; + + var ATLAS_TYPE_KINDS = { + topic: "TOPIC", + entity: "ENTITY", + source: "SOURCE" + }; + + function normalizeAtlasType(type) { + var normalized = String(type || "entity").toLowerCase(); + return ATLAS_TYPE_LABELS[normalized] ? normalized : "entity"; + } + + function normalizeAtlasConfidence(confidence) { + var normalized = String(confidence || "EXTRACTED").toUpperCase(); + return ATLAS_CONFIDENCE_LABELS[normalized] ? normalized : "EXTRACTED"; + } + + function atlasConfidenceLabel(confidence) { + var normalized = normalizeAtlasConfidence(confidence); + return ATLAS_CONFIDENCE_LABELS[normalized]; + } + + function atlasTypeLabel(type) { + var normalized = normalizeAtlasType(type); + return ATLAS_TYPE_LABELS[normalized]; + } + + function atlasNodeKind(type) { + var normalized = normalizeAtlasType(type); + return ATLAS_TYPE_KINDS[normalized]; + } + + function clampAtlasNumber(value, fallback, min, max) { + var numeric = Number(value); + if (!Number.isFinite(numeric)) numeric = fallback; + if (Number.isFinite(Number(min))) numeric = Math.max(Number(min), numeric); + if (Number.isFinite(Number(max))) numeric = Math.min(Number(max), numeric); + return numeric; + } + + function normalizeAtlasViewportSize(size) { + var safe = size && typeof size === "object" ? size : {}; + return { + width: clampAtlasNumber(safe.width, ATLAS_WORLD_WIDTH, 1, 100000), + height: clampAtlasNumber(safe.height, ATLAS_WORLD_HEIGHT, 1, 100000) + }; + } + + function normalizeAtlasViewport(viewport) { + var safe = viewport && typeof viewport === "object" ? viewport : {}; + return { + x: clampAtlasNumber(safe.x, 0, -1000000, 1000000), + y: clampAtlasNumber(safe.y, 0, -1000000, 1000000), + scale: clampAtlasNumber(safe.scale, 1, ATLAS_MIN_SCALE, ATLAS_MAX_SCALE) + }; + } + + function atlasNodePoint(node) { + var safe = node && typeof node === "object" ? node : {}; + return { + x: clampAtlasNumber(safe.x, 50, 0, 100) / 100 * ATLAS_WORLD_WIDTH, + y: clampAtlasNumber(safe.y, 50, 0, 100) / 100 * ATLAS_WORLD_HEIGHT + }; + } + + function getAtlasModelBounds(nodes, padding) { + var list = Array.isArray(nodes) ? nodes : []; + var pad = Number.isFinite(Number(padding)) ? Math.max(0, Number(padding)) : 48; + if (!list.length) { + return { + x: 0, + y: 0, + width: ATLAS_WORLD_WIDTH, + height: ATLAS_WORLD_HEIGHT, + minX: 0, + minY: 0, + maxX: ATLAS_WORLD_WIDTH, + maxY: ATLAS_WORLD_HEIGHT + }; + } + + var minX = ATLAS_WORLD_WIDTH; + var minY = ATLAS_WORLD_HEIGHT; + var maxX = 0; + var maxY = 0; + list.forEach(function (node) { + var point = atlasNodePoint(node); + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); + }); + + minX = clampAtlasNumber(minX - pad, 0, 0, ATLAS_WORLD_WIDTH); + minY = clampAtlasNumber(minY - pad, 0, 0, ATLAS_WORLD_HEIGHT); + maxX = clampAtlasNumber(maxX + pad, ATLAS_WORLD_WIDTH, 0, ATLAS_WORLD_WIDTH); + maxY = clampAtlasNumber(maxY + pad, ATLAS_WORLD_HEIGHT, 0, ATLAS_WORLD_HEIGHT); + + return { + x: minX, + y: minY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY), + minX: minX, + minY: minY, + maxX: maxX, + maxY: maxY + }; + } + + function clampAtlasViewport(viewport, viewportSize, options) { + var size = normalizeAtlasViewportSize(viewportSize); + var safe = normalizeAtlasViewport(viewport); + var opts = options && typeof options === "object" ? options : {}; + var minScale = clampAtlasNumber(opts.minScale, ATLAS_MIN_SCALE, 0.1, ATLAS_MAX_SCALE); + var maxScale = clampAtlasNumber(opts.maxScale, ATLAS_MAX_SCALE, minScale, 10); + var marginX = clampAtlasNumber(opts.marginX, size.width * 0.38, 0, size.width); + var marginY = clampAtlasNumber(opts.marginY, size.height * 0.38, 0, size.height); + var scale = clampAtlasNumber(safe.scale, 1, minScale, maxScale); + var scaledWidth = size.width * scale; + var scaledHeight = size.height * scale; + var minX = size.width - scaledWidth - marginX; + var maxX = marginX; + var minY = size.height - scaledHeight - marginY; + var maxY = marginY; + + if (scaledWidth <= size.width) { + var centerX = (size.width - scaledWidth) / 2; + minX = centerX - marginX; + maxX = centerX + marginX; + } + if (scaledHeight <= size.height) { + var centerY = (size.height - scaledHeight) / 2; + minY = centerY - marginY; + maxY = centerY + marginY; + } + + return { + x: clampAtlasNumber(safe.x, 0, minX, maxX), + y: clampAtlasNumber(safe.y, 0, minY, maxY), + scale: scale + }; + } + + function fitAtlasViewport(bounds, viewportSize, options) { + var safeBounds = bounds && typeof bounds === "object" ? bounds : getAtlasModelBounds([]); + var size = normalizeAtlasViewportSize(viewportSize); + var opts = options && typeof options === "object" ? options : {}; + var padding = clampAtlasNumber(opts.padding, 0.84, 0.2, 1); + var minScale = clampAtlasNumber(opts.minScale, ATLAS_MIN_SCALE, 0.1, ATLAS_MAX_SCALE); + var maxScale = clampAtlasNumber(opts.maxScale, 2.15, minScale, ATLAS_MAX_SCALE); + var widthScale = ATLAS_WORLD_WIDTH * padding / Math.max(1, safeBounds.width || 1); + var heightScale = ATLAS_WORLD_HEIGHT * padding / Math.max(1, safeBounds.height || 1); + var scale = clampAtlasNumber(Math.min(widthScale, heightScale), 1, minScale, maxScale); + var centerX = (safeBounds.minX != null && safeBounds.maxX != null) + ? (safeBounds.minX + safeBounds.maxX) / 2 + : (safeBounds.x || 0) + (safeBounds.width || ATLAS_WORLD_WIDTH) / 2; + var centerY = (safeBounds.minY != null && safeBounds.maxY != null) + ? (safeBounds.minY + safeBounds.maxY) / 2 + : (safeBounds.y || 0) + (safeBounds.height || ATLAS_WORLD_HEIGHT) / 2; + + return clampAtlasViewport({ + x: size.width / 2 - scale * (centerX / ATLAS_WORLD_WIDTH * size.width), + y: size.height / 2 - scale * (centerY / ATLAS_WORLD_HEIGHT * size.height), + scale: scale + }, size, opts); + } + + function centerAtlasViewportOnPoint(point, viewportSize, scale, options) { + var safePoint = point && typeof point === "object" ? point : { x: ATLAS_WORLD_WIDTH / 2, y: ATLAS_WORLD_HEIGHT / 2 }; + var size = normalizeAtlasViewportSize(viewportSize); + var viewportScale = clampAtlasNumber(scale, 1, ATLAS_MIN_SCALE, ATLAS_MAX_SCALE); + return clampAtlasViewport({ + x: size.width / 2 - viewportScale * (safePoint.x / ATLAS_WORLD_WIDTH * size.width), + y: size.height / 2 - viewportScale * (safePoint.y / ATLAS_WORLD_HEIGHT * size.height), + scale: viewportScale + }, size, options); + } + + function zoomAtlasViewport(viewport, factor, screenPoint, viewportSize, options) { + var size = normalizeAtlasViewportSize(viewportSize); + var safe = normalizeAtlasViewport(viewport); + var point = screenPoint && typeof screenPoint === "object" + ? { x: clampAtlasNumber(screenPoint.x, size.width / 2, 0, size.width), y: clampAtlasNumber(screenPoint.y, size.height / 2, 0, size.height) } + : { x: size.width / 2, y: size.height / 2 }; + var zoomFactor = clampAtlasNumber(factor, 1, 0.2, 5); + var opts = options && typeof options === "object" ? options : {}; + var minScale = clampAtlasNumber(opts.minScale, ATLAS_MIN_SCALE, 0.1, ATLAS_MAX_SCALE); + var maxScale = clampAtlasNumber(opts.maxScale, ATLAS_MAX_SCALE, minScale, 10); + var nextScale = clampAtlasNumber(safe.scale * zoomFactor, safe.scale, minScale, maxScale); + var ratio = nextScale / safe.scale; + return clampAtlasViewport({ + x: point.x - (point.x - safe.x) * ratio, + y: point.y - (point.y - safe.y) * ratio, + scale: nextScale + }, size, opts); + } + + function atlasViewportRect(viewport, viewportSize) { + var size = normalizeAtlasViewportSize(viewportSize); + var safe = normalizeAtlasViewport(viewport); + var x = (0 - safe.x) / safe.scale / size.width * ATLAS_WORLD_WIDTH; + var y = (0 - safe.y) / safe.scale / size.height * ATLAS_WORLD_HEIGHT; + var width = size.width / safe.scale / size.width * ATLAS_WORLD_WIDTH; + var height = size.height / safe.scale / size.height * ATLAS_WORLD_HEIGHT; + var minX = clampAtlasNumber(x, 0, 0, ATLAS_WORLD_WIDTH); + var minY = clampAtlasNumber(y, 0, 0, ATLAS_WORLD_HEIGHT); + var maxX = clampAtlasNumber(x + width, ATLAS_WORLD_WIDTH, 0, ATLAS_WORLD_WIDTH); + var maxY = clampAtlasNumber(y + height, ATLAS_WORLD_HEIGHT, 0, ATLAS_WORLD_HEIGHT); + return { + x: minX, + y: minY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY), + minX: minX, + minY: minY, + maxX: maxX, + maxY: maxY + }; + } + + function atlasPointToMinimap(point) { + var safePoint = point && typeof point === "object" ? point : { x: 0, y: 0 }; + return { + x: MINIMAP_VIEWBOX.x + clampAtlasNumber(safePoint.x, 0, 0, ATLAS_WORLD_WIDTH) / ATLAS_WORLD_WIDTH * MINIMAP_VIEWBOX.width, + y: MINIMAP_VIEWBOX.y + clampAtlasNumber(safePoint.y, 0, 0, ATLAS_WORLD_HEIGHT) / ATLAS_WORLD_HEIGHT * MINIMAP_VIEWBOX.height + }; + } + + function minimapPointToAtlasPoint(point) { + var safePoint = point && typeof point === "object" ? point : { x: MINIMAP_VIEWBOX.x, y: MINIMAP_VIEWBOX.y }; + return { + x: clampAtlasNumber((safePoint.x - MINIMAP_VIEWBOX.x) / MINIMAP_VIEWBOX.width * ATLAS_WORLD_WIDTH, 0, 0, ATLAS_WORLD_WIDTH), + y: clampAtlasNumber((safePoint.y - MINIMAP_VIEWBOX.y) / MINIMAP_VIEWBOX.height * ATLAS_WORLD_HEIGHT, 0, 0, ATLAS_WORLD_HEIGHT) + }; + } + + function atlasViewportToMinimapRect(viewport, viewportSize) { + var rect = atlasViewportRect(viewport, viewportSize); + var topLeft = atlasPointToMinimap({ x: rect.x, y: rect.y }); + var bottomRight = atlasPointToMinimap({ x: rect.x + rect.width, y: rect.y + rect.height }); + return { + x: topLeft.x, + y: topLeft.y, + width: Math.max(2, bottomRight.x - topLeft.x), + height: Math.max(2, bottomRight.y - topLeft.y) + }; + } + + function atlasEndpointId(value) { + if (value && typeof value === "object" && value.id != null) return String(value.id); + return value == null ? "" : String(value); + } + + function stripAtlasMarkdown(raw) { + return String(raw || "") + .replace(/^---[\s\S]*?---\s*/m, "") + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\[\[([^\]|]+)\|?([^\]]*)\]\]/g, function (_, target, label) { + return label || target; + }) + .replace(/^#{1,6}\s+/gm, "") + .replace(/^[-*+]\s+/gm, "") + .replace(/^\d+\.\s+/gm, "") + .replace(/[*_`>#]/g, "") + .replace(/\s+/g, " ") + .trim(); + } + + function deriveAtlasSummary(node, content) { + var explicitSummary = node && node.summary != null ? String(node.summary).trim() : ""; + if (explicitSummary) return explicitSummary.length > 170 ? explicitSummary.slice(0, 170).trim() + "…" : explicitSummary; + var summarySource = String(content || (node && node.content) || "").replace(/^#\s+.*(?:\r?\n)+/, ""); + var stripped = stripAtlasMarkdown(summarySource); + if (!stripped) return ""; + return stripped.length > 170 ? stripped.slice(0, 170).trim() + "…" : stripped; + } + + function normalizeAtlasNode(rawNode, index) { + var raw = rawNode && typeof rawNode === "object" ? rawNode : {}; + var id = raw.id == null ? "node-" + index : String(raw.id); + var label = raw.label == null || String(raw.label).trim() === "" ? id : String(raw.label).trim(); + var content = raw.content == null ? "" : String(raw.content); + var type = normalizeAtlasType(raw.type); + var community = raw.community == null || raw.community === "" ? "_none" : String(raw.community); + var x = Number(raw.x); + var y = Number(raw.y); + var hasX = (typeof raw.x === "number" || (typeof raw.x === "string" && raw.x.trim() !== "")) && Number.isFinite(x); + var hasY = (typeof raw.y === "number" || (typeof raw.y === "string" && raw.y.trim() !== "")) && Number.isFinite(y); + return { + id: id, + label: label, + type: type, + type_label: atlasTypeLabel(type), + kind: atlasNodeKind(type), + community: community, + source_path: raw.source_path || raw.source || raw.path || "", + confidence: normalizeAtlasConfidence(raw.confidence || raw.type_confidence), + confidence_label: atlasConfidenceLabel(raw.confidence || raw.type_confidence), + content: content, + summary: deriveAtlasSummary(raw, content), + unavailable: raw.unavailable === true || raw.available === false, + degree: 0, + weight: clampAtlasNumber(raw.weight != null ? raw.weight : raw.score, 50, 0, 100), + priority: 0, + idx: index, + x: hasX ? x : null, + y: hasY ? y : null + }; + } + + function normalizeAtlasEdge(rawEdge, index) { + var raw = rawEdge && typeof rawEdge === "object" ? rawEdge : {}; + var sourceId = atlasEndpointId(raw.from != null ? raw.from : raw.source); + var targetId = atlasEndpointId(raw.to != null ? raw.to : raw.target); + return { + id: raw.id == null ? "edge-" + index : String(raw.id), + source: sourceId, + target: targetId, + from: sourceId, + to: targetId, + type: normalizeAtlasConfidence(raw.type || raw.confidence), + confidence_label: atlasConfidenceLabel(raw.type || raw.confidence), + weight: clampAtlasNumber(raw.weight, 0.6, 0, 1), + signals: raw.signals && typeof raw.signals === "object" ? raw.signals : {}, + source_signal_available: raw.source_signal_available === true + }; + } + + function buildAtlasSearchHaystack(node) { + return [ + node && node.label, + node && node.id, + node && node.type_label, + node && node.source_path, + node && node.summary, + node && stripAtlasMarkdown(node.content) + ].join("\n").toLowerCase(); + } + + function buildAtlasSearchIndex(nodes) { + if (!Array.isArray(nodes)) return []; + return nodes.map(function (node) { + return { node: node, haystack: buildAtlasSearchHaystack(node) }; + }); + } + + function deriveAtlasCommunities(rawGraph, nodes, communityById) { + var learning = normalizeLearning(rawGraph && rawGraph.learning); + var fromLearning = Array.isArray(learning.communities) ? learning.communities : []; + var communities = []; + var seen = {}; + + fromLearning.forEach(function (community) { + if (!community || community.id == null) return; + var id = String(community.id); + var derived = communityById[id] || { nodes: [] }; + seen[id] = true; + communities.push({ + id: id, + label: community.label || id, + node_count: Number.isFinite(Number(community.node_count)) ? Number(community.node_count) : derived.nodes.length, + source_count: Number.isFinite(Number(community.source_count)) ? Number(community.source_count) : 0, + is_primary: community.is_primary === true, + recommended_start_node_id: community.recommended_start_node_id || null, + color_index: communities.length + }); + }); + + Object.keys(communityById).sort().forEach(function (id) { + if (seen[id]) return; + var group = communityById[id]; + var topic = group.nodes.find(function (node) { return node.type === "topic"; }); + communities.push({ + id: id, + label: id === "_none" ? "未分组" : (topic && topic.label) || id, + node_count: group.nodes.length, + source_count: group.nodes.filter(function (node) { return node.type === "source"; }).length, + is_primary: communities.length === 0, + recommended_start_node_id: null, + color_index: communities.length + }); + }); + + communities.sort(function (left, right) { + if (!!right.is_primary !== !!left.is_primary) return right.is_primary ? 1 : -1; + if ((right.node_count || 0) !== (left.node_count || 0)) return (right.node_count || 0) - (left.node_count || 0); + return String(left.label || left.id).localeCompare(String(right.label || right.id)); + }); + communities.forEach(function (community, index) { + community.color_index = index; + }); + return communities; + } + + function buildAtlasStarts(rawGraph, nodes, byId, communities) { + var starts = []; + var seen = {}; + function add(id, reason) { + if (id == null) return; + var nodeId = String(id); + if (!byId[nodeId] || seen[nodeId]) return; + seen[nodeId] = true; + starts.push({ node: byId[nodeId], reason: reason || "" }); + } + var learning = normalizeLearning(rawGraph && rawGraph.learning); + add(learning.entry && learning.entry.recommended_start_node_id, "全局推荐起点"); + communities.forEach(function (community) { + add(community.recommended_start_node_id, community.label + " · 推荐起点"); + }); + nodes.slice().sort(function (left, right) { + return (right.priority || 0) - (left.priority || 0); + }).forEach(function (node) { + if (starts.length < 6) add(node.id, atlasTypeLabel(node.type) + " · " + atlasConfidenceLabel(node.confidence)); + }); + return starts.slice(0, 6); + } + + function normalizeAtlasInsights(insights) { + var safe = insights && typeof insights === "object" ? insights : {}; + return { + surprising_connections: Array.isArray(safe.surprising_connections) ? safe.surprising_connections : [], + isolated_nodes: Array.isArray(safe.isolated_nodes) ? safe.isolated_nodes : [], + bridge_nodes: Array.isArray(safe.bridge_nodes) ? safe.bridge_nodes : [], + sparse_communities: Array.isArray(safe.sparse_communities) ? safe.sparse_communities : [], + meta: safe.meta && typeof safe.meta === "object" ? safe.meta : { degraded: false } + }; + } + + function buildAtlasModel(rawGraph) { + var raw = rawGraph && typeof rawGraph === "object" ? rawGraph : {}; + var nodes = Array.isArray(raw.nodes) ? raw.nodes.map(normalizeAtlasNode) : []; + var byId = {}; + var communityById = {}; + nodes.forEach(function (node) { + byId[node.id] = node; + if (!communityById[node.community]) communityById[node.community] = { id: node.community, nodes: [] }; + communityById[node.community].nodes.push(node); + }); + + var edges = (Array.isArray(raw.edges) ? raw.edges : []) + .map(normalizeAtlasEdge) + .filter(function (edge) { + return !!(byId[edge.source] && byId[edge.target]); + }); + + edges.forEach(function (edge) { + byId[edge.source].degree += 1; + byId[edge.target].degree += 1; + }); + nodes.forEach(function (node) { + node.priority = node.degree * 12 + node.weight + (node.type === "topic" ? 12 : node.type === "source" ? 6 : 0); + }); + + var communities = deriveAtlasCommunities(raw, nodes, communityById); + var communityMap = {}; + communities.forEach(function (community) { + communityMap[community.id] = community; + }); + + return { + meta: { + wiki_title: raw.meta && raw.meta.wiki_title ? String(raw.meta.wiki_title) : "知识库", + total_nodes: nodes.length, + total_edges: edges.length, + build_date: raw.meta && raw.meta.build_date ? String(raw.meta.build_date) : "" + }, + nodes: nodes, + edges: edges, + byId: byId, + communities: communities, + communityById: communityMap, + starts: buildAtlasStarts(raw, nodes, byId, communities), + searchIndex: buildAtlasSearchIndex(nodes), + insights: normalizeAtlasInsights(raw.insights) + }; + } + + function deriveAtlasLayout(model) { + var safe = model && typeof model === "object" ? model : { nodes: [], communities: [] }; + var centers = [ + { x: 50, y: 48 }, + { x: 30, y: 34 }, + { x: 70, y: 36 }, + { x: 30, y: 72 }, + { x: 72, y: 70 }, + { x: 18, y: 52 }, + { x: 84, y: 52 }, + { x: 50, y: 78 } + ]; + var communityIndex = {}; + (safe.communities || []).forEach(function (community, index) { + communityIndex[community.id] = index; + }); + var grouped = {}; + (safe.nodes || []).forEach(function (node) { + if (!grouped[node.community]) grouped[node.community] = []; + grouped[node.community].push(node); + }); + Object.keys(grouped).forEach(function (communityId) { + grouped[communityId].sort(function (left, right) { + return (right.priority || 0) - (left.priority || 0); + }); + var center = centers[(communityIndex[communityId] || 0) % centers.length]; + var count = grouped[communityId].length; + grouped[communityId].forEach(function (node, index) { + if (node.x != null && node.y != null && Number.isFinite(Number(node.x)) && Number.isFinite(Number(node.y))) { + node.x = clampAtlasNumber(node.x, center.x, 5, 95); + node.y = clampAtlasNumber(node.y, center.y, 8, 92); + return; + } + var ring = Math.floor(index / 8); + var ringIndex = index % 8; + var angle = ((ringIndex / Math.min(8, Math.max(1, count))) * Math.PI * 2) + ring * 0.42; + var radiusX = 7 + ring * 5 + Math.min(5, count * 0.16); + var radiusY = 5 + ring * 4 + Math.min(4, count * 0.12); + node.x = clampAtlasNumber(center.x + Math.cos(angle) * radiusX, center.x, 5, 95); + node.y = clampAtlasNumber(center.y + Math.sin(angle) * radiusY, center.y, 8, 92); + }); + }); + return { + nodes: (safe.nodes || []).slice(), + edges: (safe.edges || []).slice(), + nodePositions: (safe.nodes || []).reduce(function (out, node) { + out[node.id] = { x: node.x, y: node.y }; + return out; + }, {}) + }; + } + + function getAtlasDensityMode(count) { + var nodeCount = Number.isFinite(Number(count)) ? Number(count) : 0; + if (nodeCount > 500) return "overview"; + if (nodeCount > 200) return "point-plus-focus"; + if (nodeCount > 80) return "compact-card"; + return "card"; + } + + function atlasLabelBudget(mode, count) { + if (mode === "overview") return 40; + if (mode === "point-plus-focus") return 60; + if (mode === "compact-card") return Math.min(120, count); + return count; + } + + function atlasEdgeBudget(mode, count) { + if (mode === "overview") return 1000; + if (mode === "point-plus-focus") return 800; + return count; + } + + function resolveAtlasVisibleSnapshot(model, layout, uiState) { + var safeModel = model && typeof model === "object" ? model : buildAtlasModel({}); + var safeUI = uiState && typeof uiState === "object" ? uiState : {}; + var activeCommunityId = safeUI.activeCommunityId == null ? "all" : String(safeUI.activeCommunityId); + var query = typeof safeUI.query === "string" ? safeUI.query.trim().toLowerCase() : ""; + var focusMode = safeUI.focusMode || "all"; + var selectedNodeId = safeUI.selectedNodeId == null ? null : String(safeUI.selectedNodeId); + var filters = safeUI.filters && typeof safeUI.filters === "object" ? safeUI.filters : {}; + + var baseNodes = safeModel.nodes.filter(function (node) { + if (activeCommunityId !== "all" && node.community !== activeCommunityId) return false; + if (focusMode === "source" && node.type !== "source") return false; + return true; + }); + + if (focusMode === "core" && baseNodes.length > 8) { + var keepCount = Math.max(8, Math.ceil(baseNodes.length * 0.45)); + var keep = {}; + baseNodes.slice().sort(function (left, right) { + return (right.priority || 0) - (left.priority || 0); + }).slice(0, keepCount).forEach(function (node) { + keep[node.id] = true; + }); + if (selectedNodeId && safeModel.byId[selectedNodeId]) keep[selectedNodeId] = true; + baseNodes = baseNodes.filter(function (node) { return !!keep[node.id]; }); + } + + var baseIdSet = {}; + baseNodes.forEach(function (node) { baseIdSet[node.id] = true; }); + var searchIndex = buildAtlasSearchIndex(baseNodes); + var matchedIds = {}; + var visibleNodes = !query + ? baseNodes + : searchIndex.filter(function (entry) { + return entry.haystack.indexOf(query) !== -1; + }).map(function (entry) { + matchedIds[entry.node.id] = true; + return entry.node; + }); + var visibleIdSet = {}; + visibleNodes.forEach(function (node) { visibleIdSet[node.id] = true; }); + + var visibleEdges = safeModel.edges.filter(function (edge) { + var edgeType = edge.type || "EXTRACTED"; + if (filters[edgeType] === false) return false; + return !!(visibleIdSet[edge.source] && visibleIdSet[edge.target]); + }); + + var densityMode = getAtlasDensityMode(visibleNodes.length); + var labelBudget = atlasLabelBudget(densityMode, visibleNodes.length); + var labelNodeIds = {}; + var startNodeIds = {}; + var importantNodeIds = {}; + var visibleStartEntries = safeModel.starts.filter(function (entry) { + return !!(entry && entry.node && visibleIdSet[entry.node.id]) && + (activeCommunityId === "all" || entry.node.community === activeCommunityId); + }); + + visibleStartEntries.forEach(function (entry) { + startNodeIds[entry.node.id] = true; + importantNodeIds[entry.node.id] = true; + }); + + visibleNodes.slice().sort(function (left, right) { + return (right.priority || 0) - (left.priority || 0); + }).slice(0, Math.max(0, Math.min(8, Math.ceil(visibleNodes.length * 0.08)))).forEach(function (node) { + importantNodeIds[node.id] = true; + }); + + visibleNodes.slice().sort(function (left, right) { + var leftForced = (selectedNodeId === left.id || matchedIds[left.id] || importantNodeIds[left.id]) ? 1 : 0; + var rightForced = (selectedNodeId === right.id || matchedIds[right.id] || importantNodeIds[right.id]) ? 1 : 0; + if (rightForced !== leftForced) return rightForced - leftForced; + return (right.priority || 0) - (left.priority || 0); + }).slice(0, labelBudget).forEach(function (node) { + labelNodeIds[node.id] = true; + }); + if (selectedNodeId && visibleIdSet[selectedNodeId]) labelNodeIds[selectedNodeId] = true; + Object.keys(matchedIds).forEach(function (id) { + if (visibleIdSet[id]) { + labelNodeIds[id] = true; + importantNodeIds[id] = true; + } + }); + Object.keys(startNodeIds).forEach(function (id) { + if (visibleIdSet[id]) labelNodeIds[id] = true; + }); + if (selectedNodeId && visibleIdSet[selectedNodeId]) importantNodeIds[selectedNodeId] = true; + + var edgeBudget = atlasEdgeBudget(densityMode, visibleEdges.length); + visibleEdges = visibleEdges.slice().sort(function (left, right) { + var leftSelected = selectedNodeId && (left.source === selectedNodeId || left.target === selectedNodeId) ? 1 : 0; + var rightSelected = selectedNodeId && (right.source === selectedNodeId || right.target === selectedNodeId) ? 1 : 0; + if (rightSelected !== leftSelected) return rightSelected - leftSelected; + return (right.weight || 0) - (left.weight || 0); + }).slice(0, edgeBudget); + + return { + node_ids: visibleNodes.map(function (node) { return node.id; }), + nodes: visibleNodes, + edges: visibleEdges, + links: visibleEdges, + searchIndex: searchIndex, + densityMode: densityMode, + labelNodeIds: labelNodeIds, + matchedNodeIds: matchedIds, + importantNodeIds: importantNodeIds, + startNodeIds: startNodeIds, + starts: visibleStartEntries, + counts: { + visible_nodes: visibleNodes.length, + visible_edges: visibleEdges.length, + total_nodes: safeModel.nodes.length, + total_edges: safeModel.edges.length, + total_communities: safeModel.communities.length + } + }; + } + + function resolveAtlasSelectedNodeId(model, visibleSnapshot, selectedNodeId) { + var safeModel = model && typeof model === "object" ? model : buildAtlasModel({}); + var selected = selectedNodeId == null ? null : String(selectedNodeId); + var visible = visibleSnapshot && typeof visibleSnapshot === "object" ? visibleSnapshot : null; + var visibleIds = {}; + if (visible && Array.isArray(visible.node_ids)) { + visible.node_ids.forEach(function (id) { visibleIds[String(id)] = true; }); + } + + if (selected && safeModel.byId && safeModel.byId[selected] && (!visible || visibleIds[selected])) { + return selected; + } + return null; + } + + var helpers = { + splitLabelGraphemes: splitLabelGraphemes, + labelCharWidth: labelCharWidth, + measureLabelWidth: measureLabelWidth, + truncateLabel: truncateLabel, + cardDims: cardDims, + createSafeStorage: createSafeStorage, + getWikiStorageNamespace: getWikiStorageNamespace, + defaultQueue: defaultQueue, + normalizeQueue: normalizeQueue, + toggleQueueFavorite: toggleQueueFavorite, + appendQueueNote: appendQueueNote, + summarizeQueue: summarizeQueue, + defaultLearning: defaultLearning, + normalizeLearning: normalizeLearning, + resolveInitialMode: resolveInitialMode, + getCommunityNodeIds: getCommunityNodeIds, + getVisibleNodeIds: getVisibleNodeIds, + getVisibleLinks: getVisibleLinks, + buildSearchHaystack: buildSearchHaystack, + buildSearchIndex: buildSearchIndex, + filterLinksByTypes: filterLinksByTypes, + applySearchToNodeIds: applySearchToNodeIds, + applyFocusMode: applyFocusMode, + resolveVisibleSnapshot: resolveVisibleSnapshot, + shouldAutoOpenDrawer: shouldAutoOpenDrawer, + buildAtlasModel: buildAtlasModel, + deriveAtlasLayout: deriveAtlasLayout, + resolveAtlasVisibleSnapshot: resolveAtlasVisibleSnapshot, + resolveAtlasSelectedNodeId: resolveAtlasSelectedNodeId, + getAtlasDensityMode: getAtlasDensityMode, + normalizeAtlasViewport: normalizeAtlasViewport, + atlasNodePoint: atlasNodePoint, + getAtlasModelBounds: getAtlasModelBounds, + clampAtlasViewport: clampAtlasViewport, + fitAtlasViewport: fitAtlasViewport, + centerAtlasViewportOnPoint: centerAtlasViewportOnPoint, + zoomAtlasViewport: zoomAtlasViewport, + atlasViewportRect: atlasViewportRect, + atlasPointToMinimap: atlasPointToMinimap, + minimapPointToAtlasPoint: minimapPointToAtlasPoint, + atlasViewportToMinimapRect: atlasViewportToMinimapRect, + atlasConfidenceLabel: atlasConfidenceLabel, + atlasTypeLabel: atlasTypeLabel, + atlasNodeKind: atlasNodeKind, + stripAtlasMarkdown: stripAtlasMarkdown + }; + + root.WikiGraphWashHelpers = helpers; + if (typeof module !== "undefined" && module.exports) { + module.exports = helpers; + } +})(typeof window !== "undefined" ? window : this); diff --git a/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/graph-wash.js b/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/graph-wash.js new file mode 100644 index 000000000..1cfa96c0e --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/graph-wash.js @@ -0,0 +1,1009 @@ +/* DEPRECATED: Legacy wash runtime retained for one release cycle after Stage 4 engine HTML migration. Do not extend; use packages/graph-engine instead. */ +/* ============================================================ + Knowledge Graph - Oriental editorial atlas runtime + ============================================================ */ +(function () { + "use strict"; + + const helpers = window.WikiGraphWashHelpers; + if (!helpers) { + console.error("[wiki] graph-wash-helpers.js is missing or failed to load"); + return; + } + + const { + createSafeStorage, + getWikiStorageNamespace, + defaultQueue, + normalizeQueue, + toggleQueueFavorite, + appendQueueNote, + summarizeQueue, + buildAtlasModel, + deriveAtlasLayout, + resolveAtlasVisibleSnapshot, + resolveAtlasSelectedNodeId, + atlasNodePoint, + getAtlasModelBounds, + fitAtlasViewport, + centerAtlasViewportOnPoint, + zoomAtlasViewport, + atlasViewportToMinimapRect, + atlasPointToMinimap, + minimapPointToAtlasPoint, + atlasConfidenceLabel, + atlasTypeLabel, + atlasNodeKind, + stripAtlasMarkdown + } = helpers; + + const DENSITY_SMALL_LIMIT = 80; + const DENSITY_MEDIUM_LIMIT = 200; + const DENSITY_LARGE_LIMIT = 500; + const QUEUE_NOTE_LIMIT = 50; + const NOTE_EXCERPT_LIMIT = 140; + const COMMUNITY_COLORS = ["#8b2e24", "#315f72", "#4b7564", "#b7791f", "#6f557f", "#3e6b4b", "#9b6a36", "#5d6f91"]; + + const dataEl = document.getElementById("graph-data"); + let DATA; + let dataError = false; + try { + DATA = dataEl ? JSON.parse(dataEl.textContent) : window.SAMPLE_GRAPH; + } catch (err) { + console.error("[wiki] graph data parse failed:", err); + DATA = { meta: {}, nodes: [], edges: [], insights: { meta: { degraded: true } } }; + dataError = true; + } + + const app = document.getElementById("app"); + const atlas = document.getElementById("atlas"); + const nodeLayer = document.getElementById("node-layer"); + const edgeLayer = document.getElementById("edge-layer"); + const communityList = document.getElementById("community-list"); + const startList = document.getElementById("start-list"); + const searchInput = document.getElementById("search"); + const noResults = document.getElementById("no-results"); + const canvasTitle = document.getElementById("canvas-title"); + const canvasSubtitle = document.getElementById("canvas-subtitle"); + const insightTitle = document.getElementById("insight-title"); + const insightCopy = document.getElementById("insight-copy"); + const drawer = document.getElementById("drawer"); + const drawerNeighbors = document.getElementById("neighbor-details"); + const drawerNeighborsHeading = drawerNeighbors ? drawerNeighbors.querySelector("summary") : null; + const neighborList = document.getElementById("neighbor-list"); + const minimapEl = document.getElementById("minimap"); + const minimapToggle = document.getElementById("minimap-toggle"); + const minimapSvg = document.getElementById("mini-map-svg"); + + if (!atlas || !nodeLayer || !edgeLayer) { + console.error("[wiki] atlas shell is incomplete"); + return; + } + + let rawLocalStorage = null; + try { + rawLocalStorage = window.localStorage; + } catch (_) {} + + const atlasModel = buildAtlasModel(DATA); + const atlasLayout = deriveAtlasLayout(atlasModel); + const safeLocalStorage = createSafeStorage(rawLocalStorage, console.warn); + const storageNamespace = getWikiStorageNamespace(atlasModel.meta, window.location && window.location.pathname); + + const state = { + atlasModel, + atlasLayout, + queue: loadQueueState(), + ui: { + selectedNodeId: null, + activeCommunityId: "all", + focusMode: "all", + query: "", + dimUnselected: false, + dataMode: dataError ? "error" : (atlasModel.nodes.length ? "normal" : "empty"), + neighborExpanded: false, + filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: true, UNVERIFIED: true } + }, + viewport: { x: 0, y: 0, scale: 1 }, + viewportReady: false, + visible: null + }; + + let viewportPaintFrame = 0; + let panState = null; + + function queueStorageKey(name) { + return storageNamespace + ":" + name; + } + + function loadQueueState() { + const raw = safeLocalStorage.get(queueStorageKey("queue")); + if (!raw) return defaultQueue(); + try { + return normalizeQueue(JSON.parse(raw)); + } catch (err) { + console.warn("[wiki] queue storage parse failed:", err); + return defaultQueue(); + } + } + + function persistQueueState() { + safeLocalStorage.set(queueStorageKey("queue"), JSON.stringify(state.queue)); + } + + function escapeHtml(value) { + return String(value == null ? "" : value).replace(/[&<>"']/g, (ch) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + })[ch]); + } + + function clampWeight(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0.6; + return Math.max(0, Math.min(1, numeric)); + } + + function edgeStrokeWidth(edge) { + return 1.1 + clampWeight(edge && edge.weight) * 1.8; + } + + function edgeOpacity(edge) { + return 0.32 + clampWeight(edge && edge.weight) * 0.44; + } + + function edgeStrengthSize(edge) { + return 6 + clampWeight(edge && edge.weight) * 8; + } + + function currentDensityMode() { + return state.visible ? state.visible.densityMode : "card"; + } + + function communityColor(communityId) { + const community = state.atlasModel.communityById[communityId]; + const index = community ? community.color_index || 0 : 0; + return COMMUNITY_COLORS[index % COMMUNITY_COLORS.length]; + } + + function getSelectedNode() { + const selectedNodeId = resolveAtlasSelectedNodeId(state.atlasModel, state.visible, state.ui.selectedNodeId); + return selectedNodeId ? state.atlasModel.byId[selectedNodeId] || null : null; + } + + function getPreviewStartEntry(visibleSnapshot) { + if (state.ui.selectedNodeId) return null; + const visible = visibleSnapshot || state.visible || refreshVisibleSnapshot(); + const visibleIds = new Set((visible.node_ids || []).map(String)); + const starts = visible.starts && visible.starts.length + ? visible.starts + : state.atlasModel.starts.filter((entry) => entry && entry.node && visibleIds.has(entry.node.id)); + if (starts.length) return starts[0]; + const fallback = (visible.nodes || []).slice().filter((node) => { + return node && (node.summary || node.content || node.source_path); + }).sort((left, right) => (right.priority || 0) - (left.priority || 0))[0]; + return fallback ? { node: fallback, reason: "当前范围 · 推荐预览" } : null; + } + + function refreshVisibleSnapshot() { + state.visible = resolveAtlasVisibleSnapshot(state.atlasModel, state.atlasLayout, state.ui); + state.ui.selectedNodeId = resolveAtlasSelectedNodeId(state.atlasModel, state.visible, state.ui.selectedNodeId); + return state.visible; + } + + function currentViewportSize() { + const rect = atlas.getBoundingClientRect(); + return { + width: rect && rect.width ? rect.width : 1000, + height: rect && rect.height ? rect.height : 680 + }; + } + + function viewportOptions() { + return { minScale: 0.62, maxScale: 3.2 }; + } + + function edgeTransformForViewport(viewport, size) { + const safeSize = size || currentViewportSize(); + const x = (viewport.x / safeSize.width) * 1000; + const y = (viewport.y / safeSize.height) * 680; + return `translate(${x} ${y}) scale(${viewport.scale})`; + } + + function applyViewportTransform() { + const size = currentViewportSize(); + if (nodeLayer) { + nodeLayer.style.transform = `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.scale})`; + } + if (edgeLayer) { + edgeLayer.setAttribute("transform", edgeTransformForViewport(state.viewport, size)); + } + updateMinimapViewport(); + } + + function updateMinimapViewport() { + if (!minimapSvg) return; + const rect = minimapSvg.querySelector(".mini-map-viewport"); + if (!rect) return; + const miniRect = atlasViewportToMinimapRect(state.viewport, currentViewportSize()); + rect.setAttribute("x", String(miniRect.x)); + rect.setAttribute("y", String(miniRect.y)); + rect.setAttribute("width", String(miniRect.width)); + rect.setAttribute("height", String(miniRect.height)); + } + + function scheduleViewportPaint() { + if (viewportPaintFrame) return; + viewportPaintFrame = window.requestAnimationFrame(() => { + viewportPaintFrame = 0; + applyViewportTransform(); + }); + } + + function setViewport(nextViewport, immediate) { + state.viewport = helpers.clampAtlasViewport(nextViewport, currentViewportSize(), viewportOptions()); + if (immediate) { + if (viewportPaintFrame) { + window.cancelAnimationFrame(viewportPaintFrame); + viewportPaintFrame = 0; + } + applyViewportTransform(); + } else { + scheduleViewportPaint(); + } + } + + function fitVisibleViewport() { + const visible = state.visible || refreshVisibleSnapshot(); + const bounds = getAtlasModelBounds(visible.nodes, visible.nodes.length <= 1 ? 160 : 56); + setViewport(fitAtlasViewport(bounds, currentViewportSize(), { padding: 0.82, minScale: 0.62, maxScale: 1.18 }), true); + state.viewportReady = true; + } + + function centerViewportOnNode(nodeId) { + const node = state.atlasModel.byId[nodeId]; + if (!node) return; + const scale = Math.max(1.05, Math.min(state.viewport.scale || 1, 1.6)); + setViewport(centerAtlasViewportOnPoint(atlasNodePoint(node), currentViewportSize(), scale, viewportOptions()), true); + state.viewportReady = true; + } + + function makePath(a, b, edge) { + const sourcePoint = atlasNodePoint(a); + const targetPoint = atlasNodePoint(b); + const x1 = sourcePoint.x; + const y1 = sourcePoint.y; + const x2 = targetPoint.x; + const y2 = targetPoint.y; + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2; + const curve = Math.max(-76, Math.min(76, (a.y - b.y) * 1.8 + (clampWeight(edge.weight) - 0.5) * 24)); + return `M ${x1} ${y1} Q ${mx + curve} ${my - 22} ${x2} ${y2}`; + } + + function edgeClass(edge) { + return String(edge.type || "EXTRACTED").toLowerCase(); + } + + function connectedIds(id) { + const out = new Set([id]); + state.atlasModel.edges.forEach((edge) => { + if (edge.source === id) out.add(edge.target); + if (edge.target === id) out.add(edge.source); + }); + return out; + } + + function isNodeImportant(node) { + return !!(node && state.visible && state.visible.importantNodeIds && state.visible.importantNodeIds[node.id]); + } + + function nodeVisualRole(node, displayMode, previewNodeId) { + if (!node) return "landmark"; + if (node.id === state.ui.selectedNodeId) return "cinnabar-note"; + if (displayMode === "point" || displayMode === "overview") return "map-pin"; + if (state.visible && state.visible.matchedNodeIds[node.id]) return "index-slip"; + if (previewNodeId && node.id === previewNodeId) return "index-slip"; + if (isNodeImportant(node)) return "index-slip"; + return "landmark"; + } + + function nodeDisplayMode(node, previewNodeId) { + const mode = currentDensityMode(); + if (!node) return "card"; + if (node.id === state.ui.selectedNodeId) return "card"; + if (state.visible && state.visible.matchedNodeIds[node.id]) return "card"; + if (previewNodeId && node.id === previewNodeId && (mode === "overview" || mode === "point-plus-focus")) return "compact-card"; + if (isNodeImportant(node) && (mode === "overview" || mode === "point-plus-focus")) return "compact-card"; + if (mode === "overview") return state.visible.labelNodeIds[node.id] ? "compact-card" : "overview"; + if (mode === "point-plus-focus") return state.visible.labelNodeIds[node.id] ? "compact-card" : "point"; + return mode; + } + + function renderTopbar() { + const title = document.getElementById("wiki-title"); + if (title) title.textContent = `${state.atlasModel.meta.wiki_title} 知识舆图`; + } + + function renderSidebar() { + if (!communityList || !startList) return; + + const visible = state.visible || refreshVisibleSnapshot(); + communityList.innerHTML = ""; + + const allButton = document.createElement("button"); + allButton.className = "nav-item"; + allButton.type = "button"; + allButton.dataset.community = "all"; + allButton.setAttribute("aria-pressed", state.ui.activeCommunityId === "all" ? "true" : "false"); + allButton.innerHTML = ` + + 全部社区${state.atlasModel.nodes.length} 个节点 + ALL + `; + allButton.addEventListener("click", () => setCommunity("all")); + communityList.appendChild(allButton); + + state.atlasModel.communities.forEach((community) => { + const button = document.createElement("button"); + button.className = "nav-item"; + button.type = "button"; + button.dataset.community = community.id; + button.setAttribute("aria-pressed", state.ui.activeCommunityId === community.id ? "true" : "false"); + button.innerHTML = ` + + ${escapeHtml(community.label)}${community.node_count || 0} 个节点 + ${community.node_count || 0} + `; + button.addEventListener("click", () => setCommunity(community.id)); + communityList.appendChild(button); + }); + + startList.innerHTML = ""; + const previewEntry = getPreviewStartEntry(visible); + const previewNodeId = previewEntry && previewEntry.node ? previewEntry.node.id : null; + const starts = visible.starts.length ? visible.starts : state.atlasModel.starts; + starts.slice(0, 4).forEach((entry) => { + const node = entry.node; + const button = document.createElement("button"); + button.className = "start-card"; + button.type = "button"; + button.dataset.previewStart = node.id === previewNodeId ? "true" : "false"; + button.innerHTML = `${escapeHtml(node.label)}${escapeHtml(entry.reason || atlasTypeLabel(node.type))}`; + button.addEventListener("click", () => focusNode(node.id, true)); + startList.appendChild(button); + }); + if (!startList.children.length) { + const empty = document.createElement("div"); + empty.className = "note-card"; + empty.textContent = "暂无推荐起点。"; + startList.appendChild(empty); + } + + const summary = summarizeQueue(state.queue, state.atlasModel.byId, 3); + const metrics = document.querySelectorAll(".queue-metrics .metric b"); + if (metrics[0]) metrics[0].textContent = summary.favorite_count; + if (metrics[1]) metrics[1].textContent = summary.note_count; + const queueList = document.querySelector(".queue-list"); + if (queueList) { + queueList.innerHTML = ""; + if (!summary.recent_items.length) { + const empty = document.createElement("div"); + empty.className = "note-card"; + empty.textContent = "选中节点后可加入学习队列。"; + queueList.appendChild(empty); + return; + } + summary.recent_items.slice(0, 3).forEach((item) => { + const node = state.atlasModel.byId[item.node_id]; + const button = document.createElement("button"); + const kindLabel = item.kind === "note" ? "札记" : "待读"; + const meta = node ? `${atlasTypeLabel(node.type)} · ${atlasConfidenceLabel(node.confidence)}` : "队列条目"; + button.className = "queue-item"; + button.type = "button"; + button.dataset.kind = item.kind; + button.setAttribute("aria-current", item.node_id === state.ui.selectedNodeId ? "true" : "false"); + button.innerHTML = ` + + ${escapeHtml(item.label)}${escapeHtml(meta)} + ${kindLabel} + `; + button.addEventListener("click", () => focusNode(item.node_id, true)); + queueList.appendChild(button); + }); + } + } + + function renderCanvas() { + const visible = state.visible || refreshVisibleSnapshot(); + atlas.dataset.mode = state.ui.dataMode; + atlas.dataset.density = visible.densityMode; + + edgeLayer.innerHTML = ""; + visible.edges.forEach((edge) => { + const source = state.atlasModel.byId[edge.source]; + const target = state.atlasModel.byId[edge.target]; + if (!source || !target) return; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", makePath(source, target, edge)); + path.setAttribute("class", `edge ${edgeClass(edge)}`); + path.setAttribute("data-from", edge.source); + path.setAttribute("data-to", edge.target); + path.setAttribute("data-edge-id", edge.id); + path.style.strokeWidth = edgeStrokeWidth(edge); + path.style.opacity = edgeOpacity(edge); + edgeLayer.appendChild(path); + }); + + nodeLayer.innerHTML = ""; + const previewEntry = getPreviewStartEntry(visible); + const previewNodeId = previewEntry && previewEntry.node ? previewEntry.node.id : null; + visible.nodes.forEach((node) => { + const displayMode = nodeDisplayMode(node, previewNodeId); + const visualRole = nodeVisualRole(node, displayMode, previewNodeId); + const button = document.createElement("button"); + button.className = "node"; + if (node.unavailable) button.classList.add("is-disabled"); + if (displayMode === "compact-card") button.classList.add("is-compact"); + if (displayMode === "point") button.classList.add("is-point"); + if (displayMode === "overview") button.classList.add("is-overview"); + if (visualRole === "index-slip") button.classList.add("is-index-slip"); + if (visualRole === "cinnabar-note") button.classList.add("is-cinnabar-note"); + if (visualRole === "map-pin") button.classList.add("is-map-pin"); + if (node.id === previewNodeId) button.classList.add("is-preview-start"); + if (state.visible && !state.visible.labelNodeIds[node.id]) button.classList.add("is-label-hidden"); + button.type = "button"; + button.dataset.id = node.id; + button.dataset.type = node.type; + button.dataset.community = node.community; + button.dataset.densityMode = displayMode; + button.dataset.visualRole = visualRole; + button.dataset.startNode = state.visible && state.visible.startNodeIds[node.id] ? "true" : "false"; + button.dataset.previewStart = node.id === previewNodeId ? "true" : "false"; + button.style.left = `${node.x}%`; + button.style.top = `${node.y}%`; + button.title = node.label; + button.setAttribute("aria-pressed", node.id === state.ui.selectedNodeId ? "true" : "false"); + button.innerHTML = ` + ${node.kind} + ${escapeHtml(node.label)} + ${node.unavailable ? "来源暂不可用" : Math.round(node.priority || node.weight || 0)} + `; + button.addEventListener("click", () => selectNode(node.id)); + button.addEventListener("mouseenter", () => highlightNeighborhood(node.id)); + button.addEventListener("mouseleave", () => applyFilters()); + nodeLayer.appendChild(button); + }); + + applyFilters(); + renderMinimap(); + applyViewportTransform(); + } + + function setDrawerActions(mode, node) { + const queueAction = document.getElementById("queue-action"); + const sourceAction = document.getElementById("source-action"); + if (queueAction) { + queueAction.textContent = mode === "preview" ? "从这里开始" : "加入学习队列"; + queueAction.disabled = mode === "empty"; + } + if (sourceAction) { + sourceAction.textContent = "查看来源"; + sourceAction.disabled = !(node && node.source_path); + } + } + + function renderStartPreview(entry) { + const node = entry && entry.node; + if (!node) return false; + const neighbors = getNeighbors(node.id); + const community = state.atlasModel.communityById[node.community]; + const communityLabel = community ? community.label : "未分组"; + const excerpt = stripAtlasMarkdown(node.content || node.summary || "").slice(0, 220); + + document.getElementById("drawer-kind").innerHTML = `从这里开始 · 预览`; + document.getElementById("drawer-title").textContent = node.label; + document.getElementById("drawer-subtitle").textContent = `${entry.reason || atlasTypeLabel(node.type)} · ${communityLabel} · 点击后进入阅读态`; + document.getElementById("drawer-summary").textContent = node.summary || excerpt || "这个节点适合作为当前图谱的起点。"; + document.getElementById("drawer-neighbor-count").textContent = `${neighbors.length} 个`; + + const content = document.getElementById("drawer-content"); + if (content) { + content.innerHTML = ` +

这是一条推荐起点预览。全局图仍保持中立,点击“从这里开始”或左侧推荐起点后,才会进入选中阅读态。

+

${escapeHtml(excerpt || node.summary || "当前节点暂无正文,但可以从相邻节点继续展开。")}

+ `; + } + + if (neighborList) { + neighborList.innerHTML = ""; + if (!neighbors.length) { + const empty = document.createElement("div"); + empty.className = "note-card"; + empty.textContent = "这个起点暂时没有相邻节点。"; + neighborList.appendChild(empty); + } + neighbors.slice(0, 4).forEach((entryItem) => { + const neighbor = entryItem.node; + const button = document.createElement("button"); + button.className = "neighbor-card"; + button.type = "button"; + button.innerHTML = `${escapeHtml(neighbor.label)}${atlasTypeLabel(neighbor.type)} · ${atlasConfidenceLabel(entryItem.edge.type)}`; + button.addEventListener("click", () => focusNode(neighbor.id, true)); + neighborList.appendChild(button); + }); + } + + setDrawerActions("preview", node); + return true; + } + + function renderDrawer() { + const selected = getSelectedNode(); + if (!drawer) return; + const previewEntry = getPreviewStartEntry(); + if (app) { + app.dataset.reading = selected ? "1" : "0"; + app.dataset.startPreview = !selected && previewEntry ? "1" : "0"; + } + drawer.dataset.state = selected ? "reading" : (previewEntry ? "start-preview" : "empty"); + if (!selected && previewEntry && renderStartPreview(previewEntry)) return; + if (!selected) { + setDrawerActions("empty", null); + document.getElementById("drawer-kind").innerHTML = `当前范围`; + document.getElementById("drawer-title").textContent = "没有匹配节点"; + document.getElementById("drawer-subtitle").textContent = "调整搜索或筛选后查看知识内容"; + document.getElementById("drawer-summary").textContent = "当前范围内没有可显示的节点。"; + document.getElementById("drawer-neighbor-count").textContent = "0 个"; + const content = document.getElementById("drawer-content"); + if (content) content.innerHTML = "

清除搜索词或切回全部社区后,可以继续查看节点摘要和知识内容。

"; + if (neighborList) { + neighborList.innerHTML = ""; + const empty = document.createElement("div"); + empty.className = "note-card"; + empty.textContent = "暂无相邻节点。"; + neighborList.appendChild(empty); + } + return; + } + state.ui.selectedNodeId = selected.id; + setDrawerActions("reading", selected); + + const neighbors = getNeighbors(selected.id); + const community = state.atlasModel.communityById[selected.community]; + const communityLabel = community ? community.label : "未分组"; + + document.getElementById("drawer-kind").innerHTML = `${atlasTypeLabel(selected.type)} · 已选中`; + document.getElementById("drawer-title").textContent = selected.label; + document.getElementById("drawer-subtitle").textContent = `${communityLabel} · ${atlasConfidenceLabel(selected.confidence)}${selected.source_path ? " · " + selected.source_path : ""}`; + document.getElementById("drawer-summary").textContent = selected.summary || "暂无摘要。"; + document.getElementById("drawer-neighbor-count").textContent = `${neighbors.length} 个`; + + renderKnowledgeCard(selected, neighbors); + + if (neighborList) { + neighborList.innerHTML = ""; + if (!neighbors.length) { + const empty = document.createElement("div"); + empty.className = "note-card"; + empty.textContent = "这个节点暂时没有相邻节点。"; + neighborList.appendChild(empty); + } + neighbors.forEach((entry) => { + const node = entry.node; + const button = document.createElement("button"); + button.className = "neighbor-card"; + button.type = "button"; + button.innerHTML = `${escapeHtml(node.label)}${atlasTypeLabel(node.type)} · ${atlasConfidenceLabel(entry.edge.type)}`; + button.addEventListener("click", () => focusNode(node.id, true)); + neighborList.appendChild(button); + }); + } + } + + function renderKnowledgeCard(node, neighbors) { + const content = document.getElementById("drawer-content"); + if (!content) return; + const rawContent = String(node.content || "").trim(); + if (rawContent) { + const linked = rawContent.replace(/\[\[([^\]]+)\]\]/g, (_, inner) => { + const parts = inner.split("|"); + const target = parts[0].trim(); + const label = (parts[1] || parts[0]).trim(); + const exists = state.atlasModel.nodes.some((item) => item.id === target || item.label === target); + const cls = exists ? "wikilink" : "wikilink wikilink--dead"; + return `${escapeHtml(label)}`; + }); + const html = typeof marked === "undefined" ? `

${escapeHtml(stripAtlasMarkdown(linked))}

` : marked.parse(linked, { breaks: false, gfm: true }); + const safe = typeof DOMPurify === "undefined" + ? html + : DOMPurify.sanitize(html, { ADD_ATTR: ["target", "data-target", "tabindex"] }); + content.innerHTML = safe; + } else { + const related = neighbors.length + ? `它当前连接到 ${neighbors.map((entry) => `「${entry.node.label}」`).join("、")}。` + : "这个节点暂时没有相邻节点。"; + content.innerHTML = `

${escapeHtml(node.label)}属于知识库中的「${escapeHtml(atlasTypeLabel(node.type))}」节点。

${escapeHtml(node.summary || related)}

`; + } + + content.querySelectorAll("a.wikilink").forEach((link) => { + link.addEventListener("click", (event) => { + event.preventDefault(); + if (link.classList.contains("wikilink--dead")) return; + const target = link.getAttribute("data-target"); + const hit = state.atlasModel.nodes.find((item) => item.id === target || item.label === target); + if (hit) focusNode(hit.id, true); + }); + }); + } + + function getNeighbors(nodeId) { + const out = []; + state.atlasModel.edges.forEach((edge) => { + if (edge.source === nodeId && state.atlasModel.byId[edge.target]) { + out.push({ node: state.atlasModel.byId[edge.target], edge, direction: "to" }); + } else if (edge.target === nodeId && state.atlasModel.byId[edge.source]) { + out.push({ node: state.atlasModel.byId[edge.source], edge, direction: "from" }); + } + }); + return out.sort((left, right) => clampWeight(right.edge.weight) - clampWeight(left.edge.weight)); + } + + function renderInsights() { + if (!insightTitle || !insightCopy) return; + const visible = state.visible || refreshVisibleSnapshot(); + const insights = state.atlasModel.insights; + const bridge = insights.bridge_nodes && insights.bridge_nodes[0]; + const surprising = insights.surprising_connections && insights.surprising_connections[0]; + if (surprising) { + insightTitle.textContent = "发现跨社区强连接"; + insightCopy.textContent = `${surprising.from} 与 ${surprising.to} 的关系权重较高,适合作为下一步阅读线索。`; + } else if (bridge) { + insightTitle.textContent = "桥接节点值得优先阅读"; + insightCopy.textContent = `${bridge.label || bridge.id} 连接多个社区,可帮助从局部主题进入全局图谱。`; + } else if (visible.densityMode === "overview") { + insightTitle.textContent = "当前视图过密"; + insightCopy.textContent = "建议搜索关键词或筛选社区,先缩小图谱范围再阅读节点内容。"; + } else { + insightTitle.textContent = "从选中节点进入阅读"; + insightCopy.textContent = "右侧札记会随节点、搜索和社区筛选同步更新。"; + } + } + + function renderMinimap() { + if (!minimapSvg) return; + const visible = state.visible || refreshVisibleSnapshot(); + while (minimapSvg.firstChild) minimapSvg.removeChild(minimapSvg.firstChild); + const ns = "http://www.w3.org/2000/svg"; + const path = document.createElementNS(ns, "path"); + path.setAttribute("d", "M8 40 C34 20 54 36 76 22 C98 8 118 24 150 12"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "#cfc4b1"); + path.setAttribute("stroke-width", "1.4"); + minimapSvg.appendChild(path); + + visible.nodes.slice(0, 60).forEach((node) => { + const point = atlasPointToMinimap(atlasNodePoint(node)); + const circle = document.createElementNS(ns, "circle"); + circle.setAttribute("cx", String(point.x)); + circle.setAttribute("cy", String(point.y)); + circle.setAttribute("r", node.id === state.ui.selectedNodeId ? "3.2" : "2.2"); + circle.setAttribute("fill", communityColor(node.community)); + if (node.id === state.ui.selectedNodeId) circle.classList.add("is-selected"); + minimapSvg.appendChild(circle); + }); + + const rect = document.createElementNS(ns, "rect"); + rect.setAttribute("class", "mini-map-viewport"); + rect.setAttribute("rx", "5"); + minimapSvg.appendChild(rect); + updateMinimapViewport(); + } + + function applyFilters() { + const visible = state.visible || refreshVisibleSnapshot(); + const visibleIds = new Set(visible.node_ids); + const selectedIds = state.ui.selectedNodeId ? connectedIds(state.ui.selectedNodeId) : new Set(); + + document.querySelectorAll(".node").forEach((nodeEl) => { + const id = nodeEl.dataset.id; + const isVisible = visibleIds.has(id); + nodeEl.classList.toggle("is-hidden", !isVisible); + const dim = state.ui.dimUnselected && state.ui.selectedNodeId && !selectedIds.has(id); + nodeEl.classList.toggle("is-dim", isVisible && dim); + nodeEl.setAttribute("aria-pressed", id === state.ui.selectedNodeId ? "true" : "false"); + }); + + document.querySelectorAll(".edge").forEach((edgeEl) => { + const from = edgeEl.dataset.from; + const to = edgeEl.dataset.to; + const visibleEdge = visibleIds.has(from) && visibleIds.has(to); + const dim = state.ui.dimUnselected && state.ui.selectedNodeId && from !== state.ui.selectedNodeId && to !== state.ui.selectedNodeId; + edgeEl.classList.toggle("is-dim", !visibleEdge || dim); + }); + + const hasNoResults = !!state.ui.query && visible.nodes.length === 0 && state.ui.dataMode === "normal"; + if (noResults) noResults.classList.toggle("is-visible", hasNoResults); + + const communityName = state.ui.activeCommunityId === "all" + ? "全局视图" + : (state.atlasModel.communityById[state.ui.activeCommunityId] || {}).label || "当前社区"; + if (canvasTitle) canvasTitle.textContent = `知识地图 · ${communityName}`; + if (canvasSubtitle) { + const densityLabel = ({ + card: "卡片", + "compact-card": "紧凑卡片", + "point-plus-focus": "点位聚焦", + overview: "总览" + })[visible.densityMode] || "卡片"; + canvasSubtitle.textContent = hasNoResults + ? "当前筛选没有匹配节点" + : `${visible.nodes.length} 个节点在当前范围内可见 · ${densityLabel}模式`; + } + } + + function highlightNeighborhood(id) { + if (state.ui.dataMode !== "normal") return; + const ids = connectedIds(id); + document.querySelectorAll(".node").forEach((nodeEl) => { + nodeEl.classList.toggle("is-dim", !ids.has(nodeEl.dataset.id)); + }); + document.querySelectorAll(".edge").forEach((edgeEl) => { + edgeEl.classList.toggle("is-dim", edgeEl.dataset.from !== id && edgeEl.dataset.to !== id); + }); + } + + function renderAtlasView(options) { + const opts = options && typeof options === "object" ? options : {}; + refreshVisibleSnapshot(); + if (app) app.dataset.reading = state.ui.selectedNodeId ? "1" : "0"; + if (opts.fitViewport || !state.viewportReady) fitVisibleViewport(); + renderTopbar(); + renderSidebar(); + renderCanvas(); + renderDrawer(); + renderInsights(); + } + + function selectNode(id) { + if (!state.atlasModel.byId[id]) return; + state.ui.selectedNodeId = id; + renderAtlasView(); + } + + function focusNode(nodeId, openDrawer) { + if (!state.atlasModel.byId[nodeId]) return; + state.ui.selectedNodeId = nodeId; + if (openDrawer !== false && drawer) { + drawer.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + renderAtlasView(); + centerViewportOnNode(nodeId); + } + + function closeDrawer() { + state.ui.selectedNodeId = null; + renderAtlasView({ fitViewport: true }); + } + + function setCommunity(communityId) { + state.ui.activeCommunityId = communityId || "all"; + renderAtlasView({ fitViewport: true }); + } + + function buildNoteText(node) { + const stripped = stripAtlasMarkdown(node && node.content); + const excerpt = stripped.slice(0, NOTE_EXCERPT_LIMIT); + return node && node.label ? `${node.label}${excerpt ? ":" + excerpt : ""}` : excerpt; + } + + function handleQueueAction() { + const node = getSelectedNode(); + if (!node) { + const previewEntry = getPreviewStartEntry(); + if (previewEntry && previewEntry.node) focusNode(previewEntry.node.id, true); + return; + } + state.queue = toggleQueueFavorite(state.queue, node.id); + state.queue = appendQueueNote(state.queue, { + id: `${node.id}:${Date.now()}`, + node_id: node.id, + label: node.label, + text: buildNoteText(node), + created_at: new Date().toISOString() + }, QUEUE_NOTE_LIMIT); + persistQueueState(); + renderSidebar(); + } + + function setupSearch() { + if (!searchInput) return; + searchInput.addEventListener("input", () => { + state.ui.query = searchInput.value.trim().toLowerCase(); + renderAtlasView({ fitViewport: true }); + }); + searchInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + const hit = state.visible && state.visible.nodes[0]; + if (hit) focusNode(hit.id, true); + }); + } + + function isCanvasPanTarget(target) { + return !(target && target.closest && target.closest(".node, button, a, input, textarea, summary, details")); + } + + function setupViewportInteractions() { + atlas.addEventListener("pointerdown", (event) => { + if (event.button !== 0 || !isCanvasPanTarget(event.target)) return; + panState = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + viewport: { x: state.viewport.x, y: state.viewport.y, scale: state.viewport.scale } + }; + atlas.classList.add("is-panning"); + atlas.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + + atlas.addEventListener("pointermove", (event) => { + if (!panState || panState.pointerId !== event.pointerId) return; + setViewport({ + x: panState.viewport.x + event.clientX - panState.startX, + y: panState.viewport.y + event.clientY - panState.startY, + scale: panState.viewport.scale + }); + event.preventDefault(); + }); + + function finishPan(event) { + if (!panState || panState.pointerId !== event.pointerId) return; + panState = null; + atlas.classList.remove("is-panning"); + if (atlas.hasPointerCapture && atlas.hasPointerCapture(event.pointerId)) { + atlas.releasePointerCapture(event.pointerId); + } + } + + atlas.addEventListener("pointerup", finishPan); + atlas.addEventListener("pointercancel", finishPan); + atlas.addEventListener("wheel", (event) => { + if (!isCanvasPanTarget(event.target) && !(event.target && event.target.closest && event.target.closest(".node"))) return; + const rect = atlas.getBoundingClientRect(); + const factor = Math.exp(-event.deltaY * 0.0012); + setViewport(zoomAtlasViewport(state.viewport, factor, { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }, currentViewportSize(), viewportOptions())); + event.preventDefault(); + }, { passive: false }); + } + + function setupMinimapNavigation() { + if (!minimapSvg) return; + minimapSvg.addEventListener("click", (event) => { + const matrix = minimapSvg.getScreenCTM && minimapSvg.getScreenCTM(); + if (!matrix) return; + const point = minimapSvg.createSVGPoint(); + point.x = event.clientX; + point.y = event.clientY; + const local = point.matrixTransform(matrix.inverse()); + const atlasPoint = minimapPointToAtlasPoint({ x: local.x, y: local.y }); + setViewport(centerAtlasViewportOnPoint(atlasPoint, currentViewportSize(), state.viewport.scale, viewportOptions()), true); + }); + } + + function setupControls() { + document.querySelectorAll("[data-focus]").forEach((button) => { + button.addEventListener("click", () => { + state.ui.focusMode = button.dataset.focus || "all"; + document.querySelectorAll("[data-focus]").forEach((item) => { + item.setAttribute("aria-pressed", item === button ? "true" : "false"); + }); + renderAtlasView({ fitViewport: true }); + }); + }); + + document.querySelectorAll(".state-button[data-mode]").forEach((button) => { + button.addEventListener("click", () => { + state.ui.dataMode = button.dataset.mode || "normal"; + atlas.dataset.mode = state.ui.dataMode; + document.querySelectorAll(".state-button[data-mode]").forEach((item) => { + item.setAttribute("aria-pressed", item === button ? "true" : "false"); + }); + applyFilters(); + }); + }); + + const dimButton = document.getElementById("toggle-dim"); + if (dimButton) { + dimButton.addEventListener("click", () => { + state.ui.dimUnselected = !state.ui.dimUnselected; + dimButton.textContent = state.ui.dimUnselected ? "显示全部层级" : "弱化未选中"; + applyFilters(); + }); + } + + const fitButton = document.getElementById("fit-view"); + if (fitButton) { + fitButton.addEventListener("click", () => { + state.ui.activeCommunityId = "all"; + state.ui.focusMode = "all"; + state.ui.query = ""; + state.ui.selectedNodeId = null; + state.ui.dataMode = "normal"; + if (searchInput) searchInput.value = ""; + document.querySelectorAll("[data-focus]").forEach((item) => { + item.setAttribute("aria-pressed", item.dataset.focus === "all" ? "true" : "false"); + }); + document.querySelectorAll(".state-button[data-mode]").forEach((item) => { + item.setAttribute("aria-pressed", item.dataset.mode === "normal" ? "true" : "false"); + }); + renderAtlasView({ fitViewport: true }); + }); + } + + const queueAction = document.getElementById("queue-action"); + if (queueAction) queueAction.addEventListener("click", handleQueueAction); + + const sourceAction = document.getElementById("source-action"); + if (sourceAction) { + sourceAction.addEventListener("click", () => { + const previewEntry = getPreviewStartEntry(); + const node = getSelectedNode() || (previewEntry && previewEntry.node); + if (node && node.source_path) window.location.href = node.source_path; + }); + } + } + + function applyNeighborsCollapsed(collapsed) { + if (!drawerNeighbors || !drawerNeighborsHeading) return; + drawerNeighbors.open = !collapsed; + drawerNeighbors.setAttribute("data-collapsed", collapsed ? "1" : "0"); + drawerNeighborsHeading.setAttribute("aria-expanded", collapsed ? "false" : "true"); + } + + function toggleNeighbors() { + if (!drawerNeighbors) return; + const nextCollapsed = drawerNeighbors.open; + applyNeighborsCollapsed(nextCollapsed); + safeLocalStorage.set(queueStorageKey("neighbors-collapsed"), nextCollapsed ? "1" : "0"); + } + + function setupNeighborToggle() { + const storedNeighborsCollapsed = safeLocalStorage.get(queueStorageKey("neighbors-collapsed")); + applyNeighborsCollapsed(storedNeighborsCollapsed == null ? true : storedNeighborsCollapsed === "1"); + if (!drawerNeighbors || !drawerNeighborsHeading) return; + drawerNeighbors.addEventListener("toggle", () => { + state.ui.neighborExpanded = drawerNeighbors.open; + drawerNeighbors.setAttribute("data-collapsed", drawerNeighbors.open ? "0" : "1"); + drawerNeighborsHeading.setAttribute("aria-expanded", drawerNeighbors.open ? "true" : "false"); + }); + drawerNeighborsHeading.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleNeighbors(); + } + }); + } + + function applyMinimapCollapsed(collapsed) { + if (!minimapEl || !minimapToggle) return; + minimapEl.setAttribute("data-collapsed", collapsed ? "1" : "0"); + minimapToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + minimapToggle.setAttribute("aria-label", collapsed ? "展开小地图" : "折叠小地图"); + } + + setupSearch(); + setupViewportInteractions(); + setupMinimapNavigation(); + setupControls(); + setupNeighborToggle(); + applyMinimapCollapsed(false); + window.addEventListener("resize", () => renderAtlasView({ fitViewport: true })); + renderAtlasView({ fitViewport: true }); +})(); diff --git a/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/header.html b/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/header.html new file mode 100644 index 000000000..81a23cfb7 --- /dev/null +++ b/graphify/bundled_skills/llm-wiki/templates/graph-styles/wash/header.html @@ -0,0 +1,2002 @@ + + + + + + + 知识图谱 · __WIKI_TITLE__ + + + + +
+
+
+ +
+

__WIKI_TITLE__ 知识舆图

+

国风知识库·数字山水图

+
+
+
+ 已生成 __NODE_COUNT__ 个索引点 + 关系 __EDGE_COUNT__ 条 + 最近更新 __BUILD_DATE__ + + + GitHub + +
+
+ +
+
+ 数字山水图谱 + __NODE_COUNT__ 点 · __EDGE_COUNT__ 关系 +
+ +
+ + + +
+
+
+ 知识地图 · 全局视图 + 社区地貌、关系路径与批注状态保持同步 +
+
+ + +
+ + + + +
+
+
+ +
+ + +
+
+ +

正在铺开知识地图

+

图谱数据已读取,正在整理社区、关系和标签。

+
+
+

这座知识库还没有图谱

+

消化素材后会自动生成节点、来源和关系。

+
+ +
当前视图过密,可搜索或筛选社区。
+
+ +
+
+ +
+ 直接提取 + 推断关联 + 存在歧义 + 未核实 +
+
+
+ + +
+
+ + 来源节点连接度偏高 +

建议从“知识编译”进入,可同时看到方法论、缓存与图谱生成的关系。

+
+
+
+ + +
+ + + {_html_styles()} @@ -817,6 +845,7 @@ def _js_safe(obj) -> str: """ + _emit_vis_js(Path(output_path)) Path(output_path).write_text(html, encoding="utf-8") # nosec diff --git a/pyproject.toml b/pyproject.toml index 35e78159b..0e4577a20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,50 @@ sql = ["tree-sitter-sql"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] +# Runtime extras group: the code-only dependency set that internal-PyPI-proxy +# users typically want when installing `graphifyy[windows-offline]`. The zip +# offline installer no longer bundles these locally — it pulls them from +# http://192.168.21.14:25000/pypi/repository/pypi-all/simple at install time. +windows-offline = [ + "networkx>=3.4", + "numpy>=1.21", + "rapidfuzz>=3.0", + "tree-sitter>=0.23.0,<0.26", + "tree-sitter-python>=0.23,<0.26", + "tree-sitter-javascript>=0.23,<0.26", + "tree-sitter-typescript>=0.23,<0.25", + "tree-sitter-go>=0.23,<0.26", + "tree-sitter-rust>=0.23,<0.25", + "tree-sitter-java>=0.23,<0.25", + "tree-sitter-groovy>=0.1,<0.3", + "tree-sitter-c>=0.23,<0.26", + "tree-sitter-cpp>=0.23,<0.25", + "tree-sitter-ruby>=0.23,<0.25", + "tree-sitter-c-sharp>=0.23,<0.25", + "tree-sitter-kotlin>=1.0,<2.0", + "tree-sitter-scala>=0.23,<0.27", + "tree-sitter-php>=0.23,<0.25", + "tree-sitter-swift>=0.7,<0.9", + "tree-sitter-lua>=0.2,<0.6", + "tree-sitter-zig>=1.0,<2.0", + "tree-sitter-powershell>=0.26,<0.28", + "tree-sitter-elixir>=0.3,<0.5", + "tree-sitter-objc>=3.0,<4.0", + "tree-sitter-julia>=0.23,<0.25", + "tree-sitter-verilog>=1.0,<2.0", + "tree-sitter-fortran>=0.6,<0.8", + "tree-sitter-bash>=0.23,<0.27", + "tree-sitter-json>=0.23,<0.26", + "anthropic", + "mcp", + "starlette>=1.3.1", + "graspologic; python_version < '3.13'", + "tree-sitter-sql", + "tree-sitter-hcl", + "jieba", + "watchdog", + "matplotlib", +] all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl"] [project.scripts] @@ -87,18 +131,21 @@ dev = [ "build>=1.5.0", "hypothesis>=6.152.7", "nuitka>=4.1", + "ordered-set>=4.1", "patchelf>=0.17.2.4 ; sys_platform != 'win32'", "pip-audit>=2.10.0", "pre-commit>=4.6.0", "pyright>=1.1.409", "pytest>=9.0.3", "pytest-cov>=7.1.0", + "pyyaml>=6.0", "ruff>=0.15.13", "safety>=3.7.0", "setuptools>=82.0.1", "wheel>=0.47.0", "tomli>=2.0 ; python_version < '3.11'", "tree-sitter-hcl>=1.2.0", + "zstandard>=0.18", ] [tool.uv] @@ -116,7 +163,27 @@ include-package-data = false # under graphify/skills//references/, and the always-on injection blocks # under graphify/always_on/. There is no graphify/skills//SKILL.md in the # repo, so no SKILL.md glob is needed here. -graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md"] +graphify = [ + "skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", + "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", + "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", + "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", + "skills/*/references/*.md", + "always_on/*.md", + "assets/vis-network.min.js", + "bundled_skills/**/*.md", + "bundled_skills/**/*.txt", + "bundled_skills/**/*.sh", + "bundled_skills/**/*.ps1", + "bundled_skills/**/*.js", + "bundled_skills/**/*.tsv", + "bundled_skills/**/LICENSE*", + "bundled_skills/**/NOTICE*", + "bundled_skills/**/*.ts", + "bundled_skills/**/*.py", + "bundled_skills/**/*.json", + "bundled_skills/**/*.html", +] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py index 835ff5e52..a21e95f51 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Any import pytest @@ -18,3 +19,15 @@ def pytest_collection_modifyitems(items: list[Any]) -> None: continue for warning_filter in _ANALYZE_WARNING_FILTERS: item.add_marker(pytest.mark.filterwarnings(warning_filter)) + + +@pytest.fixture +def package_root() -> Path: + """Absolute path to the graphify package source root. + + Used by bundled_skills tests to assert that BundledSkill.source_subpath + entries point at real files (independent of `importlib.resources` which + only sees installed packages). + """ + import graphify + return Path(graphify.__file__).parent.resolve() diff --git a/tests/test_agents_platform.py b/tests/test_agents_platform.py index 398623cfc..5908bf5a5 100644 --- a/tests/test_agents_platform.py +++ b/tests/test_agents_platform.py @@ -95,7 +95,7 @@ def test_uninstall_platform_agents_removes_user_global_skill(tmp_path): _run(cwd, ["uninstall"], home) assert not skill.exists() - # The now-empty skill tree is walked away. + # The now-empty skill tree is walked away (bundled skills also removed). assert not (home / ".agents" / "skills").exists() diff --git a/tests/test_export.py b/tests/test_export.py index 6a1a439d1..90af21f15 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -98,33 +98,25 @@ def test_to_html_contains_visjs(): to_html(G, communities, str(out)) content = out.read_text() assert "vis-network" in content + # New: same-origin reference, no CDN, no SRI, no crossorigin. + assert '' in content + assert 'unpkg.com' not in content + assert 'integrity=' not in content + assert 'crossorigin="anonymous"' not in content -def test_to_html_pins_visjs_version_with_sri(): - """vis-network script tag must use a pinned versioned URL with a sha384 - Subresource Integrity hash and crossorigin=anonymous. Without this, - a compromised CDN could ship arbitrary JavaScript into every rendered - graph viewer. The hash was verified against the upstream file at - https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js - (sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1). - Bumping the vis-network version MUST update both the URL and the hash. - """ +def test_to_html_emits_local_vis_network_asset(): + from graphify.export import _vendored_vis_js G = make_graph() communities = cluster(G) with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "graph.html" to_html(G, communities, str(out)) - content = out.read_text() - - # Versioned URL — unversioned `vis-network/standalone/...` is rejected. - assert "vis-network@9.1.6/standalone/umd/vis-network.min.js" in content - assert "https://unpkg.com/vis-network/standalone" not in content - - # SRI integrity attribute pinning the known-good hash. - assert 'integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"' in content + asset = out.parent / "vis-network.min.js" + assert asset.exists() + # Byte-equality with the vendored copy is the correctness contract. + assert asset.read_bytes() == _vendored_vis_js() - # crossorigin="anonymous" is required for SRI on cross-origin scripts. - assert 'crossorigin="anonymous"' in content def test_to_html_contains_search(): G = make_graph() @@ -517,3 +509,50 @@ def test_backup_env_disable(tmp_path, monkeypatch): (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}') (tmp_path / ".graphify_semantic_marker").write_text("{}") assert backup_if_protected(tmp_path) is None + + +def test_vis_network_filename_constant(): + from graphify.export import _VIS_NETWORK_FILENAME + assert _VIS_NETWORK_FILENAME == "vis-network.min.js" + + +def test_vendored_vis_js_returns_committed_file_bytes(): + from importlib.resources import files + expected = files("graphify").joinpath("assets", "vis-network.min.js").read_bytes() + from graphify.export import _vendored_vis_js + assert _vendored_vis_js() == expected + assert len(_vendored_vis_js()) > 0 + + +def test_emit_vis_js_creates_file_when_missing(tmp_path): + from graphify.export import _emit_vis_js, _VIS_NETWORK_FILENAME, _vendored_vis_js + target = tmp_path / "graph.html" # parent dir is what matters here + assert not (target.parent / _VIS_NETWORK_FILENAME).exists() + _emit_vis_js(target) + assert (target.parent / _VIS_NETWORK_FILENAME).exists() + assert (target.parent / _VIS_NETWORK_FILENAME).read_bytes() == _vendored_vis_js() + + +def test_emit_vis_js_skips_rewrite_when_bytes_identical(tmp_path): + from graphify.export import _emit_vis_js, _VIS_NETWORK_FILENAME, _vendored_vis_js + html = tmp_path / "graph.html" + _emit_vis_js(html) + target = html.parent / _VIS_NETWORK_FILENAME + mtime_before = target.stat().st_mtime_ns + # Force a tiny clock tick so a no-op write would still register. + import time + time.sleep(0.01) + _emit_vis_js(html) + mtime_after = target.stat().st_mtime_ns + assert mtime_after == mtime_before + assert target.read_bytes() == _vendored_vis_js() + + +def test_emit_vis_js_overwrites_when_vendored_changes(monkeypatch, tmp_path): + from graphify import export + monkeypatch.setattr(export, "_vendored_vis_js", lambda: b"DIFFERENT-BYTES") + from graphify.export import _emit_vis_js, _VIS_NETWORK_FILENAME + html = tmp_path / "graph.html" + _emit_vis_js(html) + target = html.parent / _VIS_NETWORK_FILENAME + assert target.read_bytes() == b"DIFFERENT-BYTES" diff --git a/tests/test_install_references.py b/tests/test_install_references.py index 0f6061a5b..3e9c75d17 100644 --- a/tests/test_install_references.py +++ b/tests/test_install_references.py @@ -130,7 +130,7 @@ def test_uninstall_removes_references_then_walks_dirs(tmp_path, fake_bundle): assert removed assert not skill_dir.exists() - # The 3-level walk collapsed the now-empty skill dirs. + # The 3-level walk collapsed the now-empty skill dirs (bundled skills also removed). assert not (tmp_path / ".claude" / "skills").exists() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index ce6055d8b..aff532c28 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -80,7 +80,7 @@ def run_pipeline(tmp_path: Path) -> dict: to_html(G, communities, str(html_path), community_labels=labels) assert html_path.exists() html = html_path.read_text() - assert "vis-network" in html + assert './vis-network.min.js' in html assert "RAW_NODES" in html # Step 9: export - Obsidian vault diff --git a/tests/tools/test_build_zip_installer.sh b/tests/tools/test_build_zip_installer.sh new file mode 100755 index 000000000..c4bb6e6e0 --- /dev/null +++ b/tests/tools/test_build_zip_installer.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Smoke test for tools/build_zip_installer.sh. +# Builds the zip in a temp dir and asserts structural / content invariants. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BUILD_SCRIPT="$REPO_ROOT/tools/build_zip_installer.sh" +WORK="$(mktemp -d -t graphify-zip-test.XXXXXX)" +trap 'rm -rf "$WORK"' EXIT + +if [[ ! -x "$BUILD_SCRIPT" ]]; then + echo "FAIL: $BUILD_SCRIPT is not executable" + exit 1 +fi + +echo "==> Running build script..." +env \ + INTERNAL_PYPI_PROXY="http://192.168.21.14:25000/pypi/repository/pypi-all/simple" \ + INTERNAL_TRUSTED_HOST="192.168.21.14" \ + INTERNAL_TIMEOUT="6000" \ + "$BUILD_SCRIPT" + +ZIP="$REPO_ROOT/dist/graphify-offline-installer.zip" +if [[ ! -f "$ZIP" ]]; then + echo "FAIL: $ZIP not produced" + exit 1 +fi + +# Extract to inspect +mkdir -p "$WORK/extracted" +unzip -q "$ZIP" -d "$WORK/extracted" + +# Invariant 1: install.bat exists, no placeholders, contains the real URL +INSTALL_BAT="$WORK/extracted/install.bat" +[[ -f "$INSTALL_BAT" ]] || { echo "FAIL: install.bat missing"; exit 1; } +grep -q "/dev/null || stat -c%s "$ZIP") +SIZE_MB=$((SIZE_BYTES / 1024 / 1024)) +[[ $SIZE_MB -lt 20 ]] || { echo "FAIL: zip too large ($SIZE_MB MB)"; exit 1; } + +echo "PASS: all invariants satisfied (zip=$SIZE_MB MB)" diff --git a/tools/build_zip_installer.sh b/tools/build_zip_installer.sh new file mode 100755 index 000000000..65904536c --- /dev/null +++ b/tools/build_zip_installer.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Build graphify-offline-installer.zip — minimal offline Windows installer. +# +# Runs on any platform with curl + unzip + zip + sed — no Python, no Windows. +# +# Outputs: dist/graphify-offline-installer.zip (~10 MB) +# +# Required inputs: +# tools/installer/install.bat (with placeholders) +# tools/installer/uninstall.bat +# docs/offline-installer-README.txt +# +# Configurable via env vars: +# INTERNAL_PYPI_PROXY default: http://192.168.21.14:25000/pypi/repository/pypi-all/simple +# INTERNAL_TRUSTED_HOST default: 192.168.21.14 +# INTERNAL_TIMEOUT default: 6000 +# PY_VERSION default: 3.12.10 + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="$REPO_ROOT/build/zip-installer" +DIST="$REPO_ROOT/dist" +CACHE="$REPO_ROOT/build/cache" + +PY_VERSION="${PY_VERSION:-3.12.10}" +EMBED_ZIP="python-${PY_VERSION}-embed-amd64.zip" +EMBED_URL="https://www.python.org/ftp/python/${PY_VERSION}/${EMBED_ZIP}" + +INTERNAL_PYPI_PROXY="${INTERNAL_PYPI_PROXY:-http://192.168.21.14:25000/pypi/repository/pypi-all/simple}" +INTERNAL_TRUSTED_HOST="${INTERNAL_TRUSTED_HOST:-192.168.21.14}" +INTERNAL_TIMEOUT="${INTERNAL_TIMEOUT:-6000}" + +# Sanity: required source files +for f in tools/installer/install.bat tools/installer/uninstall.bat docs/offline-installer-README.txt; do + [[ -f "$REPO_ROOT/$f" ]] || { echo "error: missing $f"; exit 1; } +done + +rm -rf "$BUILD" +mkdir -p "$BUILD/python" "$DIST" "$CACHE" + +# 1. Download (if needed) and extract Python embeddable +CACHED_ZIP="$CACHE/$EMBED_ZIP" +if [[ -f "$CACHED_ZIP" ]]; then + echo "==> Using cached Python ${PY_VERSION} embeddable ($CACHED_ZIP)" +else + echo "==> Downloading Python ${PY_VERSION} embeddable..." + curl -fsSL -o "$CACHED_ZIP" "$EMBED_URL" +fi +unzip -qo "$CACHED_ZIP" -d "$BUILD/python" + +# 2. Enable site-packages in _pth +_PTH="$(find "$BUILD/python" -maxdepth 1 -name 'python*._pth' | head -n1)" +[[ -n "$_PTH" ]] || { echo "error: no ._pth file found in python/"; exit 1; } +# The embeddable ._pth ships with CRLF line endings (Windows origin). Strip CRs +# first so the sed pattern matches the full line content cleanly. +tr -d '\r' < "$_PTH" > "${_PTH}.tmp" && mv "${_PTH}.tmp" "$_PTH" +sed -i.bak 's/^#import site$/import site/' "$_PTH" +rm -f "${_PTH}.bak" + +# 3. Build graphify wheel (pure Python, platform-independent) +echo "==> Building graphify wheel..." +mkdir -p "$BUILD/wheels" +uv run python -m build --wheel --outdir "$BUILD/wheels" + +# 4. Copy scripts and README +cp "$REPO_ROOT/tools/installer/install.bat" "$BUILD/" +cp "$REPO_ROOT/tools/installer/uninstall.bat" "$BUILD/" +cp "$REPO_ROOT/docs/offline-installer-README.txt" "$BUILD/README.txt" + +# 5. Substitute placeholders in install.bat +sed -i.bak \ + -e "s||${INTERNAL_PYPI_PROXY}|g" \ + -e "s||${INTERNAL_TRUSTED_HOST}|g" \ + -e "s||${INTERNAL_TIMEOUT}|g" \ + "$BUILD/install.bat" +rm -f "$BUILD/install.bat.bak" + +# 6. Package +OUT="$DIST/graphify-offline-installer.zip" +rm -f "$OUT" +cd "$BUILD" +zip -r "$OUT" . >/dev/null +cd - >/dev/null + +# 7. Report +SIZE_MB=$(du -m "$OUT" | cut -f1) +echo "==> Done: $OUT (${SIZE_MB} MB)" \ No newline at end of file diff --git a/tools/installer/.gitkeep b/tools/installer/.gitkeep new file mode 100644 index 000000000..88cafda87 --- /dev/null +++ b/tools/installer/.gitkeep @@ -0,0 +1,2 @@ +# Reserved for the new minimal zip-based offline installer (install.bat, uninstall.bat, README.txt). +# Empty placeholder until Tasks 2-4 populate the directory. diff --git a/tools/installer/install.bat b/tools/installer/install.bat new file mode 100644 index 000000000..80c5b7721 --- /dev/null +++ b/tools/installer/install.bat @@ -0,0 +1,80 @@ +@echo off +setlocal + +rem -- 1. Detect Python -- +set "NEED_PATH=0" +where python >nul 2>nul +if %ERRORLEVEL% == 0 ( + set "PYTHON=python" + echo [1/5] Using system python +) else ( + set "PYTHON=%~dp0python\python.exe" + set "NEED_PATH=1" + if not exist "%PYTHON%" ( + echo ERROR: python not found, check python\ subdirectory + pause + exit /b 1 + ) + echo [1/5] Using embedded Python: %PYTHON% +) + +rem -- 2. Pre-clean: uninstall old skill if graphify is already installed -- +"%PYTHON%" -c "import graphify" >nul 2>nul +if %ERRORLEVEL% == 0 ( + echo [2/5] Existing graphify found, cleaning up previous skill... + "%PYTHON%" -m graphify uninstall claude 2>nul +) + +rem -- 3. Configure PyPI proxy -- +set "PIP_INDEX_URL=" +set "PIP_TRUSTED_HOST=" +echo [3/5] Using PyPI proxy: %PIP_INDEX_URL% + +rem -- 4. Install graphifyy -- +echo [4/5] Installing graphifyy (~30-60 sec)... + +rem Resolve the wheel filename with CMD's native wildcard expansion. +rem Passing a glob directly to pip is fragile: pip's internal glob may +rem fail on paths containing parentheses or other special characters. +set "WHEEL_FILE=" +for %%f in ("%~dp0wheels\graphifyy-*.whl") do set "WHEEL_FILE=%%f" +if not defined WHEEL_FILE ( + echo ERROR: graphify wheel not found in wheels\ directory + echo Expected: wheels\graphifyy-*.whl + pause + exit /b 1 +) + +"%PYTHON%" -m pip install ^ + --upgrade ^ + --index-url "%PIP_INDEX_URL%" ^ + --trusted-host "%PIP_TRUSTED_HOST%" ^ + --timeout ^ + "%WHEEL_FILE%" +if errorlevel 1 ( + echo ERROR: pip install graphifyy failed + pause + exit /b 1 +) + +rem -- 5. Deploy SKILL.md -- +echo [5/5] Deploying SKILL.md to Claude Code... +"%PYTHON%" -m graphify install claude +if errorlevel 1 ( + echo WARNING: SKILL.md deploy failed, but graphifyy is installed +) + +rem -- 6. Register PATH (embedded Python only) -- +if "%NEED_PATH%"=="1" ( + echo [6/6] Registering PATH... + setx PATH "%PATH%;%~dp0python\Scripts" >nul + if errorlevel 1 ( + echo WARNING: PATH registration failed, add this path to user PATH manually: + echo %~dp0python\Scripts + ) +) + +echo. +echo [OK] Install complete. Open a new cmd window to use graphify. +pause +endlocal diff --git a/tools/installer/uninstall.bat b/tools/installer/uninstall.bat new file mode 100644 index 000000000..0a6ae075a --- /dev/null +++ b/tools/installer/uninstall.bat @@ -0,0 +1,38 @@ +@echo off +setlocal + +rem -- Detect Python (same logic as install.bat: prefer system, fall back to embedded) -- +where python >nul 2>nul +if %ERRORLEVEL% == 0 ( + set "PYTHON=python" + echo Using system python +) else ( + set "PYTHON=%~dp0python\python.exe" + if not exist "%PYTHON%" ( + echo ERROR: python not found, check python\ subdirectory + pause + exit /b 1 + ) + echo Using embedded Python: %PYTHON% +) + +echo. +echo [1/2] Uninstalling SKILL.md and bundled skills (all platforms)... +"%PYTHON%" -m graphify uninstall +if errorlevel 1 ( + echo WARNING: graphify uninstall had errors (may already be uninstalled) + pause +) + +echo. +echo [2/2] Uninstalling graphifyy package... +"%PYTHON%" -m pip uninstall -y graphifyy +if errorlevel 1 ( + echo WARNING: pip uninstall graphifyy failed (may already be uninstalled) + pause +) + +echo. +echo [OK] Uninstall complete. Installer files kept for future use. +pause +endlocal diff --git a/uv.lock b/uv.lock index 70e258d84..50f831fd3 100644 --- a/uv.lock +++ b/uv.lock @@ -1150,7 +1150,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1282,6 +1282,48 @@ video = [ watch = [ { name = "watchdog" }, ] +windows-offline = [ + { name = "anthropic" }, + { name = "graspologic", marker = "python_full_version < '3.13'" }, + { name = "jieba" }, + { name = "matplotlib" }, + { name = "mcp" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "rapidfuzz" }, + { name = "starlette" }, + { name = "tree-sitter" }, + { name = "tree-sitter-bash" }, + { name = "tree-sitter-c" }, + { name = "tree-sitter-c-sharp" }, + { name = "tree-sitter-cpp" }, + { name = "tree-sitter-elixir" }, + { name = "tree-sitter-fortran" }, + { name = "tree-sitter-go" }, + { name = "tree-sitter-groovy" }, + { name = "tree-sitter-hcl" }, + { name = "tree-sitter-java" }, + { name = "tree-sitter-javascript" }, + { name = "tree-sitter-json" }, + { name = "tree-sitter-julia" }, + { name = "tree-sitter-kotlin" }, + { name = "tree-sitter-lua" }, + { name = "tree-sitter-objc" }, + { name = "tree-sitter-php" }, + { name = "tree-sitter-powershell" }, + { name = "tree-sitter-python" }, + { name = "tree-sitter-ruby" }, + { name = "tree-sitter-rust" }, + { name = "tree-sitter-scala" }, + { name = "tree-sitter-sql" }, + { name = "tree-sitter-swift" }, + { name = "tree-sitter-typescript" }, + { name = "tree-sitter-verilog" }, + { name = "tree-sitter-zig" }, + { name = "watchdog" }, +] [package.dev-dependencies] dev = [ @@ -1289,24 +1331,28 @@ dev = [ { name = "build" }, { name = "hypothesis" }, { name = "nuitka" }, + { name = "ordered-set" }, { name = "patchelf", marker = "sys_platform != 'win32'" }, { name = "pip-audit" }, { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "safety" }, { name = "setuptools" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tree-sitter-hcl" }, { name = "wheel" }, + { name = "zstandard" }, ] [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'all'" }, { name = "anthropic", marker = "extra == 'anthropic'" }, + { name = "anthropic", marker = "extra == 'windows-offline'" }, { name = "boto3", marker = "extra == 'all'" }, { name = "boto3", marker = "extra == 'bedrock'" }, { name = "falkordb", marker = "extra == 'all'" }, @@ -1315,20 +1361,26 @@ requires-dist = [ { name = "faster-whisper", marker = "python_full_version >= '3.11' and extra == 'video'" }, { name = "graspologic", marker = "python_full_version < '3.13' and extra == 'all'" }, { name = "graspologic", marker = "python_full_version < '3.13' and extra == 'leiden'" }, + { name = "graspologic", marker = "python_full_version < '3.13' and extra == 'windows-offline'" }, { name = "jieba", marker = "extra == 'all'" }, { name = "jieba", marker = "extra == 'chinese'" }, + { name = "jieba", marker = "extra == 'windows-offline'" }, { name = "markdownify", marker = "extra == 'all'" }, { name = "markdownify", marker = "extra == 'pdf'" }, { name = "matplotlib", marker = "extra == 'all'" }, { name = "matplotlib", marker = "extra == 'svg'" }, + { name = "matplotlib", marker = "extra == 'windows-offline'" }, { name = "mcp", marker = "extra == 'all'" }, { name = "mcp", marker = "extra == 'mcp'" }, + { name = "mcp", marker = "extra == 'windows-offline'" }, { name = "neo4j", marker = "extra == 'all'" }, { name = "neo4j", marker = "extra == 'neo4j'" }, { name = "networkx", specifier = ">=3.4" }, + { name = "networkx", marker = "extra == 'windows-offline'", specifier = ">=3.4" }, { name = "numpy", specifier = ">=1.21" }, { name = "numpy", marker = "python_full_version >= '3.13' and extra == 'all'", specifier = ">=2.0" }, { name = "numpy", marker = "python_full_version >= '3.13' and extra == 'svg'", specifier = ">=2.0" }, + { name = "numpy", marker = "extra == 'windows-offline'", specifier = ">=1.21" }, { name = "openai", marker = "extra == 'all'" }, { name = "openai", marker = "extra == 'gemini'" }, { name = "openai", marker = "extra == 'kimi'" }, @@ -1343,50 +1395,81 @@ requires-dist = [ { name = "python-docx", marker = "extra == 'all'" }, { name = "python-docx", marker = "extra == 'office'" }, { name = "rapidfuzz", specifier = ">=3.0" }, + { name = "rapidfuzz", marker = "extra == 'windows-offline'", specifier = ">=3.0" }, { name = "starlette", marker = "extra == 'all'", specifier = ">=1.3.1" }, { name = "starlette", marker = "extra == 'mcp'", specifier = ">=1.3.1" }, + { name = "starlette", marker = "extra == 'windows-offline'", specifier = ">=1.3.1" }, { name = "tiktoken", marker = "extra == 'all'" }, { name = "tiktoken", marker = "extra == 'gemini'" }, { name = "tiktoken", marker = "extra == 'kimi'" }, { name = "tiktoken", marker = "extra == 'openai'" }, { name = "tree-sitter", specifier = ">=0.23.0,<0.26" }, + { name = "tree-sitter", marker = "extra == 'windows-offline'", specifier = ">=0.23.0,<0.26" }, { name = "tree-sitter-bash", specifier = ">=0.23,<0.27" }, + { name = "tree-sitter-bash", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.27" }, { name = "tree-sitter-c", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-c", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-c-sharp", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-c-sharp", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-cpp", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-cpp", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-dm", marker = "extra == 'all'" }, { name = "tree-sitter-dm", marker = "extra == 'dm'" }, { name = "tree-sitter-elixir", specifier = ">=0.3,<0.5" }, + { name = "tree-sitter-elixir", marker = "extra == 'windows-offline'", specifier = ">=0.3,<0.5" }, { name = "tree-sitter-fortran", specifier = ">=0.6,<0.8" }, + { name = "tree-sitter-fortran", marker = "extra == 'windows-offline'", specifier = ">=0.6,<0.8" }, { name = "tree-sitter-go", specifier = ">=0.23,<0.26" }, + { name = "tree-sitter-go", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-groovy", specifier = ">=0.1,<0.3" }, + { name = "tree-sitter-groovy", marker = "extra == 'windows-offline'", specifier = ">=0.1,<0.3" }, { name = "tree-sitter-hcl", marker = "extra == 'all'" }, { name = "tree-sitter-hcl", marker = "extra == 'terraform'" }, + { name = "tree-sitter-hcl", marker = "extra == 'windows-offline'" }, { name = "tree-sitter-java", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-java", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-javascript", specifier = ">=0.23,<0.26" }, + { name = "tree-sitter-javascript", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-json", specifier = ">=0.23,<0.26" }, + { name = "tree-sitter-json", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-julia", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-julia", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-kotlin", specifier = ">=1.0,<2.0" }, + { name = "tree-sitter-kotlin", marker = "extra == 'windows-offline'", specifier = ">=1.0,<2.0" }, { name = "tree-sitter-lua", specifier = ">=0.2,<0.6" }, + { name = "tree-sitter-lua", marker = "extra == 'windows-offline'", specifier = ">=0.2,<0.6" }, { name = "tree-sitter-objc", specifier = ">=3.0,<4.0" }, + { name = "tree-sitter-objc", marker = "extra == 'windows-offline'", specifier = ">=3.0,<4.0" }, { name = "tree-sitter-php", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-php", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-powershell", specifier = ">=0.26,<0.28" }, + { name = "tree-sitter-powershell", marker = "extra == 'windows-offline'", specifier = ">=0.26,<0.28" }, { name = "tree-sitter-python", specifier = ">=0.23,<0.26" }, + { name = "tree-sitter-python", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-ruby", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-ruby", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-rust", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-rust", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-scala", specifier = ">=0.23,<0.27" }, + { name = "tree-sitter-scala", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.27" }, { name = "tree-sitter-sql", marker = "extra == 'all'" }, { name = "tree-sitter-sql", marker = "extra == 'sql'" }, + { name = "tree-sitter-sql", marker = "extra == 'windows-offline'" }, { name = "tree-sitter-swift", specifier = ">=0.7,<0.9" }, + { name = "tree-sitter-swift", marker = "extra == 'windows-offline'", specifier = ">=0.7,<0.9" }, { name = "tree-sitter-typescript", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-typescript", marker = "extra == 'windows-offline'", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-verilog", specifier = ">=1.0,<2.0" }, + { name = "tree-sitter-verilog", marker = "extra == 'windows-offline'", specifier = ">=1.0,<2.0" }, { name = "tree-sitter-zig", specifier = ">=1.0,<2.0" }, + { name = "tree-sitter-zig", marker = "extra == 'windows-offline'", specifier = ">=1.0,<2.0" }, { name = "watchdog", marker = "extra == 'all'" }, { name = "watchdog", marker = "extra == 'watch'" }, + { name = "watchdog", marker = "extra == 'windows-offline'" }, { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.6.9" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.6.9" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "dm", "terraform", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "dm", "terraform", "windows-offline", "all"] [package.metadata.requires-dev] dev = [ @@ -1394,18 +1477,21 @@ dev = [ { name = "build", specifier = ">=1.5.0" }, { name = "hypothesis", specifier = ">=6.152.7" }, { name = "nuitka", specifier = ">=4.1" }, + { name = "ordered-set", specifier = ">=4.1" }, { name = "patchelf", marker = "sys_platform != 'win32'", specifier = ">=0.17.2.4" }, { name = "pip-audit", specifier = ">=2.10.0" }, { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pyright", specifier = ">=1.1.409" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.15.13" }, { name = "safety", specifier = ">=3.7.0" }, { name = "setuptools", specifier = ">=82.0.1" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "tree-sitter-hcl", specifier = ">=1.2.0" }, { name = "wheel", specifier = ">=0.47.0" }, + { name = "zstandard", specifier = ">=0.18" }, ] [[package]] @@ -2685,6 +2771,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] +[[package]] +name = "ordered-set" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6" @@ -5406,3 +5501,93 @@ sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964d wheels = [ { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +]