From 753f7841e83dfbbd039736a85902cbc9b39b9e26 Mon Sep 17 00:00:00 2001 From: weishen Date: Thu, 16 Apr 2026 13:13:32 +0800 Subject: [PATCH] chore: ignore raw and wiki, update remote --- .gitignore | 2 + CLAUDE.md | 352 ++++++++++----- CLAUDE.md.bak | 230 ++++++++++ raw | 1 + raw/.gitkeep | 0 tools/__pycache__/sync.cpython-311.pyc | Bin 0 -> 30098 bytes tools/sync.py | 567 +++++++++++++++++++++++++ wiki | 1 + wiki/index.md | 14 - wiki/log.md | 9 - wiki/overview.md | 17 - 11 files changed, 1038 insertions(+), 155 deletions(-) create mode 100644 .gitignore create mode 100644 CLAUDE.md.bak create mode 120000 raw delete mode 100644 raw/.gitkeep create mode 100644 tools/__pycache__/sync.cpython-311.pyc create mode 100755 tools/sync.py create mode 120000 wiki delete mode 100644 wiki/index.md delete mode 100644 wiki/log.md delete mode 100644 wiki/overview.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70e7c56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +raw/ +wiki/ diff --git a/CLAUDE.md b/CLAUDE.md index 345219f..8fc63ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,78 +1,132 @@ -# LLM Wiki Agent — Schema & Workflow Instructions +# LLM Wiki Agent — Schema & Workflow Instructions(中文版增强规范) -This wiki is maintained entirely by Claude Code. No API key or Python scripts needed — just open this repo in Claude Code and talk to it. +本 Wiki 完全由 Claude Code 自动维护。无需 API Key 或 Python 脚本 —— 只需在 Claude Code 中打开本仓库并与其对话。 -## Slash Commands (Claude Code) +--- +# 🔴 全局强制规则(CRITICAL) -| Command | What to say | -|---|---| -| `/wiki-ingest` | `ingest raw/my-article.md` | -| `/wiki-query` | `query: what are the main themes?` | -| `/wiki-lint` | `lint the wiki` | -| `/wiki-graph` | `build the knowledge graph` | +## 1. 输出语言(必须遵守) -Or just describe what you want in plain English: -- *"Ingest this file: raw/papers/attention-is-all-you-need.md"* -- *"What does the wiki say about transformer models?"* -- *"Check the wiki for orphan pages and contradictions"* -- *"Build the graph and show me what's connected to RAG"* +- 所有输出必须使用**简体中文** +- 专有名词允许保留英文,但首次出现必须附带中文解释 +- 如果原始文件名是中文,则source页面的名称尽量用中文,不要用拼音表示, 如果有特殊字符可以忽略 +- 禁止中英混合句(术语除外) +- 不允许输出纯英文总结或分析 -Claude Code reads this file automatically and follows the workflows below. +示例: + +Transformer(变压器模型,一种基于注意力机制的神经网络架构) --- -## Directory Layout +## 2. 输出风格(严格限制) -``` -raw/ # Immutable source documents — never modify these -wiki/ # Claude owns this layer entirely - index.md # Catalog of all pages — update on every ingest - log.md # Append-only chronological record - overview.md # Living synthesis across all sources - sources/ # One summary page per source document - entities/ # People, companies, projects, products - concepts/ # Ideas, frameworks, methods, theories - syntheses/ # Saved query answers -graph/ # Auto-generated graph data -tools/ # Optional standalone Python scripts (require ANTHROPIC_API_KEY) -``` +所有输出必须: + +- 去修辞(禁止 narrative 风格) +- 去模糊(禁止“可能”“大概”等词) +- 信息密度最大化 +- 面向“知识结构化”,而非阅读体验 + +优先级: + +结构 > 关系 > 结论 > 描述 --- -## Page Format +## 3. 结构化语义(必须) -Every wiki page uses this frontmatter: +所有页面必须遵循结构化语义规则: + +- Summary 必须使用固定字段 +- Claim 必须符合标准语法 +- Connections 必须使用关系类型 +- 禁止自由发挥 + +--- + +# Slash Commands(Claude Code) + +| Command | 使用方式 | +| -------------- | --------------------------- | +| `/wiki-ingest` | `ingest raw/your-file.md` | +| `/wiki-query` | `query: 你的问题` | +| `/wiki-lint` | `lint the wiki` | +| `/wiki-graph` | `build the knowledge graph` | + +--- + +## 自然语言示例 + +- ingest raw/papers/attention-is-all-you-need.md +- query: Transformer 的核心机制是什么? +- lint the wiki +- build the graph and analyze RAG + +Claude Code 会自动读取本文件并执行以下工作流。 + + + +--- + +# Directory Layout(目录结构) + +``` +raw/ # 原始文档(不可修改) +wiki/ # 知识层(由 Claude 完全维护) + index.md # 页面索引(每次 ingest 必须更新) + log.md # 追加式日志 + overview.md # 全局知识总结 + sources/ # 每个原始文档对应一个页面 + entities/ # 实体(人/公司/产品/项目) + concepts/ # 概念(方法/理论/框架) + syntheses/ # 查询结果沉淀 +graph/ # 自动生成的图数据 +tools/ # 可选 Python 工具 (require ANTHROPIC_API_KEY) +```` + + +--- + +# Page Format(页面格式) + +每个页面必须包含: ```yaml --- +id: unique_id title: "Page Title" type: source | entity | concept | synthesis tags: [] -sources: [] # list of source slugs that inform this page +sources: [] # 来源 last_updated: YYYY-MM-DD --- -``` +```` -Use `[[PageName]]` wikilinks to link to other wiki pages. +必须使用 `[[PageName]]` 进行链接。 --- -## Ingest Workflow +# Ingest Workflow(摄取流程) +**重要** 请严格按照摄取流程进行操作,每分析一个页面必须要创建/更新source page,entity, concept等。不可遗漏! -Triggered by: *"ingest "* or `/wiki-ingest` +触发方式: +- `/wiki-ingest` +- 或:`ingest ` +## 执行步骤(严格顺序) +1. 使用 Read 工具完整读取 source 文档 +2. 读取 `wiki/index.md` 和 `wiki/overview.md` +3. 生成 `wiki/sources/原始中文名.md` (非中文使用 slug.md) +4. 更新 `wiki/index.md` +5. 更新 `wiki/overview.md`(如有必要) +6. 创建或更新 Entity 页面 +7. 创建或更新 Concept 页面 +8. 检测并记录冲突 +9. 追加 `wiki/log.md` -Steps (in order): -1. Read the source document fully using the Read tool -2. Read `wiki/index.md` and `wiki/overview.md` for current wiki context -3. Write `wiki/sources/.md` — use the source page format below -4. Update `wiki/index.md` — add entry under Sources section -5. Update `wiki/overview.md` — revise synthesis if warranted -6. Update/create entity pages for key people, companies, projects mentioned -7. Update/create concept pages for key ideas and frameworks discussed -8. Flag any contradictions with existing wiki content -9. Append to `wiki/log.md`: `## [YYYY-MM-DD] ingest | ` +--- -### Source Page Format +# Source Page Format(增强结构) ```markdown --- @@ -80,32 +134,46 @@ title: "Source Title" type: source tags: [] date: YYYY-MM-DD -source_file: raw/... --- +## Source File +- [[raw/...]] + ## Summary -2–4 sentence summary. +- 核心主题: +- 问题域: +- 方法/机制: +- 结论/价值: ## Key Claims -- Claim 1 -- Claim 2 +- (必须符合:主体 + 机制 + 结果) ## Key Quotes -> "Quote here" — context +> "引用内容" — 上下文说明 + +## Key Concepts +- [[ConceptName]]:定义 + +## Key Entities +- [[EntityName]]:角色说明 ## Connections -- [[EntityName]] — how they relate -- [[ConceptName]] — how it connects +- [[A]] ← depends_on ← [[B]] +- [[C]] ← extends ← [[D]] ## Contradictions -- Contradicts [[OtherPage]] on: ... +- 与 [[OtherPage]] 冲突: + - 冲突点: + - 当前观点: + - 对方观点: ``` -### Domain-Specific Templates +--- -If the source falls into a specific domain (e.g., personal diary, meeting notes), the agent should use a specialized template instead of the default generic one above: +# Domain-Specific Templates(领域模板) + +## Diary / Journal -#### Diary / Journal Template ```markdown --- title: "YYYY-MM-DD Diary" @@ -114,18 +182,16 @@ tags: [diary] date: YYYY-MM-DD --- ## Event Summary -... ## Key Decisions -... ## Energy & Mood -... ## Connections -... ## Shifts & Contradictions -... ``` -#### Meeting Notes Template +--- + +## Meeting Notes + ```markdown --- title: "Meeting Title" @@ -134,97 +200,153 @@ tags: [meeting] date: YYYY-MM-DD --- ## Goal -... ## Key Discussions -... ## Decisions Made -... ## Action Items -... ``` --- -## Query Workflow +# Entity & Concept Rules(关键增强) -Triggered by: *"query: <question>"* or `/wiki-query` +## Entity(实体) -Steps: -1. Read `wiki/index.md` to identify relevant pages -2. Read those pages with the Read tool -3. Synthesize an answer with inline citations as `[[PageName]]` wikilinks -4. Ask the user if they want the answer filed as `wiki/syntheses/<slug>.md` +创建条件: +- 出现 ≥ 2 次 + 或 +- 对主题有关键影响 + +类型: +- 人 / 公司 / 产品 / 项目 --- -## Lint Workflow +## Concept(概念) +创建条件: +- 可抽象 +- 可复用 +- 非具体实例 +--- -Triggered by: *"lint the wiki"* or `/wiki-lint` +## 命名规范(强制) +- 使用唯一标准名称 +- 所有别名写入页面: -Use Grep and Read tools to check for: -- **Orphan pages** — wiki pages with no inbound `[[links]]` from other pages -- **Broken links** — `[[WikiLinks]]` pointing to pages that don't exist -- **Contradictions** — claims that conflict across pages -- **Stale summaries** — pages not updated after newer sources -- **Missing entity pages** — entities mentioned in 3+ pages but lacking their own page -- **Data gaps** — questions the wiki can't answer; suggest new sources - -Output a lint report and ask if the user wants it saved to `wiki/lint-report.md`. +```markdown +## Aliases +- GPT4 +- GPT-4 +``` --- -## Graph Workflow +## 去重机制(必须) -Triggered by: *"build the knowledge graph"* or `/wiki-graph` - -When the user asks to build the graph, run `tools/build_graph.py` which: -- Pass 1: Parses all `[[wikilinks]]` → deterministic `EXTRACTED` edges -- Pass 2: Infers implicit relationships → `INFERRED` edges with confidence scores -- Runs Louvain community detection -- Outputs `graph/graph.json` + `graph/graph.html` - -If the user doesn't have Python/dependencies set up, instead generate the graph data manually: -1. Use Grep to find all `[[wikilinks]]` across wiki pages -2. Build a node/edge list -3. Write `graph/graph.json` directly -4. Write `graph/graph.html` using the vis.js template +创建前必须: +1. 搜索 index +2. 判断是否存在 +3. 存在则更新 --- -## Naming Conventions +# Query Workflow(查询流程) -- Source slugs: `kebab-case` matching source filename -- Entity pages: `TitleCase.md` (e.g. `OpenAI.md`, `SamAltman.md`) -- Concept pages: `TitleCase.md` (e.g. `ReinforcementLearning.md`, `RAG.md`) -- Source pages: `kebab-case.md` +触发: +- `/wiki-query` +- 或:`query: 问题` -## Index Format +--- + +## 步骤 + +1. 读取 index +2. 找到相关页面 +3. 使用 Read 工具加载 +4. 输出结构化答案 +5. 使用 `[[Page]]` 引用 +6. 询问是否保存为 synthesis + +--- + +# Lint Workflow(校验) + +检查内容: + +- 孤立页面 +- 断链 +- 冲突 +- 过期内容 +- 缺失Entity +- 缺失Concept +- 知识空白 + +--- + +# Graph Workflow(知识图谱) + +触发: +- `/wiki-graph` + +--- + +执行: +- 优先运行 `tools/build_graph.py` +- 否则手动构建: + +步骤: +1. 提取所有 `[[links]]` +2. 构建节点与边 +3. 输出 `graph.json` + +--- + +# Naming Conventions(命名规范) +- Source:保留原始中文名称(去除特殊符号),非中文使用 kebab-case +- Entity:TitleCase +- Concept:TitleCase + +--- + +# Index Format(索引结构) ```markdown # Wiki Index ## Overview -- [Overview](overview.md) — living synthesis +- [Overview](overview.md) ## Sources -- [Source Title](sources/slug.md) — one-line summary +- [Title](sources/原始中文名.md) ## Entities -- [Entity Name](entities/EntityName.md) — one-line description +- [Entity](entities/Entity.md) ## Concepts -- [Concept Name](concepts/ConceptName.md) — one-line description +- [Concept](concepts/Concept.md) ## Syntheses -- [Analysis Title](syntheses/slug.md) — what question it answers +- [Title](syntheses/slug.md) ``` -## Log Format +--- -Each entry starts with `## [YYYY-MM-DD] <operation> | <title>` so it's grep-parseable: +# Log Format(日志) ``` -grep "^## \[" wiki/log.md | tail -10 +## [YYYY-MM-DD] ingest | 标题 ``` -Operations: `ingest`, `query`, `lint`, `graph` +--- + +# ✅ 最终目标 + +该系统用于: + +- 知识沉淀 +- 结构化理解 +- 自动图谱构建 +- Agent 推理支持 + +--- + +# END \ No newline at end of file diff --git a/CLAUDE.md.bak b/CLAUDE.md.bak new file mode 100644 index 0000000..345219f --- /dev/null +++ b/CLAUDE.md.bak @@ -0,0 +1,230 @@ +# LLM Wiki Agent — Schema & Workflow Instructions + +This wiki is maintained entirely by Claude Code. No API key or Python scripts needed — just open this repo in Claude Code and talk to it. + +## Slash Commands (Claude Code) + +| Command | What to say | +|---|---| +| `/wiki-ingest` | `ingest raw/my-article.md` | +| `/wiki-query` | `query: what are the main themes?` | +| `/wiki-lint` | `lint the wiki` | +| `/wiki-graph` | `build the knowledge graph` | + +Or just describe what you want in plain English: +- *"Ingest this file: raw/papers/attention-is-all-you-need.md"* +- *"What does the wiki say about transformer models?"* +- *"Check the wiki for orphan pages and contradictions"* +- *"Build the graph and show me what's connected to RAG"* + +Claude Code reads this file automatically and follows the workflows below. + +--- + +## Directory Layout + +``` +raw/ # Immutable source documents — never modify these +wiki/ # Claude owns this layer entirely + index.md # Catalog of all pages — update on every ingest + log.md # Append-only chronological record + overview.md # Living synthesis across all sources + sources/ # One summary page per source document + entities/ # People, companies, projects, products + concepts/ # Ideas, frameworks, methods, theories + syntheses/ # Saved query answers +graph/ # Auto-generated graph data +tools/ # Optional standalone Python scripts (require ANTHROPIC_API_KEY) +``` + +--- + +## Page Format + +Every wiki page uses this frontmatter: + +```yaml +--- +title: "Page Title" +type: source | entity | concept | synthesis +tags: [] +sources: [] # list of source slugs that inform this page +last_updated: YYYY-MM-DD +--- +``` + +Use `[[PageName]]` wikilinks to link to other wiki pages. + +--- + +## Ingest Workflow + +Triggered by: *"ingest <file>"* or `/wiki-ingest` + +Steps (in order): +1. Read the source document fully using the Read tool +2. Read `wiki/index.md` and `wiki/overview.md` for current wiki context +3. Write `wiki/sources/<slug>.md` — use the source page format below +4. Update `wiki/index.md` — add entry under Sources section +5. Update `wiki/overview.md` — revise synthesis if warranted +6. Update/create entity pages for key people, companies, projects mentioned +7. Update/create concept pages for key ideas and frameworks discussed +8. Flag any contradictions with existing wiki content +9. Append to `wiki/log.md`: `## [YYYY-MM-DD] ingest | <Title>` + +### Source Page Format + +```markdown +--- +title: "Source Title" +type: source +tags: [] +date: YYYY-MM-DD +source_file: raw/... +--- + +## Summary +2–4 sentence summary. + +## Key Claims +- Claim 1 +- Claim 2 + +## Key Quotes +> "Quote here" — context + +## Connections +- [[EntityName]] — how they relate +- [[ConceptName]] — how it connects + +## Contradictions +- Contradicts [[OtherPage]] on: ... +``` + +### Domain-Specific Templates + +If the source falls into a specific domain (e.g., personal diary, meeting notes), the agent should use a specialized template instead of the default generic one above: + +#### Diary / Journal Template +```markdown +--- +title: "YYYY-MM-DD Diary" +type: source +tags: [diary] +date: YYYY-MM-DD +--- +## Event Summary +... +## Key Decisions +... +## Energy & Mood +... +## Connections +... +## Shifts & Contradictions +... +``` + +#### Meeting Notes Template +```markdown +--- +title: "Meeting Title" +type: source +tags: [meeting] +date: YYYY-MM-DD +--- +## Goal +... +## Key Discussions +... +## Decisions Made +... +## Action Items +... +``` + +--- + +## Query Workflow + +Triggered by: *"query: <question>"* or `/wiki-query` + +Steps: +1. Read `wiki/index.md` to identify relevant pages +2. Read those pages with the Read tool +3. Synthesize an answer with inline citations as `[[PageName]]` wikilinks +4. Ask the user if they want the answer filed as `wiki/syntheses/<slug>.md` + +--- + +## Lint Workflow + +Triggered by: *"lint the wiki"* or `/wiki-lint` + +Use Grep and Read tools to check for: +- **Orphan pages** — wiki pages with no inbound `[[links]]` from other pages +- **Broken links** — `[[WikiLinks]]` pointing to pages that don't exist +- **Contradictions** — claims that conflict across pages +- **Stale summaries** — pages not updated after newer sources +- **Missing entity pages** — entities mentioned in 3+ pages but lacking their own page +- **Data gaps** — questions the wiki can't answer; suggest new sources + +Output a lint report and ask if the user wants it saved to `wiki/lint-report.md`. + +--- + +## Graph Workflow + +Triggered by: *"build the knowledge graph"* or `/wiki-graph` + +When the user asks to build the graph, run `tools/build_graph.py` which: +- Pass 1: Parses all `[[wikilinks]]` → deterministic `EXTRACTED` edges +- Pass 2: Infers implicit relationships → `INFERRED` edges with confidence scores +- Runs Louvain community detection +- Outputs `graph/graph.json` + `graph/graph.html` + +If the user doesn't have Python/dependencies set up, instead generate the graph data manually: +1. Use Grep to find all `[[wikilinks]]` across wiki pages +2. Build a node/edge list +3. Write `graph/graph.json` directly +4. Write `graph/graph.html` using the vis.js template + +--- + +## Naming Conventions + +- Source slugs: `kebab-case` matching source filename +- Entity pages: `TitleCase.md` (e.g. `OpenAI.md`, `SamAltman.md`) +- Concept pages: `TitleCase.md` (e.g. `ReinforcementLearning.md`, `RAG.md`) +- Source pages: `kebab-case.md` + +## Index Format + +```markdown +# Wiki Index + +## Overview +- [Overview](overview.md) — living synthesis + +## Sources +- [Source Title](sources/slug.md) — one-line summary + +## Entities +- [Entity Name](entities/EntityName.md) — one-line description + +## Concepts +- [Concept Name](concepts/ConceptName.md) — one-line description + +## Syntheses +- [Analysis Title](syntheses/slug.md) — what question it answers +``` + +## Log Format + +Each entry starts with `## [YYYY-MM-DD] <operation> | <title>` so it's grep-parseable: + +``` +grep "^## \[" wiki/log.md | tail -10 +``` + +Operations: `ingest`, `query`, `lint`, `graph` diff --git a/raw b/raw new file mode 120000 index 0000000..9bb82eb --- /dev/null +++ b/raw @@ -0,0 +1 @@ +/Users/weishen/Workspace/nexus/raw \ No newline at end of file diff --git a/raw/.gitkeep b/raw/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tools/__pycache__/sync.cpython-311.pyc b/tools/__pycache__/sync.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0533199b80d393d9785d5717d1002c2741757e9a GIT binary patch literal 30098 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�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 literal 0 HcmV?d00001 diff --git a/tools/sync.py b/tools/sync.py new file mode 100755 index 0000000..70c4ba9 --- /dev/null +++ b/tools/sync.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +""" +Wiki ↔ Raw 三向同步工具 + +功能: + - 检测 raw/ 下文件变化(新增/修改/删除) + - 自动调用 ingest.py 进行同步 + - 维护 manifest.json 状态映射 + - 检测 orphan entity/concept(仅报告,不删除) + +用法: + python tools/sync.py --check 预览变化(不执行) + python tools/sync.py --sync 执行同步 + python tools/sync.py --rebuild 从 manifest 重建 wiki/index(兜底) + python tools/sync.py --bootstrap 从现有 wiki sources 反向生成 manifest(首次用,跳过已 ingest 的文件) + +manifest.json 格式: +{ + "version": 1, + "updated_at": "ISO timestamp", + "files": { + "relative/path/to/file.md": { + "hash": "sha256", + "modified": "ISO timestamp", + "slug": "wiki-source-slug", + "source_path": "wiki/sources/slug.md", + "ingested": true + } + } +} +""" + +import os +import sys +import json +import hashlib +import subprocess +from pathlib import Path +from datetime import datetime, timezone + + +REPO_ROOT = Path(__file__).parent.parent +WIKI_DIR = REPO_ROOT / "wiki" +MANIFEST_FILE = WIKI_DIR / "manifest.json" +SCHEMA_FILE = REPO_ROOT / "CLAUDE.md" + + +# ─── 工具函数 ─────────────────────────────────────────────── + +def green(text): + return f"\033[92m{text}\033[0m" + +def yellow(text): + return f"\033[93m{text}\033[0m" + +def red(text): + return f"\033[91m{text}\033[0m" + +def dim(text): + return f"\033[2m{text}\033[0m" + +def bold(text): + return f"\033[1m{text}\033[0m" + + +def log(msg, style="normal"): + prefixes = { + "normal": " ", + "info": " ℹ ", + "success": " ✓ ", + "warn": " ⚠ ", + "error": " ✗ ", + "section": "\n── ", + } + print(f"{prefixes.get(style, ' ')}{msg}") + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest()[:16] + + +def iso_now(): + return datetime.now(timezone.utc).isoformat() + + +def load_manifest() -> dict: + if MANIFEST_FILE.exists(): + try: + return json.loads(MANIFEST_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, IOError): + pass + return {"version": 1, "updated_at": iso_now(), "files": {}} + + +def save_manifest(manifest: dict): + manifest["updated_at"] = iso_now() + MANIFEST_FILE.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + + +def scan_raw() -> dict[str, dict]: + """返回 {relative_path: {hash, modified, size}}""" + raw_dir = REPO_ROOT / "raw" + result = {} + if not raw_dir.exists(): + return result + for p in raw_dir.rglob("*.md"): + if p.is_file() and not p.name.startswith("."): + rel = str(p.relative_to(REPO_ROOT)) + stat = p.stat() + result[rel] = { + "hash": sha256_file(p), + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "size": stat.st_size, + "abs_path": str(p), + } + return result + + +def build_slug_from_path(rel_path: str) -> str: + """从相对路径生成 slug(尽量保留中文,kebab-case)""" + name = Path(rel_path).stem + name = name.replace(" ", "-").replace("/", "-").replace("\\", "-") + name = "".join(c if c.isalnum() or c in ("-", "_", "·") else "-" for c in name) + name = name.strip("-") + return name or "untitled" + + +def call_ingest(source_path: str, slug: str = None) -> dict: + """调用 ingest.py,返回结果""" + cmd = [sys.executable, str(REPO_ROOT / "tools" / "ingest.py"), source_path] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, + cwd=str(REPO_ROOT), + ) + return { + "success": result.returncode == 0, + "stdout": result.stdout, + "stderr": result.stderr, + } + except subprocess.TimeoutExpired: + return {"success": False, "stdout": "", "stderr": "Timeout (>5min)"} + except Exception as e: + return {"success": False, "stdout": "", "stderr": str(e)} + + +def find_orphan_entity_concept(manifest: dict) -> tuple[list, list]: + """检测未被任何 source page 引用的 entity 和 concept""" + # 从所有 source 内容中提取 [[wikilinks]] + import re + wikilink_pattern = re.compile(r"\[\[([^\]]+)\]\]") + + sources_dir = WIKI_DIR / "sources" + referenced_entities = set() + referenced_concepts = set() + + if sources_dir.exists(): + for src in sources_dir.glob("*.md"): + content = src.read_text(encoding="utf-8") + for link in wikilink_pattern.findall(content): + name = link.strip() + if name.startswith("entities/"): + referenced_entities.add(Path(name).stem) + elif name.startswith("concepts/"): + referenced_concepts.add(Path(name).stem) + elif "/" not in name: + # 裸 wikilink,可能是 entity 或 concept + referenced_entities.add(name) + referenced_concepts.add(name) + + # 检查 entity 目录 + orphan_entities = [] + entities_dir = WIKI_DIR / "entities" + if entities_dir.exists(): + for f in entities_dir.glob("*.md"): + if f.stem not in referenced_entities: + orphan_entities.append(f.name) + + # 检查 concept 目录 + orphan_concepts = [] + concepts_dir = WIKI_DIR / "concepts" + if concepts_dir.exists(): + for f in concepts_dir.glob("*.md"): + if f.stem not in referenced_concepts: + orphan_concepts.append(f.name) + + return orphan_entities, orphan_concepts + + +# ─── 核心同步逻辑 ─────────────────────────────────────────────── + +def check_changes(manifest: dict, raw_files: dict) -> dict: + """对比 manifest 和实际 raw 文件,返回变化""" + changes = {"new": [], "updated": [], "deleted": [], "unchanged": []} + manifest_files = manifest.get("files", {}) + + # 遍历当前 raw 文件 + for rel_path, info in raw_files.items(): + if rel_path not in manifest_files: + changes["new"].append({"rel_path": rel_path, **info}) + elif info["hash"] != manifest_files[rel_path]["hash"]: + changes["updated"].append({ + "rel_path": rel_path, + "old_hash": manifest_files[rel_path]["hash"], + **info, + }) + else: + changes["unchanged"].append(rel_path) + + # 遍历 manifest,找已删除的 + for rel_path in manifest_files: + abs_path = REPO_ROOT / rel_path + if not abs_path.exists(): + changes["deleted"].append({ + "rel_path": rel_path, + "slug": manifest_files[rel_path].get("slug", build_slug_from_path(rel_path)), + "source_path": manifest_files[rel_path].get("source_path"), + }) + + return changes + + +def run_sync(dry_run: bool = False, verbose: bool = False): + print(f"\n{bold('=== Wiki Sync')}\n") + print(f" Date: {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print(f" Raw: {REPO_ROOT / 'raw'}") + print(f" Wiki: {WIKI_DIR}") + print(f" Mode: {'DRY-RUN (preview only)' if dry_run else 'LIVE SYNC'}") + print() + + # Step 1: load manifest + manifest = load_manifest() + log("manifest.json loaded", "info") + + # Step 2: scan raw/ + raw_files = scan_raw() + log(f"raw/ scan: {len(raw_files)} .md files found", "info") + + # Step 3: check changes + changes = check_changes(manifest, raw_files) + total_changes = len(changes["new"]) + len(changes["updated"]) + len(changes["deleted"]) + + if total_changes == 0: + log("No changes detected — wiki is up to date.", "success") + return + + # ─── Report ─── + print(f"\n{bold('--- Changes ---')}") + print(f" {green('+')} New: {len(changes['new'])}") + print(f" {yellow('~')} Updated: {len(changes['updated'])}") + print(f" {red('-')} Deleted: {len(changes['deleted'])}") + + if verbose or not dry_run: + if changes["new"]: + print(f"\n {bold('New Files:')}") + for f in changes["new"]: + log(f"{green('[+')} {f['rel_path']}", "normal") + + if changes["updated"]: + print(f"\n {bold('Updated Files:')}") + for f in changes["updated"]: + log(f"{yellow('[~]')} {f['rel_path']} (hash changed)", "normal") + + if changes["deleted"]: + print(f"\n {bold('Deleted Files:')}") + for f in changes["deleted"]: + log(f"{red('[-]')} {f['rel_path']}", "normal") + + if dry_run: + log("\nDry-run complete. Run with --sync to apply.", "warn") + return + + # ─── Apply Sync ─── + print(f"\n{bold('--- Applying Sync ---')}") + + updated_manifest = manifest.copy() + updated_manifest["files"] = manifest.get("files", {}).copy() + + # ① 新增 → ingest + for f in changes["new"]: + rel_path = f["rel_path"] + abs_path = f["abs_path"] + slug = build_slug_from_path(rel_path) + print(f"\n {green('[+]')} New: {rel_path}") + print(f" slug: {slug}") + + result = call_ingest(abs_path, slug) + if result["success"]: + log(f"Ingested: {slug}.md", "success") + updated_manifest["files"][rel_path] = { + "hash": f["hash"], + "modified": f["modified"], + "slug": slug, + "source_path": f"wiki/sources/{slug}.md", + "ingested": True, + "ingested_at": iso_now(), + } + else: + log(f"Failed: {result['stderr'][:200]}", "error") + # 仍然记录(避免重复 ingest) + updated_manifest["files"][rel_path] = { + "hash": f["hash"], + "modified": f["modified"], + "slug": slug, + "source_path": f"wiki/sources/{slug}.md", + "ingested": False, + "ingested_at": None, + "error": result["stderr"][:500], + } + + # ② 修改 → re-ingest + for f in changes["updated"]: + rel_path = f["rel_path"] + abs_path = f["abs_path"] + old_slug = manifest["files"].get(rel_path, {}).get("slug") or build_slug_from_path(rel_path) + print(f"\n {yellow('[~]')} Updated: {rel_path}") + + result = call_ingest(abs_path, old_slug) + if result["success"]: + log(f"Re-ingested: {old_slug}.md", "success") + updated_manifest["files"][rel_path] = { + **updated_manifest["files"].get(rel_path, {}), + "hash": f["hash"], + "modified": f["modified"], + "slug": old_slug, + "source_path": f"wiki/sources/{old_slug}.md", + "ingested": True, + "ingested_at": iso_now(), + } + else: + log(f"Failed: {result['stderr'][:200]}", "error") + + # ③ 删除 → 保留 wiki 内容,仅从 manifest 移除(按用户要求保留 orphan) + for f in changes["deleted"]: + rel_path = f["rel_path"] + source_path = f.get("source_path") + print(f"\n {red('[-]')} Deleted: {rel_path}") + if source_path: + sp = WIKI_DIR / source_path + log(f" Wiki source kept: {sp}", "warn") + # 从 manifest 移除(不删除 wiki 文件) + if rel_path in updated_manifest["files"]: + del updated_manifest["files"][rel_path] + + # Step 4: Save manifest + save_manifest(updated_manifest) + log(f"\nmanifest.json updated ({len(updated_manifest['files'])} entries)", "success") + + # Step 5: Orphan detection + orphan_entities, orphan_concepts = find_orphan_entity_concept(updated_manifest) + if orphan_entities or orphan_concepts: + print(f"\n{bold('--- Orphan Report (kept as requested) ---')}") + if orphan_entities: + print(f" {bold('Orphan Entities')} ({len(orphan_entities)}):") + for e in sorted(orphan_entities): + print(f" {dim('?')} {e}") + if orphan_concepts: + print(f" {bold('Orphan Concepts')} ({len(orphan_concepts)}):") + for c in sorted(orphan_concepts): + print(f" {dim('?')} {c}") + log("\nOrphan pages are kept (not deleted per user request).", "info") + else: + log("No orphan entity/concept detected.", "success") + + print(f"\n{bold('Done.')}") + + +def run_bootstrap(): + """从现有 wiki sources 反向生成 manifest,跳过已 ingest 的文件""" + import re + + print(f"\n{bold('=== Wiki Bootstrap')}\n") + print(f" Scanning existing wiki sources to build manifest ...\n") + + sources_dir = WIKI_DIR / "sources" + if not sources_dir.exists(): + print(f" {red('✗')} No wiki/sources/ directory found. Nothing to bootstrap.") + return + + wikilink_pattern = re.compile(r"\[\[?raw/([^\]\s]+\.md)\]?]?", re.IGNORECASE) + + manifest = {"version": 1, "updated_at": iso_now(), "files": {}} + raw_dir = (REPO_ROOT / "raw").resolve() # 解析 symlink 到真实路径 + repo_raw_prefix = str(REPO_ROOT / "raw") # 用于 strip 前缀得到相对路径 + bootstrapped = 0 + skipped_not_found = 0 + skipped_no_source_field = 0 + + for src in sources_dir.glob("*.md"): + content = src.read_text(encoding="utf-8") + + # 尝试从 ## Source File 字段提取原始路径 + match = wikilink_pattern.search(content) + if not match: + skipped_no_source_field += 1 + continue + + # raw_rel 格式如 "Agent/usecases/xxx.md"(不含 raw/ 前缀) + raw_rel = match.group(1).lstrip("/") + # 用 resolved 后的 raw_dir 拼接(follow symlink) + raw_path = raw_dir / raw_rel + + if not raw_path.exists(): + # 文件已删除,保留 source page 但不加入 manifest + skipped_not_found += 1 + continue + + stat = raw_path.stat() + file_hash = sha256_file(raw_path) + slug = src.stem + + # manifest key 用 "raw/Agent/xxx.md" 格式(REPO_ROOT 相对路径) + manifest_key = f"raw/{raw_rel}" + manifest["files"][manifest_key] = { + "hash": file_hash, + "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "slug": slug, + "source_path": f"wiki/sources/{slug}.md", + "ingested": True, + "ingested_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + bootstrapped += 1 + + save_manifest(manifest) + + print(f" {bold('Result:')}") + print(f" {green('✓')} Manifest entries created: {bootstrapped}") + print(f" {yellow('~')} Skipped (source file deleted): {skipped_not_found}") + print(f" {dim('-')} Skipped (no source_file field): {skipped_no_source_field}") + print(f"\n {green('✓')} manifest.json created at: {MANIFEST_FILE}") + print(f"\n Run now: {bold('python tools/sync.py --check')} to preview new/updated files.\n") + + +def run_check(): + """只预览变化,不执行""" + manifest = load_manifest() + raw_files = scan_raw() + changes = check_changes(manifest, raw_files) + total = len(changes["new"]) + len(changes["updated"]) + len(changes["deleted"]) + + print(f"\n{bold('=== Wiki Sync Check')} (preview mode)\n") + print(f" Raw files: {len(raw_files)}") + print(f" Manifest entries: {len(manifest.get('files', {}))}") + print(f" {green('+')} New: {len(changes['new'])}") + print(f" {yellow('~')} Updated: {len(changes['updated'])}") + print(f" {red('-')} Deleted: {len(changes['deleted'])}") + + if total > 0: + if changes["new"]: + print(f"\n {bold('New Files:')}") + for f in changes["new"]: + print(f" {green('[+]')} {f['rel_path']}") + if changes["updated"]: + print(f"\n {bold('Updated Files:')}") + for f in changes["updated"]: + print(f" {yellow('[~]')} {f['rel_path']} (was {f['old_hash']}, now {f['hash']})") + if changes["deleted"]: + print(f"\n {bold('Deleted Files:')}") + for f in changes["deleted"]: + print(f" {red('[-]')} {f['rel_path']}") + else: + print(f"\n {green('No changes — wiki is in sync.')}") + + print() + + +def run_rebuild(): + """从 manifest 重建 wiki/index.md(兜底方案)""" + manifest = load_manifest() + print(f"\n{bold('=== Wiki Rebuild from Manifest')}\n") + print(f" Manifest entries: {len(manifest.get('files', {}))}") + print(f" Rebuilding index.md ...\n") + + index_lines = [ + "# Wiki Index\n", + "\n## Overview\n", + "- [Overview](overview.md) — living synthesis\n", + "\n## Sources\n", + ] + + files = manifest.get("files", {}) + # 按 modified 时间倒序 + sorted_files = sorted(files.items(), key=lambda x: x[1].get("modified", ""), reverse=True) + + for rel_path, info in sorted_files: + slug = info.get("slug", build_slug_from_path(rel_path)) + source_md_path = WIKI_DIR / "sources" / f"{slug}.md" + if source_md_path.exists(): + title = source_md_path.read_text(encoding="utf-8").split("\n")[0].lstrip("# ").strip() + index_lines.append(f"- [{title}](sources/{slug}.md)\n") + else: + index_lines.append(f"- [{slug}](sources/{slug}.md) — (source missing)\n") + + index_lines.append("\n## Entities\n\n## Concepts\n\n## Syntheses\n") + + index_file = WIKI_DIR / "index.md" + index_file.write_text("".join(index_lines), encoding="utf-8") + print(f" {green('✓')} index.md rebuilt with {len(sorted_files)} sources") + + # Orphan report + orphan_entities, orphan_concepts = find_orphan_entity_concept(manifest) + if orphan_entities: + print(f" {dim('?')} Orphan entities: {len(orphan_entities)}") + if orphan_concepts: + print(f" {dim('?')} Orphan concepts: {len(orphan_concepts)}") + + print(f"\nDone.") + + +# ─── CLI 入口 ─────────────────────────────────────────────── + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Wiki ↔ Raw 三向同步工具", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--check", + action="store_true", + help="预览变化,不执行同步", + ) + parser.add_argument( + "--sync", + action="store_true", + help="执行完整同步(新增/修改/删除 + orphan 检测)", + ) + parser.add_argument( + "--rebuild", + action="store_true", + help="从 manifest 重建 wiki/index.md(兜底方案)", + ) + parser.add_argument( + "--bootstrap", + action="store_true", + help="从现有 wiki sources 反向生成 manifest(首次使用,跳过已 ingest 的文件)", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="详细输出", + ) + + args = parser.parse_args() + + if args.bootstrap: + run_bootstrap() + elif args.rebuild: + run_rebuild() + elif args.check: + run_check() + elif args.sync: + run_sync(dry_run=False, verbose=args.verbose) + else: + parser.print_help() + print("\n示例:") + print(" python tools/sync.py --check # 预览变化") + print(" python tools/sync.py --sync # 执行同步") + print(" python tools/sync.py --sync -v # 详细模式") + print(" python tools/sync.py --rebuild # 重建 index") + print(" python tools/sync.py --bootstrap # 首次:从 wiki sources 生成 manifest") diff --git a/wiki b/wiki new file mode 120000 index 0000000..31bd750 --- /dev/null +++ b/wiki @@ -0,0 +1 @@ +/Users/weishen/Workspace/nexus/wiki \ No newline at end of file diff --git a/wiki/index.md b/wiki/index.md deleted file mode 100644 index 647ecb0..0000000 --- a/wiki/index.md +++ /dev/null @@ -1,14 +0,0 @@ -# Wiki Index - -This file is maintained by the LLM. Updated on every ingest. - -## Overview -- [Overview](overview.md) — living synthesis across all sources - -## Sources - -## Entities - -## Concepts - -## Syntheses diff --git a/wiki/log.md b/wiki/log.md deleted file mode 100644 index 66a9285..0000000 --- a/wiki/log.md +++ /dev/null @@ -1,9 +0,0 @@ -# Wiki Log - -Append-only chronological record of all operations. - -Format: `## [YYYY-MM-DD] <operation> | <title>` - -Parse recent entries: `grep "^## \[" wiki/log.md | tail -10` - ---- diff --git a/wiki/overview.md b/wiki/overview.md deleted file mode 100644 index f71416d..0000000 --- a/wiki/overview.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "Overview" -type: synthesis -tags: [] -sources: [] -last_updated: "" ---- - -# Overview - -*This page is maintained by the LLM. It is updated on every ingest to reflect the current synthesis across all sources.* - -No sources ingested yet. Add your first source with: - -```bash -python tools/ingest.py raw/your-source.md -```