diff --git a/HERMES.md b/HERMES.md new file mode 100644 index 00000000..714d30f8 --- /dev/null +++ b/HERMES.md @@ -0,0 +1,40 @@ +# llm-wiki-nashsu — Hermes Skill 入口 + +> **适配状态**:⚠️ 部分适配(需完成 GUI→CLI 工程改造后方可完整使用) +> **当前可用**:graph、insights、search 命令(无需 LLM) +> **待完成**:ingest、deep-research 命令(需替换 Tauri IPC → Node.js fs) + +## 触发条件 + +加载本技能当用户明确提到: +- "图谱分析"、"知识图谱"、"图谱洞察" +- "深度研究" +- "知识缺口"、"惊人连接" + +## 与 llm-wiki-skill 的关系 + +本技能**补充** llm-wiki-skill,提供更高级的图谱分析能力: +- llm-wiki-skill:负责日常 ingest、Hermes 调度、中文内容源 +- llm-wiki-nashsu:负责高级图谱分析、深度研究 + +## 主要工作流 + +详见 `SKILL.md`。 + +## 安装路径 + +```bash +# Hermes 安装 +bash install.sh --platform hermes + +# 直接使用 +node skill/cli.js graph +node skill/cli.js insights +node skill/cli.js search +``` + +## 注意事项 + +- Node.js >= 20 运行时必须可用 +- 中文素材源(微信/知乎/小红书)请使用 llm-wiki-skill +- ingest 功能目前需要完成 Tauri IPC 替换工程(约 10-13 人日) diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 00000000..cc27b77f --- /dev/null +++ b/SKILL.md @@ -0,0 +1,293 @@ +--- +name: llm-wiki-nashsu +version: 0.4.6-skill +author: nashsu (GUI→Skill 适配: bid-sys team) +license: MIT +description: | + 基于 nashsu/llm_wiki 后端逻辑提取的知识库技能(无 GUI)。 + 核心算法包括:4 信号图谱相关度模型、Louvain 社区检测、图谱洞察(惊人连接+知识缺口)、 + RRF 混合搜索(BM25+向量)、深度研究(网络搜索+自动消化)、异步审核队列。 + 触发条件:用户明确提到知识库、wiki、图谱分析、深度研究,或要求对已初始化的知识库执行 + 消化、搜索、健康检查等操作。 +metadata: + hermes: + tags: + - knowledge-base + - wiki + - graph-analysis + - deep-research + - semantic-search + origin: nashsu/llm_wiki (GUI stripped, backend extracted) + runtime: node >= 20 + adapted_from: https://github.com/nashsu/llm_wiki +--- + +# llm-wiki-nashsu — 高级知识库后端技能 + +> 从 nashsu/llm_wiki 提取的后端逻辑,去除 Tauri GUI 后适配为 Hermes Skill。 +> 与 llm-wiki-skill 相比,本技能具有**显著更强的图谱分析能力**,但需要 Node.js 运行时。 + +## 核心差异化能力 + +| 能力 | 本技能 | llm-wiki-skill | +|------|-------|----------------| +| **图谱相关度** | 4 信号模型(直接链接×3 + 来源重叠×4 + Adamic-Adar×1.5 + 类型亲和×1)| 仅 wikilink,无权重 | +| **社区检测** | Louvain 算法 + 凝聚度评分 | 主题页→社区(启发式)| +| **图谱洞察** | 惊人连接 + 知识缺口 + 桥节点检测 | 无 | +| **搜索** | RRF 混合(BM25 + 向量) | Grep 关键词 | +| **深度研究** | 网络搜索→LLM 综合→自动消化 | 无 | +| **审核队列** | 异步异步 sweep-reviews 系统 | 无 | +| **图像处理** | 视觉 API 图像标注管线 | 无 | + +--- + +## Script Directory + +Scripts located in `skill/` subdirectory relative to this SKILL.md. + +**Path Resolution**: +1. `SKILL_DIR` = this SKILL.md's directory +2. Script path = `${SKILL_DIR}/skill/` + +--- + +## 依赖要求 + +``` +node >= 20 +npm >= 9 +``` + +**可选依赖(启用向量搜索)**: +- 配置 `EMBEDDING_API_BASE` 和 `EMBEDDING_MODEL` 环境变量(OpenAI 兼容端点) + +--- + +## 工作流命令 + +### 1. init — 初始化知识库 + +```bash +node ${SKILL_DIR}/skill/cli.js init [topic] [lang] +``` + +**参数**: +- `wiki_root`:wiki 工作目录(绝对路径) +- `topic`:知识库主题(可选,默认 "My Knowledge Base") +- `lang`:语言(可选,`zh`|`en`,默认 `en`) + +**产物**: +``` +/ +├── wiki/ +│ ├── entities/ +│ ├── concepts/ +│ ├── sources/ +│ ├── queries/ +│ └── index.md +├── raw/ +└── .wiki-config.json +``` + +--- + +### 2. ingest — 消化素材 + +```bash +node ${SKILL_DIR}/skill/cli.js ingest [--llm-api-key=KEY] +``` + +**参数**: +- `wiki_root`:wiki 工作目录 +- `file_path`:待消化的文件路径(支持 .md / .txt;PDF/DOCX 需先转为文本) +- `--llm-api-key`:LLM API Key(也可通过 `OPENAI_API_KEY` 环境变量传入) + +**处理流程**(源自 `ingest.ts`): +1. **Step 1**:LLM 分析素材 → 生成结构化 JSON(实体、概念、关系) +2. **Step 2**:基于 JSON 生成 wiki 页面: + - `wiki/sources/{slug}.md` — 素材摘要页(含 `sources: []` frontmatter) + - `wiki/entities/{name}.md` — 实体页(仅限新实体) + - `wiki/concepts/{name}.md` — 概念页(仅限新概念) +3. **自动消化**:生成的页面自动进入 wiki 图谱(下次 graph 命令时生效) +4. **审核标记**:LLM 自动标记需人工判断的条目(`review: true` frontmatter) + +**产物示例**: +```json +{ + "status": "success", + "pages": [ + "wiki/sources/2026-04-30-企业资质证书.md", + "wiki/entities/市政公用工程施工总承包壹级.md" + ], + "reviews_pending": 1 +} +``` + +--- + +### 3. batch-ingest — 批量消化 + +```bash +node ${SKILL_DIR}/skill/cli.js batch-ingest +``` + +按目录递归处理所有 `.md`/`.txt` 文件,保留目录结构作为分类上下文。 +失败不阻塞后续文件(标记失败项,继续)。 + +--- + +### 4. search — 智能搜索 + +```bash +node ${SKILL_DIR}/skill/cli.js search [--limit=20] +``` + +**算法**(源自 `search.ts`,18KB): +1. **BM25 词法搜索**:中文 CJK bigram 分词 + 停用词过滤 + 精确词组匹配加权 +2. **向量语义搜索**(可选):LanceDB ANN 检索(需配置嵌入端点) +3. **RRF 融合**:倒数秩融合(K=60),避免量纲不一致 + +**输出**:JSON 格式检索结果(path, title, snippet, score, images) + +--- + +### 5. graph — 构建知识图谱 + +```bash +node ${SKILL_DIR}/skill/cli.js graph [--output=graph-data.json] +``` + +**算法**(源自 `wiki-graph.ts` + `graph-relevance.ts`): +1. **读取所有 wiki 页面**,提取标题、类型、wikilink +2. **4 信号相关度计算**(每条边): + - 直接链接(weight 3.0) + - 来源重叠(weight 4.0,基于 `sources: []` frontmatter) + - Adamic-Adar 共同邻居(weight 1.5) + - 类型亲和度(weight 1.0) +3. **Louvain 社区检测**(graphology-communities-louvain): + - 自动聚类,计算每个社区凝聚度(实际边/可能边) + - 低凝聚度社区(<0.15)标记为警告 +4. **输出**:`graph-data.json`(nodes + edges + communities) + +**输出格式**: +```json +{ + "nodes": [{ "id": "xxx", "label": "...", "type": "entity", "linkCount": 5, "community": 0 }], + "edges": [{ "source": "xxx", "target": "yyy", "weight": 7.2 }], + "communities": [{ "id": 0, "nodeCount": 12, "cohesion": 0.24, "topNodes": ["..."] }] +} +``` + +--- + +### 6. insights — 图谱洞察 + +```bash +node ${SKILL_DIR}/skill/cli.js insights +``` + +**算法**(源自 `graph-insights.ts`,193 行): +1. **惊人连接**(Surprising Connections): + - 跨社区边 +3,跨类型边 +2,边缘↔枢纽耦合 +2,弱连接 +1 + - 阈值 ≥3 才输出 +2. **知识缺口**(Knowledge Gaps): + - 孤立节点(degree ≤1) + - 稀疏社区(cohesion <0.15 且 ≥3 节点) + - 桥节点(连接 ≥3 个社区) + +**输出**:Markdown 格式洞察报告 + +--- + +### 7. deep-research — 深度研究 + +```bash +node ${SKILL_DIR}/skill/cli.js deep-research [--queries="q1|q2|q3"] +``` + +**流程**(源自 `deep-research.ts`,244 行): +1. **网络搜索**:多查询并行搜索(Tavily API),URL 去重合并 +2. **LLM 综合**:搜索结果 → wiki 页面(带 `[[wikilink]]` 交叉引用) +3. **保存**:`wiki/queries/research-{slug}-{date}.md` +4. **自动消化**:研究结果自动 ingest,提取实体/概念 + +**环境变量**:`TAVILY_API_KEY`(或 `SERPER_API_KEY`) + +--- + +### 8. lint — 健康检查 + +```bash +node ${SKILL_DIR}/skill/cli.js lint +``` + +**检查项**(源自 `lint.ts`): +- 孤立页面(无入链且无出链) +- 断链(`[[wikilink]]` 指向不存在的页面) +- 过短页面(< 100 字) +- 语言不一致(frontmatter `lang` 与内容不符) +- 重复内容(相似度过高的页面) + +--- + +### 9. sweep-reviews — 处理审核队列 + +```bash +node ${SKILL_DIR}/skill/cli.js sweep-reviews +``` + +**功能**(源自 `sweep-reviews.ts`,14KB): +- 扫描所有 `review: true` 的 wiki 页面 +- 基于规则匹配 + LLM 语义判断自动解决 +- 预定义动作:Create Page / Skip(防止 LLM 幻觉任意动作) + +--- + +### 10. status — 知识库状态 + +```bash +node ${SKILL_DIR}/skill/cli.js status +``` + +**输出**:JSON 格式统计(页面数、实体数、概念数、源数、待审核数) + +--- + +## 配置环境变量 + +| 变量 | 用途 | 示例 | +|------|------|------| +| `OPENAI_API_KEY` | LLM API Key(OpenAI/Anthropic 兼容)| `sk-...` | +| `OPENAI_API_BASE` | 自定义 LLM 端点(Ollama/代理)| `http://localhost:11434/v1` | +| `LLM_MODEL` | 模型名称 | `gpt-4o` / `claude-3-5-sonnet` | +| `EMBEDDING_API_BASE` | 嵌入端点(可选,启用向量搜索)| `http://localhost:11434/v1` | +| `EMBEDDING_MODEL` | 嵌入模型(可选)| `text-embedding-3-small` | +| `TAVILY_API_KEY` | 深度研究搜索 API(可选)| `tvly-...` | + +--- + +## 与 llm-wiki-skill 的关键互补 + +本技能建议**配合** llm-wiki-skill 使用而非替代: + +| 场景 | 推荐方案 | +|------|---------| +| 日常 ingest(速度优先)| llm-wiki-skill(Shell,零开销)| +| 图谱质量分析 | 本技能(graph + insights 命令)| +| 深度研究专项 | 本技能(deep-research 命令)| +| 中文内容源(微信/知乎/小红书)| llm-wiki-skill | +| Hermes Runtime 集成 | llm-wiki-skill(已有 HERMES.md)| +| 本技能 Hermes 集成 | 参见 HERMES.md(需手动适配)| + +--- + +## 安装 + +```bash +# 安装 CLI 依赖 +cd ${SKILL_DIR}/skill +npm install + +# 验证安装 +node cli.js --version +``` diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..71d1d6f3 --- /dev/null +++ b/install.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# llm-wiki-nashsu install script +# Installs the nashsu backend skill (CLI-based, no GUI) + +set -euo pipefail + +SKILL_DIR="$(cd "$(dirname "$0")" && pwd)" +PLATFORM="${1:-}" + +install_skill_files() { + local target_dir="$1" + mkdir -p "$target_dir" + cp -r "$SKILL_DIR"/* "$target_dir/" + echo "✅ Skill files installed to $target_dir" +} + +install_npm_deps() { + local skill_dir="${1:-$SKILL_DIR}" + if [ -d "$skill_dir/skill" ]; then + echo "📦 Installing Node.js dependencies..." + cd "$skill_dir/skill" + npm install --quiet + echo "✅ Dependencies installed" + fi +} + +echo "🔧 llm-wiki-nashsu Skill Installer" +echo " Source: $SKILL_DIR" +echo "" + +case "$PLATFORM" in + --platform=hermes|--platform\ hermes) + HERMES_SKILLS="${HOME}/.hermes/skills" + TARGET="${HERMES_SKILLS}/llm-wiki-nashsu" + echo "🎯 Platform: Hermes" + install_skill_files "$TARGET" + install_npm_deps "$TARGET" + echo "" + echo "✅ Installed to: $TARGET" + echo " Usage: hermes run llm-wiki-nashsu graph " + ;; + + --platform=claude|--platform\ claude) + echo "🎯 Platform: Claude Code" + echo " Add to CLAUDE.md:" + echo " @${SKILL_DIR}/SKILL.md" + install_npm_deps + ;; + + "") + echo "📦 Local installation (no platform)" + install_npm_deps + echo "" + echo "✅ Ready. Usage:" + echo " node ${SKILL_DIR}/skill/src/cli.ts graph " + ;; + + *) + echo "⚠️ Unknown platform: $PLATFORM" + echo " Supported: --platform hermes, --platform claude" + exit 1 + ;; +esac diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 00000000..bcdeb72f --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,78 @@ +# llm_wiki MCP Server + +An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that exposes **llm_wiki** backend operations as AI-callable tools. + +Use it with Claude Desktop, VS Code Copilot Chat, Cursor, or any MCP-compatible host to give your AI assistant direct access to your wiki knowledge base. + +## Tools + +| Tool | Description | +|------|-------------| +| `wiki_status` | Page count and type breakdown | +| `wiki_search` | BM25 keyword search (+ optional vector via `EMBEDDING_ENABLED=true`) | +| `wiki_graph` | Build Louvain knowledge graph — nodes, edges, community clusters | +| `wiki_insights` | Find surprising cross-community connections + knowledge gaps | +| `wiki_lint` | Structural lint: orphaned pages, isolated nodes, broken links | + +## Quick Start + +```bash +cd mcp-server +npm install +npm run build +``` + +### Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "llm-wiki": { + "command": "node", + "args": ["/path/to/llm_wiki/mcp-server/dist/index.js"], + "env": { + "WIKI_PATH": "/path/to/your/wiki-project" + } + } + } +} +``` + +### VS Code Copilot (`.vscode/mcp.json`) + +```json +{ + "servers": { + "llm-wiki": { + "type": "stdio", + "command": "node", + "args": ["${workspaceFolder}/mcp-server/dist/index.js"], + "env": { "WIKI_PATH": "${workspaceFolder}" } + } + } +} +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `WIKI_PATH` | Default project path (used when `project_path` not specified) | `process.cwd()` | +| `EMBEDDING_ENABLED` | Enable vector search via LanceDB | `false` | +| `EMBEDDING_MODEL` | Embedding model name (e.g. `text-embedding-3-small`) | — | +| `OPENAI_API_KEY` | API key for LLM + embedding calls | — | +| `SKILL_VERBOSE` | Set to `1` for verbose activity logging | — | + +## Architecture + +The MCP server runs entirely in Node.js without the Tauri desktop app. It replaces the Tauri IPC layer (`@/commands/fs`) with standard Node.js `fs` operations, making it suitable for headless server and CI/CD environments. + +**Capabilities without Tauri**: +- ✅ `wiki_search` — BM25 keyword search +- ✅ `wiki_graph` — Louvain community detection +- ✅ `wiki_insights` — Surprising connections + knowledge gaps +- ✅ `wiki_lint` — Structural lint +- ⚠️ `wiki_search` with vector — requires `EMBEDDING_ENABLED=true` + configured API +- ❌ `ingest` — PDF/DOCX extraction not supported (use pre-converted Markdown) diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json new file mode 100644 index 00000000..68e090ca --- /dev/null +++ b/mcp-server/package-lock.json @@ -0,0 +1,1269 @@ +{ + "name": "llm-wiki-mcp-server", + "version": "0.4.6", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "llm-wiki-mcp-server", + "version": "0.4.6", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "graphology": "^0.25.4", + "graphology-communities-louvain": "^2.0.0" + }, + "bin": { + "llm-wiki-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphology": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.25.4.tgz", + "integrity": "sha512-33g0Ol9nkWdD6ulw687viS8YJQBxqG5LWII6FI6nul0pq6iM2t5EKquOTFDbyTblRB3O9I+7KX4xI8u5ffekAQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "obliterator": "^2.0.2" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-communities-louvain": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", + "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.4", + "mnemonist": "^0.39.0", + "pandemonium": "^2.4.1" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.16", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", + "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pandemonium": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", + "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", + "license": "MIT", + "dependencies": { + "mnemonist": "^0.39.2" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 00000000..523cd426 --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,26 @@ +{ + "name": "llm-wiki-mcp-server", + "version": "0.4.6", + "description": "MCP server for llm_wiki — exposes wiki graph, search, insights, and lint as Model Context Protocol tools", + "main": "dist/index.js", + "bin": { + "llm-wiki-mcp": "dist/index.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "keywords": ["mcp", "wiki", "knowledge-graph", "llm", "model-context-protocol"], + "license": "MIT", + "dependencies": { + "graphology": "^0.25.4", + "graphology-communities-louvain": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 00000000..b07ee8df --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** + * llm-wiki MCP Server + * + * Exposes nashsu/llm_wiki backend operations as Model Context Protocol tools. + * Works with Claude Desktop, VS Code Copilot, and any MCP-compatible host. + * + * Tools: + * wiki_status — Page count and type breakdown for a project + * wiki_search — BM25 keyword search (+ optional vector via EMBEDDING_ENABLED) + * wiki_graph — Build knowledge graph (nodes, edges, Louvain communities) + * wiki_insights — Surprising connections and knowledge gaps analysis + * wiki_lint — Structural lint: orphans, no-outlinks, broken links + * + * Usage: + * node dist/index.js + * WIKI_PATH=/path/to/project node dist/index.js (default project path) + */ +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ErrorCode, + McpError, +} from "@modelcontextprotocol/sdk/types.js" + +import * as path from "path" +import { buildWikiGraph } from "./lib/wiki-graph" +import { findSurprisingConnections, detectKnowledgeGaps } from "./lib/graph-insights" +import { searchWiki } from "./lib/search" + +const DEFAULT_WIKI_PATH = process.env.WIKI_PATH ?? process.cwd() +const PKG_VERSION = "0.4.6-mcp" + +const server = new Server( + { name: "llm-wiki", version: PKG_VERSION }, + { capabilities: { tools: {} } }, +) + +// ── Tool definitions ────────────────────────────────────────────────────────── +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "wiki_status", + description: "Get page count and type breakdown for a wiki project. Returns statistics about the knowledge base.", + inputSchema: { + type: "object", + properties: { + project_path: { + type: "string", + description: "Absolute path to the wiki project directory (contains wiki/ subdirectory)", + }, + }, + required: [], + }, + }, + { + name: "wiki_search", + description: "Search wiki pages using BM25 keyword matching with optional vector search (RRF fusion). Returns ranked results with snippets.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query (supports Chinese and English)" }, + project_path: { type: "string", description: "Path to wiki project (defaults to WIKI_PATH env var)" }, + limit: { type: "number", description: "Max results to return (default: 10)" }, + }, + required: ["query"], + }, + }, + { + name: "wiki_graph", + description: "Build knowledge graph from wiki pages: wikilinks, type-based edges, Louvain community detection. Returns nodes, edges, and community clusters.", + inputSchema: { + type: "object", + properties: { + project_path: { type: "string", description: "Path to wiki project" }, + format: { + type: "string", + enum: ["json", "summary"], + description: "Output format: 'json' for full graph data, 'summary' for human-readable overview (default: summary)", + }, + }, + required: [], + }, + }, + { + name: "wiki_insights", + description: "Analyze wiki graph structure to find surprising cross-community connections and knowledge gaps (isolated pages, sparse clusters, bridge nodes).", + inputSchema: { + type: "object", + properties: { + project_path: { type: "string", description: "Path to wiki project" }, + max_connections: { type: "number", description: "Max surprising connections to return (default: 5)" }, + max_gaps: { type: "number", description: "Max knowledge gaps to return (default: 8)" }, + }, + required: [], + }, + }, + { + name: "wiki_lint", + description: "Structural lint of wiki pages: find orphaned pages (no links), no-outlinks, and connectivity issues.", + inputSchema: { + type: "object", + properties: { + project_path: { type: "string", description: "Path to wiki project" }, + }, + required: [], + }, + }, + ], +})) + +// ── Tool handlers ───────────────────────────────────────────────────────────── +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params + const projectPath = path.resolve((args.project_path as string | undefined) ?? DEFAULT_WIKI_PATH) + + try { + switch (name) { + case "wiki_status": { + const { nodes, communities } = await buildWikiGraph(projectPath) + const typeCounts: Record = {} + for (const n of nodes) typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1 + const summary = [ + `Wiki: ${projectPath}`, + `Total pages: ${nodes.length}`, + `Communities: ${communities.length}`, + ...Object.entries(typeCounts) + .sort((a, b) => b[1] - a[1]) + .map(([t, c]) => ` ${t}: ${c}`), + ].join("\n") + return { content: [{ type: "text", text: summary }] } + } + + case "wiki_search": { + if (!args.query) throw new McpError(ErrorCode.InvalidParams, "query is required") + const results = await searchWiki(projectPath, args.query as string) + const limit = typeof args.limit === "number" ? args.limit : 10 + const top = results.slice(0, limit) + if (top.length === 0) { + return { content: [{ type: "text", text: `No results for: "${args.query}"` }] } + } + const lines = [`# Search: "${args.query}"\n`] + for (const r of top) { + const relPath = path.relative(projectPath, r.path) + lines.push(`## ${r.title}`) + lines.push(`**Path**: ${relPath} | **Score**: ${r.score.toFixed(4)}`) + lines.push(r.snippet) + lines.push("") + } + return { content: [{ type: "text", text: lines.join("\n") }] } + } + + case "wiki_graph": { + const graphData = await buildWikiGraph(projectPath) + const format = (args.format as string | undefined) ?? "summary" + if (format === "json") { + return { content: [{ type: "text", text: JSON.stringify(graphData, null, 2) }] } + } + // Summary format + const { nodes, edges, communities } = graphData + const typeCounts: Record = {} + for (const n of nodes) typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1 + const lines = [ + `# Knowledge Graph Summary`, + ``, + `**Nodes**: ${nodes.length} | **Edges**: ${edges.length} | **Communities**: ${communities.length}`, + ``, + `## Node Types`, + ...Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).map(([t, c]) => `- ${t}: ${c}`), + ``, + `## Top Communities`, + ...communities.slice(0, 5).map((c, i) => + `### Community ${i + 1} (${c.nodeCount} pages, cohesion: ${c.cohesion.toFixed(2)})\nKey pages: ${c.topNodes.join(", ")}` + ), + ``, + `## Top Hubs (by link count)`, + ...nodes.sort((a, b) => b.linkCount - a.linkCount).slice(0, 10) + .map((n) => `- ${n.label} (${n.type}, ${n.linkCount} links)`), + ] + return { content: [{ type: "text", text: lines.join("\n") }] } + } + + case "wiki_insights": { + const { nodes, edges, communities } = await buildWikiGraph(projectPath) + const maxConn = typeof args.max_connections === "number" ? args.max_connections : 5 + const maxGaps = typeof args.max_gaps === "number" ? args.max_gaps : 8 + const connections = findSurprisingConnections(nodes, edges, communities, maxConn) + const gaps = detectKnowledgeGaps(nodes, edges, communities, maxGaps) + + const lines = [`# Wiki Insights\n`, `## Surprising Connections\n`] + if (connections.length === 0) lines.push("_No surprising connections found yet._\n") + for (const c of connections) { + lines.push(`### ${c.source.label} ↔ ${c.target.label}`) + lines.push(`- Score: ${c.score} | ${c.reasons.join(", ")}\n`) + } + lines.push(`## Knowledge Gaps\n`) + if (gaps.length === 0) lines.push("_No gaps detected._\n") + for (const g of gaps) { + lines.push(`### ${g.title}`) + lines.push(`${g.description}`) + lines.push(`💡 ${g.suggestion}\n`) + } + return { content: [{ type: "text", text: lines.join("\n") }] } + } + + case "wiki_lint": { + const { nodes, edges } = await buildWikiGraph(projectPath) + if (nodes.length === 0) { + return { content: [{ type: "text", text: "No wiki pages found." }] } + } + const edgeTargets = new Set(edges.map((e) => e.target)) + const edgeSources = new Set(edges.map((e) => e.source)) + const allLinked = new Set([...edgeTargets, ...edgeSources]) + const issues: string[] = [] + for (const n of nodes) { + if (n.id === "index" || n.id === "log" || n.id === "overview") continue + if (!allLinked.has(n.id)) issues.push(`[orphan] ${n.label} (${n.id}.md)`) + else if (n.linkCount <= 1) issues.push(`[isolated] ${n.label} — only ${n.linkCount} link(s)`) + } + const text = issues.length === 0 + ? `✓ All ${nodes.length} pages are properly connected.` + : `Found ${issues.length} issue(s) in ${nodes.length} pages:\n\n${issues.join("\n")}` + return { content: [{ type: "text", text: text }] } + } + + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`) + } + } catch (err) { + if (err instanceof McpError) throw err + throw new McpError( + ErrorCode.InternalError, + `Tool '${name}' failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } +}) + +// ── Start server ────────────────────────────────────────────────────────────── +async function main() { + const transport = new StdioServerTransport() + await server.connect(transport) + console.error(`llm-wiki MCP server v${PKG_VERSION} started`) + console.error(`Default wiki path: ${DEFAULT_WIKI_PATH}`) +} + +main().catch((err) => { + console.error("Failed to start MCP server:", err) + process.exit(1) +}) diff --git a/mcp-server/src/lib/graph-insights.ts b/mcp-server/src/lib/graph-insights.ts new file mode 100644 index 00000000..56fa212f --- /dev/null +++ b/mcp-server/src/lib/graph-insights.ts @@ -0,0 +1,150 @@ +import type { GraphNode, GraphEdge, CommunityInfo } from "./wiki-graph" + +export interface SurprisingConnection { + source: GraphNode + target: GraphNode + score: number + reasons: string[] + key: string +} + +export interface KnowledgeGap { + type: "isolated-node" | "sparse-community" | "bridge-node" + title: string + description: string + nodeIds: string[] + suggestion: string +} + +export function findSurprisingConnections( + nodes: GraphNode[], + edges: GraphEdge[], + _communities: CommunityInfo[], + limit: number = 5, +): SurprisingConnection[] { + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const degreeMap = new Map(nodes.map((n) => [n.id, n.linkCount])) + const maxDegree = Math.max(...nodes.map((n) => n.linkCount), 1) + const STRUCTURAL_IDS = new Set(["index", "log", "overview"]) + const scored: SurprisingConnection[] = [] + + for (const edge of edges) { + const source = nodeMap.get(edge.source) + const target = nodeMap.get(edge.target) + if (!source || !target) continue + if (STRUCTURAL_IDS.has(source.id) || STRUCTURAL_IDS.has(target.id)) continue + + let score = 0 + const reasons: string[] = [] + + if (source.community !== target.community) { + score += 3 + reasons.push("crosses community boundary") + } + if (source.type !== target.type) { + const distantPairs = new Set([ + "source-concept", "concept-source", "source-synthesis", "synthesis-source", + "query-entity", "entity-query", + ]) + if (distantPairs.has(`${source.type}-${target.type}`)) { + score += 2 + reasons.push(`connects ${source.type} to ${target.type}`) + } else { + score += 1 + reasons.push("different types") + } + } + const sourceDeg = degreeMap.get(source.id) ?? 0 + const targetDeg = degreeMap.get(target.id) ?? 0 + const minDeg = Math.min(sourceDeg, targetDeg) + const maxDeg = Math.max(sourceDeg, targetDeg) + if (minDeg <= 2 && maxDeg >= maxDegree * 0.5) { + score += 2 + reasons.push("peripheral node links to hub") + } + if (edge.weight < 2 && edge.weight > 0) { + score += 1 + reasons.push("weak but present connection") + } + if (score >= 3 && reasons.length > 0) { + const key = [source.id, target.id].sort().join(":::") + scored.push({ source, target, score, reasons, key }) + } + } + + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, limit) +} + +export function detectKnowledgeGaps( + nodes: GraphNode[], + edges: GraphEdge[], + communities: CommunityInfo[], + limit: number = 8, +): KnowledgeGap[] { + const gaps: KnowledgeGap[] = [] + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + + // 1. Isolated nodes (degree ≤ 1) + const isolatedNodes = nodes.filter( + (n) => n.linkCount <= 1 && n.type !== "overview" && n.id !== "index" && n.id !== "log", + ) + if (isolatedNodes.length > 0) { + const topIsolated = isolatedNodes.slice(0, 5) + gaps.push({ + type: "isolated-node", + title: `${isolatedNodes.length} isolated page${isolatedNodes.length > 1 ? "s" : ""}`, + description: topIsolated.map((n) => n.label).join(", ") + + (isolatedNodes.length > 5 ? ` and ${isolatedNodes.length - 5} more` : ""), + nodeIds: isolatedNodes.map((n) => n.id), + suggestion: "These pages have few or no connections. Consider adding [[wikilinks]] to related pages.", + }) + } + + // 2. Sparse communities (low cohesion) + for (const comm of communities) { + if (comm.cohesion < 0.15 && comm.nodeCount >= 3) { + gaps.push({ + type: "sparse-community", + title: `Sparse cluster: ${comm.topNodes[0] ?? `Community ${comm.id}`}`, + description: `${comm.nodeCount} pages with cohesion ${comm.cohesion.toFixed(2)} — internal connections are weak.`, + nodeIds: nodes.filter((n) => n.community === comm.id).map((n) => n.id), + suggestion: "This knowledge area lacks internal cross-references. Consider adding links between these pages.", + }) + } + } + + // 3. Bridge nodes (connected to multiple communities) + const communityNeighbors = new Map>() + for (const node of nodes) communityNeighbors.set(node.id, new Set()) + for (const edge of edges) { + const sourceNode = nodeMap.get(edge.source) + const targetNode = nodeMap.get(edge.target) + if (sourceNode && targetNode) { + communityNeighbors.get(edge.source)?.add(targetNode.community) + communityNeighbors.get(edge.target)?.add(sourceNode.community) + } + } + const STRUCTURAL_IDS = new Set(["index", "log", "overview"]) + const bridgeNodes = nodes + .filter((n) => { + if (STRUCTURAL_IDS.has(n.id)) return false + const neighborComms = communityNeighbors.get(n.id) + return neighborComms && neighborComms.size >= 3 + }) + .sort((a, b) => (communityNeighbors.get(b.id)?.size ?? 0) - (communityNeighbors.get(a.id)?.size ?? 0)) + .slice(0, 3) + + for (const bridge of bridgeNodes) { + const commCount = communityNeighbors.get(bridge.id)?.size ?? 0 + gaps.push({ + type: "bridge-node", + title: `Key bridge: ${bridge.label}`, + description: `Connects ${commCount} different knowledge clusters. This is a critical junction in your wiki.`, + nodeIds: [bridge.id], + suggestion: "This page bridges multiple knowledge areas. Ensure it's well-maintained and expanded.", + }) + } + + return gaps.slice(0, limit) +} diff --git a/mcp-server/src/lib/graph-relevance.ts b/mcp-server/src/lib/graph-relevance.ts new file mode 100644 index 00000000..db27e7b1 --- /dev/null +++ b/mcp-server/src/lib/graph-relevance.ts @@ -0,0 +1,229 @@ +import { readFile, listDirectory } from "../shims/fs-node" +import type { FileNode } from "../types/wiki" +import { normalizePath } from "./path-utils" + +export interface RetrievalNode { + readonly id: string + readonly title: string + readonly type: string + readonly path: string + readonly sources: readonly string[] + readonly outLinks: ReadonlySet + readonly inLinks: ReadonlySet +} + +export interface RetrievalGraph { + readonly nodes: ReadonlyMap + readonly dataVersion: number +} + +const WIKILINK_REGEX = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g + +const WEIGHTS = { + directLink: 3.0, + sourceOverlap: 4.0, + commonNeighbor: 1.5, + typeAffinity: 1.0, +} as const + +const TYPE_AFFINITY: Record> = { + entity: { concept: 1.2, entity: 0.8, source: 1.0, synthesis: 1.0, query: 0.8 }, + concept: { entity: 1.2, concept: 0.8, source: 1.0, synthesis: 1.2, query: 1.0 }, + source: { entity: 1.0, concept: 1.0, source: 0.5, query: 0.8, synthesis: 1.0 }, + query: { concept: 1.0, entity: 0.8, synthesis: 1.0, source: 0.8, query: 0.5 }, + synthesis: { concept: 1.2, entity: 1.0, source: 1.0, query: 1.0, synthesis: 0.8 }, +} + +let cachedGraph: RetrievalGraph | null = null + +function flattenMdFiles(nodes: readonly FileNode[]): FileNode[] { + const files: FileNode[] = [] + for (const node of nodes) { + if (node.is_dir && node.children) { + files.push(...flattenMdFiles(node.children)) + } else if (!node.is_dir && node.name.endsWith(".md")) { + files.push(node) + } + } + return files +} + +function fileNameToId(fileName: string): string { + return fileName.replace(/\.md$/, "") +} + +function extractFrontmatter(content: string): { title: string; type: string; sources: string[] } { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/) + const fm = fmMatch ? fmMatch[1] : "" + const titleMatch = fm.match(/^title:\s*["']?(.+?)["']?\s*$/m) + const typeMatch = fm.match(/^type:\s*["']?(.+?)["']?\s*$/m) + const sources: string[] = [] + const sourcesBlockMatch = fm.match(/^sources:\s*\n((?:\s+-\s+.+\n?)*)/m) + if (sourcesBlockMatch) { + const lines = sourcesBlockMatch[1].split("\n") + for (const line of lines) { + const itemMatch = line.match(/^\s+-\s+["']?(.+?)["']?\s*$/) + if (itemMatch) sources.push(itemMatch[1]) + } + } else { + const inlineMatch = fm.match(/^sources:\s*\[([^\]]*)\]/m) + if (inlineMatch) { + const items = inlineMatch[1].split(",") + for (const item of items) { + const trimmed = item.trim().replace(/^["']|["']$/g, "") + if (trimmed) sources.push(trimmed) + } + } + } + let title = titleMatch ? titleMatch[1].trim() : "" + if (!title) { + const headingMatch = content.match(/^#\s+(.+)$/m) + title = headingMatch ? headingMatch[1].trim() : "" + } + return { title, type: typeMatch ? typeMatch[1].trim().toLowerCase() : "other", sources } +} + +function extractWikilinks(content: string): string[] { + const links: string[] = [] + const regex = new RegExp(WIKILINK_REGEX.source, "g") + let match: RegExpExecArray | null + while ((match = regex.exec(content)) !== null) links.push(match[1].trim()) + return links +} + +function resolveTarget(raw: string, nodeIds: ReadonlySet): string | null { + if (nodeIds.has(raw)) return raw + const normalized = raw.toLowerCase().replace(/\s+/g, "-") + for (const id of nodeIds) { + const idLower = id.toLowerCase() + if (idLower === normalized) return id + if (idLower === raw.toLowerCase()) return id + if (idLower.replace(/\s+/g, "-") === normalized) return id + } + return null +} + +function getNeighbors(node: RetrievalNode): ReadonlySet { + const neighbors = new Set() + for (const id of node.outLinks) neighbors.add(id) + for (const id of node.inLinks) neighbors.add(id) + return neighbors +} + +function getNodeDegree(node: RetrievalNode): number { + return node.outLinks.size + node.inLinks.size +} + +export async function buildRetrievalGraph( + projectPath: string, + dataVersion: number = 0, +): Promise { + if (cachedGraph !== null && cachedGraph.dataVersion === dataVersion) return cachedGraph + + const wikiRoot = `${normalizePath(projectPath)}/wiki` + let tree: FileNode[] + try { + tree = await listDirectory(wikiRoot) + } catch { + const emptyGraph: RetrievalGraph = { nodes: new Map(), dataVersion } + cachedGraph = emptyGraph + return emptyGraph + } + + const mdFiles = flattenMdFiles(tree) + const rawNodes: Array<{ + id: string; title: string; type: string; path: string + sources: string[]; rawLinks: string[]; fileName: string + }> = [] + + for (const file of mdFiles) { + const id = fileNameToId(file.name) + let content = "" + try { content = await readFile(file.path) } catch { continue } + const fm = extractFrontmatter(content) + rawNodes.push({ + id, title: fm.title || file.name.replace(/\.md$/, "").replace(/-/g, " "), + type: fm.type, path: file.path, sources: fm.sources, + rawLinks: extractWikilinks(content), fileName: file.name, + }) + } + + const nodeIds = new Set(rawNodes.map((n) => n.id)) + const outLinksMap = new Map>() + const inLinksMap = new Map>() + for (const id of nodeIds) { + outLinksMap.set(id, new Set()) + inLinksMap.set(id, new Set()) + } + + for (const raw of rawNodes) { + for (const linkTarget of raw.rawLinks) { + const resolvedId = resolveTarget(linkTarget, nodeIds) + if (resolvedId === null || resolvedId === raw.id) continue + outLinksMap.get(raw.id)!.add(resolvedId) + inLinksMap.get(resolvedId)!.add(raw.id) + } + } + + const nodes = new Map() + for (const raw of rawNodes) { + nodes.set(raw.id, { + id: raw.id, title: raw.title, type: raw.type, path: raw.path, + sources: Object.freeze([...raw.sources]), + outLinks: Object.freeze(outLinksMap.get(raw.id) ?? new Set()), + inLinks: Object.freeze(inLinksMap.get(raw.id) ?? new Set()), + }) + } + + const graph: RetrievalGraph = { nodes, dataVersion } + cachedGraph = graph + return graph +} + +export function calculateRelevance( + nodeA: RetrievalNode, nodeB: RetrievalNode, graph: RetrievalGraph, +): number { + if (nodeA.id === nodeB.id) return 0 + const forwardLinks = nodeA.outLinks.has(nodeB.id) ? 1 : 0 + const backwardLinks = nodeB.outLinks.has(nodeA.id) ? 1 : 0 + const directLinkScore = (forwardLinks + backwardLinks) * WEIGHTS.directLink + const sourcesA = new Set(nodeA.sources) + let sharedSourceCount = 0 + for (const src of nodeB.sources) { if (sourcesA.has(src)) sharedSourceCount += 1 } + const sourceOverlapScore = sharedSourceCount * WEIGHTS.sourceOverlap + const neighborsA = getNeighbors(nodeA) + const neighborsB = getNeighbors(nodeB) + let adamicAdar = 0 + for (const neighborId of neighborsA) { + if (neighborsB.has(neighborId)) { + const neighbor = graph.nodes.get(neighborId) + if (neighbor) { + const degree = getNodeDegree(neighbor) + adamicAdar += 1 / Math.log(Math.max(degree, 2)) + } + } + } + const commonNeighborScore = adamicAdar * WEIGHTS.commonNeighbor + const affinityMap = TYPE_AFFINITY[nodeA.type] + const typeAffinityScore = (affinityMap?.[nodeB.type] ?? 0.5) * WEIGHTS.typeAffinity + return directLinkScore + sourceOverlapScore + commonNeighborScore + typeAffinityScore +} + +export function getRelatedNodes( + nodeId: string, graph: RetrievalGraph, limit: number = 5, +): ReadonlyArray<{ node: RetrievalNode; relevance: number }> { + const sourceNode = graph.nodes.get(nodeId) + if (!sourceNode) return [] + const scored: Array<{ node: RetrievalNode; relevance: number }> = [] + for (const [id, node] of graph.nodes) { + if (id === nodeId) continue + const relevance = calculateRelevance(sourceNode, node, graph) + if (relevance > 0) scored.push({ node, relevance }) + } + scored.sort((a, b) => b.relevance - a.relevance) + return scored.slice(0, limit) +} + +export function clearGraphCache(): void { + cachedGraph = null +} diff --git a/mcp-server/src/lib/path-utils.ts b/mcp-server/src/lib/path-utils.ts new file mode 100644 index 00000000..3296d0a9 --- /dev/null +++ b/mcp-server/src/lib/path-utils.ts @@ -0,0 +1,38 @@ +export function normalizePath(p: string): string { + return p.replace(/\\/g, "/") +} + +export function joinPath(...segments: string[]): string { + return segments + .map((s) => s.replace(/\\/g, "/")) + .join("/") + .replace(/\/+/g, "/") +} + +export function getFileName(p: string): string { + const normalized = p.replace(/\\/g, "/") + return normalized.split("/").pop() ?? p +} + +export function getFileStem(p: string): string { + const name = getFileName(p) + const lastDot = name.lastIndexOf(".") + return lastDot > 0 ? name.slice(0, lastDot) : name +} + +export function getRelativePath(fullPath: string, basePath: string): string { + const normalFull = normalizePath(fullPath) + const normalBase = normalizePath(basePath).replace(/\/$/, "") + if (normalFull.startsWith(normalBase + "/")) { + return normalFull.slice(normalBase.length + 1) + } + return normalFull +} + +export function isAbsolutePath(p: string): boolean { + if (!p) return false + if (p.startsWith("/")) return true + if (/^[A-Za-z]:[\\/]/.test(p)) return true + if (p.startsWith("\\\\") || p.startsWith("//")) return true + return false +} diff --git a/mcp-server/src/lib/search.ts b/mcp-server/src/lib/search.ts new file mode 100644 index 00000000..d67e4e51 --- /dev/null +++ b/mcp-server/src/lib/search.ts @@ -0,0 +1,228 @@ +import { readFile, listDirectory } from "../shims/fs-node" +import type { FileNode } from "../types/wiki" +import { normalizePath, getFileStem } from "./path-utils" + +export interface ImageRef { + url: string + alt: string +} + +export interface SearchResult { + path: string + title: string + snippet: string + titleMatch: boolean + score: number + images: ImageRef[] +} + +const MAX_RESULTS = 20 +const SNIPPET_CONTEXT = 80 +const RRF_K = 60 +const FILENAME_EXACT_BONUS = 200 +const PHRASE_IN_TITLE_BONUS = 50 +const PHRASE_IN_CONTENT_PER_OCC = 20 +const MAX_PHRASE_OCC_COUNTED = 10 +const TITLE_TOKEN_WEIGHT = 5 +const CONTENT_TOKEN_WEIGHT = 1 + +const STOP_WORDS = new Set([ + "的", "是", "了", "什么", "在", "有", "和", "与", "对", "从", + "the", "is", "a", "an", "what", "how", "are", "was", "were", + "do", "does", "did", "be", "been", "being", "have", "has", "had", + "it", "its", "in", "on", "at", "to", "for", "of", "with", "by", + "this", "that", "these", "those", +]) + +export function tokenizeQuery(query: string): string[] { + const rawTokens = query + .toLowerCase() + .split(/[\s,,。!?、;:""''()()\-_/\\·~~…]+/) + .filter((t) => t.length > 1) + .filter((t) => !STOP_WORDS.has(t)) + + const tokens: string[] = [] + for (const token of rawTokens) { + const hasCJK = /[\u4e00-\u9fff\u3400-\u4dbf]/.test(token) + if (hasCJK && token.length > 2) { + const chars = [...token] + for (let i = 0; i < chars.length - 1; i++) tokens.push(chars[i] + chars[i + 1]) + for (const ch of chars) { if (!STOP_WORDS.has(ch)) tokens.push(ch) } + tokens.push(token) + } else { + tokens.push(token) + } + } + return [...new Set(tokens)] +} + +function tokenMatchScore(text: string, tokens: readonly string[]): number { + const lower = text.toLowerCase() + let score = 0 + for (const token of tokens) { if (lower.includes(token)) score += 1 } + return score +} + +function countOccurrences(haystackLower: string, needleLower: string): number { + if (!needleLower) return 0 + let count = 0; let pos = 0 + while (true) { + const idx = haystackLower.indexOf(needleLower, pos) + if (idx === -1) break + count++; pos = idx + needleLower.length + } + return count +} + +function flattenMdFiles(nodes: FileNode[]): FileNode[] { + const files: FileNode[] = [] + for (const node of nodes) { + if (node.is_dir && node.children) files.push(...flattenMdFiles(node.children)) + else if (!node.is_dir && node.name.endsWith(".md")) files.push(node) + } + return files +} + +function extractTitle(content: string, fileName: string): string { + const fm = content.match(/^---\n[\s\S]*?^title:\s*["']?(.+?)["']?\s*$/m) + if (fm) return fm[1].trim() + const h = content.match(/^#\s+(.+)$/m) + if (h) return h[1].trim() + return fileName.replace(/\.md$/, "").replace(/-/g, " ") +} + +const IMAGE_REF_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g + +function extractImageRefs(content: string): ImageRef[] { + const seen = new Set(); const out: ImageRef[] = [] + for (const m of content.matchAll(IMAGE_REF_RE)) { + const url = m[2] + if (seen.has(url)) continue + seen.add(url); out.push({ url, alt: m[1] }) + } + return out +} + +function buildSnippet(content: string, query: string): string { + const lower = content.toLowerCase(); const lowerQuery = query.toLowerCase() + const idx = lower.indexOf(lowerQuery) + if (idx === -1) return content.slice(0, SNIPPET_CONTEXT * 2).replace(/\n/g, " ") + const start = Math.max(0, idx - SNIPPET_CONTEXT) + const end = Math.min(content.length, idx + query.length + SNIPPET_CONTEXT) + let snippet = content.slice(start, end).replace(/\n/g, " ") + if (start > 0) snippet = "..." + snippet + if (end < content.length) snippet = snippet + "..." + return snippet +} + +const TRIM_PUNCT_RE = /^[\s,,。!?、;:""''()()\-_/\\·~~…]+|[\s,,。!?、;:""''()()\-_/\\·~~…]+$/g +const SEARCH_READ_CONCURRENCY = 16 + +function scoreFile( + file: FileNode, content: string, tokens: readonly string[], queryPhrase: string, query: string, +): SearchResult | null { + const title = extractTitle(content, file.name) + const titleText = `${title} ${file.name}` + const titleLower = titleText.toLowerCase() + const contentLower = content.toLowerCase() + const fileStem = file.name.replace(/\.md$/, "").toLowerCase() + + const filenameExact = fileStem === queryPhrase + const titleHasPhrase = queryPhrase.length > 0 && titleLower.includes(queryPhrase) + const contentPhraseOcc = Math.min(countOccurrences(contentLower, queryPhrase), MAX_PHRASE_OCC_COUNTED) + const titleTokenScore = tokenMatchScore(titleText, tokens) + const contentTokenScore = tokenMatchScore(content, tokens) + + if (!filenameExact && !titleHasPhrase && contentPhraseOcc === 0 && titleTokenScore === 0 && contentTokenScore === 0) return null + + const score = + (filenameExact ? FILENAME_EXACT_BONUS : 0) + + (titleHasPhrase ? PHRASE_IN_TITLE_BONUS : 0) + + contentPhraseOcc * PHRASE_IN_CONTENT_PER_OCC + + titleTokenScore * TITLE_TOKEN_WEIGHT + + contentTokenScore * CONTENT_TOKEN_WEIGHT + + const snippetAnchor = contentPhraseOcc > 0 ? queryPhrase : (tokens.find((t) => contentLower.includes(t)) ?? query) + return { + path: file.path, title, snippet: buildSnippet(content, snippetAnchor), + titleMatch: titleTokenScore > 0 || titleHasPhrase, score, images: extractImageRefs(content), + } +} + +async function searchFiles( + files: FileNode[], tokens: readonly string[], query: string, results: SearchResult[], +): Promise { + const queryPhrase = query.trim().toLowerCase().replace(TRIM_PUNCT_RE, "") + for (let i = 0; i < files.length; i += SEARCH_READ_CONCURRENCY) { + const batch = files.slice(i, i + SEARCH_READ_CONCURRENCY) + const batchResults = await Promise.all( + batch.map(async (file) => { + let content: string + try { content = await readFile(file.path) } catch { return null } + return scoreFile(file, content, tokens, queryPhrase, query) + }), + ) + for (const r of batchResults) { if (r) results.push(r) } + } +} + +export async function searchWiki(projectPath: string, query: string): Promise { + if (!query.trim()) return [] + const pp = normalizePath(projectPath) + const tokens = tokenizeQuery(query) + const effectiveTokens = tokens.length > 0 ? tokens : [query.trim().toLowerCase()] + const results: SearchResult[] = [] + + try { + const wikiTree = await listDirectory(`${pp}/wiki`) + const wikiFiles = flattenMdFiles(wikiTree) + await searchFiles(wikiFiles, effectiveTokens, query, results) + } catch { /* no wiki directory */ } + + const tokenSorted = [...results].sort((a, b) => b.score - a.score) + const tokenRank = new Map() + tokenSorted.forEach((r, i) => tokenRank.set(normalizePath(r.path), i + 1)) + + // Vector search (optional — gracefully degrades when embedding not configured) + let vectorRank = new Map() + let vectorCount = 0 + try { + const { useWikiStore } = await import("../shims/stores-node") + const embCfg = useWikiStore.getState().embeddingConfig + if (embCfg.enabled && embCfg.model) { + const { searchByEmbedding } = await import("../shims/embedding-stub") + const vectorResults = await searchByEmbedding(pp, query, embCfg, 10) + vectorCount = vectorResults.length + vectorResults.forEach((vr, i) => vectorRank.set(vr.id, i + 1)) + + const knownIds = new Set(results.map((r) => getFileStem(r.path))) + for (const vr of vectorResults) { + if (knownIds.has(vr.id)) continue + const dirs = ["entities", "concepts", "sources", "synthesis", "comparison", "queries"] + for (const dir of dirs) { + const tryPath = `${pp}/wiki/${dir}/${vr.id}.md` + try { + const content = await readFile(tryPath) + const title = extractTitle(content, `${vr.id}.md`) + results.push({ path: tryPath, title, snippet: buildSnippet(content, query), titleMatch: false, score: 0, images: extractImageRefs(content) }) + knownIds.add(vr.id); break + } catch { /* not in this dir */ } + } + } + } + } catch { /* vector search not available */ } + + // RRF fusion + for (const r of results) { + const tRank = tokenRank.get(normalizePath(r.path)) + const vRank = vectorRank.get(getFileStem(r.path)) + let rrf = 0 + if (tRank !== undefined) rrf += 1 / (RRF_K + tRank) + if (vRank !== undefined) rrf += 1 / (RRF_K + vRank) + r.score = rrf + } + + results.sort((a, b) => b.score !== a.score ? b.score - a.score : a.path.localeCompare(b.path)) + console.error(`[search] "${query}" | token:${tokenRank.size} vector:${vectorCount} → ${results.length} results`) + return results.slice(0, MAX_RESULTS) +} diff --git a/mcp-server/src/lib/wiki-graph.ts b/mcp-server/src/lib/wiki-graph.ts new file mode 100644 index 00000000..5160563a --- /dev/null +++ b/mcp-server/src/lib/wiki-graph.ts @@ -0,0 +1,211 @@ +import { readFile, listDirectory } from "../shims/fs-node" +import type { FileNode } from "../types/wiki" +import { buildRetrievalGraph, calculateRelevance } from "./graph-relevance" +import { normalizePath } from "./path-utils" +import Graph from "graphology" +import louvain from "graphology-communities-louvain" + +export interface GraphNode { + id: string + label: string + type: string + path: string + linkCount: number + community: number +} + +export interface GraphEdge { + source: string + target: string + weight: number +} + +export interface CommunityInfo { + id: number + nodeCount: number + cohesion: number + topNodes: string[] +} + +function detectCommunities( + nodes: { id: string; label: string; linkCount: number }[], + edges: GraphEdge[], +): { assignments: Map; communities: CommunityInfo[] } { + if (nodes.length === 0) return { assignments: new Map(), communities: [] } + + const g = new Graph({ type: "undirected" }) + for (const node of nodes) g.addNode(node.id) + for (const edge of edges) { + if (g.hasNode(edge.source) && g.hasNode(edge.target)) { + const key = `${edge.source}->${edge.target}` + if (!g.hasEdge(key) && !g.hasEdge(`${edge.target}->${edge.source}`)) { + g.addEdgeWithKey(key, edge.source, edge.target, { weight: edge.weight }) + } + } + } + + const communityMap: Record = louvain(g, { resolution: 1 }) + const assignments = new Map(Object.entries(communityMap).map(([k, v]) => [k, v as number])) + + const groups = new Map() + for (const [nodeId, commId] of assignments) { + const list = groups.get(commId) ?? [] + list.push(nodeId) + groups.set(commId, list) + } + + const edgeSet = new Set() + for (const edge of edges) { + edgeSet.add(`${edge.source}:::${edge.target}`) + edgeSet.add(`${edge.target}:::${edge.source}`) + } + + const nodeInfo = new Map(nodes.map((n) => [n.id, { label: n.label, linkCount: n.linkCount }])) + const communities: CommunityInfo[] = [] + + for (const [commId, memberIds] of groups) { + const n = memberIds.length + let intraEdges = 0 + for (let i = 0; i < memberIds.length; i++) { + for (let j = i + 1; j < memberIds.length; j++) { + if (edgeSet.has(`${memberIds[i]}:::${memberIds[j]}`)) intraEdges++ + } + } + const possibleEdges = n > 1 ? (n * (n - 1)) / 2 : 1 + const cohesion = intraEdges / possibleEdges + const sorted = [...memberIds].sort( + (a, b) => (nodeInfo.get(b)?.linkCount ?? 0) - (nodeInfo.get(a)?.linkCount ?? 0) + ) + communities.push({ id: commId, nodeCount: n, cohesion, topNodes: sorted.slice(0, 5).map((id) => nodeInfo.get(id)?.label ?? id) }) + } + + communities.sort((a, b) => b.nodeCount - a.nodeCount) + const idRemap = new Map() + communities.forEach((c, idx) => { idRemap.set(c.id, idx); c.id = idx }) + for (const [nodeId, oldId] of assignments) assignments.set(nodeId, idRemap.get(oldId) ?? 0) + + return { assignments, communities } +} + +const WIKILINK_REGEX = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g + +function flattenMdFiles(nodes: FileNode[]): FileNode[] { + const files: FileNode[] = [] + for (const node of nodes) { + if (node.is_dir && node.children) files.push(...flattenMdFiles(node.children)) + else if (!node.is_dir && node.name.endsWith(".md")) files.push(node) + } + return files +} + +function extractTitle(content: string, fileName: string): string { + const fm = content.match(/^---\n[\s\S]*?^title:\s*["']?(.+?)["']?\s*$/m) + if (fm) return fm[1].trim() + const h = content.match(/^#\s+(.+)$/m) + if (h) return h[1].trim() + return fileName.replace(/\.md$/, "").replace(/-/g, " ") +} + +function extractType(content: string): string { + const m = content.match(/^---\n[\s\S]*?^type:\s*["']?(.+?)["']?\s*$/m) + return m ? m[1].trim().toLowerCase() : "other" +} + +function extractWikilinks(content: string): string[] { + const links: string[] = [] + const regex = new RegExp(WIKILINK_REGEX.source, "g") + let match: RegExpExecArray | null + while ((match = regex.exec(content)) !== null) links.push(match[1].trim()) + return links +} + +function resolveTarget(raw: string, nodeMap: Map): string | null { + if (nodeMap.has(raw)) return raw + const normalized = raw.toLowerCase().replace(/\s+/g, "-") + for (const id of nodeMap.keys()) { + if (id.toLowerCase() === normalized) return id + if (id.toLowerCase() === raw.toLowerCase()) return id + if (id.toLowerCase().replace(/\s+/g, "-") === normalized) return id + } + return null +} + +export async function buildWikiGraph( + projectPath: string, +): Promise<{ nodes: GraphNode[]; edges: GraphEdge[]; communities: CommunityInfo[] }> { + const wikiRoot = `${normalizePath(projectPath)}/wiki` + let tree: FileNode[] + try { tree = await listDirectory(wikiRoot) } catch { + return { nodes: [], edges: [], communities: [] } + } + + const mdFiles = flattenMdFiles(tree) + if (mdFiles.length === 0) return { nodes: [], edges: [], communities: [] } + + const nodeMap = new Map() + for (const file of mdFiles) { + const id = file.name.replace(/\.md$/, "") + let content = "" + try { content = await readFile(file.path) } catch { continue } + nodeMap.set(id, { id, label: extractTitle(content, file.name), type: extractType(content), path: file.path, links: extractWikilinks(content) }) + } + + const HIDDEN_TYPES = new Set(["query"]) + for (const [id, node] of nodeMap) { + if (HIDDEN_TYPES.has(node.type)) nodeMap.delete(id) + } + + const linkCounts = new Map() + for (const [id] of nodeMap) linkCounts.set(id, 0) + + const rawEdges: GraphEdge[] = [] + for (const [sourceId, nodeData] of nodeMap) { + for (const targetRaw of nodeData.links) { + const targetId = resolveTarget(targetRaw, nodeMap) + if (targetId === null || targetId === sourceId) continue + rawEdges.push({ source: sourceId, target: targetId, weight: 1 }) + linkCounts.set(sourceId, (linkCounts.get(sourceId) ?? 0) + 1) + linkCounts.set(targetId, (linkCounts.get(targetId) ?? 0) + 1) + } + } + + const seenEdges = new Set() + const dedupedEdges: { source: string; target: string }[] = [] + for (const edge of rawEdges) { + const key = `${edge.source}:::${edge.target}` + const reverseKey = `${edge.target}:::${edge.source}` + if (!seenEdges.has(key) && !seenEdges.has(reverseKey)) { + seenEdges.add(key) + dedupedEdges.push(edge) + } + } + + // Try to get retrieval graph for weighted edges (gracefully degrades) + let retrievalGraph: Awaited> | null = null + try { + const { useWikiStore } = await import("../shims/stores-node") + const dv = useWikiStore.getState().dataVersion + retrievalGraph = await buildRetrievalGraph(normalizePath(projectPath), dv) + } catch { /* ignore — weights default to 1 */ } + + const edges: GraphEdge[] = dedupedEdges.map((e) => { + let weight = 1 + if (retrievalGraph) { + const nodeA = retrievalGraph.nodes.get(e.source) + const nodeB = retrievalGraph.nodes.get(e.target) + if (nodeA && nodeB) weight = calculateRelevance(nodeA, nodeB, retrievalGraph) + } + return { source: e.source, target: e.target, weight } + }) + + const prelimNodes = Array.from(nodeMap.values()).map((n) => ({ id: n.id, label: n.label, linkCount: linkCounts.get(n.id) ?? 0 })) + const { assignments, communities } = detectCommunities(prelimNodes, edges) + + const nodes: GraphNode[] = Array.from(nodeMap.values()).map((n) => ({ + id: n.id, label: n.label, type: n.type, path: n.path, + linkCount: linkCounts.get(n.id) ?? 0, + community: assignments.get(n.id) ?? 0, + })) + + return { nodes, edges, communities } +} diff --git a/mcp-server/src/shims/embedding-stub.ts b/mcp-server/src/shims/embedding-stub.ts new file mode 100644 index 00000000..8a2117aa --- /dev/null +++ b/mcp-server/src/shims/embedding-stub.ts @@ -0,0 +1,32 @@ +/** + * Stub for @/lib/embedding — vector search is not available in the skill/MCP layer + * without a running LanceDB instance. Returns empty results so BM25-only search works. + */ +export interface EmbeddingConfig { + enabled: boolean + model: string + apiBase?: string + apiKey?: string +} + +export interface VectorSearchResult { + id: string + score: number + path?: string +} + +export async function searchByEmbedding( + _projectPath: string, + _query: string, + _config: EmbeddingConfig, + _limit: number = 10, +): Promise { + return [] +} + +export async function createEmbedding( + _text: string, + _config: EmbeddingConfig, +): Promise { + return [] +} diff --git a/mcp-server/src/shims/fs-node.ts b/mcp-server/src/shims/fs-node.ts new file mode 100644 index 00000000..de343bda --- /dev/null +++ b/mcp-server/src/shims/fs-node.ts @@ -0,0 +1,101 @@ +/** + * Node.js drop-in replacement for @/commands/fs (nashsu Tauri IPC layer). + * Replaces all invoke("...") calls with standard Node.js fs operations. + */ +import * as fs from "fs" +import * as path from "path" +import type { FileNode } from "../types/wiki" + +export async function readFile(filePath: string): Promise { + return fs.readFileSync(filePath, "utf-8") +} + +export async function writeFile(filePath: string, content: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, content, "utf-8") +} + +export async function listDirectory(dirPath: string): Promise { + function walk(dir: string): FileNode[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + return entries.map((entry) => { + const entryPath = path.join(dir, entry.name).replace(/\\/g, "/") + if (entry.isDirectory()) { + return { + name: entry.name, + path: entryPath, + is_dir: true, + children: walk(entryPath), + } + } + return { name: entry.name, path: entryPath, is_dir: false } + }) + } + return walk(dirPath) +} + +export async function copyFile(from: string, to: string): Promise { + fs.mkdirSync(path.dirname(to), { recursive: true }) + fs.copyFileSync(from, to) +} + +export async function deleteFile(filePath: string): Promise { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) +} + +export async function createDirectory(dirPath: string): Promise { + fs.mkdirSync(dirPath, { recursive: true }) +} + +export async function fileExists(filePath: string): Promise { + return fs.existsSync(filePath) +} + +export async function readFileAsBase64(filePath: string): Promise { + return fs.readFileSync(filePath).toString("base64") +} + +/** + * Text extraction for PDFs/DOCX/etc. + * In Node mode: returns raw file content if text-based, otherwise empty string. + * For real PDF extraction, users should pre-extract with markitdown or pdftotext. + */ +export async function preprocessFile(filePath: string): Promise { + const ext = path.extname(filePath).toLowerCase() + const textExts = [".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".html", ".htm"] + if (textExts.includes(ext)) { + try { + return fs.readFileSync(filePath, "utf-8") + } catch { + return "" + } + } + // For binary files (PDF, DOCX, etc.) return empty — use pre-extracted markdown + console.warn(`[fs-node] preprocessFile: binary format not supported in Node mode: ${filePath}`) + return "" +} + +export async function findRelatedWikiPages( + sourceFile: string, + wikiRoot: string, +): Promise { + const stem = path.basename(sourceFile, path.extname(sourceFile)).toLowerCase() + const results: string[] = [] + function walk(dir: string): void { + if (!fs.existsSync(dir)) return + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (entry.name.endsWith(".md")) { + try { + const content = fs.readFileSync(full, "utf-8") + if (content.toLowerCase().includes(stem)) { + results.push(full.replace(/\\/g, "/")) + } + } catch { /* skip */ } + } + } + } + walk(wikiRoot) + return results +} diff --git a/mcp-server/src/shims/stores-node.ts b/mcp-server/src/shims/stores-node.ts new file mode 100644 index 00000000..66fa3773 --- /dev/null +++ b/mcp-server/src/shims/stores-node.ts @@ -0,0 +1,135 @@ +/** + * Node.js drop-in for React zustand stores used by nashsu/llm_wiki lib files. + * Replaces all useXxxStore.getState() calls with module-level state. + */ + +export interface LlmConfig { + provider: string + apiKey: string + model: string + baseUrl?: string + temperature?: number + maxTokens?: number +} + +export interface EmbeddingConfig { + enabled: boolean + model: string + apiBase?: string + apiKey?: string +} + +interface WikiState { + projectPath: string + dataVersion: number + llmConfig: LlmConfig + embeddingConfig: EmbeddingConfig +} + +let wikiState: WikiState = { + projectPath: "", + dataVersion: 0, + llmConfig: { + provider: process.env.LLM_PROVIDER ?? "openai", + apiKey: process.env.OPENAI_API_KEY ?? process.env.LLM_API_KEY ?? "", + model: process.env.LLM_MODEL ?? "gpt-4o-mini", + baseUrl: process.env.LLM_BASE_URL, + }, + embeddingConfig: { + enabled: (process.env.EMBEDDING_ENABLED ?? "false") === "true", + model: process.env.EMBEDDING_MODEL ?? "", + apiBase: process.env.EMBEDDING_BASE_URL, + apiKey: process.env.EMBEDDING_API_KEY ?? process.env.OPENAI_API_KEY, + }, +} + +export const useWikiStore = { + getState: () => ({ ...wikiState }), + setState: (updater: Partial | ((s: WikiState) => Partial)) => { + if (typeof updater === "function") { + wikiState = { ...wikiState, ...updater(wikiState) } + } else { + wikiState = { ...wikiState, ...updater } + } + }, +} + +/** Configure the wiki store from environment variables or explicit config */ +export function configureWikiStore(config: Partial) { + wikiState = { ...wikiState, ...config } +} + +// ── Research store ─────────────────────────────────────────────────────────── +interface ResearchState { + activeProjectPath: string + isResearching: boolean +} + +let researchState: ResearchState = { + activeProjectPath: "", + isResearching: false, +} + +export const useResearchStore = { + getState: () => ({ ...researchState }), + setState: (updater: Partial) => { + researchState = { ...researchState, ...updater } + }, +} + +// ── Activity store (replaces Tauri event system) ───────────────────────────── +export interface ActivityItem { + id: string + type: string + title: string + status: "pending" | "running" | "done" | "error" + detail?: string + filesWritten?: string[] +} + +let activityItems: ActivityItem[] = [] +let activityIdCounter = 0 + +export const useActivityStore = { + getState: () => ({ + items: [...activityItems], + addItem: (item: Omit): string => { + const id = `activity-${++activityIdCounter}` + const newItem: ActivityItem = { id, ...item } + activityItems.push(newItem) + if (process.env.SKILL_VERBOSE === "1") { + console.error(`[activity:${item.type}] ${item.title} — ${item.status}`) + } + return id + }, + updateItem: (id: string, updates: Partial): void => { + const idx = activityItems.findIndex((i) => i.id === id) + if (idx >= 0) { + activityItems[idx] = { ...activityItems[idx], ...updates } + if (process.env.SKILL_VERBOSE === "1") { + const item = activityItems[idx] + console.error(`[activity:update] ${item.title} — ${item.status}: ${item.detail ?? ""}`) + } + } + }, + clearItems: () => { activityItems = [] }, + }), + addItem: (item: Omit): string => { + return useActivityStore.getState().addItem(item) + }, + updateItem: (id: string, updates: Partial): void => { + useActivityStore.getState().updateItem(id, updates) + }, +} + +// ── Chat store ─────────────────────────────────────────────────────────────── +export const useChatStore = { + getState: () => ({ messages: [] as unknown[] }), + setState: (_updater: unknown) => {}, +} + +// ── Review store ───────────────────────────────────────────────────────────── +export const useReviewStore = { + getState: () => ({ queue: [] as unknown[], isProcessing: false }), + setState: (_updater: unknown) => {}, +} diff --git a/mcp-server/src/types/wiki.ts b/mcp-server/src/types/wiki.ts new file mode 100644 index 00000000..a51ace03 --- /dev/null +++ b/mcp-server/src/types/wiki.ts @@ -0,0 +1,18 @@ +export interface WikiProject { + id: string + name: string + path: string +} + +export interface FileNode { + name: string + path: string + is_dir: boolean + children?: FileNode[] +} + +export interface WikiPage { + path: string + content: string + frontmatter: Record +} diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 00000000..e37a4324 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/skill/package-lock.json b/skill/package-lock.json new file mode 100644 index 00000000..ff78e035 --- /dev/null +++ b/skill/package-lock.json @@ -0,0 +1,1269 @@ +{ + "name": "llm-wiki-nashsu-skill", + "version": "0.4.6-skill.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "llm-wiki-nashsu-skill", + "version": "0.4.6-skill.1", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "graphology": "^0.25.4", + "graphology-communities-louvain": "^2.0.0" + }, + "bin": { + "llm-wiki": "dist/cli.js", + "llm-wiki-mcp": "dist/mcp-server.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphology": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.25.4.tgz", + "integrity": "sha512-33g0Ol9nkWdD6ulw687viS8YJQBxqG5LWII6FI6nul0pq6iM2t5EKquOTFDbyTblRB3O9I+7KX4xI8u5ffekAQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "obliterator": "^2.0.2" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-communities-louvain": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", + "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.4", + "mnemonist": "^0.39.0", + "pandemonium": "^2.4.1" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.16", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", + "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pandemonium": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", + "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", + "license": "MIT", + "dependencies": { + "mnemonist": "^0.39.2" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/skill/package.json b/skill/package.json new file mode 100644 index 00000000..8b8f8da7 --- /dev/null +++ b/skill/package.json @@ -0,0 +1,25 @@ +{ + "name": "llm-wiki-nashsu-skill", + "version": "0.4.6-skill.1", + "description": "nashsu/llm_wiki backend as Node.js CLI + MCP server (no Tauri/GUI)", + "main": "dist/cli.js", + "bin": { + "llm-wiki": "dist/cli.js", + "llm-wiki-mcp": "dist/mcp-server.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "node dist/cli.js", + "mcp": "node dist/mcp-server.js" + }, + "dependencies": { + "graphology": "^0.25.4", + "graphology-communities-louvain": "^2.0.0", + "@modelcontextprotocol/sdk": "^1.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/skill/src/cli.ts b/skill/src/cli.ts new file mode 100644 index 00000000..991097ab --- /dev/null +++ b/skill/src/cli.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * llm-wiki CLI — nashsu/llm_wiki backend as a standalone Node.js tool + * + * Commands: + * graph Build graph JSON (nodes, edges, communities) + * insights Show surprising connections + knowledge gaps + * search BM25+RRF search across wiki pages + * status Page count statistics by type + * init Create wiki directory structure + * lint Structural lint (broken links, orphans) + */ +import * as path from "path" +import * as fs from "fs" +import { buildWikiGraph } from "./lib/wiki-graph" +import { findSurprisingConnections, detectKnowledgeGaps } from "./lib/graph-insights" +import { searchWiki } from "./lib/search" + +async function main() { + const [, , command, ...args] = process.argv + if (!command || command === "help" || command === "--help") { usage(); return } + switch (command) { + case "graph": return cmdGraph(args) + case "insights": return cmdInsights(args) + case "search": return cmdSearch(args) + case "status": return cmdStatus(args) + case "init": return cmdInit(args) + case "lint": return cmdLint(args) + default: + console.error(`Unknown command: ${command}`) + usage() + process.exit(1) + } +} + +function usage() { + console.log(` +llm-wiki — nashsu/llm_wiki backend skill (no Tauri/GUI) + +USAGE: + llm-wiki [options] + +COMMANDS: + graph Build knowledge graph (outputs JSON) + insights Show surprising connections + knowledge gaps + search Keyword search (BM25+RRF) + status Page count and type breakdown + init Initialize wiki directory structure + lint Check for broken links and orphan pages + +ENV VARS: + SKILL_VERBOSE=1 Enable verbose activity logging + WIKI_PATH Default project path for MCP server + +EXAMPLES: + llm-wiki graph ./my-project + llm-wiki search ./my-project "attention mechanism" + llm-wiki insights ./my-project +`.trim()) +} + +async function cmdGraph(args: string[]) { + const wikiRoot = args[0] + if (!wikiRoot) { console.error("Usage: graph "); process.exit(1) } + const projectPath = path.resolve(wikiRoot) + console.error(`Building graph: ${projectPath}`) + const result = await buildWikiGraph(projectPath) + process.stdout.write(JSON.stringify(result, null, 2) + "\n") + console.error(`\n✓ ${result.nodes.length} nodes, ${result.edges.length} edges, ${result.communities.length} communities`) +} + +async function cmdInsights(args: string[]) { + const wikiRoot = args[0] + if (!wikiRoot) { console.error("Usage: insights "); process.exit(1) } + const projectPath = path.resolve(wikiRoot) + const { nodes, edges, communities } = await buildWikiGraph(projectPath) + const connections = findSurprisingConnections(nodes, edges, communities, 10) + const gaps = detectKnowledgeGaps(nodes, edges, communities, 8) + const lines: string[] = ["# Wiki Insights\n", "## Surprising Connections\n"] + if (connections.length === 0) lines.push("_No surprising connections found (need more linked pages)._\n") + for (const c of connections) { + lines.push(`### ${c.source.label} ↔ ${c.target.label}`) + lines.push(`- **Score**: ${c.score} | **Why**: ${c.reasons.join(", ")}\n`) + } + lines.push("## Knowledge Gaps\n") + if (gaps.length === 0) lines.push("_No knowledge gaps detected._\n") + for (const g of gaps) { + lines.push(`### ${g.title}`) + lines.push(`**Type**: ${g.type}\n${g.description}`) + lines.push(`💡 ${g.suggestion}\n`) + } + process.stdout.write(lines.join("\n")) +} + +async function cmdSearch(args: string[]) { + const [wikiRoot, ...queryParts] = args + const query = queryParts.join(" ") + if (!wikiRoot || !query) { console.error("Usage: search "); process.exit(1) } + const projectPath = path.resolve(wikiRoot) + const results = await searchWiki(projectPath, query) + if (results.length === 0) { console.log(`No results for: "${query}"`); return } + const lines: string[] = [`# Search: "${query}"\n`] + for (const r of results) { + const relPath = path.relative(projectPath, r.path) + lines.push(`## ${r.title}`) + lines.push(`**Path**: ${relPath} | **Score**: ${r.score.toFixed(4)}`) + lines.push(r.snippet + "\n") + } + process.stdout.write(lines.join("\n")) +} + +async function cmdStatus(args: string[]) { + const wikiRoot = args[0] + if (!wikiRoot) { console.error("Usage: status "); process.exit(1) } + const projectPath = path.resolve(wikiRoot) + const { nodes, communities } = await buildWikiGraph(projectPath) + const typeCounts: Record = {} + for (const n of nodes) typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1 + console.log(`Wiki: ${projectPath}\nTotal pages: ${nodes.length}\nCommunities: ${communities.length}`) + for (const [type, count] of Object.entries(typeCounts).sort((a, b) => b[1] - a[1])) { + console.log(` ${type}: ${count}`) + } +} + +async function cmdInit(args: string[]) { + const wikiRoot = args[0] + if (!wikiRoot) { console.error("Usage: init "); process.exit(1) } + const projectPath = path.resolve(wikiRoot) + const dirs = ["wiki/entities", "wiki/concepts", "wiki/sources", "wiki/synthesis", "wiki/queries"] + for (const dir of dirs) fs.mkdirSync(path.join(projectPath, dir), { recursive: true }) + const indexPath = path.join(projectPath, "wiki/index.md") + if (!fs.existsSync(indexPath)) { + fs.writeFileSync(indexPath, ["---", "title: Index", "type: overview", "---", "", "# Knowledge Base", "", "Welcome to your wiki.", ""].join("\n")) + } + console.log(`✓ Initialized wiki at: ${projectPath}`) +} + +async function cmdLint(args: string[]) { + const wikiRoot = args[0] + if (!wikiRoot) { console.error("Usage: lint "); process.exit(1) } + const projectPath = path.resolve(wikiRoot) + const { nodes, edges } = await buildWikiGraph(projectPath) + if (nodes.length === 0) { console.log("No wiki pages found."); return } + const edgeTargets = new Set(edges.map((e) => e.target)) + const edgeSources = new Set(edges.map((e) => e.source)) + const allLinked = new Set([...edgeTargets, ...edgeSources]) + let issues = 0 + for (const n of nodes) { + if (n.id === "index" || n.id === "log" || n.id === "overview") continue + if (!allLinked.has(n.id)) { console.log(`[orphan] ${n.label} (${n.id}.md)`); issues++ } + else if (n.linkCount <= 1) { console.log(`[isolated] ${n.label} — ${n.linkCount} link(s)`); issues++ } + } + console.log(`\n✓ ${nodes.length} pages checked — ${issues} issue(s)`) +} + +main().catch((err) => { + console.error("Error:", err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/skill/src/fs-node.ts b/skill/src/fs-node.ts new file mode 100644 index 00000000..0512ed89 --- /dev/null +++ b/skill/src/fs-node.ts @@ -0,0 +1,156 @@ +/** + * Node.js drop-in replacement for Tauri's @/commands/fs IPC layer. + * Maps all Tauri invoke() calls to standard Node.js fs operations. + * + * Original (Tauri): invoke("read_file", { path }) + * Replacement: fs.readFileSync(path, 'utf-8') + */ +import * as fs from "fs" +import * as path from "path" + +export interface FileNode { + name: string + path: string + is_dir: boolean + children?: FileNode[] +} + +export interface WikiProject { + id: string + name: string + path: string +} + +export interface FileBase64 { + base64: string + mimeType: string +} + +// --------------------------------------------------------------------------- +// Core file operations (replaces Tauri IPC) +// --------------------------------------------------------------------------- + +export async function readFile(filePath: string): Promise { + return fs.readFileSync(filePath, "utf-8") +} + +export async function writeFile(filePath: string, contents: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, contents, "utf-8") +} + +export async function listDirectory(dirPath: string): Promise { + if (!fs.existsSync(dirPath)) { + throw new Error(`Directory not found: ${dirPath}`) + } + return _listDirRecursive(dirPath) +} + +function _listDirRecursive(dirPath: string): FileNode[] { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + return entries.map((entry) => { + const fullPath = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + return { + name: entry.name, + path: fullPath, + is_dir: true, + children: _listDirRecursive(fullPath), + } + } + return { + name: entry.name, + path: fullPath, + is_dir: false, + } + }) +} + +export async function copyFile(source: string, destination: string): Promise { + fs.mkdirSync(path.dirname(destination), { recursive: true }) + fs.copyFileSync(source, destination) +} + +export async function deleteFile(filePath: string): Promise { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } +} + +export async function createDirectory(dirPath: string): Promise { + fs.mkdirSync(dirPath, { recursive: true }) +} + +export async function fileExists(filePath: string): Promise { + return fs.existsSync(filePath) +} + +export async function readFileAsBase64(filePath: string): Promise { + const buffer = fs.readFileSync(filePath) + const base64 = buffer.toString("base64") + const ext = path.extname(filePath).toLowerCase() + const mimeMap: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".pdf": "application/pdf", + } + return { base64, mimeType: mimeMap[ext] ?? "application/octet-stream" } +} + +export async function preprocessFile(filePath: string): Promise { + // For non-GUI mode: just read the file as text + // Real Tauri version uses Rust pdf-extract / docx-rs for binary formats + return fs.readFileSync(filePath, "utf-8") +} + +export async function findRelatedWikiPages( + projectPath: string, + sourceName: string, +): Promise { + const wikiDir = path.join(projectPath, "wiki") + if (!fs.existsSync(wikiDir)) return [] + + const results: string[] = [] + const searchTerm = path.basename(sourceName, path.extname(sourceName)).toLowerCase() + + function searchDir(dir: string) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + searchDir(fullPath) + } else if (entry.name.endsWith(".md")) { + try { + const content = fs.readFileSync(fullPath, "utf-8") + if (content.toLowerCase().includes(searchTerm)) { + results.push(fullPath) + } + } catch { + // skip unreadable files + } + } + } + } + + searchDir(wikiDir) + return results +} + +export async function createProject(name: string, projectPath: string): Promise { + fs.mkdirSync(projectPath, { recursive: true }) + return { id: path.basename(projectPath), name, path: projectPath } +} + +export async function openProject(projectPath: string): Promise { + if (!fs.existsSync(projectPath)) { + throw new Error(`Project not found: ${projectPath}`) + } + return { + id: path.basename(projectPath), + name: path.basename(projectPath), + path: projectPath, + } +} diff --git a/skill/src/lib/graph-insights.ts b/skill/src/lib/graph-insights.ts new file mode 100644 index 00000000..56fa212f --- /dev/null +++ b/skill/src/lib/graph-insights.ts @@ -0,0 +1,150 @@ +import type { GraphNode, GraphEdge, CommunityInfo } from "./wiki-graph" + +export interface SurprisingConnection { + source: GraphNode + target: GraphNode + score: number + reasons: string[] + key: string +} + +export interface KnowledgeGap { + type: "isolated-node" | "sparse-community" | "bridge-node" + title: string + description: string + nodeIds: string[] + suggestion: string +} + +export function findSurprisingConnections( + nodes: GraphNode[], + edges: GraphEdge[], + _communities: CommunityInfo[], + limit: number = 5, +): SurprisingConnection[] { + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const degreeMap = new Map(nodes.map((n) => [n.id, n.linkCount])) + const maxDegree = Math.max(...nodes.map((n) => n.linkCount), 1) + const STRUCTURAL_IDS = new Set(["index", "log", "overview"]) + const scored: SurprisingConnection[] = [] + + for (const edge of edges) { + const source = nodeMap.get(edge.source) + const target = nodeMap.get(edge.target) + if (!source || !target) continue + if (STRUCTURAL_IDS.has(source.id) || STRUCTURAL_IDS.has(target.id)) continue + + let score = 0 + const reasons: string[] = [] + + if (source.community !== target.community) { + score += 3 + reasons.push("crosses community boundary") + } + if (source.type !== target.type) { + const distantPairs = new Set([ + "source-concept", "concept-source", "source-synthesis", "synthesis-source", + "query-entity", "entity-query", + ]) + if (distantPairs.has(`${source.type}-${target.type}`)) { + score += 2 + reasons.push(`connects ${source.type} to ${target.type}`) + } else { + score += 1 + reasons.push("different types") + } + } + const sourceDeg = degreeMap.get(source.id) ?? 0 + const targetDeg = degreeMap.get(target.id) ?? 0 + const minDeg = Math.min(sourceDeg, targetDeg) + const maxDeg = Math.max(sourceDeg, targetDeg) + if (minDeg <= 2 && maxDeg >= maxDegree * 0.5) { + score += 2 + reasons.push("peripheral node links to hub") + } + if (edge.weight < 2 && edge.weight > 0) { + score += 1 + reasons.push("weak but present connection") + } + if (score >= 3 && reasons.length > 0) { + const key = [source.id, target.id].sort().join(":::") + scored.push({ source, target, score, reasons, key }) + } + } + + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, limit) +} + +export function detectKnowledgeGaps( + nodes: GraphNode[], + edges: GraphEdge[], + communities: CommunityInfo[], + limit: number = 8, +): KnowledgeGap[] { + const gaps: KnowledgeGap[] = [] + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + + // 1. Isolated nodes (degree ≤ 1) + const isolatedNodes = nodes.filter( + (n) => n.linkCount <= 1 && n.type !== "overview" && n.id !== "index" && n.id !== "log", + ) + if (isolatedNodes.length > 0) { + const topIsolated = isolatedNodes.slice(0, 5) + gaps.push({ + type: "isolated-node", + title: `${isolatedNodes.length} isolated page${isolatedNodes.length > 1 ? "s" : ""}`, + description: topIsolated.map((n) => n.label).join(", ") + + (isolatedNodes.length > 5 ? ` and ${isolatedNodes.length - 5} more` : ""), + nodeIds: isolatedNodes.map((n) => n.id), + suggestion: "These pages have few or no connections. Consider adding [[wikilinks]] to related pages.", + }) + } + + // 2. Sparse communities (low cohesion) + for (const comm of communities) { + if (comm.cohesion < 0.15 && comm.nodeCount >= 3) { + gaps.push({ + type: "sparse-community", + title: `Sparse cluster: ${comm.topNodes[0] ?? `Community ${comm.id}`}`, + description: `${comm.nodeCount} pages with cohesion ${comm.cohesion.toFixed(2)} — internal connections are weak.`, + nodeIds: nodes.filter((n) => n.community === comm.id).map((n) => n.id), + suggestion: "This knowledge area lacks internal cross-references. Consider adding links between these pages.", + }) + } + } + + // 3. Bridge nodes (connected to multiple communities) + const communityNeighbors = new Map>() + for (const node of nodes) communityNeighbors.set(node.id, new Set()) + for (const edge of edges) { + const sourceNode = nodeMap.get(edge.source) + const targetNode = nodeMap.get(edge.target) + if (sourceNode && targetNode) { + communityNeighbors.get(edge.source)?.add(targetNode.community) + communityNeighbors.get(edge.target)?.add(sourceNode.community) + } + } + const STRUCTURAL_IDS = new Set(["index", "log", "overview"]) + const bridgeNodes = nodes + .filter((n) => { + if (STRUCTURAL_IDS.has(n.id)) return false + const neighborComms = communityNeighbors.get(n.id) + return neighborComms && neighborComms.size >= 3 + }) + .sort((a, b) => (communityNeighbors.get(b.id)?.size ?? 0) - (communityNeighbors.get(a.id)?.size ?? 0)) + .slice(0, 3) + + for (const bridge of bridgeNodes) { + const commCount = communityNeighbors.get(bridge.id)?.size ?? 0 + gaps.push({ + type: "bridge-node", + title: `Key bridge: ${bridge.label}`, + description: `Connects ${commCount} different knowledge clusters. This is a critical junction in your wiki.`, + nodeIds: [bridge.id], + suggestion: "This page bridges multiple knowledge areas. Ensure it's well-maintained and expanded.", + }) + } + + return gaps.slice(0, limit) +} diff --git a/skill/src/lib/graph-relevance.ts b/skill/src/lib/graph-relevance.ts new file mode 100644 index 00000000..db27e7b1 --- /dev/null +++ b/skill/src/lib/graph-relevance.ts @@ -0,0 +1,229 @@ +import { readFile, listDirectory } from "../shims/fs-node" +import type { FileNode } from "../types/wiki" +import { normalizePath } from "./path-utils" + +export interface RetrievalNode { + readonly id: string + readonly title: string + readonly type: string + readonly path: string + readonly sources: readonly string[] + readonly outLinks: ReadonlySet + readonly inLinks: ReadonlySet +} + +export interface RetrievalGraph { + readonly nodes: ReadonlyMap + readonly dataVersion: number +} + +const WIKILINK_REGEX = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g + +const WEIGHTS = { + directLink: 3.0, + sourceOverlap: 4.0, + commonNeighbor: 1.5, + typeAffinity: 1.0, +} as const + +const TYPE_AFFINITY: Record> = { + entity: { concept: 1.2, entity: 0.8, source: 1.0, synthesis: 1.0, query: 0.8 }, + concept: { entity: 1.2, concept: 0.8, source: 1.0, synthesis: 1.2, query: 1.0 }, + source: { entity: 1.0, concept: 1.0, source: 0.5, query: 0.8, synthesis: 1.0 }, + query: { concept: 1.0, entity: 0.8, synthesis: 1.0, source: 0.8, query: 0.5 }, + synthesis: { concept: 1.2, entity: 1.0, source: 1.0, query: 1.0, synthesis: 0.8 }, +} + +let cachedGraph: RetrievalGraph | null = null + +function flattenMdFiles(nodes: readonly FileNode[]): FileNode[] { + const files: FileNode[] = [] + for (const node of nodes) { + if (node.is_dir && node.children) { + files.push(...flattenMdFiles(node.children)) + } else if (!node.is_dir && node.name.endsWith(".md")) { + files.push(node) + } + } + return files +} + +function fileNameToId(fileName: string): string { + return fileName.replace(/\.md$/, "") +} + +function extractFrontmatter(content: string): { title: string; type: string; sources: string[] } { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/) + const fm = fmMatch ? fmMatch[1] : "" + const titleMatch = fm.match(/^title:\s*["']?(.+?)["']?\s*$/m) + const typeMatch = fm.match(/^type:\s*["']?(.+?)["']?\s*$/m) + const sources: string[] = [] + const sourcesBlockMatch = fm.match(/^sources:\s*\n((?:\s+-\s+.+\n?)*)/m) + if (sourcesBlockMatch) { + const lines = sourcesBlockMatch[1].split("\n") + for (const line of lines) { + const itemMatch = line.match(/^\s+-\s+["']?(.+?)["']?\s*$/) + if (itemMatch) sources.push(itemMatch[1]) + } + } else { + const inlineMatch = fm.match(/^sources:\s*\[([^\]]*)\]/m) + if (inlineMatch) { + const items = inlineMatch[1].split(",") + for (const item of items) { + const trimmed = item.trim().replace(/^["']|["']$/g, "") + if (trimmed) sources.push(trimmed) + } + } + } + let title = titleMatch ? titleMatch[1].trim() : "" + if (!title) { + const headingMatch = content.match(/^#\s+(.+)$/m) + title = headingMatch ? headingMatch[1].trim() : "" + } + return { title, type: typeMatch ? typeMatch[1].trim().toLowerCase() : "other", sources } +} + +function extractWikilinks(content: string): string[] { + const links: string[] = [] + const regex = new RegExp(WIKILINK_REGEX.source, "g") + let match: RegExpExecArray | null + while ((match = regex.exec(content)) !== null) links.push(match[1].trim()) + return links +} + +function resolveTarget(raw: string, nodeIds: ReadonlySet): string | null { + if (nodeIds.has(raw)) return raw + const normalized = raw.toLowerCase().replace(/\s+/g, "-") + for (const id of nodeIds) { + const idLower = id.toLowerCase() + if (idLower === normalized) return id + if (idLower === raw.toLowerCase()) return id + if (idLower.replace(/\s+/g, "-") === normalized) return id + } + return null +} + +function getNeighbors(node: RetrievalNode): ReadonlySet { + const neighbors = new Set() + for (const id of node.outLinks) neighbors.add(id) + for (const id of node.inLinks) neighbors.add(id) + return neighbors +} + +function getNodeDegree(node: RetrievalNode): number { + return node.outLinks.size + node.inLinks.size +} + +export async function buildRetrievalGraph( + projectPath: string, + dataVersion: number = 0, +): Promise { + if (cachedGraph !== null && cachedGraph.dataVersion === dataVersion) return cachedGraph + + const wikiRoot = `${normalizePath(projectPath)}/wiki` + let tree: FileNode[] + try { + tree = await listDirectory(wikiRoot) + } catch { + const emptyGraph: RetrievalGraph = { nodes: new Map(), dataVersion } + cachedGraph = emptyGraph + return emptyGraph + } + + const mdFiles = flattenMdFiles(tree) + const rawNodes: Array<{ + id: string; title: string; type: string; path: string + sources: string[]; rawLinks: string[]; fileName: string + }> = [] + + for (const file of mdFiles) { + const id = fileNameToId(file.name) + let content = "" + try { content = await readFile(file.path) } catch { continue } + const fm = extractFrontmatter(content) + rawNodes.push({ + id, title: fm.title || file.name.replace(/\.md$/, "").replace(/-/g, " "), + type: fm.type, path: file.path, sources: fm.sources, + rawLinks: extractWikilinks(content), fileName: file.name, + }) + } + + const nodeIds = new Set(rawNodes.map((n) => n.id)) + const outLinksMap = new Map>() + const inLinksMap = new Map>() + for (const id of nodeIds) { + outLinksMap.set(id, new Set()) + inLinksMap.set(id, new Set()) + } + + for (const raw of rawNodes) { + for (const linkTarget of raw.rawLinks) { + const resolvedId = resolveTarget(linkTarget, nodeIds) + if (resolvedId === null || resolvedId === raw.id) continue + outLinksMap.get(raw.id)!.add(resolvedId) + inLinksMap.get(resolvedId)!.add(raw.id) + } + } + + const nodes = new Map() + for (const raw of rawNodes) { + nodes.set(raw.id, { + id: raw.id, title: raw.title, type: raw.type, path: raw.path, + sources: Object.freeze([...raw.sources]), + outLinks: Object.freeze(outLinksMap.get(raw.id) ?? new Set()), + inLinks: Object.freeze(inLinksMap.get(raw.id) ?? new Set()), + }) + } + + const graph: RetrievalGraph = { nodes, dataVersion } + cachedGraph = graph + return graph +} + +export function calculateRelevance( + nodeA: RetrievalNode, nodeB: RetrievalNode, graph: RetrievalGraph, +): number { + if (nodeA.id === nodeB.id) return 0 + const forwardLinks = nodeA.outLinks.has(nodeB.id) ? 1 : 0 + const backwardLinks = nodeB.outLinks.has(nodeA.id) ? 1 : 0 + const directLinkScore = (forwardLinks + backwardLinks) * WEIGHTS.directLink + const sourcesA = new Set(nodeA.sources) + let sharedSourceCount = 0 + for (const src of nodeB.sources) { if (sourcesA.has(src)) sharedSourceCount += 1 } + const sourceOverlapScore = sharedSourceCount * WEIGHTS.sourceOverlap + const neighborsA = getNeighbors(nodeA) + const neighborsB = getNeighbors(nodeB) + let adamicAdar = 0 + for (const neighborId of neighborsA) { + if (neighborsB.has(neighborId)) { + const neighbor = graph.nodes.get(neighborId) + if (neighbor) { + const degree = getNodeDegree(neighbor) + adamicAdar += 1 / Math.log(Math.max(degree, 2)) + } + } + } + const commonNeighborScore = adamicAdar * WEIGHTS.commonNeighbor + const affinityMap = TYPE_AFFINITY[nodeA.type] + const typeAffinityScore = (affinityMap?.[nodeB.type] ?? 0.5) * WEIGHTS.typeAffinity + return directLinkScore + sourceOverlapScore + commonNeighborScore + typeAffinityScore +} + +export function getRelatedNodes( + nodeId: string, graph: RetrievalGraph, limit: number = 5, +): ReadonlyArray<{ node: RetrievalNode; relevance: number }> { + const sourceNode = graph.nodes.get(nodeId) + if (!sourceNode) return [] + const scored: Array<{ node: RetrievalNode; relevance: number }> = [] + for (const [id, node] of graph.nodes) { + if (id === nodeId) continue + const relevance = calculateRelevance(sourceNode, node, graph) + if (relevance > 0) scored.push({ node, relevance }) + } + scored.sort((a, b) => b.relevance - a.relevance) + return scored.slice(0, limit) +} + +export function clearGraphCache(): void { + cachedGraph = null +} diff --git a/skill/src/lib/path-utils.ts b/skill/src/lib/path-utils.ts new file mode 100644 index 00000000..3296d0a9 --- /dev/null +++ b/skill/src/lib/path-utils.ts @@ -0,0 +1,38 @@ +export function normalizePath(p: string): string { + return p.replace(/\\/g, "/") +} + +export function joinPath(...segments: string[]): string { + return segments + .map((s) => s.replace(/\\/g, "/")) + .join("/") + .replace(/\/+/g, "/") +} + +export function getFileName(p: string): string { + const normalized = p.replace(/\\/g, "/") + return normalized.split("/").pop() ?? p +} + +export function getFileStem(p: string): string { + const name = getFileName(p) + const lastDot = name.lastIndexOf(".") + return lastDot > 0 ? name.slice(0, lastDot) : name +} + +export function getRelativePath(fullPath: string, basePath: string): string { + const normalFull = normalizePath(fullPath) + const normalBase = normalizePath(basePath).replace(/\/$/, "") + if (normalFull.startsWith(normalBase + "/")) { + return normalFull.slice(normalBase.length + 1) + } + return normalFull +} + +export function isAbsolutePath(p: string): boolean { + if (!p) return false + if (p.startsWith("/")) return true + if (/^[A-Za-z]:[\\/]/.test(p)) return true + if (p.startsWith("\\\\") || p.startsWith("//")) return true + return false +} diff --git a/skill/src/lib/search.ts b/skill/src/lib/search.ts new file mode 100644 index 00000000..d67e4e51 --- /dev/null +++ b/skill/src/lib/search.ts @@ -0,0 +1,228 @@ +import { readFile, listDirectory } from "../shims/fs-node" +import type { FileNode } from "../types/wiki" +import { normalizePath, getFileStem } from "./path-utils" + +export interface ImageRef { + url: string + alt: string +} + +export interface SearchResult { + path: string + title: string + snippet: string + titleMatch: boolean + score: number + images: ImageRef[] +} + +const MAX_RESULTS = 20 +const SNIPPET_CONTEXT = 80 +const RRF_K = 60 +const FILENAME_EXACT_BONUS = 200 +const PHRASE_IN_TITLE_BONUS = 50 +const PHRASE_IN_CONTENT_PER_OCC = 20 +const MAX_PHRASE_OCC_COUNTED = 10 +const TITLE_TOKEN_WEIGHT = 5 +const CONTENT_TOKEN_WEIGHT = 1 + +const STOP_WORDS = new Set([ + "的", "是", "了", "什么", "在", "有", "和", "与", "对", "从", + "the", "is", "a", "an", "what", "how", "are", "was", "were", + "do", "does", "did", "be", "been", "being", "have", "has", "had", + "it", "its", "in", "on", "at", "to", "for", "of", "with", "by", + "this", "that", "these", "those", +]) + +export function tokenizeQuery(query: string): string[] { + const rawTokens = query + .toLowerCase() + .split(/[\s,,。!?、;:""''()()\-_/\\·~~…]+/) + .filter((t) => t.length > 1) + .filter((t) => !STOP_WORDS.has(t)) + + const tokens: string[] = [] + for (const token of rawTokens) { + const hasCJK = /[\u4e00-\u9fff\u3400-\u4dbf]/.test(token) + if (hasCJK && token.length > 2) { + const chars = [...token] + for (let i = 0; i < chars.length - 1; i++) tokens.push(chars[i] + chars[i + 1]) + for (const ch of chars) { if (!STOP_WORDS.has(ch)) tokens.push(ch) } + tokens.push(token) + } else { + tokens.push(token) + } + } + return [...new Set(tokens)] +} + +function tokenMatchScore(text: string, tokens: readonly string[]): number { + const lower = text.toLowerCase() + let score = 0 + for (const token of tokens) { if (lower.includes(token)) score += 1 } + return score +} + +function countOccurrences(haystackLower: string, needleLower: string): number { + if (!needleLower) return 0 + let count = 0; let pos = 0 + while (true) { + const idx = haystackLower.indexOf(needleLower, pos) + if (idx === -1) break + count++; pos = idx + needleLower.length + } + return count +} + +function flattenMdFiles(nodes: FileNode[]): FileNode[] { + const files: FileNode[] = [] + for (const node of nodes) { + if (node.is_dir && node.children) files.push(...flattenMdFiles(node.children)) + else if (!node.is_dir && node.name.endsWith(".md")) files.push(node) + } + return files +} + +function extractTitle(content: string, fileName: string): string { + const fm = content.match(/^---\n[\s\S]*?^title:\s*["']?(.+?)["']?\s*$/m) + if (fm) return fm[1].trim() + const h = content.match(/^#\s+(.+)$/m) + if (h) return h[1].trim() + return fileName.replace(/\.md$/, "").replace(/-/g, " ") +} + +const IMAGE_REF_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g + +function extractImageRefs(content: string): ImageRef[] { + const seen = new Set(); const out: ImageRef[] = [] + for (const m of content.matchAll(IMAGE_REF_RE)) { + const url = m[2] + if (seen.has(url)) continue + seen.add(url); out.push({ url, alt: m[1] }) + } + return out +} + +function buildSnippet(content: string, query: string): string { + const lower = content.toLowerCase(); const lowerQuery = query.toLowerCase() + const idx = lower.indexOf(lowerQuery) + if (idx === -1) return content.slice(0, SNIPPET_CONTEXT * 2).replace(/\n/g, " ") + const start = Math.max(0, idx - SNIPPET_CONTEXT) + const end = Math.min(content.length, idx + query.length + SNIPPET_CONTEXT) + let snippet = content.slice(start, end).replace(/\n/g, " ") + if (start > 0) snippet = "..." + snippet + if (end < content.length) snippet = snippet + "..." + return snippet +} + +const TRIM_PUNCT_RE = /^[\s,,。!?、;:""''()()\-_/\\·~~…]+|[\s,,。!?、;:""''()()\-_/\\·~~…]+$/g +const SEARCH_READ_CONCURRENCY = 16 + +function scoreFile( + file: FileNode, content: string, tokens: readonly string[], queryPhrase: string, query: string, +): SearchResult | null { + const title = extractTitle(content, file.name) + const titleText = `${title} ${file.name}` + const titleLower = titleText.toLowerCase() + const contentLower = content.toLowerCase() + const fileStem = file.name.replace(/\.md$/, "").toLowerCase() + + const filenameExact = fileStem === queryPhrase + const titleHasPhrase = queryPhrase.length > 0 && titleLower.includes(queryPhrase) + const contentPhraseOcc = Math.min(countOccurrences(contentLower, queryPhrase), MAX_PHRASE_OCC_COUNTED) + const titleTokenScore = tokenMatchScore(titleText, tokens) + const contentTokenScore = tokenMatchScore(content, tokens) + + if (!filenameExact && !titleHasPhrase && contentPhraseOcc === 0 && titleTokenScore === 0 && contentTokenScore === 0) return null + + const score = + (filenameExact ? FILENAME_EXACT_BONUS : 0) + + (titleHasPhrase ? PHRASE_IN_TITLE_BONUS : 0) + + contentPhraseOcc * PHRASE_IN_CONTENT_PER_OCC + + titleTokenScore * TITLE_TOKEN_WEIGHT + + contentTokenScore * CONTENT_TOKEN_WEIGHT + + const snippetAnchor = contentPhraseOcc > 0 ? queryPhrase : (tokens.find((t) => contentLower.includes(t)) ?? query) + return { + path: file.path, title, snippet: buildSnippet(content, snippetAnchor), + titleMatch: titleTokenScore > 0 || titleHasPhrase, score, images: extractImageRefs(content), + } +} + +async function searchFiles( + files: FileNode[], tokens: readonly string[], query: string, results: SearchResult[], +): Promise { + const queryPhrase = query.trim().toLowerCase().replace(TRIM_PUNCT_RE, "") + for (let i = 0; i < files.length; i += SEARCH_READ_CONCURRENCY) { + const batch = files.slice(i, i + SEARCH_READ_CONCURRENCY) + const batchResults = await Promise.all( + batch.map(async (file) => { + let content: string + try { content = await readFile(file.path) } catch { return null } + return scoreFile(file, content, tokens, queryPhrase, query) + }), + ) + for (const r of batchResults) { if (r) results.push(r) } + } +} + +export async function searchWiki(projectPath: string, query: string): Promise { + if (!query.trim()) return [] + const pp = normalizePath(projectPath) + const tokens = tokenizeQuery(query) + const effectiveTokens = tokens.length > 0 ? tokens : [query.trim().toLowerCase()] + const results: SearchResult[] = [] + + try { + const wikiTree = await listDirectory(`${pp}/wiki`) + const wikiFiles = flattenMdFiles(wikiTree) + await searchFiles(wikiFiles, effectiveTokens, query, results) + } catch { /* no wiki directory */ } + + const tokenSorted = [...results].sort((a, b) => b.score - a.score) + const tokenRank = new Map() + tokenSorted.forEach((r, i) => tokenRank.set(normalizePath(r.path), i + 1)) + + // Vector search (optional — gracefully degrades when embedding not configured) + let vectorRank = new Map() + let vectorCount = 0 + try { + const { useWikiStore } = await import("../shims/stores-node") + const embCfg = useWikiStore.getState().embeddingConfig + if (embCfg.enabled && embCfg.model) { + const { searchByEmbedding } = await import("../shims/embedding-stub") + const vectorResults = await searchByEmbedding(pp, query, embCfg, 10) + vectorCount = vectorResults.length + vectorResults.forEach((vr, i) => vectorRank.set(vr.id, i + 1)) + + const knownIds = new Set(results.map((r) => getFileStem(r.path))) + for (const vr of vectorResults) { + if (knownIds.has(vr.id)) continue + const dirs = ["entities", "concepts", "sources", "synthesis", "comparison", "queries"] + for (const dir of dirs) { + const tryPath = `${pp}/wiki/${dir}/${vr.id}.md` + try { + const content = await readFile(tryPath) + const title = extractTitle(content, `${vr.id}.md`) + results.push({ path: tryPath, title, snippet: buildSnippet(content, query), titleMatch: false, score: 0, images: extractImageRefs(content) }) + knownIds.add(vr.id); break + } catch { /* not in this dir */ } + } + } + } + } catch { /* vector search not available */ } + + // RRF fusion + for (const r of results) { + const tRank = tokenRank.get(normalizePath(r.path)) + const vRank = vectorRank.get(getFileStem(r.path)) + let rrf = 0 + if (tRank !== undefined) rrf += 1 / (RRF_K + tRank) + if (vRank !== undefined) rrf += 1 / (RRF_K + vRank) + r.score = rrf + } + + results.sort((a, b) => b.score !== a.score ? b.score - a.score : a.path.localeCompare(b.path)) + console.error(`[search] "${query}" | token:${tokenRank.size} vector:${vectorCount} → ${results.length} results`) + return results.slice(0, MAX_RESULTS) +} diff --git a/skill/src/lib/wiki-graph.ts b/skill/src/lib/wiki-graph.ts new file mode 100644 index 00000000..5160563a --- /dev/null +++ b/skill/src/lib/wiki-graph.ts @@ -0,0 +1,211 @@ +import { readFile, listDirectory } from "../shims/fs-node" +import type { FileNode } from "../types/wiki" +import { buildRetrievalGraph, calculateRelevance } from "./graph-relevance" +import { normalizePath } from "./path-utils" +import Graph from "graphology" +import louvain from "graphology-communities-louvain" + +export interface GraphNode { + id: string + label: string + type: string + path: string + linkCount: number + community: number +} + +export interface GraphEdge { + source: string + target: string + weight: number +} + +export interface CommunityInfo { + id: number + nodeCount: number + cohesion: number + topNodes: string[] +} + +function detectCommunities( + nodes: { id: string; label: string; linkCount: number }[], + edges: GraphEdge[], +): { assignments: Map; communities: CommunityInfo[] } { + if (nodes.length === 0) return { assignments: new Map(), communities: [] } + + const g = new Graph({ type: "undirected" }) + for (const node of nodes) g.addNode(node.id) + for (const edge of edges) { + if (g.hasNode(edge.source) && g.hasNode(edge.target)) { + const key = `${edge.source}->${edge.target}` + if (!g.hasEdge(key) && !g.hasEdge(`${edge.target}->${edge.source}`)) { + g.addEdgeWithKey(key, edge.source, edge.target, { weight: edge.weight }) + } + } + } + + const communityMap: Record = louvain(g, { resolution: 1 }) + const assignments = new Map(Object.entries(communityMap).map(([k, v]) => [k, v as number])) + + const groups = new Map() + for (const [nodeId, commId] of assignments) { + const list = groups.get(commId) ?? [] + list.push(nodeId) + groups.set(commId, list) + } + + const edgeSet = new Set() + for (const edge of edges) { + edgeSet.add(`${edge.source}:::${edge.target}`) + edgeSet.add(`${edge.target}:::${edge.source}`) + } + + const nodeInfo = new Map(nodes.map((n) => [n.id, { label: n.label, linkCount: n.linkCount }])) + const communities: CommunityInfo[] = [] + + for (const [commId, memberIds] of groups) { + const n = memberIds.length + let intraEdges = 0 + for (let i = 0; i < memberIds.length; i++) { + for (let j = i + 1; j < memberIds.length; j++) { + if (edgeSet.has(`${memberIds[i]}:::${memberIds[j]}`)) intraEdges++ + } + } + const possibleEdges = n > 1 ? (n * (n - 1)) / 2 : 1 + const cohesion = intraEdges / possibleEdges + const sorted = [...memberIds].sort( + (a, b) => (nodeInfo.get(b)?.linkCount ?? 0) - (nodeInfo.get(a)?.linkCount ?? 0) + ) + communities.push({ id: commId, nodeCount: n, cohesion, topNodes: sorted.slice(0, 5).map((id) => nodeInfo.get(id)?.label ?? id) }) + } + + communities.sort((a, b) => b.nodeCount - a.nodeCount) + const idRemap = new Map() + communities.forEach((c, idx) => { idRemap.set(c.id, idx); c.id = idx }) + for (const [nodeId, oldId] of assignments) assignments.set(nodeId, idRemap.get(oldId) ?? 0) + + return { assignments, communities } +} + +const WIKILINK_REGEX = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g + +function flattenMdFiles(nodes: FileNode[]): FileNode[] { + const files: FileNode[] = [] + for (const node of nodes) { + if (node.is_dir && node.children) files.push(...flattenMdFiles(node.children)) + else if (!node.is_dir && node.name.endsWith(".md")) files.push(node) + } + return files +} + +function extractTitle(content: string, fileName: string): string { + const fm = content.match(/^---\n[\s\S]*?^title:\s*["']?(.+?)["']?\s*$/m) + if (fm) return fm[1].trim() + const h = content.match(/^#\s+(.+)$/m) + if (h) return h[1].trim() + return fileName.replace(/\.md$/, "").replace(/-/g, " ") +} + +function extractType(content: string): string { + const m = content.match(/^---\n[\s\S]*?^type:\s*["']?(.+?)["']?\s*$/m) + return m ? m[1].trim().toLowerCase() : "other" +} + +function extractWikilinks(content: string): string[] { + const links: string[] = [] + const regex = new RegExp(WIKILINK_REGEX.source, "g") + let match: RegExpExecArray | null + while ((match = regex.exec(content)) !== null) links.push(match[1].trim()) + return links +} + +function resolveTarget(raw: string, nodeMap: Map): string | null { + if (nodeMap.has(raw)) return raw + const normalized = raw.toLowerCase().replace(/\s+/g, "-") + for (const id of nodeMap.keys()) { + if (id.toLowerCase() === normalized) return id + if (id.toLowerCase() === raw.toLowerCase()) return id + if (id.toLowerCase().replace(/\s+/g, "-") === normalized) return id + } + return null +} + +export async function buildWikiGraph( + projectPath: string, +): Promise<{ nodes: GraphNode[]; edges: GraphEdge[]; communities: CommunityInfo[] }> { + const wikiRoot = `${normalizePath(projectPath)}/wiki` + let tree: FileNode[] + try { tree = await listDirectory(wikiRoot) } catch { + return { nodes: [], edges: [], communities: [] } + } + + const mdFiles = flattenMdFiles(tree) + if (mdFiles.length === 0) return { nodes: [], edges: [], communities: [] } + + const nodeMap = new Map() + for (const file of mdFiles) { + const id = file.name.replace(/\.md$/, "") + let content = "" + try { content = await readFile(file.path) } catch { continue } + nodeMap.set(id, { id, label: extractTitle(content, file.name), type: extractType(content), path: file.path, links: extractWikilinks(content) }) + } + + const HIDDEN_TYPES = new Set(["query"]) + for (const [id, node] of nodeMap) { + if (HIDDEN_TYPES.has(node.type)) nodeMap.delete(id) + } + + const linkCounts = new Map() + for (const [id] of nodeMap) linkCounts.set(id, 0) + + const rawEdges: GraphEdge[] = [] + for (const [sourceId, nodeData] of nodeMap) { + for (const targetRaw of nodeData.links) { + const targetId = resolveTarget(targetRaw, nodeMap) + if (targetId === null || targetId === sourceId) continue + rawEdges.push({ source: sourceId, target: targetId, weight: 1 }) + linkCounts.set(sourceId, (linkCounts.get(sourceId) ?? 0) + 1) + linkCounts.set(targetId, (linkCounts.get(targetId) ?? 0) + 1) + } + } + + const seenEdges = new Set() + const dedupedEdges: { source: string; target: string }[] = [] + for (const edge of rawEdges) { + const key = `${edge.source}:::${edge.target}` + const reverseKey = `${edge.target}:::${edge.source}` + if (!seenEdges.has(key) && !seenEdges.has(reverseKey)) { + seenEdges.add(key) + dedupedEdges.push(edge) + } + } + + // Try to get retrieval graph for weighted edges (gracefully degrades) + let retrievalGraph: Awaited> | null = null + try { + const { useWikiStore } = await import("../shims/stores-node") + const dv = useWikiStore.getState().dataVersion + retrievalGraph = await buildRetrievalGraph(normalizePath(projectPath), dv) + } catch { /* ignore — weights default to 1 */ } + + const edges: GraphEdge[] = dedupedEdges.map((e) => { + let weight = 1 + if (retrievalGraph) { + const nodeA = retrievalGraph.nodes.get(e.source) + const nodeB = retrievalGraph.nodes.get(e.target) + if (nodeA && nodeB) weight = calculateRelevance(nodeA, nodeB, retrievalGraph) + } + return { source: e.source, target: e.target, weight } + }) + + const prelimNodes = Array.from(nodeMap.values()).map((n) => ({ id: n.id, label: n.label, linkCount: linkCounts.get(n.id) ?? 0 })) + const { assignments, communities } = detectCommunities(prelimNodes, edges) + + const nodes: GraphNode[] = Array.from(nodeMap.values()).map((n) => ({ + id: n.id, label: n.label, type: n.type, path: n.path, + linkCount: linkCounts.get(n.id) ?? 0, + community: assignments.get(n.id) ?? 0, + })) + + return { nodes, edges, communities } +} diff --git a/skill/src/mcp-server.ts b/skill/src/mcp-server.ts new file mode 100644 index 00000000..769e6ec0 --- /dev/null +++ b/skill/src/mcp-server.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** + * llm-wiki MCP Server + * + * Exposes nashsu/llm_wiki backend operations as Model Context Protocol tools. + * Works with Claude Desktop, VS Code Copilot, and any MCP-compatible host. + * + * Tools: + * wiki_status — Page count and type breakdown for a project + * wiki_search — BM25 keyword search (+ optional vector via EMBEDDING_ENABLED) + * wiki_graph — Build knowledge graph (nodes, edges, Louvain communities) + * wiki_insights — Surprising connections and knowledge gaps analysis + * wiki_lint — Structural lint: orphans, no-outlinks, broken links + * + * Usage: + * node dist/mcp-server.js + * WIKI_PATH=/path/to/project node dist/mcp-server.js (default project path) + */ +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ErrorCode, + McpError, +} from "@modelcontextprotocol/sdk/types.js" + +import * as path from "path" +import { buildWikiGraph } from "./lib/wiki-graph" +import { findSurprisingConnections, detectKnowledgeGaps } from "./lib/graph-insights" +import { searchWiki } from "./lib/search" + +const DEFAULT_WIKI_PATH = process.env.WIKI_PATH ?? process.cwd() +const PKG_VERSION = "0.4.6-mcp" + +const server = new Server( + { name: "llm-wiki", version: PKG_VERSION }, + { capabilities: { tools: {} } }, +) + +// ── Tool definitions ────────────────────────────────────────────────────────── +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "wiki_status", + description: "Get page count and type breakdown for a wiki project. Returns statistics about the knowledge base.", + inputSchema: { + type: "object", + properties: { + project_path: { + type: "string", + description: "Absolute path to the wiki project directory (contains wiki/ subdirectory)", + }, + }, + required: [], + }, + }, + { + name: "wiki_search", + description: "Search wiki pages using BM25 keyword matching with optional vector search (RRF fusion). Returns ranked results with snippets.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query (supports Chinese and English)" }, + project_path: { type: "string", description: "Path to wiki project (defaults to WIKI_PATH env var)" }, + limit: { type: "number", description: "Max results to return (default: 10)" }, + }, + required: ["query"], + }, + }, + { + name: "wiki_graph", + description: "Build knowledge graph from wiki pages: wikilinks, type-based edges, Louvain community detection. Returns nodes, edges, and community clusters.", + inputSchema: { + type: "object", + properties: { + project_path: { type: "string", description: "Path to wiki project" }, + format: { + type: "string", + enum: ["json", "summary"], + description: "Output format: 'json' for full graph data, 'summary' for human-readable overview (default: summary)", + }, + }, + required: [], + }, + }, + { + name: "wiki_insights", + description: "Analyze wiki graph structure to find surprising cross-community connections and knowledge gaps (isolated pages, sparse clusters, bridge nodes).", + inputSchema: { + type: "object", + properties: { + project_path: { type: "string", description: "Path to wiki project" }, + max_connections: { type: "number", description: "Max surprising connections to return (default: 5)" }, + max_gaps: { type: "number", description: "Max knowledge gaps to return (default: 8)" }, + }, + required: [], + }, + }, + { + name: "wiki_lint", + description: "Structural lint of wiki pages: find orphaned pages (no links), no-outlinks, and connectivity issues.", + inputSchema: { + type: "object", + properties: { + project_path: { type: "string", description: "Path to wiki project" }, + }, + required: [], + }, + }, + ], +})) + +// ── Tool handlers ───────────────────────────────────────────────────────────── +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params + const projectPath = path.resolve((args.project_path as string | undefined) ?? DEFAULT_WIKI_PATH) + + try { + switch (name) { + case "wiki_status": { + const { nodes, communities } = await buildWikiGraph(projectPath) + const typeCounts: Record = {} + for (const n of nodes) typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1 + const summary = [ + `Wiki: ${projectPath}`, + `Total pages: ${nodes.length}`, + `Communities: ${communities.length}`, + ...Object.entries(typeCounts) + .sort((a, b) => b[1] - a[1]) + .map(([t, c]) => ` ${t}: ${c}`), + ].join("\n") + return { content: [{ type: "text", text: summary }] } + } + + case "wiki_search": { + if (!args.query) throw new McpError(ErrorCode.InvalidParams, "query is required") + const results = await searchWiki(projectPath, args.query as string) + const limit = typeof args.limit === "number" ? args.limit : 10 + const top = results.slice(0, limit) + if (top.length === 0) { + return { content: [{ type: "text", text: `No results for: "${args.query}"` }] } + } + const lines = [`# Search: "${args.query}"\n`] + for (const r of top) { + const relPath = path.relative(projectPath, r.path) + lines.push(`## ${r.title}`) + lines.push(`**Path**: ${relPath} | **Score**: ${r.score.toFixed(4)}`) + lines.push(r.snippet) + lines.push("") + } + return { content: [{ type: "text", text: lines.join("\n") }] } + } + + case "wiki_graph": { + const graphData = await buildWikiGraph(projectPath) + const format = (args.format as string | undefined) ?? "summary" + if (format === "json") { + return { content: [{ type: "text", text: JSON.stringify(graphData, null, 2) }] } + } + // Summary format + const { nodes, edges, communities } = graphData + const typeCounts: Record = {} + for (const n of nodes) typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1 + const lines = [ + `# Knowledge Graph Summary`, + ``, + `**Nodes**: ${nodes.length} | **Edges**: ${edges.length} | **Communities**: ${communities.length}`, + ``, + `## Node Types`, + ...Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).map(([t, c]) => `- ${t}: ${c}`), + ``, + `## Top Communities`, + ...communities.slice(0, 5).map((c, i) => + `### Community ${i + 1} (${c.nodeCount} pages, cohesion: ${c.cohesion.toFixed(2)})\nKey pages: ${c.topNodes.join(", ")}` + ), + ``, + `## Top Hubs (by link count)`, + ...nodes.sort((a, b) => b.linkCount - a.linkCount).slice(0, 10) + .map((n) => `- ${n.label} (${n.type}, ${n.linkCount} links)`), + ] + return { content: [{ type: "text", text: lines.join("\n") }] } + } + + case "wiki_insights": { + const { nodes, edges, communities } = await buildWikiGraph(projectPath) + const maxConn = typeof args.max_connections === "number" ? args.max_connections : 5 + const maxGaps = typeof args.max_gaps === "number" ? args.max_gaps : 8 + const connections = findSurprisingConnections(nodes, edges, communities, maxConn) + const gaps = detectKnowledgeGaps(nodes, edges, communities, maxGaps) + + const lines = [`# Wiki Insights\n`, `## Surprising Connections\n`] + if (connections.length === 0) lines.push("_No surprising connections found yet._\n") + for (const c of connections) { + lines.push(`### ${c.source.label} ↔ ${c.target.label}`) + lines.push(`- Score: ${c.score} | ${c.reasons.join(", ")}\n`) + } + lines.push(`## Knowledge Gaps\n`) + if (gaps.length === 0) lines.push("_No gaps detected._\n") + for (const g of gaps) { + lines.push(`### ${g.title}`) + lines.push(`${g.description}`) + lines.push(`💡 ${g.suggestion}\n`) + } + return { content: [{ type: "text", text: lines.join("\n") }] } + } + + case "wiki_lint": { + const { nodes, edges } = await buildWikiGraph(projectPath) + if (nodes.length === 0) { + return { content: [{ type: "text", text: "No wiki pages found." }] } + } + const edgeTargets = new Set(edges.map((e) => e.target)) + const edgeSources = new Set(edges.map((e) => e.source)) + const allLinked = new Set([...edgeTargets, ...edgeSources]) + const issues: string[] = [] + for (const n of nodes) { + if (n.id === "index" || n.id === "log" || n.id === "overview") continue + if (!allLinked.has(n.id)) issues.push(`[orphan] ${n.label} (${n.id}.md)`) + else if (n.linkCount <= 1) issues.push(`[isolated] ${n.label} — only ${n.linkCount} link(s)`) + } + const text = issues.length === 0 + ? `✓ All ${nodes.length} pages are properly connected.` + : `Found ${issues.length} issue(s) in ${nodes.length} pages:\n\n${issues.join("\n")}` + return { content: [{ type: "text", text: text }] } + } + + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`) + } + } catch (err) { + if (err instanceof McpError) throw err + throw new McpError( + ErrorCode.InternalError, + `Tool '${name}' failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } +}) + +// ── Start server ────────────────────────────────────────────────────────────── +async function main() { + const transport = new StdioServerTransport() + await server.connect(transport) + console.error(`llm-wiki MCP server v${PKG_VERSION} started`) + console.error(`Default wiki path: ${DEFAULT_WIKI_PATH}`) +} + +main().catch((err) => { + console.error("Failed to start MCP server:", err) + process.exit(1) +}) diff --git a/skill/src/shims/embedding-stub.ts b/skill/src/shims/embedding-stub.ts new file mode 100644 index 00000000..8a2117aa --- /dev/null +++ b/skill/src/shims/embedding-stub.ts @@ -0,0 +1,32 @@ +/** + * Stub for @/lib/embedding — vector search is not available in the skill/MCP layer + * without a running LanceDB instance. Returns empty results so BM25-only search works. + */ +export interface EmbeddingConfig { + enabled: boolean + model: string + apiBase?: string + apiKey?: string +} + +export interface VectorSearchResult { + id: string + score: number + path?: string +} + +export async function searchByEmbedding( + _projectPath: string, + _query: string, + _config: EmbeddingConfig, + _limit: number = 10, +): Promise { + return [] +} + +export async function createEmbedding( + _text: string, + _config: EmbeddingConfig, +): Promise { + return [] +} diff --git a/skill/src/shims/fs-node.ts b/skill/src/shims/fs-node.ts new file mode 100644 index 00000000..de343bda --- /dev/null +++ b/skill/src/shims/fs-node.ts @@ -0,0 +1,101 @@ +/** + * Node.js drop-in replacement for @/commands/fs (nashsu Tauri IPC layer). + * Replaces all invoke("...") calls with standard Node.js fs operations. + */ +import * as fs from "fs" +import * as path from "path" +import type { FileNode } from "../types/wiki" + +export async function readFile(filePath: string): Promise { + return fs.readFileSync(filePath, "utf-8") +} + +export async function writeFile(filePath: string, content: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, content, "utf-8") +} + +export async function listDirectory(dirPath: string): Promise { + function walk(dir: string): FileNode[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + return entries.map((entry) => { + const entryPath = path.join(dir, entry.name).replace(/\\/g, "/") + if (entry.isDirectory()) { + return { + name: entry.name, + path: entryPath, + is_dir: true, + children: walk(entryPath), + } + } + return { name: entry.name, path: entryPath, is_dir: false } + }) + } + return walk(dirPath) +} + +export async function copyFile(from: string, to: string): Promise { + fs.mkdirSync(path.dirname(to), { recursive: true }) + fs.copyFileSync(from, to) +} + +export async function deleteFile(filePath: string): Promise { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) +} + +export async function createDirectory(dirPath: string): Promise { + fs.mkdirSync(dirPath, { recursive: true }) +} + +export async function fileExists(filePath: string): Promise { + return fs.existsSync(filePath) +} + +export async function readFileAsBase64(filePath: string): Promise { + return fs.readFileSync(filePath).toString("base64") +} + +/** + * Text extraction for PDFs/DOCX/etc. + * In Node mode: returns raw file content if text-based, otherwise empty string. + * For real PDF extraction, users should pre-extract with markitdown or pdftotext. + */ +export async function preprocessFile(filePath: string): Promise { + const ext = path.extname(filePath).toLowerCase() + const textExts = [".md", ".txt", ".json", ".yaml", ".yml", ".csv", ".html", ".htm"] + if (textExts.includes(ext)) { + try { + return fs.readFileSync(filePath, "utf-8") + } catch { + return "" + } + } + // For binary files (PDF, DOCX, etc.) return empty — use pre-extracted markdown + console.warn(`[fs-node] preprocessFile: binary format not supported in Node mode: ${filePath}`) + return "" +} + +export async function findRelatedWikiPages( + sourceFile: string, + wikiRoot: string, +): Promise { + const stem = path.basename(sourceFile, path.extname(sourceFile)).toLowerCase() + const results: string[] = [] + function walk(dir: string): void { + if (!fs.existsSync(dir)) return + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (entry.name.endsWith(".md")) { + try { + const content = fs.readFileSync(full, "utf-8") + if (content.toLowerCase().includes(stem)) { + results.push(full.replace(/\\/g, "/")) + } + } catch { /* skip */ } + } + } + } + walk(wikiRoot) + return results +} diff --git a/skill/src/shims/stores-node.ts b/skill/src/shims/stores-node.ts new file mode 100644 index 00000000..66fa3773 --- /dev/null +++ b/skill/src/shims/stores-node.ts @@ -0,0 +1,135 @@ +/** + * Node.js drop-in for React zustand stores used by nashsu/llm_wiki lib files. + * Replaces all useXxxStore.getState() calls with module-level state. + */ + +export interface LlmConfig { + provider: string + apiKey: string + model: string + baseUrl?: string + temperature?: number + maxTokens?: number +} + +export interface EmbeddingConfig { + enabled: boolean + model: string + apiBase?: string + apiKey?: string +} + +interface WikiState { + projectPath: string + dataVersion: number + llmConfig: LlmConfig + embeddingConfig: EmbeddingConfig +} + +let wikiState: WikiState = { + projectPath: "", + dataVersion: 0, + llmConfig: { + provider: process.env.LLM_PROVIDER ?? "openai", + apiKey: process.env.OPENAI_API_KEY ?? process.env.LLM_API_KEY ?? "", + model: process.env.LLM_MODEL ?? "gpt-4o-mini", + baseUrl: process.env.LLM_BASE_URL, + }, + embeddingConfig: { + enabled: (process.env.EMBEDDING_ENABLED ?? "false") === "true", + model: process.env.EMBEDDING_MODEL ?? "", + apiBase: process.env.EMBEDDING_BASE_URL, + apiKey: process.env.EMBEDDING_API_KEY ?? process.env.OPENAI_API_KEY, + }, +} + +export const useWikiStore = { + getState: () => ({ ...wikiState }), + setState: (updater: Partial | ((s: WikiState) => Partial)) => { + if (typeof updater === "function") { + wikiState = { ...wikiState, ...updater(wikiState) } + } else { + wikiState = { ...wikiState, ...updater } + } + }, +} + +/** Configure the wiki store from environment variables or explicit config */ +export function configureWikiStore(config: Partial) { + wikiState = { ...wikiState, ...config } +} + +// ── Research store ─────────────────────────────────────────────────────────── +interface ResearchState { + activeProjectPath: string + isResearching: boolean +} + +let researchState: ResearchState = { + activeProjectPath: "", + isResearching: false, +} + +export const useResearchStore = { + getState: () => ({ ...researchState }), + setState: (updater: Partial) => { + researchState = { ...researchState, ...updater } + }, +} + +// ── Activity store (replaces Tauri event system) ───────────────────────────── +export interface ActivityItem { + id: string + type: string + title: string + status: "pending" | "running" | "done" | "error" + detail?: string + filesWritten?: string[] +} + +let activityItems: ActivityItem[] = [] +let activityIdCounter = 0 + +export const useActivityStore = { + getState: () => ({ + items: [...activityItems], + addItem: (item: Omit): string => { + const id = `activity-${++activityIdCounter}` + const newItem: ActivityItem = { id, ...item } + activityItems.push(newItem) + if (process.env.SKILL_VERBOSE === "1") { + console.error(`[activity:${item.type}] ${item.title} — ${item.status}`) + } + return id + }, + updateItem: (id: string, updates: Partial): void => { + const idx = activityItems.findIndex((i) => i.id === id) + if (idx >= 0) { + activityItems[idx] = { ...activityItems[idx], ...updates } + if (process.env.SKILL_VERBOSE === "1") { + const item = activityItems[idx] + console.error(`[activity:update] ${item.title} — ${item.status}: ${item.detail ?? ""}`) + } + } + }, + clearItems: () => { activityItems = [] }, + }), + addItem: (item: Omit): string => { + return useActivityStore.getState().addItem(item) + }, + updateItem: (id: string, updates: Partial): void => { + useActivityStore.getState().updateItem(id, updates) + }, +} + +// ── Chat store ─────────────────────────────────────────────────────────────── +export const useChatStore = { + getState: () => ({ messages: [] as unknown[] }), + setState: (_updater: unknown) => {}, +} + +// ── Review store ───────────────────────────────────────────────────────────── +export const useReviewStore = { + getState: () => ({ queue: [] as unknown[], isProcessing: false }), + setState: (_updater: unknown) => {}, +} diff --git a/skill/src/stores-node.ts b/skill/src/stores-node.ts new file mode 100644 index 00000000..b18d2a62 --- /dev/null +++ b/skill/src/stores-node.ts @@ -0,0 +1,160 @@ +/** + * Node.js state management replacement for React stores. + * Replaces zustand-based stores with simple module-level state. + * + * Affected stores: + * @/stores/wiki-store → wikiStore + * @/stores/research-store → researchStore + * @/stores/chat-store → chatStore + * @/stores/activity-store → activityStore + * @/stores/review-store → reviewStore + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface LlmConfig { + provider: "openai" | "anthropic" | "google" | "ollama" | "custom" + apiKey: string + model: string + baseUrl?: string +} + +export interface EmbeddingConfig { + enabled: boolean + model: string + apiBase?: string +} + +export interface SearchApiConfig { + provider: "tavily" | "serper" | "none" + apiKey?: string +} + +export interface ReviewItem { + id: string + filePath: string + content: string + reason: string +} + +// --------------------------------------------------------------------------- +// Wiki Store (replaces useWikiStore) +// --------------------------------------------------------------------------- + +const _wikiState = { + projectPath: "", + dataVersion: 0, + embeddingConfig: { + enabled: false, + model: "", + apiBase: undefined as string | undefined, + } as EmbeddingConfig, + llmConfig: { + provider: "openai" as const, + apiKey: process.env.OPENAI_API_KEY ?? "", + model: process.env.LLM_MODEL ?? "gpt-4o", + baseUrl: process.env.OPENAI_API_BASE, + } as LlmConfig, + fileTree: [] as unknown[], +} + +export const useWikiStore = { + getState: () => ({ + ..._wikiState, + setFileTree: (tree: unknown[]) => { _wikiState.fileTree = tree }, + bumpDataVersion: () => { _wikiState.dataVersion++ }, + }), +} + +export function configureWikiStore(opts: { + projectPath: string + llmConfig?: Partial + embeddingConfig?: Partial +}) { + _wikiState.projectPath = opts.projectPath + if (opts.llmConfig) Object.assign(_wikiState.llmConfig, opts.llmConfig) + if (opts.embeddingConfig) Object.assign(_wikiState.embeddingConfig, opts.embeddingConfig) +} + +// --------------------------------------------------------------------------- +// Research Store (replaces useResearchStore) +// --------------------------------------------------------------------------- + +interface ResearchTask { + id: string + topic: string + status: "queued" | "searching" | "synthesizing" | "saving" | "done" | "error" + searchQueries?: string[] + webResults?: unknown[] + synthesis?: string + savedPath?: string + error?: string +} + +const _researchState = { + tasks: [] as ResearchTask[], + maxConcurrent: 3, + panelOpen: false, +} + +export const useResearchStore = { + getState: () => ({ + ..._researchState, + addTask: (topic: string) => { + const id = `task-${Date.now()}-${Math.random().toString(36).slice(2)}` + _researchState.tasks.push({ id, topic, status: "queued" }) + return id + }, + updateTask: (id: string, updates: Partial) => { + const task = _researchState.tasks.find((t) => t.id === id) + if (task) Object.assign(task, updates) + }, + getNextQueued: () => _researchState.tasks.find((t) => t.status === "queued") ?? null, + getRunningCount: () => _researchState.tasks.filter( + (t) => t.status === "searching" || t.status === "synthesizing" || t.status === "saving" + ).length, + setPanelOpen: (open: boolean) => { _researchState.panelOpen = open }, + }), +} + +// --------------------------------------------------------------------------- +// Activity Store (replaces useActivityStore) +// --------------------------------------------------------------------------- + +export const useActivityStore = { + getState: () => ({ + addActivity: (msg: string) => { + console.log(`[Activity] ${msg}`) + }, + }), +} + +// --------------------------------------------------------------------------- +// Chat Store (replaces useChatStore) +// --------------------------------------------------------------------------- + +export const useChatStore = { + getState: () => ({ + addMessage: () => {}, + }), +} + +// --------------------------------------------------------------------------- +// Review Store (replaces useReviewStore) +// --------------------------------------------------------------------------- + +const _reviewState = { + items: [] as ReviewItem[], +} + +export const useReviewStore = { + getState: () => ({ + items: _reviewState.items, + addItem: (item: ReviewItem) => { _reviewState.items.push(item) }, + removeItem: (id: string) => { + _reviewState.items = _reviewState.items.filter((i) => i.id !== id) + }, + }), +} diff --git a/skill/src/types/wiki.ts b/skill/src/types/wiki.ts new file mode 100644 index 00000000..a51ace03 --- /dev/null +++ b/skill/src/types/wiki.ts @@ -0,0 +1,18 @@ +export interface WikiProject { + id: string + name: string + path: string +} + +export interface FileNode { + name: string + path: string + is_dir: boolean + children?: FileNode[] +} + +export interface WikiPage { + path: string + content: string + frontmatter: Record +} diff --git a/skill/tsconfig.json b/skill/tsconfig.json new file mode 100644 index 00000000..54aad9ba --- /dev/null +++ b/skill/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true, + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/skill/tsconfig.skill.json b/skill/tsconfig.skill.json new file mode 100644 index 00000000..1b37550e --- /dev/null +++ b/skill/tsconfig.skill.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "paths": { + "@/commands/fs": ["./src/fs-node.ts"], + "@/stores/wiki-store": ["./src/stores-node.ts"], + "@/stores/research-store": ["./src/stores-node.ts"], + "@/stores/chat-store": ["./src/stores-node.ts"], + "@/stores/activity-store": ["./src/stores-node.ts"], + "@/stores/review-store": ["./src/stores-node.ts"], + "@/lib/*": ["../../src/lib/*"], + "@/types/*": ["../../src/types/*"] + } + }, + "include": ["src/**/*", "../../src/lib/**/*", "../../src/types/**/*"], + "exclude": [ + "../../src/components/**/*", + "../../src/App.tsx", + "../../src/main.tsx" + ] +}