Metadata-Version: 2.4
Name: local-wiki
Version: 1.3.0
Summary: Local-first agent wiki with caller-side AI conflict resolution — pending-queue commit model for safe multi-profile/multi-session writes
Author: warrior-kite
License: MIT
Keywords: wiki,mcp,knowledge-base,agent,local-first,conflict-resolution
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp>=1.0
Requires-Dist: PyYAML>=6.0
Requires-Dist: tokenizers>=0.19
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Dynamic: license-file

# local-wiki

Local-first agent wiki with **caller-side AI conflict resolution**. Stores knowledge as plain Markdown on your local filesystem, safe for multi-profile / multi-session concurrent writes via a **pending-queue commit model** — no torn writes, no silent overwrites.

Built as a standard MCP server, so it works with **any MCP client** (Hermes, Claude, Cursor, Codex, OpenCode...). Same code on any machine: `pip install local-wiki` or `uvx local-wiki`.

**Version 1.3.0** — production release. Runs from PyPI (`uvx local-wiki` / `uv tool install local-wiki`), as a standalone executable (see [Executable release](#executable-release)), as a **one-shot CLI**, or as a global HTTP service.

## Why

- Skills should hold *experience* (how); **knowledge** (what) belongs in a wiki — this is the knowledge store for that.
- Naive file writes / single-writer tools have **no concurrency control** → multi-agent writes corrupt pages.
- This project fixes it with a **pending queue**: writes are staged (one file per draft in `pending/`), a single committer serializes them under an OS lock, and conflicts are resolved **by the caller (the agent) in its own conversation** — the server itself never calls an LLM.

## Design: AI resolution lives in the caller's conversation

The committer is intentionally **dumb and deterministic** (lock → hash check → atomic write → index update). It does **not** call any LLM:

1. `wiki_add` creates new pages directly (target missing) or stages a draft into `pending/<id>.json` (recording the base-hash of the version the writer read).
2. `wiki_commit` drains the queue under a single `LockFileEx`/`flock` lock (FIFO by file mtime).
   - **No drift** → commit directly.
   - **Drift** (someone else committed meanwhile) → write a conflict record `pending/<id>.conflict.json` (current + draft full text), keep the draft queued.
3. The caller sees the conflict (`wiki_conflicts` returns both versions), **merges them in its own conversation** (the agent is the AI), and submits the result:
   - `wiki_resolve(conflict_id, side='merged', merged_content=...)` → apply your merge.
   - or `side='draft'` / `side='current'`.
4. Resolution removes the draft from the queue; `wiki_commit` can then drain the rest.

**Result:** zero LLM/key dependencies in the server, fully offline, and semantic merge quality comes from the caller's model — not from a hardcoded prompt.

## Install

```bash
pip install local-wiki          # or
uvx local-wiki --help           # runs the latest PyPI release
```

## Run (MCP server)

### stdio (default)

```bash
local-wiki --wiki-root <dir>
```

### HTTP (Streamable)

```bash
local-wiki serve --wiki-root <dir> --host 127.0.0.1 --port 8300   # 推荐(v1.3.0 子命令)
# 或旧形式(等价):
local-wiki --wiki-root <dir> --host 127.0.0.1 --port 8300
```
默认端点为 `http://127.0.0.1:8300/mcp`。`--host 127.0.0.1` 仅本机回环——MCP 无鉴权，勿绑 `0.0.0.0` 暴露公网。

### Standalone executable (no Python required)

```bash
local-wiki.exe --wiki-root <dir>
```

## Run (one-shot CLI — no service needed, v1.3.0)

Every `wiki_*` MCP tool has a CLI mirror. Each command builds its own `Storage`,
prints a single JSON document (`ensure_ascii=False`), and exits — nothing stays
running. `--wiki-root` defaults to the `LOCAL_WIKI_ROOT` env var.

```bash
LOCAL_WIKI_ROOT=C:/Users/Administrator/AppData/Local/hermes/wiki
local-wiki search nginx              # → {"count": N, "matches": [...]}
local-wiki list --json               # JSON always
local-wiki read global <rel>
local-wiki lint
local-wiki add "K8s 排障" --root global --keywords k8s,pod,集群 --content "..."
local-wiki commit                    # write commands honour the pending gate
local-wiki service status            # Windows service management
local-wiki update --check            # PyPI version check
```

## Run (global HTTP service + lifecycle, v1.3.0)

```bash
uv tool install local-wiki                       # managed install (upgradeable)
local-wiki service install --start-now --wiki-root C:/Users/Administrator/AppData/Local/hermes/wiki
local-wiki service status                        # liveness = port + HTTP 200
local-wiki update --check                        # compare against PyPI
local-wiki update --wiki-root ...                # upgrade + restart service
```

### service / update 命令速查

```bash
local-wiki service install [--start-now] [--host 127.0.0.1] [--port 8300] [--wiki-root <root>]
local-wiki service start   [--host] [--port] [--wiki-root <root>]   # 立即拉起(端口探测就绪)
local-wiki service stop    [--host] [--port]                        # 按端口找 PID 终止
local-wiki service status  [--host] [--port]                        # 判活: schtasks 注册 + 端口/HTTP + serverInfo 版本
local-wiki service uninstall [--host] [--port]                      # 删任务/进程/文件
local-wiki update [--check] [--no-restart] [--host] [--port] [--wiki-root <root>]
```

### 并发与索引新鲜度

- **单进程收敛**：所有档案经 `url` 连同一个 HTTP 进程 → 单一 `Storage` 内存索引，多档案无内存索引竞争；写路径仍由 pending 队列 + OS 锁（LockFileEx）串行（MCP 库对 sync 工具用线程池执行，锁覆盖真多线程并发）。
- **索引自愈（ensure_fresh）**：CLI/外部进程直写磁盘后，服务端读路径按磁盘指纹（目录 mtime）自动重载索引——无需重启服务即可查到刚写的内容。

## Wire into clients

### Hermes (`config.yaml`) — standard PyPI run, no local source build

Single global HTTP service (recommended for multi-profile sharing):

```yaml
mcp_servers:
  wiki:
    url: "http://127.0.0.1:8300/mcp"   # Streamable HTTP, single global process
```

Or per-profile stdio:

```yaml
mcp_servers:
  wiki:
    command: uvx
    args: ["local-wiki", "--wiki-root", "C:/Users/Administrator/AppData/Local/hermes/wiki"]
```

> Hermes historically used `uvx --from <local-src-path>` (build from source each launch). Since 1.0.0 the canonical setup is the PyPI package above; the standalone `.exe` can be pointed to directly with `command: <path>/local-wiki.exe`. The local-wiki **dev** setup in this repo (local-wiki-dev profile) instead runs the editable venv via `python -m mcp_server_wiki` (see "OpenCode / Hermes local-wiki-dev" above) — source edits take effect on MCP restart, no PyPI/PyInstaller needed.

### Claude Code

```bash
claude mcp add local-wiki -- uvx local-wiki --wiki-root ~/wiki
```

### OpenCode / Hermes local-wiki-dev (this repo's dev setup)

The dev wiki MCP runs from the editable venv via `python -m mcp_server_wiki` (the reload switched off the `local-wiki.exe` launcher to the source module):

```
E:/wyd_work/local-wiki/.venv/Scripts/python.exe -m mcp_server_wiki --wiki-root E:/wyd_work/local-wiki/dev-wiki
```

The venv is an editable install of this repo, so source edits take effect after restarting the MCP server process — no PyInstaller rebuild needed.

## MCP Tools (13)

| Tool | Description |
|---|---|
| `wiki_add(title, keywords=[], content="", root="")` | Unified knowledge write entry. Routes by title match → root-keyword intersection ≥5 → page-keyword intersection ≥5 → else `{error}`. New page = direct create; existing = staged to `pending`. **Replaces the old `wiki_write`.** |
| `wiki_delete(title, root="")` | Delete a page by normalized title; staged to `pending`. |
| `wiki_commit(once=False)` | Serialize pending queue → commit; on drift write conflict record, keep draft queued. |
| `wiki_conflicts()` | List open conflicts with full current+draft content for in-dialogue merge. |
| `wiki_resolve(conflict_id, side, merged_content="")` | Apply draft / keep current / apply caller's merged content. |
| `wiki_lint()` | Health check: index completeness, orphans, dead links, queue backlog, open conflicts, body length. |
| `wiki_index(profile, action="read", rel="", keywords=[])` | Maintain **root-level** keywords only. `action='update'` with `rel` omitted sets the root's category keywords; per-file keywords are no longer supported. |
| `wiki_search(query, profile="")` | Search the in-memory index by rel/title/keywords (case-insensitive). |
| `wiki_read(profile, rel)` | Read a page's full content (markdown with frontmatter). |
| `wiki_list(profile="")` | List page index entries (rel/title/updated/words/hash). |
| `wiki_create_root(key, name, workdir="", type="project", owner_profile="")` | Register a new wiki root (project shard) + its `index.json`. |
| `wiki_update_root(key, name="", workdir=None, owner_profile="", type="")` | Update a root's meta. |
| `wiki_delete_root(key, purge=False)` | Unregister a root (purge=True deletes its folder, irreversible). |

> **Note:** `wiki_write` was removed in the refactor — use `wiki_add`. `profile` (read/search/list) and `root` (write) are the same thing: the **root key** (e.g. `global`, `smart_park`).

## Storage Layout (current)

```
<wiki-root>/
├── .wiki/
│   ├── global/                    # cross-project knowledge (a normal root)
│   │   ├── <rel>.md               #   pages directly here (NO pages/ subdir)
│   │   └── index.json             #   {version, meta, keywords:[...]}
│   └── <root>/                    # one folder per registered root
│       ├── <rel>.md
│       └── index.json
└── .pending/                      # flat draft queue — one file per change
    ├── <id>.json                  #   draft record (target_path, op, content, base_hash)
    ├── <id>.conflict.json         #   conflict record (current + draft)
    └── .lock                      #   OS file lock (committer holds)
```

- Roots are registered by the existence of `.wiki/<dir>/index.json` (directory name = root key). There is **no top-level registry file**.
- `global` is just another root.
- `index.json` per root holds `{version, meta, keywords:[...]}` where `keywords` is the **root-level category list** (a top-level array). Per-page keywords are NOT stored in `index.json` — they live only in each page's frontmatter.

### Root naming — normalized to snake_case

Root folder names are normalized: camelCase, hyphens and whitespace all resolve to the same snake_case key.

- `smart-park` / `smartPark` / `SmartPark` → `smart_park`
- `wiki_create_root` accepts any spelling and stores the normalized key (must match `[a-z0-9_]+` after normalization)
- Passing an alias (e.g. `smart-park`) to read/write/search/update/delete works — it resolves to the canonical root

## Data model

### Pages

Every page is Markdown with an optional YAML frontmatter; one is auto-added if missing (title = first heading, `keywords: []`).

```markdown
---
title: Docker 使用
keywords: [特有kw]
---
<正文 body>
```

- **Keywords**: page frontmatter keywords are limited to a **count** (default 10; configurable via env `WIKI_KEYWORDS_MAX`). Root `index.json` keywords are **category** tags with no count limit. Invalid frontmatter is rejected on every write path.
- **Body length**: must be < 10 000 tokens (DeepSeek-V4 official BPE tokenizer, offline, checked by `wiki_lint`).
- **Invalid frontmatter** (starts with `---` and has a closing marker but fails YAML) is rejected on every write path.

### Write semantics (current)

- `wiki_add` on a missing target → written directly (atomic), index updated.
- `wiki_add` / `wiki_delete` on an existing target → staged into `pending/`, committed serially by `wiki_commit`.
- `wiki_add` routing when the target is ambiguous: title match → root-keyword intersection ≥5 → page-keyword intersection ≥5 → otherwise returns `{error, hint}` (not written; the caller may create a new root with `wiki_create_root`).
- **Empty-root direct create (1.2.0)**: a root with no pages and no root-level keywords (brand-new wiki-root or freshly created root) falls back to direct create instead of erroring — pointing `--wiki-root` at an empty directory and writing your first page just works, no setup step required. Non-empty roots keep the strict routing above (no silent misrouting).
- **All write paths normalize content** through one validator (`_normalize_content`): frontmatter validity, keyword count/length, auto-add missing frontmatter — **including `wiki_resolve` merged/draft**, so bad content can never enter the wiki through conflict resolution.

### Concurrency

- **Cross-root**: physical sharding (`.wiki/<root>/`) — different files, no collision.
- **Same-root, multi-session**: single committer + OS file lock serializes the queue.
- **Atomic writes**: temp-file + rename — readers never see partial state.
- **Conflict**: base-hash drift → both sides exposed; caller merges in-dialogue; nothing silently dropped.
- **Delete drift**: a delete based on a stale version surfaces as a conflict (`op=delete`); `resolve(side='draft')` executes the delete, `side='current'` keeps the concurrent update.

## Executable release

Since 1.0.0 a standalone Windows executable is built with PyInstaller (no Python/uvx needed):

```bash
# from repo root
pyinstaller packaging/local-wiki.spec --distpath dist/exe
# → dist/exe/local-wiki.exe
```

The tokenizer asset (`src/mcp_server_wiki/assets/tokenizer.json`) is bundled into the executable, so offline token counting works in the exe too.

> Build env needs `typer` installed (`pip install typer`) — `collect_all('mcp')` imports `mcp.cli`, which exits with "typer is required" if it's missing.

## Dev

```bash
pip install -e .[dev]
pytest                     # unit tests (tests/test_*.py)
```

After editing `src/`, restart the running MCP server — a running process holds the old code and will not reflect your changes.

## Companion skill

`skills/local-wiki-usage/` ships the agent skill that teaches how to read/write the wiki correctly (write routing, pending-queue workflow, conflict resolution, pitfalls). It has been updated to the current `wiki_add` / `.wiki/<root>/` API.

## License

MIT
