Просмотр исходного кода

:fire: 移动到wikipali-plugins

visuddhinanda 1 неделя назад
Родитель
Сommit
772efe7cf2

+ 0 - 229
docs/wikipali-feature-coverage.md

@@ -1,229 +0,0 @@
-# WikiPali 功能覆盖清单
-
-> `wikipali` 插件对 WikiPali API 的封装进度,按 **Library / Workspace** 两大块组织。
->
-> 用途:逐项落实的工作清单。**标 ⬜ 的需要提供一个能跑通的完整 API URL**——
-> 照着反推参数比读控制器快,也不会猜错。
->
-> 插件版本基准:**0.8.2**(2026-08-10,未发布;marketplace 上是 0.7.0)
-
-## 两大块的分界
-
-| | **Library** | **Workspace** |
-|---|---|---|
-| 身份 | **无需登录** | 需登录,操作自己账号里的数据 |
-| 语义 | 读公共语料与公开内容 | 管理属于你的东西 |
-| 产出去向 | **只能输出到控制台或本地文件** | 写回 WikiPali |
-| 出错的代价 | 读到错的东西 | **改坏别人看得见的数据** |
-
-这条线不是「读 / 写」——公开文章的阅读属于 Library,而「列出我可编辑的 channel」
-虽然是读,却属于 Workspace,因为它依赖身份。**凡是需要 token 的都在 Workspace。**
-
-**图例**:✅ 已实现 · 🔧 端点可用只差封装 · ⚠️ 能做但不到位 · ⬜ 需要 API URL
-
----
-
-## 一、Library(读取,无需登录)
-
-### 1. 字典
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 词形展开 | ✅ | `forms` → `GET /v2/case/{词}` |
-| 词典释义与形态分析 | ✅ | `word` → `GET /v2/dict?word=&lang=` |
-| 词频合计 | ✅ | `count` → 同 `case` |
-
-### 2. 术语
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 术语表(社区总表) | ✅ | `terms` → `GET /v2/term-vocabulary?view=&lang=`(17074 条,本地缓存后过滤) |
-| **单个术语查询** | 🔧 | `GET /v2/terms?view=word&word={词}` —— **2026-08-10 实测:不需要登录**,返回各 channel 下该词的译义 |
-| **按 tag 查术语** | 🔧 | `GET /v2/terms?view=tag&tag={tag}` —— 同样无需登录(`vinaya` 21 条) |
-| ~~`system-term`~~ | — | `GET /v2/system-term/{lang}/{word}` 实测报 `no channel`;上面两个已够用,不再需要它 |
-
-⚠ `term-vocabulary` 与 `terms` 是**两套东西**:前者是社区总表(一次拉全量),
-后者是 `dhamma_terms`,按 channel / studio / 用户组织,同一个词在不同 channel 下
-可以有不同译义。
-
-### 3. 三藏
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 分类目录(按 tag 找书) | ✅ | `books` → `GET /v2/book-title`(服务端已扩展返回 toc/tags/related_name) |
-| 某本书的章节目录 | ✅ | `toc` → `GET /v2/palitext?view=book-toc` |
-| 章节体量与导航 | ✅ | `chapter` → `GET /v2/palitext/{book}-{para}` |
-| 整章内容 | ✅ | `chapter --fetch` → `GET /v2/tipitaka-content/{book}-{para}` |
-| 按坐标取句 | ✅ | `get` → `GET /v2/sentence?view=paragraph` |
-| 某坐标有哪些版本 | ✅ | `versions` → `GET /v2/channel?view=paragraphs` |
-| 检索 | ✅ | `search` → `GET /v2/search-pali-wbw` |
-| 出处分布 | ✅ | `dist` → `GET /v2/search-pali-wbw-books` |
-| 短语检索 | ⬜ | `/v3/search`(OpenSearch)调试中 |
-| 相似句 | ⬜ | `GET /v2/sent-sim`?实测 500 |
-
-### 4. 相关经文(根本 ↔ 义注 ↔ 复注)
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 相关段落 | ✅ | `related` → `GET /v2/related-paragraph` |
-| 相关章节 | ⬜ | 路由里没找到 |
-| 相关书 | ⬜ | 路由里没找到 |
-
-### 5. 文章(公开)
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 文章列表与搜索 | ✅ | `articles` → `GET /v2/article?view=public` |
-| 读单篇 | ✅ | `article <uid>` → `GET /v2/article/{uid}` |
-| 文集 | ✅ | `anthology` → `GET /v2/anthology` |
-
-### 6. 讨论(公开阅读)
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 读某句/段/章的讨论 | ⬜ | `GET /v2/discussion`?实测 500,**缺参数** |
-
----
-
-## 二、Workspace(需登录,管理自己账号里的数据)
-
-### 1. auth —— 身份与凭据
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 登录 | ✅ | `wikipali-login` → `POST /v2/sign-in` |
-| 当前用户 | ✅ | `whoami --check` → `GET /v2/auth/current` |
-| 建立/更新 AI 模型记录 | ✅ | `ensure-model` → `GET/POST/PUT /v2/ai-model` |
-| 取模型身份 token | ✅ | `ensure-model` → `GET /v2/ai-model-token/{uid}` |
-| 撤销模型全部 token | ✅ | `revoke` → `DELETE /v2/ai-model-token/{uid}` |
-| 忘记/重置密码 | ⬜ | `/v2/auth/forgot-password`、`/v2/auth/reset-password`。**不打算封装**——涉及密码流程,应走网页 |
-
-### 2. channel —— 译本/版本的容器
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 列出我可编辑的 channel | ✅ | `channels` → `GET /v2/channel?view=user-edit` |
-| 签发 access token | ✅ | `grant` → `POST /v2/access-token` |
-| 新建 channel | ⬜ | `POST /v2/channel`(源码已读:需 `studio`/`name`/`type`/`lang`) |
-| 修改 channel | ⬜ | `PUT /v2/channel/{uid}`、`PATCH /v2/channel` |
-| 我的 channel 数量 | ⬜ | `GET /v2/channel-my-number` |
-| 按名字查 channel | ⬜ | `GET /v2/channel-name/{name}` |
-| 进度统计 | ⬜ | `POST /v2/channel-progress` |
-| 协作授权 | ⬜ | `/v2/share`(power ≥ 20 即可编辑) |
-
-### 3. tipitaka —— 句子与修改建议
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 写入/覆盖句子 | ✅ | `write` → `POST /v2/sentence` |
-| 逐句多版本结构(写入侧用) | ✅ | `chapter --fetch --via chapter-content` → `GET /v2/chapter-content/{id}` |
-| 取自己 channel 的句子 | ✅ | `get --channel` → `GET /v2/sentence?view=paragraph` |
-| 修改建议(sentpr) | ⬜ | `/v2/sentpr`、`POST /v2/sent-pr-tree`。**缺参数** |
-| 逐词标注(wbw) | ⬜ | `/v2/wbw-sentence`、`/v2/editable-sentence` |
-| 章节内句子批量 | ⬜ | `/v2/sentences-in-chapter`、`/v2/sent-in-channel` |
-
-### 4. article —— 自己的文章与文集
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 新建/修改/删除文章 | ⬜ | `POST/PUT/DELETE /v2/article` |
-| 新建/修改文集 | ⬜ | `POST/PUT /v2/anthology` |
-| 预览 | ⬜ | `PUT /v2/article-preview/{id}` |
-| 我的文章数量 | ⬜ | `GET /v2/article-my-number`、`/v2/anthology-my-number` |
-| 文章进度 / 导航 / 映射 | ⬜ | `/v2/article-progress`、`/v2/article-nav`、`/v2/article-map` |
-
-### 5. terms —— 自己的术语表
-
-同一个词在不同 channel 下可以有不同译义,所以术语是挂在 channel / studio / 用户上的
-(`dhamma_terms` 表),与 Library 里那张社区总表不是一回事。
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 读我的术语 | 🔧 | `GET /v2/terms?view=user&search={关键词}` —— **需登录**,实测 test161 为 0 条 |
-| 读某 studio 的术语 | 🔧 | `GET /v2/terms?view=studio&name={studio}` —— 需登录 |
-| 读某 channel 的术语 | 🔧 | `GET /v2/terms?view=channel&id={channel}` |
-| 新建 / 修改 / 删除术语 | ⬜ | `POST/PUT/DELETE /v2/terms`。**缺字段说明** |
-| 按 channel 批量建术语 | ⬜ | `GET /v2/terms?view=create-by-channel` |
-| 导入 / 导出 | ⬜ | `/v2/terms-export`、`GET /v2/terms-import` |
-| 常用译义统计 | ⬜ | `GET /v2/terms?view=hot-meaning` |
-
-行字段(实测):`guid` / `word` / `meaning` / `other_meaning` / `note` / `tag` /
-`language` / `channal`(原字段名如此拼写)/ `owner` / `editor_id`。
-
-### 6. discussion —— 讨论
-
-| 功能 | 状态 | 命令 → 端点 |
-|---|---|---|
-| 发表讨论 | ⬜ | `POST /v2/discussion`。**缺参数** |
-| 讨论树 | ⬜ | `POST /v2/sent-discussion-tree` |
-| 定位锚点 | ⬜ | `GET /v2/discussion-anchor/{id}` |
-| 未读 / 计数 | ⬜ | `/v2/discussion-count` |
-
----
-
-## 三、通往 1.0 的版本规划
-
-原则:**版本号跟着实际能力走**,每个小版本对应一块能独立验收的能力;验收不过就不
-升版本号。1.0 的含义是「Library 与 Workspace 的常用 API 都已封装,且都在生产环境
-验证过」。
-
-| 版本 | 内容 | 验收标准 | 依赖 |
-|---|---|---|---|
-| **0.8.x** ✅ | Library 主体 + Workspace 的 auth / channel 只读 / 写句子 | staging 端到端 15 项全过(2026-08-10) | 合 PR、发版 |
-| **0.8.3** | next 生产环境复测 | 在 next 上重跑 staging 那 15 项,结果一致 | next 部署完成 |
-| **0.9.0** | **sentpr 修改建议** | 对他人 channel 的句子提建议;列出收到的建议 | ⬜ 需要 URL |
-| **0.9.1** | **article Workspace** | 建一篇文章 → 编进文集 → 改 → 删,全程可回滚 | 端点已在,需定参数 |
-| **0.9.2** | **terms 全套**:Library 的单词/按 tag 查(无需登录)+ Workspace 的我的术语读写 | 查到某词在各 channel 下的译义;建一条自己的术语并读回、改、删 | 端点已确认,写侧需字段说明 |
-| **0.9.3** | **短语检索切 `/v3/search`** | 词组检索可用;规程里「拆词绕行」的说明删除 | v3 调试完成 |
-| **0.9.4** | **wbw 逐词标注**(读 + 写) | 读某句的逐词解析;提交一次修改 | 待评估 |
-| **0.9.5** | **discussion** 全套(Library 读 + Workspace 写) | 读到某句的讨论串;发一条并读回 | ⬜ 需要 URL |
-| **0.9.6** | **Library 补完**:相似句、相关章节 / 相关书 | 三项各跑通并进 `research` 规程 | ⬜ 需要 URL |
-| **0.9.7** | **channel 管理** | 建一个 channel 并授权他人编辑 | 端点已在 |
-| **1.0.0** | 全部封装 + **生产环境全量复测** + 文档定稿 | 四个线上站点重跑全部命令;`research` 规程用一篇真实论文任务验收 | 线上部署 |
-
-### 每个版本都要做的三件事
-
-1. **实测每个端点的三种响应**:正常 / 空结果 / 错误。**空结果必须与故障区分开**——
-   这是本项目反复踩到的坑(`access-token` 的 `count: 0`、`chapter-content` 的空占位、
-   `related-paragraph` 查无关联时的 500)。
-2. **契约写进 references**,含踩过的坑。写侧的内容归 `api-write.md`。
-3. **规程只写判断,事实进 references**;SKILL.md 超 150 行就往外搬。
-
-### 1.0 之后
-
-- **MCP server 形态**:读端的链式调用(检索 → 取章 → 对读)更适合 tool 而非 CLI。
-  同一个插件可以同时带 `.mcp.json`——届时读走 MCP、写仍走 skill 规程,因为写入需要
-  的是「确认再动手」的流程约束,那是 skill 的强项而非 tool 的。
-- **新流程(如佛教百科)**:按既定结构加一个 `skills/<name>/SKILL.md` 即可,
-  不复制代码、用户 `/plugin update` 就拿到。
-
----
-
-## 四、待用户决定的规范问题
-
-### ⬜ 引用格式:是否采用 `{{book-para-start-end}}`
-
-平台原生格式(见用户所写《表24:三种别住》),实测精确到句、能在平台上解析定位。
-待定:是否全面采用?论文里给人读的场合是否需要「可读书名 + `{{坐标}}`」的组合写法?
-
-定案后改 `references/conventions.md` 的「引用格式」一节。
-
-### ⬜ 译名分歧:术语表 vs 实际使用
-
-`samodhānaparivāsa` 在术语表里是「合并别住」,用户文章里用「合一别住」。规程现在
-要求「与术语表一致,不一致要说明理由」,若术语表并非唯一权威则需改写。
-
----
-
-## 五、记录在案的判断
-
-**多版本不做并排对照。** 正确做法是先查有哪些版本、一次只读一个——一次拉多个完整
-版本会撑爆上下文,而研究本来就是逐个版本读。`versions` → `get/chapter --channel`
-这条链就是最终形态。
-
-**`versions` 的粒度缺口。** 它按段落查,而实际用法常是按章节查,目前用章节起始段
-近似。同一章内不同段落的版本覆盖可能不同(某译本只译半章)。
-
-**站点不是同一个库。** 线上四站共享库与密钥;`staging` 与 `local` 各是另一个库,
-凭据与本地缓存都按桶隔离,自动 fallback 只在线上四站之间发生。**在 staging 上查到
-的坐标不能直接拿到线上引用。**

+ 0 - 310
docs/wikipali-research-agent-design.md

@@ -1,310 +0,0 @@
-# WikiPali 研究型 Agent 设计文档
-
-> 目标:让 Claude 这类 agent 用 WikiPali 的语料完成巴利文献研究——检索、取证、引用,最终产出可信的论文级文本。
->
-> 与写入型 skill(`docs/wikipali-write-skill-design.md`)同属 `wikipali` 插件,共用坐标系、channel 模型与凭据。
->
-> 状态:需求已定(§1 来自用户的真实工作流),API 盘点完成(§2 均已实测)。**主检索链路无阻塞,可以开工**(§3 修订:原列的三个缺口有两个已证伪)。
->
-> 日期:2026-08-06
-
----
-
-## 1. 需求:一次真实的论文写作
-
-用户给的样本任务是《别住在律藏中的案例分析》,成文三部分:**定义与执行流程 → 案例分类列举 → 案例规律总结**。人工做法是 11 步:
-
-| # | 动作 | 本质 |
-|---|---|---|
-| 1 | LLM 给出「别住」的巴利拼写(可能多个:名词、动词),**用词典验证** | 词形确认 |
-| 2 | 全文检索,取前 50 条:标题 + 章节路径 + 巴利段落 | 定位 |
-| 3 | 结果按黑体字加权排序,义注的名词解释自然排前 | 排序语义 |
-| 4 | 据此写「定义与执行流程」 | 产出 |
-| 5 | 同一检索取前 200 条,**分析出处分布** | 分布统计 |
-| 6 | 提取这 200 条的段落内容 | 批量取证 |
-| 7 | 对结果密集的章节,取**整章巴利全文** | 上下文展开 |
-| 8 | 据 6、7 做案例分类 | 归纳 |
-| 9 | 写「案例分类列举」 | 产出 |
-| 10 | 查相关 channel(缅文逐词解析 nissaya、泰文译本等)**核对并补充引用** | 交叉验证 |
-| 11 | 写「案例规律总结」 | 产出 |
-
-这个序列有三个特征,决定了工具形态:
-
-- **漏斗型**:定位(宽)→ 取证(窄)→ 展开(深)。不是「取一堆数据交给模型」,而是逐步收窄。
-- **两次检索、两种用途**:第一次要**排序质量**(前 50 拿定义),第二次要**覆盖面**(前 200 看分布)。同一端点,不同参数。
-- **交叉验证是最后一步不是第一步**:先用巴利原文做出判断,再拿译本核对。工具不该在早期就把多语版本一股脑塞进上下文。
-
----
-
-## 2. API 盘点(2026-08-06 逐个实测)
-
-基址 `{API}`,全部实测于 `https://www.wikipali.org/api`。**读端一律不需要凭据**——`PaliTextController` / `SearchController` / `SentencesInChapterController` 里 `AuthService::current` 出现 0 次,实测未登录直接返回数据。
-
-| 步骤 | 端点 | 状态 |
-|---|---|---|
-| 1 词形展开 | `GET /v2/case/{词}` | ✅ **主链路第一步**:猜 lemma + 列出全部实际词形 |
-| 1 释义验证 | `GET /v2/dict?word={词}&lang=zh` | ✅ 可用,附形态分析(形 → 根) |
-| 2/5 检索 | `GET /v2/search-pali-wbw?key={词形,词形,…}&bold=&limit=&offset=&book=` | ✅ **主链路第二步** |
-| 5 出处分布 | `GET /v2/search-pali-wbw-books?key={词形,…}` | ✅ 带 `paliTitle` 与 tags |
-| — 词组全文检索 | `GET /v2/search?view=pali&key=`、`/v2/search-book-list` | ❌ **500**,走 gRPC(§3.1,非阻塞) |
-| — 标题检索 | `GET /v2/search?view=title&key=` | ✅ 可用(纯 DB,不走 gRPC) |
-| 6 取段落 | `GET /v2/sentence?view=paragraph&book=&para=1,2,3&channels=` | ✅ 可用 |
-| 7 取整章 | `GET /v2/sentence?view=chapter&book=&para=&channels=` | ✅ 可用(实测 22 句) |
-| 7 目录导航 | `GET /v2/palitext?view=book-toc\|chapter\|children\|paragraph` | ✅ 可用 |
-| 10 找译本 | `GET /v2/channel?view=public`、`sentence?view=paragraph&lang=` | ⚠️ `lang=` 分支待验 |
-
-### 2.0 主检索链路(用户提供,2026-08-06 实测)
-
-**第一步:`GET /v2/case/{被搜索词}`** —— 输入可以是任意变格形,程序推测可能的词典原型,按可能性排序。
-
-```
-GET /v2/case/parivāsa  →  data: { rows: [ {word, count, case: [...] }, ... ], count }
-```
-
-取 `rows[0]`(可能性最高的 lemma),其 `case` 数组就是该词在语料中出现过的**全部实际词形**,每项带 `count` 与 `bold` 计数:
-
-```
-parivāsa (13 形): parivāsaṃ×221(黑7) · parivāso×170(黑5) · parivāse×22 · parivāsā×7 · parivāsesu×7 …
-```
-
-**第二步:`GET /v2/search-pali-wbw?key={把这些词形用逗号连起来}`**
-
-```
-count: 281 段落。rows 每项:
-{ book, paragraph, rank, path[章节路径,含 level], paliTitle, highlight }
-```
-
-实测细节:
-
-- `limit=200` 正常返回 200 行(步骤 5 取前 200 无碍;本例全库也就 281 段);
-- `view` 与 `type` 参数**实测无影响**,可省略(源码 `SearchPaliWbwController::index` 也没读它们);
-- `highlight` 用 `<span class='hl'>` 包命中词,并**保留原文的 `<span class="bld">`**——黑体信息在返回里可见;
-- `rank` = `sum(weight)`,`bold=on|off` 直接按 `style='bld'` 筛。**`bold=on` 让本例命中从 281 降到 13**;
-- 范围限定 `book=<id,id>` 或 `tags=<tag1,tag2;tag3>`(组间 OR、组内 AND)。
-
-**分布**:`GET /v2/search-pali-wbw-books?key={词形,…}` 返回 43 部书,每项带 `paliTitle` 和 **tags**:
-
-| 书 | 命中 | tags |
-|---|---|---|
-| (VN)Cūḷavaggapāḷi | 126 | vinaya, mūla, pāḷi, khandhaka, cūḷavagga |
-| Vinayālaṅkāra-ṭīkā | 40 | ṭīkā, vinaya |
-| (SP) Cūḷavagga-aṭṭhakathā | 28 | vinaya, aṭṭhakathā, samantapāsādikā |
-
-tags 里的 `mūla` / `aṭṭhakathā` / `ṭīkā` 让 agent 能直接区分**本文、义注、复注**——步骤 5 的「分析出处分布」和步骤 8 的分类都要靠它。
-
-**这条链路对步骤 3/4 有个更好的做法**:用户原方案是「取前 50,靠黑体加权让义注的名词解释排前面」。但既然有 `bold=on`,可以直接**只取黑体命中**(本例 13 条),那正是被注释书当作词条标出来的地方——比靠排序精准,而且省 90% 的上下文。
-
-### 2.1 词典 —— 可用,而且比预想的强
-
-`GET /v2/dict?word=parivāsaṃ&lang=zh` 返回的不只是释义,还有**形态分析**:
-
-```
-word: parivāsaṃ → parent: parivāsa,  type: .adj.,  grammar: .m.$.sg.$.acc.,  factors: parivāsa+[aṃ]
-                 → parent: parivāseti, type: .v.,   grammar: .1p.$.sg.$.aor.
-```
-
-即:**给一个变化形,能还原出词根与语法**。步骤 1 的「验证拼写」因此是可靠的。
-
-但注意方向:`dict` 做的是 **形 → 根**,用来确认「我选对了 lemma」。检索需要的 **根 → 全部形** 是 `case` 干的活(§2.0)。两者配合:`case` 给候选 lemma 和词形,`dict` 给释义和语法帮你确认选哪个候选。
-
-### 2.2 全文检索 —— 形状正好对得上,但服务不通
-
-`SearchController::index` 的 `view=pali` 分支走 `PaliSearch::pali_rpc()`,即 gRPC 调 `tulip` 服务(`config('mint.server.rpc.tulip.*')`)。返回经 `SearchResource` 整形为:
-
-```json
-{ "book": 93, "paragraph": 757, "rank": 0.87,
-  "highlight": "……~~parivāsaṃ~~……",   // 命中词用 ~~ 包围
-  "path": [...章节路径...], "paliTitle": "章节标题" }
-```
-
-**这正是步骤 2 要的三样东西**(标题、章节路径、巴利段落),客户端不用二次查询。几个契约细节:
-
-- `key` 用 `;` 分隔多词 = OR;
-- `match` = `case`(默认)/ `complete` / `similar`(去变音符号);
-- 范围限定用 `book=<单个 id>` 或 `tags=<tag1,tag2;tag3>`(tag 组间 OR、组内 AND);
-- 高亮标记是 `~~…~~`(`ts_headline` 的 `StartSel`),客户端要自己解析;
-- ⚠️ `orderby` 参数**只在废弃的 `pali()` 方法里生效**,`pali_rpc()` 不读它——排序由 tulip 决定;
-- ⚠️ `key` 以 `para` 开头或首字母是 `M/P/T/V/O` 会被劫持到**页码检索** `page()`。研究用词若撞上(如 `Para…`)会得到莫名其妙的结果。
-
-排序权重 `{0.1, 1, 0.3, 0.2}` 对应 tsvector 的 D/C/B/A 四档,配合索引里的 `bold1/bold2/bold3` 字段——**这就是「黑体字加权」**,用户步骤 3 依赖的行为在服务端是坐实的。
-
-### 2.3 逐词检索 —— 另一条路,语义不同
-
-`search-pali-wbw` 走 `wbw_templates` 表:`WHERE real IN (逗号分隔词表) GROUP BY book,paragraph ORDER BY sum(weight)`,并支持 `bold=on|off` 直接筛黑体(`style='bld'`)。
-
-它与全文检索是**两套东西**:前者匹配逐词解析的词形,后者匹配段落全文。dashboard 的做法是:关键词含空格 → 全文;单词 → 逐词。
-
-### 2.4 取原文与译文 —— 同一个端点
-
-关键结构事实:**巴利原文本身就是一个 channel**(`_System_Pali_VRI_`,uid `00b577c0-13b9-11ee-a05a-b7307efd9ee6`,`type=original`、`lang=pali`)。`SearchResource` 在没有 highlight 时也是去这个 channel 拼段落文本。
-
-于是「取原文」「取汉译」「取缅文 nissaya」是**同一个调用换 channel**:
-
-```
-GET /v2/sentence?view=chapter&book=93&para=757&channels=<uid[,uid...]>
-GET /v2/sentence?view=paragraph&book=93&para=757,758&channels=<uid>
-```
-
-读端与写端共用同一套坐标 `(book, paragraph, word_start, word_end, channel_uid)`。这意味着 agent 检索到的任何位置,都能直接对应到写入型 skill 能写的位置——**检索与产出天然闭环**。
-
-现成的交叉验证资源(`status=30` 公开):
-
-| channel | lang | type |
-|---|---|---|
-| `Nissaya` / `nissaya` | my | translation / nissaya |
-| `nissaya in En` | en | nissaya |
-| `Norbu AI Translations (Nissaya)` | en-US | translation |
-
----
-
-## 3. 缺口(2026-08-06 修订:原先列的三个,两个已被证伪)
-
-主检索链路(`case` → `search-pali-wbw`)**完全可用,www 与 next 都有**。下面第一条降级为非阻塞,第二条不成立。
-
-### 3.1 词组全文检索返回 500 —— 非阻塞,但确实坏了
-
-实测(www 与 next 均如此):
-
-```
-GET /v2/search?view=pali&key=parivāsa&limit=3           → HTTP 500
-GET /v2/search?view=pali&key=parivāsa dātabbo&limit=2   → HTTP 500
-GET /v2/search-book-list?view=pali&key=parivāsa         → HTTP 500
-GET /v2/search?view=title&key=parivasa                  → HTTP 200 ✅
-```
-
-只有走 gRPC(`PaliSearch` → `tulip` 服务)的分支挂,纯 DB 的分支正常 → 指向 tulip 不可达或 PHP 的 grpc 扩展缺失。
-
-**为什么不阻塞**:dashboard 只在关键词**含空格**(词组)时才走这条路,单词走 `search-pali-wbw`。研究流程的主链路是后者。所以坏的是「词组/短语检索」这一项能力。
-
-**但它确实是坏的**,且影响真实用户。修法有二:修 tulip 服务;或接上代码里已有的 `SearchController::pali()`——同样逻辑的纯 SQL 版(直接查 `fts_texts` 表 + `ts_rank`),目前没有路由指向它,加一条路由或让 `pali_rpc` 在 gRPC 失败时回落即可。
-
-### 3.2 ~~缺「词根 → 全部词形」的展开~~ —— 已证伪
-
-原判断错在:我只看到 `dict` 能做「形 → 根」,没找到反向的端点,于是以为 agent 会用词典形检索而静默漏掉材料。
-
-实际上 **`GET /v2/case/{词}` 就是反向展开**:给任意形,返回候选 lemma 及每个 lemma 在语料中的全部实际词形(带 count 与 bold 计数)。这条链路是平台既有的,不需要任何服务端改动。
-
-保留这段记录是因为**结论虽错,风险是真的**:按词典形 `parivāsa` 直接查 `search-pali-wbw` 确实返回 0 条且不报错。所以 skill 规程必须写死「**检索前一律先过 `case` 展开词形**,不得直接拿词典形去搜」——否则 agent 会以为自己搜过了。
-
-### 3.3 泰文语料未上传
-
-已确认。公开 channel 里泰文只有 1 个、97 句;实际语料是缅文 68.7 万句 > 中文 20.6 万 > 英文 8.2 万。
-
-对设计的影响:**工具必须能诚实回答「该段落在该语言下没有译文」**,而不是返回空数组让 agent 自己脑补。步骤 10 的「泰文译本(如果有)」在语料到位前应明确报「无」。
-
----
-
-## 3.4 待办:引用格式规范
-
-当前 `research` skill 用的是临时格式(用户 2026-08-06 同意暂用):
-
-```
-Cūḷavaggapāḷi, Pārivāsikakkhandhaka (VN 216:35)
-Samantapāsādikā, Pārivāsikavattakathā (SP-aṭṭ 141:63)
-```
-
-**发现(2026-08-09)**:平台自己就有引用格式。用户写的文章《表24:三种别住》里,
-引用巴利原文用的是 `{{141-120-17-40}}` —— 即 `{{book-paragraph-word_start-word_end}}`,
-**精确到句**。实测该坐标正是义注里讲 `odhānasamodhāna` 的那一句。
-
-这比我临时定的格式好:它是平台原生的,写成这样的引用在 wikipali 上能直接解析定位。
-**待用户确认是否采用**——若采用,`conventions.md` 的「引用格式」一节改为这个,
-`research` 规程要求产出中的巴利原文引用一律用它。
-
-**⬜ TODO:用户之后会给出正式的引用格式规范**,届时改 `skills/research/SKILL.md` 的「引用格式」一节。这关系到产出能否被同行接受,属于必改项,不是可选优化。
-
-相关线索:库里有 `page_numbers` 表,`type` 分 `M/P/T/V/O`(缅甸版/PTS 等不同版本的页码),正式规范多半要用到其中某一种;`GET /v2/search?view=page&key=<卷.页>&type=<版本>` 是按页码反查段落的现成端点。
-
-## 3.5 待办:channel 的译文来源标识
-
-引用译文必须能区分人译与机译,但**现有数据两个信号都不可靠**(2026-08-06 实测):
-
-| channel | 句子数 | `editor_uid` 命中 `ai_models` |
-|---|---|---|
-| AI-汉译-Nissaya | 11206 | 11205(模型 `[文本生成]-阿里-deepseek-v3`)|
-| Nissaya的AI翻译 | 967 | 0 |
-| Norbu AI Translations (Nissaya) | 191 | 0 |
-
-后两者是**人工用自己账号上传的机器译文**,`editor_uid` 是人类;只有名字里的 "AI" 泄露了来源。反过来,只靠名字也会漏掉命名里不含 AI 的机器译本。
-
-短期:skill 规程用「两个信号任一命中即按机器译文标注,都不命中时不主动断言是人译」。
-
-**⬜ TODO(服务端)**:给 `channels` 加一个来源字段(如 `provenance`: `human` / `machine` / `mixed`),让判定有据。注意写入型 skill 产生的数据天然带模型署名(`editor_uid` = 模型 uid),所以这个问题只存在于存量数据。
-
-## 3.6 待办:短语检索改走 /v3/search(OpenSearch)
-
-§3.1 记的「词组/短语检索 500」有下文:**`/v3/search` 已实现,改用 OpenSearch,2026-08-08
-时点仍在调试**(用户告知)。
-
-所以 §3.1 的两条修法(修 tulip / 接 `SearchController::pali()` 降级)都不必做了——等 v3
-稳定后,客户端把短语检索指过去即可。届时要重新盘点 v3 的参数与返回形状,它大概率与
-v2 的 `search-pali-wbw` 不同。
-
-在那之前,`research` 规程里「把短语拆成词分别展开词形」的绕行办法继续有效。
-
-## 3.7 待办:按章节聚合分布(`dist --by chapter`)
-
-现在 `dist` 只能按**书**聚合。实跑「别住」时,「命中集中在第 2 犍度(规矩)与第 3 犍度
-(授予程序)」这个结构性判断是人看 `search` 结果的 `path` 字段看出来的,工具没帮忙。
-
-做法上有个成本问题:按书聚合服务端有现成端点(`search-pali-wbw-books`),按章节没有,
-客户端得**拉全量命中**再按 `path` 的某一层聚合。`parivāsa` 是 281 段还好,几千段的常见词
-就要分页拉很多次。
-
-方案待定(用户 2026-08-08 要求稍后给)。可选方向:客户端全量拉 + 本地聚合(简单但慢)、
-服务端加一个按章节 group by 的端点(快但要改服务端)、或者只对 `--book` 限定后的结果做
-(把量压下来再聚合)。
-
----
-
-## 4. 设计要点(端点清单看不出来的那些)
-
-1. **引用可信度是第一约束**。论文场景下编造引文是致命错误。因此:任何返回给 agent 的文本片段,都必须携带可验证坐标(`book/paragraph` + channel + 章节路径),且 skill 规程要写死「没有坐标的内容不得写入论文」。这条决定所有命令的返回格式,事后改是全面返工。
-2. **上下文预算是第二约束**。一部经几十万 token。命令粒度必须支持漏斗:`search`(只回坐标+摘要)→ `get`(按坐标取指定段落)→ `chapter`(展开整章,需显式请求且要报告体量)。**不提供「取整部书」这种命令。**
-3. **channel 即译本/版本**,与写入端共用。「查某语言的译文」= 「查某 channel 在某坐标的句子」。不要为读端发明第二套概念。
-4. **空结果必须显式**。区分「该位置没有该语言的译文」与「查询出错」,两者对 agent 的下一步完全不同。
-5. **两种检索要都暴露**,并说明差别:全文(词组、黑体加权、义注优先)与逐词(确切词形、可筛黑体)。让 agent 知道什么时候用哪个,比藏起来自动选更可靠。
-
----
-
-## 5. 命令面草案
-
-沿用 `wikipali` 插件既有结构,读端加一个 skill:
-
-```
-plugins/wikipali/
-├── bin/wikipali              # 共享入口(写端也迁过来)
-├── skills/
-│   ├── write/                # 现有
-│   └── research/             # 新增:检索、取证、引用规程
-```
-
-子命令(对应 §1 的 11 步):
-
-| 命令 | 对应步骤 | 说明 |
-|---|---|---|
-| `forms <词>` | 1 | `case` 展开:候选 lemma + 全部实际词形(带 count/bold)。**检索的必经前置** |
-| `word <词>` | 1 | `dict` 释义 + 形态分析(词根、词性、语法),用于确认选对了 lemma |
-| `search <词形…>` | 2、5 | `search-pali-wbw`;`--bold` 只取黑体(定义)、`--book`/`--tags` 限范围 |
-| `dist <词形…>` | 5 | 出处分布,带 tags(`mūla`/`aṭṭhakathā`/`ṭīkā`)便于区分本文与注疏 |
-| `get <坐标…>` | 6 | 按坐标批量取文,可指定 channel |
-| `chapter <book> <para>` | 7 | 展开整章,先报体量再取 |
-| `versions <坐标>` | 10 | 该坐标有哪些语言/译本,明确列出「没有的」 |
-
-一个便利设计:`forms` 的输出可以直接管道进 `search`,或者让 `search` 接受 `--lemma parivāsa` 自动先跑一遍 `case` 再检索。**但不要把展开做成隐式的**——agent 应当看见「我把这 13 个词形搜了」,那是论文方法论的一部分,要能写进正文。
-
-`research` skill 的规程重点不在于怎么调这些命令,而在于**怎么把结果变成可信引用**,以及**什么时候该收窄、什么时候该展开**。
-
----
-
-## 6. 分阶段
-
-| 阶段 | 内容 | 依赖 |
-|---|---|---|
-| R1 | `forms` / `word` / `search` / `dist` / `get` 五个命令 + `research` skill 规程 | 无(主链路已可用) |
-| R2 | `chapter` / `versions` | R1 |
-| R2 | 词组检索的 500(§3.1):修 tulip 或接 `pali()` 降级路径 | 你定 |
-| R3 | 用《别住在律藏中的案例分析》做**验收**:agent 独立跑完 11 步,人工核对每条引用的坐标真实性 | R2 |
-| R4 | 视情况把读端改造为 MCP tools(检索链式调用更适合 tool 形态),skill 保留规程部分 | R3 |
-
-R3 是这个项目真正的验收标准:**不是「命令都能调通」,而是「产出的论文里每一条引用都能回溯到真实坐标」**。

+ 0 - 643
docs/wikipali-write-skill-design.md

@@ -1,643 +0,0 @@
-# WikiPali 写入型 Skill 设计文档
-
-> 目标:提供一个 Claude Code Skill,通过 Claude Code 以「AI 模型身份」把句子写入 WikiPali 数据库(`SentenceController`),并保持正确的作者署名与权限边界。
->
-> 分发路径:在本仓库开发调试,成熟后以**整目录复制**方式装到其他项目(§6.7)。不做独立仓库——API 仍需频繁修改,Skill 契约必须与 `api-v13` 同仓演进。
->
-> 状态:设计已定案;服务端 P0 已完成(§5.1 端点 + §5.2 abdefg);Skill P1 已完成(`plugins/wikipali/`)并在开发机上端到端跑通(2026-08-05);线上四站尚未部署
-> 对应后端:`api-v13`(Laravel 13,路由前缀 `/api/v2`)
-> 决策定案:2026-08-04(见 §9)
-
----
-
-## 1. 背景与目标
-
-现状下,只有仓库内部的组件(`ai-translate` Python worker、`app/Console/Commands/*`、`app/Services/AIAssistant/*`)能以 AI 身份写入句子库,因为它们能直接调用 `AuthService::getUserToken()` 生成模型身份 token。外部项目无此能力。
-
-本 Skill 要达成:
-
-1. 外部项目只需安装该 Skill,即可获得对 WikiPali 数据库的写入能力;
-2. 写入的句子 `editor_uid` 为 **AI 模型的 uid**(而非操作者本人),保证署名与审计正确;
-3. 权限不被放大:AI 模型只能写入「操作者本人有编辑权的 channel」,且受 `access_token` 中的 book 范围约束;
-4. 凭据管理安全、可复用,不污染用户项目仓库。
-
-### 非目标
-
-- 不提供绕过 channel 权限的写入路径;
-- 不在本期实现 wbw / sentpr / attachment 等其他资源的写入(见 §9 后续规划)。
-
----
-
-## 2. 现有 API 盘点
-
-以下均已对照源码核实。基址记为 `{API}`,形如 `https://host/api`(参见 `ai-translate/config.orig.toml` 的 `api-url`)。**`{API}` 不是唯一的**:线上四个地址共享同一套数据,差别只在地区(`.org`/`.cc`)与代码版本(`www` 稳定 / `next` 最新)——见 §6.1.2。下文契约以稳定版为准。
-
-### 2.1 登录 —— 可用 ✅
-
-`POST {API}/v2/sign-in`
-
-```json
-{ "username": "<用户名或邮箱>", "password": "<明文密码>" }
-```
-
-返回 `{ "ok": true, "data": "<JWT 字符串>", "message": "" }`。
-
-- 实现:`AuthController::signIn()`(`app/Http/Controllers/AuthController.php:70`)
-- JWT payload:`{nbf, exp, uid: userid, id: 主键 id}`,**有效期 365 天**(`AuthController.php:84`)
-- 校验入口:`AuthService::current()`(`app/Services/AuthService.php:43`),读取 `Authorization: Bearer <token>`
-
-`GET {API}/v2/auth/current`(Bearer)→ `data: {id, nickName, realName, avatar, token, roles}`。
-其中 `realName` 即 `user_info.username`,**后续 `studio_name` 参数要用它**(`AuthController.php:101`)。
-
-### 2.2 查询 / 创建 AI Model —— 部分可用 ⚠️
-
-`GET {API}/v2/ai-model?view=studio&name={studioName}&keyword={modelName}`(Bearer)
-
-- 实现:`AiModelController::index()`(`app/Http/Controllers/AiModelController.php:22`)
-- `view` 仅支持 `all` / `studio` / `usable` / `chat`;`keyword` 是 `like %kw%` **模糊**匹配,客户端需自行做精确 `name` 比对
-- ⚠️ 缺陷:`view` 传入非法值时 `$table` 未定义 → 500(`AiModelController.php:29-45`)
-
-`POST {API}/v2/ai-model`(Bearer)body `{name, studio_name, model?, url?, key?, privacy?, description?, system_prompt?}`
-
-- 实现:`AiModelController::store()`
-- ✅ 已接受全部字段;`privacy` 缺省为 `private`
-- ✅ 同一 studio 内 `name` 重复返回 **409**(客户端据此判定「已存在」)
-- `name` 必填、`studio_name` 必填,违反则 **422**(`StoreAiModelRequest`)
-- 鉴权:`canEdit($user_uid, $studioId)` 要求 `user_uid === studioId`,即**只有个人 studio 可用,group studio 会 403**
-
-`PUT {API}/v2/ai-model/{uid}`(Bearer)
-
-- 路由模型绑定按 `uid`(`AiModel::$primaryKey = 'uid'`)
-- ✅ **增量更新**:只改请求里出现的字段,未提交的保持原值,客户端可以只发一两个字段
-- 显式传 `null` 仍可清空字段(判定用 `has()` 而非 `filled()`)
-- 改名撞上同 studio 内已有名字 → **409**
-
-### 2.3 获取 AI Model 的 user token —— 可用 ✅(本次新增)
-
-`GET {API}/v2/ai-model-token/{uid}`(Bearer = **用户 token**)→ `data: { uid, name, token }`
-
-- 实现:`AiModelTokenController::show()`,见 §5.1
-- 鉴权:仅模型 owner 本人(`canEdit`),否则 403;未登录 401
-- 底层是 `AuthService::getUserToken()`(`app/Services/AuthService.php`):先查 `ai_models.uid`,命中即签模型 token,否则按人类用户签发
-- **有效期 30 天**(人类登录 token 仍是 365 天),payload 带 `typ: "ai-model"` 与 `ver`(版本号)
-- ⚠️ 仍属最高敏感凭据,但已**可撤销**,见 §2.3b
-
-### 2.3b 撤销 AI Model 的全部 token —— 可用 ✅(本次新增)
-
-`DELETE {API}/v2/ai-model-token/{uid}`(Bearer = **用户 token**)→ `data: { uid, name, token_version }`
-
-- 实现:`AiModelTokenController::destroy()`,见 §5.1
-- 鉴权同 `show`:仅 owner 本人,否则 403;未登录 401
-- 语义是「作废该模型已签出的**所有** token」,不能只废其中一张——凭据泄漏时本就该全废
-- 客户端处理:撤销后旧凭据请求一律 401,Skill 应提示重新 `ensure-model` 取 token
-
-### 2.4 签发 channel access token —— 可用 ✅
-
-`POST {API}/v2/access-token`(Bearer = **用户 token**)
-
-```json
-{ "payload": [ { "res_type": "channel", "res_id": "<channel uid>", "power": "edit", "book": 0 } ] }
-```
-
-返回 `data: { rows: [ { payload, token } ], count }`。
-
-- 实现:`AccessTokenController::store()`(`app/Http/Controllers/AccessTokenController.php:33`)
-- 鉴权:`ChannelApi::userCanEdit(user_uid, res_id)`,无权则该条被静默跳过(`continue 2`)→ **rows 可能为空数组,客户端必须判空**
-- 签名密钥:`AccessToken.token`(uuid)**重复两次拼接**,算法 HS512
-- ✅ payload 现在带 `nbf` / `exp`,**有效期 7 天**(`AccessTokenController::TOKEN_TTL`)。返回的 `payload` 里含 `exp`,客户端可据此判断何时需要重签
-- 过期后写句子会得到 403(而非 500)
-
-### 2.5 写入句子 —— 可用 ✅
-
-`POST {API}/v2/sentence`(Bearer = **AI model token**)
-
-```json
-{
-  "sentences": [
-    {
-      "book_id": 1,
-      "paragraph": 10,
-      "word_start": 0,
-      "word_end": 12,
-      "channel_uid": "<channel uid>",
-      "content": "译文",
-      "content_type": "markdown",
-      "access_token": "<§2.4 签出的 JWT>"
-    }
-  ]
-}
-```
-
-返回 `data: { rows: [SentResource...], count }`。
-
-- 实现:`SentenceController::store()`(`app/Http/Controllers/SentenceController.php:303`)
-- 权限判定 `UserCanEdit()`(`SentenceController.php:272`):
-  1. bearer 身份是 channel owner → 放行;
-  2. 否则查协作权限 `ShareApi::getResPower(...) >= 20` → 放行;
-  3. 否则用 `AccessToken.token` 重复两次作为密钥验签 `access_token`,并校验 book 范围。
-- 语义:按 `(book_id, paragraph, word_start, word_end, channel_uid)` 做 `firstOrNew`,**存在即更新,不存在则新建**(天然幂等)
-- 副作用:写入 `sent_histories`、清 Redis 缓存、`Mq::publish('progress', ...)`
-- 另一种调用形态:把 `channel` / `book` / `access_token` 放在顶层,句子数组内不再重复(`SentenceController.php:315-326`)
-- 现成参考实现:`ai-translate/ai_translate/service.py:429`
-
-**⚠️ book 字段类型陷阱**:校验用严格比较
-`if (isset($jwt->book) && $jwt->book !== 0 && $jwt->book !== $book)`,而 `$book` 已被 `(int)` 转换。
-因此签发 access token 时 `book` **必须是整数**(`0` 表示不限 book);写成 `"1"` 字符串会导致 `"1" !== 1` 恒真而被拒绝。
-
----
-
-## 3. 端到端流程
-
-```
-用户                Skill 脚本                     API
- |                     |                            |
- |-- 交互式输入口令 --->|                            |
- |                     |-- POST /v2/sign-in ------->|
- |                     |<-- userToken (365d) -------|
- |                     |-- GET /v2/auth/current --->|   取 realName 作为 studio_name
- |                     |                            |
- |                     |-- GET /v2/ai-model?view=studio&name=&keyword= -->
- |                     |<-- rows(精确匹配 name)---|
- |                     |   未命中 → POST /v2/ai-model  → PUT /v2/ai-model/{uid}
- |                     |                            |
- |                     |-- GET /v2/ai-model-token/{uid} ★新增 -->
- |                     |<-- modelToken (30d, 可撤销) |
- |-- 提供 channel_id ->|                            |
- |                     |-- POST /v2/access-token(Bearer=userToken)-->
- |                     |<-- accessToken ------------|
- |                     |                            |
- |                     |-- POST /v2/sentence(Bearer=modelToken,句内带 accessToken)-->
- |                     |<-- {rows, count} ----------|
-```
-
-三种 token 的职责必须区分清楚:
-
-| Token | 签发者 | 作用 | 用在哪 |
-|---|---|---|---|
-| userToken | `sign-in` | 代表**人类操作者** | 查/建 ai-model、签 access token |
-| modelToken | 新增端点 | 代表 **AI 模型身份** | 写句子时的 `Authorization` |
-| accessToken | `access-token` | **委托** channel 编辑权给持有者 | 写句子时的 body 字段 |
-
----
-
-## 4. 可行性结论
-
-**可行。** 五个步骤中四步已有现成 API,剩余一步需新增约 15 行后端代码。
-
-必须做的服务端改动只有 §5.1 一项;其余为质量/安全修补,建议一并处理,因为 Skill 会高频调用这些接口,缺陷会被放大。
-
-### 备选方案(若不想改后端)
-
-用 **userToken 直接写句子**,跳过 model token。代价:
-- `editor_uid` 变成人类用户,**丧失 AI 署名与审计能力**——这与本设计的核心目的冲突;
-- 若操作者是 channel owner,连 access_token 都不需要,流程退化为两步。
-
-可作为 Skill 的降级路径(`--as-self`),但不应是默认行为。
-
----
-
-## 5. 需要的服务端改动
-
-### 5.1 新增:获取 AI Model 的 user token(P0,阻塞)—— ✅ 已实施
-
-`GET {API}/v2/ai-model-token/{uid}`(Bearer = 用户 token)
-
-路由(`routes/api.php` v2 组内,紧跟 `ai-model` 的 apiResource):
-
-```php
-Route::get('ai-model-token/{ai_model}', [AiModelTokenController::class, 'show']);
-```
-
-独立控制器 `AiModelTokenController::show()`,而非挂在 `AiModelController` 上——签发身份凭据与模型的 CRUD 是两件事,分开后前者的鉴权与日志不会被 CRUD 的改动波及。路由参数名 `{ai_model}` 与 apiResource 生成的一致,隐式模型绑定按 `AiModel::$primaryKey = 'uid'` 解析。
-
-```php
-public function show(Request $request, AiModel $aiModel): JsonResponse
-{
-    $user = AuthService::current($request);
-    if (! $user) {
-        return $this->error(__('auth.failed'), null, 401);
-    }
-    if (! AiModelController::canEdit($user['user_uid'], $aiModel->owner_id)) {
-        return $this->error(__('auth.failed'), null, 403);
-    }
-
-    $token = AuthService::getUserToken($aiModel->uid);
-    if (! $token) {
-        return $this->error('ai model not found', null, 404);
-    }
-
-    OpsLog::debug($user['user_uid'], [
-        'action' => 'ai-model-token.issue',
-        'model_uid' => $aiModel->uid,
-        'model_name' => $aiModel->name,
-    ]);
-
-    return $this->ok([
-        'uid' => $aiModel->uid,
-        'name' => $aiModel->name,
-        'token' => $token,
-    ]);
-}
-```
-
-返回 `data: { uid, name, token }`。
-
-注意错误响应用的是 `$this->error($msg, null, $status)`。仓库里多数旧代码写成 `$this->error(__('auth.failed'), 401, 401)`,把状态码误当成了 `$data` 参数(`Controller::error(string $message, mixed $data, int $status)`),响应体里因此多一个 `"data": 401`。新代码不沿用这个写法。
-
-要点:
-- 鉴权用 `AiModelController::canEdit()`(仅 owner 本人)。**依据 §9 决策 1:模型记录只挂个人 studio**,不支持 group studio,因此无需 `StudioApi::userCanManage()`。与 `show/update/destroy` 口径一致;
-- 该 token 是模型身份凭据,签发与撤销都**记入 ops 日志**(`App\Tools\OpsLog`,action 为 `ai-model-token.issue` / `ai-model-token.revoke`);
-- **有效期 30 天**:`AuthService::AI_MODEL_TOKEN_TTL`。人类登录 token 的 365 天不变(`USER_TOKEN_TTL`),两者分开是因为模型 token 要落到外部客户端的凭据文件里,泄漏面大得多;
-- **撤销机制**(推翻 §9 决策 2):`ai_models.token_version` 自增即作废该模型全部已签出 token。
-  - 签发时 payload 带 `typ: "ai-model"` + `ver`;
-  - `AuthService::current()` 只对带 `typ` 的 token 查一次 `token_version` 比对,人类 token 不额外查库;
-  - 迁移 `2026_08_05_110345_add_token_version_to_ai_models_table`,默认值 1。
-
-**兼容性**:引入版本号之前签出的模型 token(无 `typ`/`ver`,payload 里 `id` 恒为 0)一律失效。名义上是破坏性变更,实际破坏面为零——本轮改动尚未部署,线上没有任何存量模型凭据;仓库内部的 `ai-translate`、`app/Console/Commands/*`、`AiTaskPrepare` 都是每次任务现签现用。人类 token 的 `id` 是 `user_infos.id`(≥1),不受影响。
-
-### 5.2 修补(P1,强烈建议)
-
-| # | 状态 | 位置 | 问题 | 建议 |
-|---|---|---|---|---|
-| a | ✅ 已修 | `AiModelController::show()` (`:103`) | **完全没有鉴权**,任何人可读任意模型 | 已加 `AuthService::current` + `canEdit` 判定(见下方遗留项) |
-| b | ✅ 已修 | `AiModelResource::toArray()` | `parent::toArray()` 把 `key`(第三方 API key)原样返回 | 改为字段白名单;`key` / `system_prompt` 仅 owner 请求时附带 |
-| c | ⬜ 待修 | `AiModelController::index()` | 非法 `view` → `$table` 未定义 → 500 | `default:` 分支返回 400 |
-| d | ✅ 已修 | `AiModelController::store()` | 不接受完整字段;无重名校验 | 已接受 `model/url/key/privacy/description`;同 studio 内重名返回 409 |
-| e | ✅ 已修 | `AiModelController::update()` | 未提供字段被置 null | 改为按 `$request->has()` 增量更新 |
-| f | ✅ 已修 | `AccessTokenController::store()` | 签出的 token **永不过期** | payload 注入 `exp`(7 天);`UserCanEdit` 捕获解码异常 |
-| g | ✅ 已修 | `AiModelController` 的 `Store/UpdateAiModelRequest` | `rules()` 为空,无任何校验 | 已补 `name` 必填、`privacy` 枚举、各字段长度上限 |
-
-(g) 本属 P2,但 (d) 的重名校验依赖 `name` 必填,只好一并做掉。剩下的 (c) 与 Skill 流程无关,留在 P2。
-
-#### (b) 的实施要点
-
-不能简单删掉 `key` / `system_prompt`:dashboard 的模型编辑页(`AiModelEdit.tsx`)靠 `GET /v2/ai-model/{uid}` 回填这两个字段,删了会导致用户一保存就把 key 清空。故按请求者是否 owner 分别返回。
-
-真正的泄漏面其实比 (a) 大得多:`index()` 的 `view=all` / `view=usable` 分支对**任何登录用户**返回全部模型记录,key 就在里面——(a) 只堵住了 `show` 一个口子。
-
-`isRequestedByOwner()` 的结果按请求缓存在 `$request->attributes` 上:`index()` 一次最多返回 1000 行,逐行解一次 JWT 不可接受。
-
-#### (f) 的连带改动
-
-给 access token 加上 `exp` 之后,`SentenceController::UserCanEdit()` 里的 `JWT::decode()` 会在 token 过期时抛 `ExpiredException`。原代码没有 try/catch,**过期 token 会变成 500 而不是 403**。已补捕获,并顺带处理了 `AccessToken` 查不到记录时 `new Key(null)` 抛异常的情况。
-
-#### (a) 的遗留项
-
-当前实现:
-
-```php
-public function show(Request $request, AiModel $aiModel)
-{
-    $user = AuthService::current($request);
-    if (! $user) {
-        return $this->error(__('auth.failed'), 401, 401);
-    }
-    if (! self::canEdit($user['user_uid'], $aiModel->owner_id)) {
-        return $this->error(__('auth.failed'), 403, 403);
-    }
-
-    return $this->ok(new AiModelResource($aiModel));
-}
-```
-
-1. ~~**签名缺 `$request`**~~——已修复:初版方法体用了 `$request` 但参数列表没有它,该端点必然 500;现已补上 `Request $request`(路由模型绑定不受影响,Laravel 按类型而非位置注入)。
-
-2. ~~**鉴权口径**~~——已定:§9 决策 1 选个人 studio,`canEdit()` 就是正确口径,与 `token()` / `update()` / `destroy()` 一致,无需改。副作用是 `privacy = public` 的模型对非 owner 也不可读;这与 `index()` 的 `view=usable`(返回 public 模型)不一致,但由于 (b) 落地后 `index` 不再泄漏敏感字段,且 Skill 只读自己的模型,暂不处理。
-
-3. **对 Skill 的影响**——§6.3 第 3 步 `POST` 之后如需回读,以及任何走 `GET /v2/ai-model/{uid}` 的路径,现在都必须带 userToken;沿用 §6.3 的 `view=studio` 列表比对方式则不受影响。
-
----
-
-## 6. Skill 设计
-
-### 6.1 目录结构
-
-**开发地点:本仓库。分发方式:Claude Code 插件(marketplace)。**(§9 决策 4、决策 7)
-
-理由:API 尚不完善,Skill 与服务端要同步改(§5 的每一项都会反映到 `references/api.md`)。放在 mint 仓库内,一次提交就能同时改 Laravel 代码和 Skill 契约;独立仓库会让两者版本漂移,且改 API 时无法在同一个 Claude Code 会话里读写后端代码。
-
-放在仓库根的 `plugins/` 下,本身就是一个合法插件:
-
-```
-plugins/wikipali/
-├── .claude-plugin/
-│   └── plugin.json       # 插件清单,version 是唯一的版本来源
-├── README.md             # 面向安装者:装之前它会动你哪些东西
-├── install.sh            # 不走 marketplace 时的后路
-└── skills/
-    └── write/            # → 调用名 wikipali-write:write
-        ├── SKILL.md      # 触发条件 + 流程说明(给模型读)
-        ├── references/
-        │   └── api.md    # 本文 §2 的精简版:端点、字段、陷阱
-        └── scripts/
-            ├── wp_login.py   # 交互式登录,仅此脚本接触密码
-            └── wp.py         # 客户端:endpoint / whoami / ensure-model /
-                              #         revoke / channels / grant / write
-```
-
-实现时比原计划多了两个子命令:`whoami`(一屏看清当前站点、三种 token 及其到期时间——排查「为什么 401」的第一步)与 `revoke`(§2.3b 的撤销端点,安全能力做了就该有入口)。`wp_login.py` 通过 `import wp` 复用 HTTP 与凭据代码,两个文件仍在同一目录内,不违反自包含约束。
-
-几个布局上的决定:
-
-- **用 `skills/write/` 而不是把 SKILL.md 放插件根**。后者也合法(单 skill 插件允许),但调用名会变成 `wikipali-write:wikipali-write`;而且 §9 后续规划里还有读取和 sentpr 两个 skill,`skills/` 布局才能容纳。
-- **`VERSION` 文件已删**。版本号只留 `plugin.json` 的 `version` 一处,两处必然漂移;`install.sh` 改为从 manifest 读。
-- **仓库根留一个 symlink** `.claude/skills/wikipali-write → ../../plugins/wikipali/skills/write`,这样在 mint 里开发时(无论从哪个子目录启动 Claude Code)skill 仍然自动加载。实测普通 skill 的向上查找会跟随 symlink;插件形态则用 `--plugin-dir ./plugins/wikipali` 测。
-- 放在**仓库根**而非 `api-v13/` 下:后者已有 `laravel-best-practices` 等目录级 skill,只在编辑 `api-v13/` 时激活;而本 Skill 是对线上 API 的客户端操作,与当前编辑哪个子目录无关。
-
-### 6.1.1 可分发性约束
-
-「能复制给别的项目用」是硬需求,因此以下几条是**约束而非偏好**:
-
-1. **目录自包含**——不引用 `plugins/wikipali/` 之外的任何路径。SKILL.md 里不能出现 `api-v13/...` 这类仓库内引用;需要的 API 事实全部落在 `references/api.md` 里。
-2. **零安装依赖,只用 Python 标准库**——用 `urllib.request` 而非 `requests`,`json` / `getpass` / `argparse` 均为内置。**不跟随 `ai-translate` 的 venv + `pip install -e` 模式**(`ai-translate/pyproject.toml` 依赖 `pika`/`requests`/`redis`/`openai`):那套在目标项目里要求用户先建虚拟环境,与「复制即用」冲突。代价是要自己处理 `urllib` 的 HTTPError/超时/JSON 编码,比 `requests` 啰嗦,但换来 `python3 scripts/wp.py` 开箱可跑。
-3. **API 地址不硬编码**——见 §6.1.2。复制到别的项目后无需改代码。
-4. **凭据与 Skill 解耦**——凭据在 `~/.wikipali/`(§6.2),多个项目里的 Skill 副本共用同一份登录态,登录一次即可。
-
-依然选 Python(而非 shell)是为了与 `ai-translate` 的请求语义保持一致,便于对照排查。
-
-### 6.1.2 多站点:四个地址,一套数据
-
-线上四个站点**共享同一个数据库和同一把 `jwt_secrets_key`**,区别只有两个维度(2026-08-05 用户确认):
-
-| api_url | 域名 | 代码版本 |
-|---|---|---|
-| `https://www.wikipali.org/api` | .org | 稳定版 |
-| `https://www.wikipali.cc/api` | .cc | 稳定版 |
-| `https://next.wikipali.org/api` | .org | 最新版 |
-| `https://next.wikipali.cc/api` | .cc | 最新版 |
-| `http://127.0.0.1:8000/api` | 开发机 | 工作副本 |
-
-- `.org` / `.cc` —— 地区可达性,用户按网络情况选;
-- `www` / `next` —— **代码版本**,不是数据环境。`next` 跑最新版,出问题可随时降级到 `www`,数据不受影响。
-
-因此 Skill **不需要「按站点分桶」这类结构**——四个地址在数据上是同一个后端:
-
-1. **线上凭据只有一份**。四个地址通用:userToken / modelToken 用同一把密钥签;channel access token 的密钥存在同一张 `access_tokens` 表里;`ai_models` 也是同一张表,模型 uid 在四个地址上都是同一个。§6.2 的凭据文件退化为 `{ online: {...}, local: {...} }` 两桶——`local` 单独一桶是因为开发机是另一个库、另一把密钥。
-2. **任意切换,不需要重新登录、不需要重跑 ensure-model、不需要重签 access token**。换地址只是换一条网络路径 + 换一版服务端代码。
-3. **允许自动 fallback,但要出声**。四个线上地址之间连不通就换下一个是安全的(同一套数据)。顺序:用户选定的 → 同版本的另一域名 → 另一版本的同域名。**唯独不能自动回退到 `local`**,那是另一套库。切换时打一行提示(`www.wikipali.org 连接失败,已改用 www.wikipali.cc`)——静默切换会掩盖「你选的站点挂了」,也会让 §6.1.2-4 的契约差异变得无从排查。
-4. **真正的风险不是写错库,是写到不同版本的代码上**。`next` 与 `www` 的 API 契约可能不一致:新端点、新字段、新校验会先上 `next`,`www` 落后一段时间。所以:
-   - Skill 依赖的新端点(如 §2.3b 的 `DELETE /v2/ai-model-token/{uid}`)在 `www` 上可能还是 404,遇到 404 要提示「当前站点代码版本较旧,请切到 next 或稍后再试」,而不是当成「模型不存在」;
-   - `references/api.md` 记录的契约以 **`www`(稳定版)** 为准,`next` 独有的能力标注出来。Skill 默认连 `www`。
-5. **写入前仍要回显 api_url**,但理由变了:不是怕写错库(写不错),而是出问题时要知道是哪一版代码写的。
-
-#### 用户如何切换(2026-08-05 定案)
-
-地址来源优先级:
-
-| 优先级 | 来源 | 是否改变默认 |
-|---|---|---|
-| 1 | `--api https://next.wikipali.org/api` | **否**,仅本次调用 |
-| 2 | `WIKIPALI_API_URL` 环境变量 | 否,仅当前 shell |
-| 3 | 凭据文件里的 `online.api_url` | 这就是默认,由 `wp.py endpoint` 写入 |
-| 4 | 都没有 → `https://www.wikipali.org/api` | 首次运行的兜底(稳定版) |
-
-**`--api` 一次性覆盖,不写回凭据文件**。否则「上周试了一次 next」会一直粘着,之后每次写入都落在最新版代码上而用户毫无察觉。改默认必须是显式动作,即下面的子命令。长期用 `next` 的人应该改默认,而不是每次带参数。
-
-**`wp.py endpoint` 是唯一改默认的入口**,让「切站点」成为可见、可回显的动作,而不是手工编辑 JSON:
-
-```
-$ python3 scripts/wp.py endpoint
-  1) https://www.wikipali.org/api   稳定版 · .org  ← 当前
-  2) https://www.wikipali.cc/api    稳定版 · .cc
-  3) https://next.wikipali.org/api  最新版 · .org
-  4) https://next.wikipali.cc/api   最新版 · .cc
-  5) http://127.0.0.1:8000/api      开发机
-
-$ python3 scripts/wp.py endpoint next
-  已切换到 https://next.wikipali.org/api(最新版 · .org)
-```
-
-不带参数时列出清单并标出当前选中;带参数时接受序号、简称(`next` / `www` / `local`)或完整 url,写回 `online.api_url`。切到 `local` 则改用 `local` 桶的凭据(§6.2)。
-
-开发机地址不做特殊照顾——`http://` 明文只在 `127.0.0.1` 放行,其余一律要求 `https://`。
-
-上表是**内置的已知站点清单**,与 §6.1.1 第 3 条(地址不硬编码)不冲突:清单只用于 `endpoint` 子命令的展示与 fallback 排序;`--api` / 环境变量给出的任意地址仍然接受,只是不在清单里的地址自成一桶,不与线上凭据互通。
-
-### 6.2 凭据存储
-
-路径:`~/.wikipali/credentials.json`(**不放在用户项目目录内**,避免被误提交),权限 `0600`。
-
-```json
-{
-  "current": "online",
-  "online": {
-    "api_url": "https://www.wikipali.org/api",
-    "user": { "uid": "...", "username": "...", "token": "..." },
-    "model": { "uid": "...", "name": "claude-opus-5", "token": "..." },
-    "access_tokens": {
-      "<channel_uid>": { "token": "...", "book": 0, "granted_at": "2026-08-03T10:00:00Z" }
-    }
-  },
-  "local": {
-    "api_url": "http://127.0.0.1:8000/api",
-    "user": {}, "model": {}, "access_tokens": {}
-  }
-}
-```
-
-只有 `online` / `local` 两桶,理由见 §6.1.2:四个线上地址共享库与密钥,凭据通用。`online.api_url` 只记「上次选的是哪个地址」,换地区或在 www / next 之间切换就是改这一个字段,`user` / `model` / `access_tokens` 全部原样沿用。`local` 单独一桶是因为开发机是另一个库、另一把 `jwt_secrets_key`。
-
-原则:
-- **Claude 永不接触明文密码**。登录由用户自己在**一个真正的终端**里执行 `python3 scripts/wp_login.py`(`getpass` 读取)。
-  ~~或在 Claude Code 中用 `! python .../wp_login.py` 前缀运行~~——2026-08-05 实测推翻:`!` 前缀跑的命令没有交互式终端(`sys.stdin.isatty()` 为假),密码提示无处输入。脚本会明确报错并指路,另提供 `--password-stdin` 供自动化场景从管道读取;密码**不能**经 argv 传(进 `ps` 与 shell history),也不能直接打进对话(进上下文);
-- Skill 读取凭据文件时只取 token,不回显到对话中(日志里 token 一律打码);
-- 任一 token 收到 401 → 提示重新登录,而不是自动重试;
-- 缓存的 `model.token` 有效期只有 30 天,且可能被 owner 主动撤销(§2.3b)。两种情况的表现都是 401,处理一致:重跑 §6.3 第 4 步重取,仍 401 才提示重新登录。
-
-### 6.3 幂等 model 记录
-
-`name` 取当前模型标识(如 `claude-opus-5`),流程:
-
-1. `GET /v2/ai-model?view=studio&name={username}&keyword={modelName}`;
-2. 在 `rows` 中做 `name` **精确**比对;
-3. 命中 → 用其 `uid`;未命中 → `POST` 一次即可带全字段(§5.2d 已落地,不再需要「POST 再 PUT」两步)。`POST` 撞 409 → 回查列表取 uid(模糊匹配漏网或并发);已存在但字段有出入 → `PUT` 增量补;
-4. `GET /v2/ai-model-token/{uid}` 取 modelToken,写入凭据文件缓存。
-
-`--name` 决定署名,故不设默认值:拿不到就报错要求显式指定,避免把句子挂到别的模型名下。
-
-### 6.4 channel 的交互式选择
-
-§9 决策 3:**不要求用户手工提供 channel uid**,由 Skill 列出可编辑 channel 供选择。
-
-接口:`GET /v2/channel?view=user-edit`(Bearer = **用户 token**)
-
-```
-ChannelController::index() 的 'user-edit' 分支:
-  ShareApi::getResList(user_uid, 2) 中 power >= 20 的 res_id
-  ∪ owner_uid == user_uid 的 channel
-```
-
-返回列 `uid / name / summary / type / owner_uid / lang / status / is_system / updated_at / created_at`。
-
-流程:
-1. 用户未指定 channel → `GET /v2/channel?view=user-edit`,按 `updated_at` 倒序展示 `name` + `lang` + `uid` 前 8 位;
-2. 用户从中选择(或用 `name` 模糊匹配后确认);
-3. 选定的 uid 进入 §3 流程的 `access-token` 签发步骤。
-
-要点:
-- 该 view **已经是「可编辑」语义**,与 `access-token` 签发用的 `ChannelApi::userCanEdit()` 是同一套 share 权限(power ≥ 20),所以列表里的 channel 正常都能签出 token。但两者代码路径不同,仍须按 §6.6 判 `count: 0`;
-- 若用户显式给了 uid,跳过列表直接用,但仍应 `GET /v2/channel/{uid}` 回显 name 供 §6.5 确认——避免写错 channel;
-- 列表为空 → 明确提示「当前账号没有任何可编辑的 channel」,而不是继续走签发流程。
-
-### 6.5 写入前的确认
-
-写库属于对外的、不易回滚的操作。Skill 必须在 `POST /v2/sentence` 之前:
-- 展示:**当前 api_url**(§6.1.2:四个线上地址写的是同一个库,但代码版本不同,出问题时要知道是哪一版写的)、目标 channel(uid + name)、book、句子条数、前若干条的 `id` 与 content 摘要;
-- 明确提示「已存在的相同句子将被覆盖」(`firstOrNew` 语义);
-- 取得用户确认后才发送。批量写入建议分批(如每批 50 条)并报告累计 `count`。
-
-### 6.6 错误处理约定
-
-| 现象 | 含义 | 处置 |
-|---|---|---|
-| 401 | token 失效/过期,或把 `local` 的 token 发到了线上(§6.1.2;四个线上地址之间不会出这个问题) | 引导重新登录,勿自动重试 |
-| 403 | 无 channel 编辑权,或非 studio owner | 明确指出缺哪一项权限 |
-| `access-token` 返回 `count: 0` | 用户对该 channel 无编辑权(被静默跳过) | 当作 403 报错,**不可继续写入** |
-| `sentence` 返回 `count` 小于提交条数 | 部分句子鉴权失败被 `continue` 跳过 | 逐条对比返回的 rows,报告被跳过的句子 id |
-| `no date` (200) | 请求缺 `sentences` 字段 | 客户端 bug |
-| 新端点 404(如 §2.3b 的 `DELETE /v2/ai-model-token/{uid}`) | 当前站点跑的是稳定版代码,该端点尚未上线(§6.1.2-4) | 提示切到 `next` 或稍后再试,**不要**当成「资源不存在」 |
-
-注意 `SentenceController::store()` 对**逐句失败是静默跳过**的(`:341`),所以「HTTP 200」不等于「全部写入成功」,必须核对 `count`。
-
-### 6.7 打包与分发(2026-08-05 改为插件,见 §9 决策 7)
-
-**主路径:Claude Code 插件 + 自建 marketplace。**
-
-```
-/plugin marketplace add iapt-platform/wikipali-plugins
-/plugin install wikipali@wikipali
-```
-
-桌面版(Claude Desktop 的 **Code** 标签页)点 `+` → Plugins → Add plugin 装同一个 marketplace。注意插件只对本地/SSH 会话生效,Chat 标签页与云会话不加载插件。
-
-**上游是 `iapt-platform`,`visuddhinanda/*` 是 fork。** 开发在 fork 上做,通过 PR 合回上游(仓库既有的工作流,见 `Merge pull request #2427`)。**面向用户的一切地址都必须指向 `iapt-platform`**——指向 fork 会让用户装到某个人的分支上。
-
-**两个仓库的分工**:
-
-| 仓库 | 内容 | 为什么 |
-|---|---|---|
-| `iapt-platform/wikipali-plugins` | 只有 `.claude-plugin/marketplace.json` + README,8 KB | `/plugin marketplace add` 会**完整克隆** marketplace 仓库,没有稀疏优化 |
-| `iapt-platform/mint` | 插件本体 `plugins/wikipali/` | 与 API 同仓演进(§6.1)。marketplace 用 `git-subdir` 源指过来,Claude Code **稀疏克隆**只取这一个子目录 |
-
-反过来「mint 自己当 marketplace」是不行的:mint 的 packfile 580 MB、HEAD 快照 212 MB,而 Claude Code 的 git 操作超时是 120 秒,且后台自动更新失败时会整仓重新 clone。
-
-**版本与更新**——版本号只有 `plugin.json` 的 `version` 一处。marketplace 条目里可以再加 `sha` 钉到具体提交,那才是真正的「发版」:用户不会静默拿到 mint 上某个未验证的中间提交。改 API 契约时的动作是:改插件 → 提交 → 推 mint → 更新 marketplace.json 的 `sha`/`version` → 用户 `/plugin update`。
-
-**`install.sh` 降级为后路**:不走 marketplace 时,它把插件目录整个复制到 `<target>/.claude/skills/wikipali-write/`,因为带 `.claude-plugin/plugin.json` 的目录会被当作 `<name>@skills-dir` 插件就地加载。代价是不会自动更新。
-
-**先后顺序**:先在本仓库把流程跑通(P1 全部完成),再打包。过早分发会把未定型的 API 契约固化到别人机器上——所以**线上四站部署 + 线上复测通过之前,不要把 marketplace 地址给别人**。
-
-#### 已发布状态(2026-08-06)
-
-`iapt-platform/wikipali-plugins` 已上线,`wikipali-write` 钉在 mint 的 `c46cf6400`。实测:
-
-- `/plugin marketplace add iapt-platform/wikipali-plugins` → `/plugin install wikipali@wikipali` 一次通过;
-- 缓存目录 `~/.claude/plugins/cache/wikipali/wikipali-write/0.1.0/` 只有 **84 KB**——`git-subdir` 的稀疏克隆确实只取了那一个子目录,没有拉 mint 的 580 MB;
-- 在与 mint 无关的目录下启动,skill 以 `wikipali-write:write` 加载,缓存里的 `wp.py` 直接可跑;
-- 常驻上下文成本 ~230 tok(就是 SKILL.md 的 description),调用时 ~2k。
-
-**发版流程**(四步,缺一步用户就拿不到新版):
-
-1. 改插件 → 提交 → **推 mint**;
-2. 有契约变更就 bump `plugins/wikipali/.claude-plugin/plugin.json` 的 `version`;
-3. 更新 `wikipali-plugins` 的 `marketplace.json`:`source.sha` 指向新提交,`version` 跟着改;
-4. 用户 `/plugin update wikipali@wikipali`。
-
-第 3 步是刻意的手工闸门:不钉 sha 的话用户会静默拿到 `development` 上任何一个中间提交,包括没验证过的。
-
-2026-08-05 的实际情况:`install.sh` 已写好并验证(装出的副本能独立运行),但**分发要等到服务端部署 + 端到端实测通过之后**。打包机制本身不依赖 API 契约,先写好没有代价;真正会把未定型契约固化出去的是「复制给别的项目」这一步。
-
----
-
-## 7. 安全考量
-
-1. **权限不放大**:AI 模型自身不是任何 channel 的 owner/协作者,其全部写权限来自用户签发的 access token,且受 book 范围限制。用户无权的 channel,签发阶段就会失败。
-2. **模型 token 有效期 30 天且可撤销**(§5.1):泄漏时 owner 调 `DELETE /v2/ai-model-token/{uid}` 即可让该模型全部已签出 token 立刻失效,不必轮换全局 `jwt_secrets_key`(那会踢掉所有用户)。撤销是全量的,不能只废一张。`~/.wikipali/credentials.json` 仍须 `0600`、不进日志/不进对话——撤销是止损手段,不是防线。
-3. **access token 有效期 7 天**(§5.2f 已修)。注意签名密钥是 `access_tokens` 表里按 `res_type + res_id` 存的 uuid,`firstOrNew` 只在首次创建,**同一 channel 的密钥不轮换**——所以 7 天只限制单张 token 的窗口,没有「立即吊销」能力。Skill 仍应把它视为高敏感数据,仅存本地、不进日志、不进对话。
-4. **密码零留存**:不写入任何文件,不进入对话上下文。
-5. **审计**:所有写入都会进 `sent_histories`(`SentenceService::saveHistory`),`editor_uid` 为模型 uid,可追溯。
-6. **部署前提**:`token_version` 的校验在 `AuthService::current()` 里,故撤销只被跑了该版本代码的站点认账。本轮改动尚未部署到任何服务器,部署时四个站点一起上即可,不存在版本差窗口。若日后单独灰度某个站点,需记得这条。
-
----
-
-## 8. 实施计划
-
-| 阶段 | 状态 | 内容 | 依赖 |
-|---|---|---|---|
-| P0 | ✅ | 服务端:新增 `GET /v2/ai-model-token/{uid}`(`AiModelTokenController::show`,用 `canEdit`)+ 测试 | — |
-| P0 | ✅ | 服务端安全修补:§5.2 (a)(b)(f) | — |
-| P0 | ✅ | 服务端:§5.2 (d)(e)(g) —— 从 P2 上提,否则 §6.3 的「POST 建档再 PUT 补字段」会被 (e) 的 null 覆盖打断 | — |
-| P0 | ✅ | 服务端:模型 token TTL 收到 30 天 + `token_version` 撤销机制 + `DELETE /v2/ai-model-token/{uid}`(推翻 §9 决策 2)| — |
-| P1 | ✅ | Skill:`wp_login.py` + 凭据存储 + `auth/current` 校验 | P0 |
-| P1 | ✅ | Skill:`ensure-model`(查/建/补字段/取 token)、`revoke`、`whoami` | P0 |
-| P1 | ✅ | Skill:`channels`(`view=user-edit` 列表 + 交互选择) | P0 |
-| P1 | ✅ | Skill:`grant`(签 access token,缓存,判 `count: 0`) | `channels` |
-| P1 | ✅ | Skill:`write`(分批 + 确认 + count 核对 + 401 自动重签一次) | 以上全部 |
-| P2 | ⬜ | 服务端质量修补:§5.2 (c) | — |
-| P1 | ✅ | Skill:`install.sh` + `VERSION`(打包分发,§6.7) | P1 全部跑通 |
-| P1 | ✅ | 端到端实测:开发机(`local`)上跑通登录 → 写入 → 断言署名 | — |
-| P2 | ⬜ | 线上复测(部署后重跑一次,确认线上无差异) | 服务端部署 |
-| P2 | ⬜ | Skill 扩展:读取能力(`GET /v2/sentence`、`sentences-in-chapter`)与 `sentpr` PR 提交 | P1 |
-
-(d)(e) 上提到 P0 的理由:§6.3 第 3 步在 (d) 落地前必须走「POST 创建 → PUT 补齐字段」两步,而 (e) 未修时那个 PUT 会把未传字段一律置 null,两个缺陷叠加使 ensure-model 无法可靠工作。
-
-测试要求(`api-v13` 使用 Pest):
-- ✅ Feature test 覆盖新端点的 401 / 403 / 200 / 404 四条路径(`tests/Feature/AiModelTokenTest.php`);
-- ✅ `AiModelResourceTest` 断言 key / system_prompt 不外泄、owner 仍可取;
-- ✅ `AiModelCrudTest` 断言 store 全字段、重名 409、update 增量不清空;
-- ✅ `AccessTokenExpiryTest` 断言签出的 token 带 `exp` 且无权时 `count: 0`;
-- ⬜ 端到端 test:登录 → 建模型 → 取 model token → 签 access token → 写句子 → 断言 `editor_uid == 模型 uid`(留待 Skill 落地时补,需要 sentences / pali_texts 等一批表的夹具)。
-
-#### 测试环境
-
-迁移文件含 Postgres 专有语句(`CREATE EXTENSION "uuid-ossp"`、`enum` 列等),**无法在 sqlite 上跑**,所以 `phpunit.xml` 里 `DB_CONNECTION=pgsql`、`DB_DATABASE=mint_test`。
-
-`RefreshDatabase` 会清空目标库,**测试库必须与开发库 `visuddhinanda_20260311` 严格分离**——后者装着完整生产数据集(`sentences` 3.3 GB、`sent_sims` 3.6 GB)。库名写死在 `phpunit.xml` 里正是为了不让它跟着 `.env` 漂移。
-
-数据库需由具备 `CREATEDB` 权限的角色创建(应用角色 `www` 没有该权限):
-
-```bash
-sudo -u postgres createdb -O www mint_test
-```
-
-#### Skill 的验证方式(2026-08-05)
-
-Skill 的验证不走 Pest——它是个纯客户端,测的是「对着服务端的响应形状与坑,客户端做对了没有」。做法是写一个模拟 API 的桩服务(复刻 `sign-in` 失败返回 400、`keyword` 模糊匹配、`access-token` 无权返回 `count: 0`、`sentence` 逐句静默跳过、返回字段名是 `book` 而非 `book_id` 这几处),把全流程跑一遍,验证点:
-
-登录(含密码错)、`ensure-model` 幂等复跑、`channels` 列表、`grant` 缓存命中不重签、`write` 的 dry-run / 分批 / 覆盖警告 / 非交互式无 `-y` 时拒绝写入、部分写入时列出漏掉的句子、`count: 0` 时中止、模型 token 被撤销后自动重签一次再重试、fallback 顺序(同版本另一域名 → 另一版本同域名,且绝不落到 `local`)、`endpoint` 切换与 `--api` 不写回、`install.sh` 装出的副本可独立运行。
-
-桩服务不进仓库:它编码的是「我以为服务端是这样」,留着会变成第二份契约来源,与 `references/api.md` 打架。
-
-#### 开发机上的端到端实测(2026-08-05)
-
-对 `local`(`php artisan serve`,开发库 `visuddhinanda_20260311`)跑了一遍完整链路:用户在真实终端里 `wp_login.py` 登录 → `ensure-model` 建档并取模型 token → `channels` 列出 130 个可编辑 channel → 写 3 条句子到「草稿二」的 `book 1 / paragraph 99901`(事先查过该位置在其所有 channel 里都是空的,只新增不覆盖)。
-
-查库断言的结果:
-
-- 3 条句子的 `editor_uid` = `79bb0934-…`(模型 uid),**不是** `ba5463f3-…`(本人 uid)——署名目标达成;
-- `language` / `status` 继承自 channel(`zh-Hans` / 10),与 `store()` 的逻辑一致;
-- `sent_histories` 每条 1 行,`user_uid` 同为模型 uid,审计链成立;
-- 改一句内容重跑,3 个 `uid` 不变、内容更新、每条历史累积到 2 行——`firstOrNew` 的幂等覆盖语义得到确认;
-- 对一个无编辑权的 channel 跑 `grant`,服务端返回 `count: 0`,客户端按约定中止并报「没有编辑权」。
-
-测试数据(3 条句子 + 6 行历史)已按 uid 精确删除,「草稿二」回到原有的 13 条;`claude-opus-5` 的 `ai_models` 记录保留,它就是日后真实写入要用的模型身份。
-
-**线上仍未验证**:四个线上地址都还没部署 P0(`POST /api/v2/ai-model-token/x` 返回 404 而非 405 —— 已注册的路由用错方法会返回 405,未注册才是 404)。部署后应重跑一次同样的链路。
-
----
-
-## 9. 决策记录
-
-四个待确认问题已于 2026-08-04 定案:
-
-| # | 问题 | 决策 | 影响 |
-|---|---|---|---|
-| 1 | 模型记录挂个人还是 group studio | **个人 studio** | §5.1 用 `canEdit()`,不引入 `StudioApi::userCanManage`;(a) 的遗留项 2 关闭 |
-| 2 | 是否提供 token 撤销机制 | ~~不做~~ → **2026-08-05 推翻,改为做** | 加 `ai_models.token_version`,模型 token payload 增 `typ`/`ver`,TTL 从 365 天收到 30 天;旧模型 token 全部失效(见 §5.1、§7-2) |
-| 3 | channel uid 如何获取 | **Skill 交互式选择** | 用 `GET /v2/channel?view=user-edit`,见 §6.4 |
-| 4 | Skill 分发形态 | **在本仓库开发,以复制方式分发**;不做独立仓库 | 放仓库根 `plugins/wikipali/`,目录自包含、零依赖,可整体复制到其他项目;见 §6.1、§6.7 |
-| 5 | 多站点(4 个线上 + 开发机)如何处理 | 四个线上地址**共享库与 `jwt_secrets_key`**,凭据只存一份(`online` / `local` 两桶),可任意切换与自动 fallback(2026-08-05 补) | 见 §6.1.2、§6.2。`.org`/`.cc` 是地区,`www`/`next` 是**代码版本**不是数据环境;随之而来的是 API 契约版本差,见 §6.1.2-4 |
-| 6 | 用户怎么切 endpoint | `--api` 一次性覆盖**不写回**;改默认只经 `wp.py endpoint` 子命令;fallback **提示后切换**不静默(2026-08-05 补) | 见 §6.1.2「用户如何切换」。三条都指向同一个原则:当前连的是哪个站点,任何时候都应当是用户明确知道的 |
-| 7 | 怎么发布给别人 | **Claude Code 插件 + 自建 marketplace**:目录文件放独立小仓库 `wikipali-plugins`,插件本体留在 mint,用 `git-subdir` 稀疏克隆(2026-08-05 补,修正决策 4 的「整目录复制」) | 见 §6.7。mint 不能直接当 marketplace——marketplace 是整仓 clone,580 MB 撞 120 秒超时。MCP server 形态排在插件跑通之后 |
-
-决策 2 原本是「不做」,理由是省掉 `token_version` 可以不动 `ai_models` 表结构、不改 `getUserToken` 的 payload。2026-08-05 推翻:趁 Skill 尚未分发、代码尚未部署、外面一份真实凭据都没有的时候补,代价最小;再往后每多一份副本,「已签出 token 全部失效」的破坏面就大一分。

+ 0 - 20
plugins/wikipali/.claude-plugin/plugin.json

@@ -1,20 +0,0 @@
-{
-  "name": "wikipali",
-  "description": "WikiPali 巴利三藏平台的客户端:检索与阅读语料做研究(词形展开、全文检索、出处分布、按坐标取原文与译本),以及以 AI 模型身份写入句子。",
-  "version": "0.8.4",
-  "author": {
-    "name": "visuddhinanda",
-    "url": "https://github.com/visuddhinanda"
-  },
-  "homepage": "https://github.com/iapt-platform/mint/tree/development/plugins/wikipali",
-  "repository": "https://github.com/iapt-platform/mint",
-  "license": "MIT",
-  "keywords": [
-    "wikipali",
-    "pali",
-    "tipitaka",
-    "buddhist-studies",
-    "research",
-    "translation"
-  ]
-}

+ 0 - 95
plugins/wikipali/README.md

@@ -1,95 +0,0 @@
-# wikipali
-
-[WikiPali](https://www.wikipali.org) 巴利三藏平台的 Claude Code 插件。
-
-两个 skill:
-
-- **`research`** —— 检索与阅读语料做研究:词形展开、按词形检索、出处分布(分本文/义注/复注)、按坐标取原文与各家译本。只读,不需要登录。
-- **`write`** —— 以 **AI 模型身份**把句子写入句子库。
-
-写入的句子 `editor_uid` 记为 AI 模型的 uid 而不是操作者本人,署名与审计因此是准确的——谁翻的就是谁翻的。
-
-## 安装
-
-```
-/plugin marketplace add iapt-platform/wikipali-plugins
-/plugin install wikipali@wikipali
-```
-
-桌面版在 **Code** 标签页里点 `+` → **Plugins** → **Add plugin** 也可以装。
-
-不走 marketplace 的话,克隆本仓库后跑 `plugins/wikipali/install.sh --user`。
-
-## 装之前请知道它会做什么
-
-插件能在你的机器上执行代码,装之前你应当知道这一个具体会干什么:
-
-- **读写 `~/.wikipali/credentials.json`**(权限 0600),里面存你的 WikiPali 登录 token、AI 模型身份 token 和 channel access token;
-- **往 wikipali.org 写数据**。写入是覆盖式的:相同位置(book / paragraph / word_start / word_end / channel)的已有句子会被替换。插件在每次写入前会回显目标并要求确认;
-- **只用 Python 标准库**,不装任何依赖,不建虚拟环境。
-
-它**不会**接触你的密码:登录由 `wikipali-login` 完成,密码只读入内存,不落盘、不进日志、不进对话。
-
-- 有终端时用 `getpass` 读;
-- **没有终端时(Claude Desktop、IDE、AI 代跑)自动弹出操作系统的密码对话框**——你把密码输给系统,AI 全程看不到;
-- 两者都不可用时它会明确报错并给出办法(Claude Desktop 按 <kbd>Ctrl</kbd>+<kbd>`</kbd> 有内置终端,仅本地会话;SSH 场景凭据在远端,要在那台机器上登录)。
-
-登录是一次性的:token 有效期 365 天,同一台机器上所有副本共用 `~/.wikipali/credentials.json`。
-
-## 用法
-
-装好后直接对 Claude 说「把这些译文写进 WikiPali 的某某 channel」即可,它会自己走完流程。手工调用:
-
-```bash
-wikipali whoami        # 看当前凭据状态
-wikipali-login         # 登录(自己跑)
-wikipali ensure-model --name <模型标识>
-wikipali channels
-wikipali write sents.json --channel <uid> --dry-run
-```
-
-句子文件的形状:
-
-```json
-{
-  "channel_uid": "<channel uid>",
-  "sentences": [
-    { "book_id": 1, "paragraph": 10, "word_start": 0, "word_end": 12,
-      "content": "译文", "content_type": "markdown" }
-  ]
-}
-```
-
-## 站点
-
-线上四个地址(`www` / `next` × `.org` / `.cc`)共享同一个数据库和密钥,凭据通用,可随时切换:
-
-```bash
-wikipali endpoint          # 列出并标出当前
-wikipali endpoint next     # 改默认
-wikipali --api next ...    # 只影响这一次调用
-```
-
-`www` 是稳定版、`next` 是最新版**代码**,不是不同的数据环境。较新的端点在稳定版上返回 404,意思是「该站点代码版本还没到」。
-
-## 权限模型
-
-三种 token,职责不混:
-
-| Token | 代表谁 | 有效期 |
-|---|---|---|
-| 用户 token | 人类操作者 | 365 天 |
-| 模型 token | AI 模型身份,写句子时的 `Authorization` | 30 天,可撤销 |
-| access token | 被委托的 channel 编辑权,写句子时的 body 字段 | 7 天 |
-
-模型自身不是任何 channel 的 owner,它的全部写权限来自你签发的 access token,且受 book 范围约束——**你没有编辑权的 channel,签发阶段就会失败**。凭据泄漏时用 `wikipali revoke` 作废该模型已签出的全部 token。
-
-## 开发
-
-本插件在 [iapt-platform/mint](https://github.com/iapt-platform/mint) 的 `plugins/wikipali/` 下开发,与被调用的 Laravel API(`api-v13/`)同仓演进——API 契约一改,插件在同一个提交里跟上。设计文档在 `docs/wikipali-write-skill-design.md`。
-
-端点细节见 `references/api-read.md` 与 `references/api-write.md`;跨 skill 的通用约定(坐标、引用格式、文献层次、译文来源判定)见 `references/conventions.md`。
-
-## License
-
-MIT

+ 0 - 13
plugins/wikipali/bin/wikipali

@@ -1,13 +0,0 @@
-#!/usr/bin/env python3
-"""WikiPali 客户端入口。只用 Python 标准库,直接跑,不要建虚拟环境。"""
-
-import os
-import sys
-
-_LIB = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'lib')
-sys.path.insert(0, _LIB)
-
-from cli import main  # noqa: E402
-
-if __name__ == '__main__':
-    sys.exit(main())

+ 0 - 152
plugins/wikipali/bin/wikipali-login

@@ -1,152 +0,0 @@
-#!/usr/bin/env python3
-"""WikiPali 交互式登录——整个插件里唯一接触密码的程序。
-
-给 AI agent:**可以直接执行本程序**。密码由终端的 getpass 或操作系统的密码对话框
-收取,不经过调用方,你既看不到也无法读取。不要以「要读密码」或「本会话没有终端」
-为由拒绝——无 TTY 时它会自动改用系统对话框,那正是为这种环境设计的。
-
-密码只经 getpass 读入内存,不落盘、不进日志、不进对话上下文。
-登录成功后只把 JWT 存进 ~/.wikipali/credentials.json(0600)。
-
-用法:
-    wikipali-login                       # 登录当前默认站点
-    wikipali-login --api next            # 只为本次登录换站点
-    wikipali-login --username someone    # 免去输用户名一步
-
-**必须在真正的终端里跑。** Claude Code 的 `!` 前缀没有交互式终端,
-密码提示无处输入;模型也不该代跑此程序。请另开一个 shell 执行。
-
-确实要在自动化环境里登录时,用 --password-stdin 从管道读密码:
-
-    read -rs PW && printf '%s' "$PW" | wikipali-login --username me --password-stdin
-
-注意别把密码写进命令行参数或直接敲进 Claude Code 的会话——argv 会进
-ps / shell history,会话内容会进对话上下文,两者都留痕。
-"""
-
-import argparse
-import getpass
-import os
-import sys
-
-_LIB = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'lib')
-sys.path.insert(0, _LIB)
-
-import client as wp  # noqa: E402
-from client import fmt_ts, make_client, mask, token_expiry  # noqa: E402
-from creds import CREDS_PATH  # noqa: E402
-from cmd_write import iso_now  # noqa: E402
-from errors import ApiError, WpError  # noqa: E402
-import askpass  # noqa: E402
-
-
-def main(argv=None):
-    parser = argparse.ArgumentParser(prog="wikipali-login", description="登录 WikiPali 并缓存用户 token")
-    parser.add_argument("--api", help="本次登录使用的 API 地址(序号/简称/完整 url)")
-    parser.add_argument("--username", help="用户名或邮箱;省略则交互输入")
-    parser.add_argument(
-        "--password-stdin", action="store_true",
-        help="从 stdin 读密码(供自动化用;别让密码经过 argv 或对话)",
-    )
-    args = parser.parse_args(argv)
-
-    try:
-        client = make_client(args)
-    except WpError as exc:
-        print(f"错误:{exc}", file=sys.stderr)
-        return 1
-
-    print(f"登录站点:{client.api_note()}")
-    if client.bucket_name != "online":
-        print("注意:该站点的凭据与线上四站不通用。")
-
-    interactive = sys.stdin.isatty()
-    use_gui = not interactive and not args.password_stdin and askpass.gui_available()
-
-    if not interactive and not args.password_stdin and not use_gui:
-        # 没有 TTY 也没有图形界面:说清楚该怎么办,别让用户对着静默的提示符发愣
-        print(
-            "错误:当前既没有交互式终端,也没有可用的图形界面,无法安全地读取密码。\n"
-            "  · Claude Desktop:按 Ctrl+` 打开内置终端,在里面跑 wikipali-login(仅本地会话有);\n"
-            "  · SSH / 远程开发机:在该机器上另开一个终端执行;\n"
-            "  · 自动化环境:... | wikipali-login --username <名字> --password-stdin。\n"
-            "无论哪种,都不要把密码写进命令行参数,也不要打进与 AI 的对话里。",
-            file=sys.stderr,
-        )
-        return 1
-
-    username = args.username
-    if not username:
-        if use_gui:
-            print("已弹出系统对话框,请在其中输入用户名与密码(本程序看不到你的输入之外的任何东西)。")
-            username = (askpass.ask("用户名或邮箱", secret=False) or "").strip()
-        elif not interactive:
-            print("错误:--password-stdin 模式必须同时给 --username。", file=sys.stderr)
-            return 1
-        else:
-            username = input("用户名或邮箱:").strip()
-    if not username:
-        print("错误:用户名为空。", file=sys.stderr)
-        return 1
-
-    if args.password_stdin:
-        password = sys.stdin.readline().rstrip("\n")
-    elif use_gui:
-        # 密码由用户直接输给系统对话框,不经过终端、不经过 argv,调用方看不到
-        password = askpass.ask(f"{username} 的 WikiPali 密码")
-        if password is None:
-            print("已取消(对话框被关闭或超时)。", file=sys.stderr)
-            return 130
-    else:
-        try:
-            password = getpass.getpass("密码(不会被保存):")
-        except (EOFError, KeyboardInterrupt):
-            print("\n已取消。", file=sys.stderr)
-            return 130
-    if not password:
-        print("错误:密码为空。", file=sys.stderr)
-        return 1
-
-    try:
-        token = client.call("POST", "v2/sign-in", body={"username": username, "password": password})
-    except ApiError as exc:
-        # sign-in 失败时服务端返回 400 + 'invalid token',措辞会让人以为是 token 问题
-        if exc.status in (400, 401):
-            print("错误:用户名或密码不正确。", file=sys.stderr)
-        else:
-            print(f"错误:登录失败(HTTP {exc.status}):{exc}", file=sys.stderr)
-        return 1
-    except WpError as exc:
-        print(f"错误:{exc}", file=sys.stderr)
-        return 1
-    finally:
-        del password
-
-    if not isinstance(token, str) or not token:
-        print("错误:服务端没有返回 token。", file=sys.stderr)
-        return 1
-
-    try:
-        current = client.call("GET", "v2/auth/current", token=token)
-    except WpError as exc:
-        print(f"错误:token 拿到了但校验失败:{exc}", file=sys.stderr)
-        return 1
-
-    client.bucket["user"] = {
-        "uid": current.get("id"),
-        "username": current.get("realName"),
-        "nickname": current.get("nickName"),
-        "token": token,
-        "logged_in_at": iso_now(),
-    }
-    client.save()
-
-    exp = token_expiry(token)
-    print(f"登录成功:{current.get('nickName')}(realName={current.get('realName')},用作 studio_name)")
-    print(f"token {mask(token)} 到期 {fmt_ts(exp)},已写入 {CREDS_PATH}(0600)")
-    print("下一步:wikipali ensure-model --name <模型标识>")
-    return 0
-
-
-if __name__ == "__main__":
-    sys.exit(main())

+ 0 - 84
plugins/wikipali/install.sh

@@ -1,84 +0,0 @@
-#!/bin/sh
-# 手工安装:把本插件整目录复制到目标项目或用户级 skills 目录。
-#
-#   ./install.sh ~/work/other-project   # 项目级,只在该项目激活
-#   ./install.sh --user                 # 用户级,所有项目可用
-#   ./install.sh --user --force         # 覆盖已存在的旧副本
-#
-# 首选方式是从 marketplace 装(见 README),那样能自动更新。本脚本是给
-# 不走 marketplace 的场景留的后路:复制过去的目录带 .claude-plugin/
-# manifest,会被当作 <name>@skills-dir 插件就地加载。
-#
-# 只复制本目录自身,不碰仓库里的任何其他文件。副本不会自动更新——
-# API 契约一改,旧副本就静默过期,靠 plugin.json 的 version 判断。
-
-set -eu
-
-SRC=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-NAME=$(basename -- "$SRC")
-FORCE=0
-TARGET=""
-
-usage() {
-    sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'
-    exit "${1:-1}"
-}
-
-while [ $# -gt 0 ]; do
-    case "$1" in
-        --user) TARGET="$HOME/.claude/skills" ;;
-        --force|-f) FORCE=1 ;;
-        -h|--help) usage 0 ;;
-        -*) echo "未知参数:$1" >&2; usage ;;
-        *)
-            if [ ! -d "$1" ]; then
-                echo "错误:目标目录不存在:$1" >&2
-                exit 1
-            fi
-            TARGET=$(CDPATH= cd -- "$1" && pwd)/.claude/skills
-            ;;
-    esac
-    shift
-done
-
-if [ -z "$TARGET" ]; then
-    echo "错误:请给出目标项目目录,或用 --user 装到用户级。" >&2
-    usage
-fi
-
-DEST="$TARGET/$NAME"
-# 版本号只有一处来源:plugin.json。VERSION 文件已废弃,两处版本必然漂移
-read_version() {
-    python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","(未标版本)"))' \
-        "$1/.claude-plugin/plugin.json" 2>/dev/null || echo "(读不到 plugin.json)"
-}
-VERSION=$(read_version "$SRC")
-
-if [ "$DEST" = "$SRC" ]; then
-    echo "错误:源和目标是同一个目录。" >&2
-    exit 1
-fi
-
-if [ -d "$DEST" ]; then
-    OLD=$(read_version "$DEST")
-    if [ "$FORCE" -ne 1 ]; then
-        echo "目标已存在:$DEST"
-        echo "  已装版本:$OLD"
-        echo "  本次版本:$VERSION"
-        echo "要覆盖请加 --force。"
-        exit 1
-    fi
-    echo "覆盖 $OLD → $VERSION"
-    rm -rf "$DEST"
-fi
-
-mkdir -p "$TARGET"
-cp -R "$SRC" "$DEST"
-find "$DEST" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true
-chmod +x "$DEST/bin/wikipali" "$DEST/bin/wikipali-login" "$DEST/install.sh"
-
-echo "已安装 $NAME $VERSION 到 $DEST"
-echo
-echo "下一步(凭据在 ~/.wikipali/,多个副本共用,通常不必重新登录):"
-echo "  $DEST/bin/wikipali whoami"
-echo "(把 $DEST/bin 加进 PATH 可以直接用 wikipali 命令)"

+ 0 - 79
plugins/wikipali/lib/askpass.py

@@ -1,79 +0,0 @@
-"""在没有终端的环境里,用操作系统的密码对话框读取密码。
-
-为什么需要它:Claude Desktop、IDE 集成、agent 代跑等场景都没有 TTY,`getpass`
-用不了。而让用户把密码打进对话、或放进命令行参数,都会让密码进入会话上下文或
-`ps` / shell history——那是真正的泄漏。
-
-系统对话框解决了这个矛盾:**密码由用户直接输给操作系统,不经过终端、不经过 argv、
-调用方(包括 agent)全程看不到**,只从子进程的 stdout 拿回来,留在本进程内存里。
-
-支持 zenity / kdialog(Linux)、osascript(macOS)、PowerShell(Windows)。
-没有图形界面时返回 None,由调用方给出可操作的提示。
-"""
-
-import os
-import shutil
-import subprocess
-import sys
-
-DIALOG_TIMEOUT = 180
-
-
-def gui_available():
-    """当前环境有没有可用的图形界面。"""
-    if sys.platform == 'darwin':
-        return shutil.which('osascript') is not None
-    if os.name == 'nt':
-        return True
-    if not (os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY')):
-        return False
-    return shutil.which('zenity') is not None or shutil.which('kdialog') is not None
-
-
-def _run(cmd):
-    """跑对话框命令,返回用户输入;取消或失败返回 None。
-
-    stdout 只在本函数内流转,绝不打印——它装着密码。
-    """
-    try:
-        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=DIALOG_TIMEOUT)
-    except (OSError, subprocess.TimeoutExpired):
-        return None
-    if proc.returncode != 0:
-        return None
-    return proc.stdout.rstrip('\n')
-
-
-def ask(prompt, title='WikiPali', secret=True):
-    """弹一个对话框问用户要一行输入。secret=True 时输入被遮蔽。
-
-    返回字符串;用户取消、超时或没有图形界面时返回 None。
-    """
-    if sys.platform == 'darwin':
-        hidden = ' with hidden answer' if secret else ''
-        script = (f'display dialog "{prompt}" with title "{title}" '
-                  f'default answer ""{hidden}')
-        out = _run(['osascript', '-e', script, '-e',
-                    'text returned of result'])
-        return out
-
-    if os.name == 'nt':
-        if secret:
-            ps = (f'$s = Read-Host -AsSecureString "{prompt}"; '
-                  '[Runtime.InteropServices.Marshal]::PtrToStringAuto('
-                  '[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))')
-        else:
-            ps = f'Read-Host "{prompt}"'
-        return _run(['powershell', '-NoProfile', '-Command', ps])
-
-    if shutil.which('zenity'):
-        if secret:
-            # --password 只给密码框;要用户名时用 --entry
-            return _run(['zenity', '--password', '--title', f'{title} — {prompt}'])
-        return _run(['zenity', '--entry', '--title', title, '--text', prompt])
-
-    if shutil.which('kdialog'):
-        flag = '--password' if secret else '--inputbox'
-        return _run(['kdialog', '--title', title, flag, prompt])
-
-    return None

+ 0 - 197
plugins/wikipali/lib/cli.py

@@ -1,197 +0,0 @@
-"""命令行装配。
-
-读侧(forms/word/search/dist/get)不需要凭据;写侧(ensure-model/channels/grant/write)
-需要,且写入前有确认闸门。登录是独立的 wikipali-login,本入口不接触密码。
-"""
-
-import argparse
-import sys
-
-import cmd_read
-import cmd_site
-import cmd_write
-from client import DEFAULT_BATCH
-from errors import WpError
-
-
-def build_parser():
-    parser = argparse.ArgumentParser(
-        prog='wikipali',
-        description='WikiPali 客户端:检索、阅读、以 AI 模型身份写入',
-        formatter_class=argparse.RawDescriptionHelpFormatter,
-        epilog='凭据在 ~/.wikipali/credentials.json(0600)。登录请跑 wikipali-login。',
-    )
-    parser.add_argument('--api', help='本次调用使用的 API 地址(序号/简称/完整 url),不写回凭据文件')
-    sub = parser.add_subparsers(dest='command', required=True)
-
-    def add(name, help_text, needs_json=True):
-        p = sub.add_parser(name, help=help_text)
-        if needs_json:
-            p.add_argument('--json', action='store_true', help='输出原始 JSON')
-        return p
-
-    # -- 站点与状态 --------------------------------------------------------
-    p = add('endpoint', '查看 / 切换 API 地址', needs_json=False)
-    p.add_argument('target', nargs='?', help='序号、简称(www/www.cc/next/next.cc/staging/local)或完整 url')
-    p.add_argument('-l', '--list', action='store_true',
-                   help='只列出不提示选择(在终端里跑而又不想被追问时用)')
-    p.set_defaults(func=cmd_site.cmd_endpoint)
-
-    p = add('whoami', '显示当前凭据状态', needs_json=False)
-    p.add_argument('--check', action='store_true', help='额外向服务端校验用户 token')
-    p.set_defaults(func=cmd_site.cmd_whoami)
-
-    # -- 读 ----------------------------------------------------------------
-    p = add('forms', '展开词形——一切检索的前置步骤')
-    p.add_argument('word', help='词根或任意变格形')
-    p.add_argument('--limit', type=int, default=3, help='显示几个候选词根,默认 3')
-    p.set_defaults(func=cmd_read.cmd_forms)
-
-    p = add('word', '词典释义与形态分析,用来确认选对了词根')
-    p.add_argument('word')
-    p.add_argument('--lang', default='zh', help='释义语言,默认 zh')
-    p.add_argument('--limit', type=int, default=3, help='最多显示几个词条')
-    p.add_argument('--dicts', type=int, default=3, help='每个词条显示几部词典')
-    p.set_defaults(func=cmd_read.cmd_word)
-
-    p = add('search', '按词形检索段落')
-    p.add_argument('forms', nargs='*', help='逗号或空格分隔的词形;或用 --lemma 自动展开')
-    p.add_argument('--lemma', help='给词根,自动先展开成全部词形再检索')
-    p.add_argument('--bold', action='store_true', help='只要黑体命中(注释书标出的词条)')
-    p.add_argument('--book', help='限定书(用 dist 输出里的 --book 值)')
-    p.add_argument('--tags', help='限定范围,如 vinaya 或 vinaya,mūla;vinaya,aṭṭhakathā')
-    p.add_argument('--limit', type=int, default=50, help='本页条数,默认 50')
-    p.add_argument('--offset', type=int, default=0)
-    p.add_argument('--width', type=int, default=200, help='每条摘要的字符数')
-    p.set_defaults(func=cmd_read.cmd_search)
-
-    p = add('dist', '出处分布:命中散布在哪些书、各多少、什么层次')
-    p.add_argument('forms', nargs='*')
-    p.add_argument('--lemma')
-    p.add_argument('--tags')
-    p.add_argument('--limit', type=int, default=25, help='最多列几部书')
-    p.set_defaults(func=cmd_read.cmd_dist)
-
-    p = add('get', '按坐标取文,如 wikipali get 216:35 216:36')
-    p.add_argument('coords', nargs='+', help='book:paragraph,可给多个')
-    p.add_argument('--channel', action='append',
-                   help='channel uid,可重复;缺省取巴利原文')
-    p.add_argument('--limit', type=int, default=200, help='每次请求最多取几句')
-    p.set_defaults(func=cmd_read.cmd_get)
-
-    p = add('toc', '章节目录')
-    p.add_argument('coord', help='book:paragraph,任意段号即可,服务端会找到所属丛书')
-    p.add_argument('--depth', type=int, default=4, help='最多显示到第几层,默认 4')
-    p.add_argument('--all', action='store_true', help='显示整套丛书而不只当前这本')
-    p.set_defaults(func=cmd_read.cmd_toc)
-
-    p = add('chapter', '先报体量,再按需取整章')
-    p.add_argument('coord', help='book:paragraph,正文段也行,会自动向上找章节')
-    p.add_argument('--fetch', action='store_true', help='确认要读全文时加这个;不加只报体量')
-    p.add_argument('--channel', action='append', help='channel uid,可重复;缺省取巴利原文')
-    p.add_argument('--warn-at', type=int, default=8000, help='超过多少字符就提示,默认 8000')
-    p.add_argument('--text', action='store_true', help='输出纯文本而非 html(黑体转成 **)')
-    p.add_argument('--via', choices=['tipitaka-content', 'chapter-content'],
-                   default='tipitaka-content',
-                   help='取文走哪个端点。默认 tipitaka-content;chapter-content 返回逐句的'
-                        '多版本结构,写入侧需要它')
-    p.add_argument('--limit', type=int, default=200)
-    p.set_defaults(func=cmd_read.cmd_chapter)
-
-    p = add('versions', '某坐标有哪些译本,以及没有哪些')
-    p.add_argument('coord', help='book:paragraph')
-    p.set_defaults(func=cmd_read.cmd_versions)
-
-    p = add('count', '词频合计(词次,不是段落数)')
-    p.add_argument('words', nargs='+', help='一个或多个词/词根')
-    p.set_defaults(func=cmd_read.cmd_count)
-
-    p = add('terms', '术语表:权威译名对照')
-    p.add_argument('keyword', nargs='?', help='按巴利词形过滤;省略则列全表')
-    p.add_argument('--lang', default='zh-Hans')
-    p.add_argument('--view', default='community')
-    p.add_argument('--limit', type=int, default=30)
-    p.add_argument('--refresh', action='store_true', help='强制重新拉取(全表有缓存)')
-    p.set_defaults(func=cmd_read.cmd_terms)
-
-    p = add('related', '本文 ↔ 义注 ↔ 复注的段落对应')
-    p.add_argument('coord', help='book:paragraph')
-    p.set_defaults(func=cmd_read.cmd_related)
-
-    p = add('articles', '列出 / 搜索文章(二手研究)')
-    p.add_argument('keyword', nargs='?', help='标题关键词')
-    p.add_argument('--lang', help='按语言过滤')
-    p.add_argument('--view', default='public')
-    p.add_argument('--limit', type=int, default=20)
-    p.add_argument('--offset', type=int, default=0)
-    p.set_defaults(func=cmd_read.cmd_articles)
-
-    p = add('article', '读一篇文章的全文')
-    p.add_argument('uid')
-    p.add_argument('--chars', type=int, default=4000, help='最多输出多少字符,0 为不截断')
-    p.set_defaults(func=cmd_read.cmd_article)
-
-    p = add('anthology', '文集:不给 uid 列表,给 uid 看目录')
-    p.add_argument('uid', nargs='?')
-    p.add_argument('--view', default='public')
-    p.add_argument('--limit', type=int, default=30)
-    p.add_argument('--offset', type=int, default=0)
-    p.set_defaults(func=cmd_read.cmd_anthology)
-
-    p = add('books', '分类目录:按 tag 找书,如「长部的复注有哪些」')
-    p.add_argument('keyword', nargs='?', help='按书名/toc 过滤')
-    p.add_argument('--tags', help='按 tag 筛,逗号分隔是**且**,如 dīghanikāya,ṭīkā')
-    p.add_argument('--tag-list', action='store_true', help='列出全部 tag 及各自的书数')
-    p.add_argument('--show-tags', action='store_true', help='每本书都列出它的 tag')
-    p.add_argument('--limit', type=int, default=40)
-    p.add_argument('--refresh', action='store_true', help='强制重新拉取书目清单(有本地缓存)')
-    p.set_defaults(func=cmd_read.cmd_books)
-
-    # -- 写 ----------------------------------------------------------------
-    p = add('ensure-model', '幂等地建立模型记录并取模型身份 token', needs_json=False)
-    p.add_argument('--name', help='模型标识,如 claude-opus-5(会成为句子作者署名)')
-    p.add_argument('--model', help='底层模型 id')
-    p.add_argument('--url', dest='url', help='模型服务地址')
-    p.add_argument('--description', help='描述')
-    p.add_argument('--privacy', choices=['private', 'public'], default='private')
-    p.set_defaults(func=cmd_write.cmd_ensure_model)
-
-    p = add('revoke', '撤销该模型已签出的全部 token', needs_json=False)
-    p.add_argument('--uid', help='模型 uid,缺省用缓存里的')
-    p.add_argument('-y', '--yes', action='store_true')
-    p.set_defaults(func=cmd_write.cmd_revoke)
-
-    p = add('channels', '列出当前账号可编辑的 channel')
-    p.add_argument('--search', help='按名字过滤')
-    p.set_defaults(func=cmd_write.cmd_channels)
-
-    p = add('grant', '为某个 channel 签发 access token 并缓存', needs_json=False)
-    p.add_argument('channel', nargs='?', help='channel uid / 列表序号 / 名字片段;省略则交互选择')
-    p.add_argument('--book', type=int, default=0, help='限定 book,0 表示不限(默认)')
-    p.add_argument('--force', action='store_true', help='即使缓存未过期也重新签发')
-    p.set_defaults(func=cmd_write.cmd_grant)
-
-    p = add('write', '写入句子', needs_json=False)
-    p.add_argument('file', help='句子 JSON 文件,- 表示从 stdin 读')
-    p.add_argument('--channel', help='目标 channel(uid / 序号 / 名字片段)')
-    p.add_argument('--book', type=int, help='access token 的 book 范围,缺省按句子推断')
-    p.add_argument('--batch', type=int, default=DEFAULT_BATCH, help=f'每批条数,默认 {DEFAULT_BATCH}')
-    p.add_argument('--content-type', default='markdown')
-    p.add_argument('--preview', type=int, default=5, help='确认时预览几条')
-    p.add_argument('--dry-run', action='store_true', help='只做校验与回显,不发请求')
-    p.add_argument('-y', '--yes', action='store_true', help='跳过交互确认(非交互环境必须显式给)')
-    p.set_defaults(func=cmd_write.cmd_write)
-
-    return parser
-
-
-def main(argv=None):
-    args = build_parser().parse_args(argv)
-    try:
-        return args.func(args)
-    except WpError as exc:
-        print(f'错误:{exc}', file=sys.stderr)
-        return 1
-    except KeyboardInterrupt:
-        print('\n已中断。', file=sys.stderr)
-        return 130

+ 0 - 184
plugins/wikipali/lib/client.py

@@ -1,184 +0,0 @@
-"""HTTP 客户端:JSON 请求、线上站点之间出声的 fallback、token 显示辅助。"""
-
-import base64
-import http.client
-import json
-import os
-import sys
-import time
-import urllib.error
-import urllib.parse
-import urllib.request
-from datetime import datetime, timezone
-
-from creds import bucket_name_for, get_bucket, load_creds, resolve_api_url, save_creds
-from errors import ApiError, WpError
-from sites import ONLINE_URLS, SITES, site_label
-
-
-TOKEN_REFRESH_MARGIN = 3600
-
-DEFAULT_TIMEOUT = 30
-WRITE_TIMEOUT = 120
-DEFAULT_BATCH = 50
-
-
-def mask(token):
-    if not token:
-        return "(无)"
-    if len(token) <= 16:
-        return token[:4] + "…"
-    return token[:8] + "…" + token[-4:]
-
-
-def jwt_payload(token):
-    """不验签地读出 JWT payload,仅用于显示有效期。"""
-    try:
-        part = token.split(".")[1]
-        part += "=" * (-len(part) % 4)
-        return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
-    except Exception:
-        return {}
-
-
-def token_expiry(token):
-    exp = jwt_payload(token).get("exp")
-    return int(exp) if isinstance(exp, (int, float)) else None
-
-
-def fmt_ts(ts):
-    if not ts:
-        return "未知"
-    return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
-
-
-def note(msg):
-    print(msg, file=sys.stderr)
-
-
-def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
-    """发一个 JSON 请求,返回解析后的响应体(dict)。
-
-    网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
-    HTTP 层失败抛 ApiError,带上服务端 message。
-    """
-    # 路径里可能有巴利词(parivāsa),urllib 只接受 ASCII,必须先百分号编码
-    url = api_url + "/" + urllib.parse.quote(path.lstrip("/"), safe="/")
-    if query:
-        url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
-    data = None
-    headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
-    if body is not None:
-        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
-        headers["Content-Type"] = "application/json"
-    if token:
-        headers["Authorization"] = "Bearer " + token
-    req = urllib.request.Request(url, data=data, headers=headers, method=method)
-    try:
-        with urllib.request.urlopen(req, timeout=timeout) as resp:
-            raw = resp.read().decode("utf-8", "replace")
-            status = resp.status
-    except urllib.error.HTTPError as exc:
-        raw = exc.read().decode("utf-8", "replace")
-        status = exc.code
-        payload = safe_json(raw)
-        message = payload.get("message") if isinstance(payload, dict) else None
-        raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
-    payload = safe_json(raw)
-    if not isinstance(payload, dict):
-        raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
-    if not payload.get("ok", False):
-        raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
-    return payload.get("data")
-
-
-def safe_json(raw):
-    try:
-        return json.loads(raw)
-    except ValueError:
-        return None
-
-
-class Client:
-    """按站点收发请求,并在线上地址之间做出声的 fallback。"""
-
-    def __init__(self, api_url, source, creds, allow_fallback=True):
-        self.api_url = api_url
-        self.source = source
-        self.creds = creds
-        self.bucket_name = bucket_name_for(api_url)
-        self.bucket = get_bucket(creds, self.bucket_name, api_url)
-        self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
-
-    # -- 凭据 ---------------------------------------------------------------
-
-    @property
-    def user_token(self):
-        token = (self.bucket.get("user") or {}).get("token")
-        if not token:
-            raise WpError(
-                "尚未登录。执行 wikipali-login 即可——没有终端时它会弹出系统密码框,\n"
-                "密码由你直接输给操作系统,AI 看不到。\n"
-                "(若命令不在 PATH 上,用 ${CLAUDE_PLUGIN_ROOT}/bin/wikipali-login,"
-                "或重启会话让 PATH 生效。)"
-            )
-        return token
-
-    @property
-    def model(self):
-        model = self.bucket.get("model") or {}
-        if not model.get("token"):
-            raise WpError("尚未取得模型身份 token。请先跑:wikipali ensure-model --name <模型名>")
-        return model
-
-    def save(self):
-        save_creds(self.creds)
-
-    # -- 请求 ---------------------------------------------------------------
-
-    def fallback_order(self):
-        """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
-        cur = next((s for s in SITES if s["url"] == self.api_url), None)
-        if not cur:
-            return []
-        others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
-        others.sort(
-            key=lambda s: (
-                0 if s["version"] == cur["version"] else 1,
-                0 if s["domain"] == cur["domain"] else 1,
-            )
-        )
-        return [s["url"] for s in others]
-
-    def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
-        urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
-        last = None
-        for idx, url in enumerate(urls):
-            try:
-                data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
-            except (urllib.error.URLError, TimeoutError, OSError,
-                    http.client.HTTPException) as exc:
-                # IncompleteRead 属于 HTTPException 而非 OSError——大响应(如两百多万
-                # 字符的术语表)传输中断时会走到这里。不捕获的话会抛裸 traceback。
-                # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
-                last = exc
-                reason = getattr(exc, "reason", exc)
-                if idx + 1 < len(urls):
-                    note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
-                continue
-            if url != self.api_url:
-                # fallback 成功后本次会话都用它,但不写回凭据文件
-                note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
-                self.api_url = url
-            return data
-        raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
-
-    def api_note(self):
-        src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
-        return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
-
-
-def make_client(args, allow_fallback=True):
-    creds = load_creds()
-    api_url, source = resolve_api_url(getattr(args, "api", None), creds)
-    return Client(api_url, source, creds, allow_fallback=allow_fallback)

+ 0 - 970
plugins/wikipali/lib/cmd_read.py

@@ -1,970 +0,0 @@
-"""检索与阅读的子命令:forms / word / search / dist / get。
-
-全部只读,不需要凭据。
-"""
-
-import html as html_mod
-import json
-import re
-import sys
-
-from client import make_client, note
-from coords import fmt_coord, fmt_path, parse_coord, parse_coords, text_layer
-from errors import ApiError, WpError, explain_api_error
-
-# 巴利原文本身就是一个 channel(_System_Pali_VRI_)。取原文、取译文、取逐词解析
-# 是同一个调用换 channel。
-PALI_CHANNEL = '00b577c0-13b9-11ee-a05a-b7307efd9ee6'
-
-# 靠 channel 名字判断机器译文很脆弱:库里既有名字含 "AI" 的,也有直接用模型名命名的
-# (deepseek / qwen-max / grok-简体中文 / gemini / 豆包 / ChatGPT),后者不含 "ai"。
-# 这个清单只用来「提醒去核实」,不作为判定依据——权威判定看 get 返回的作者是不是模型。
-MACHINE_HINTS = ('ai', 'gpt', 'chatgpt', 'claude', 'deepseek', 'gemini', 'qwen', 'grok',
-                 'llama', 'mistral', 'kimi', 'norbu', '豆包', '文心', 'ernie', '通义')
-
-# 服务端的 sentence?view=paragraph 不带 channels 会 500,所以永远要给一个默认值。
-READ_TIMEOUT = 60
-
-
-def strip_markup(raw, hl='【】', bold='**'):
-    """把服务端返回的 HTML 压成纯文本,保留命中高亮与黑体两种信息。
-
-    命中词用 <span class='hl'> 包,黑体是原文的 <span class="bld">——后者是注释书
-    标出词条的地方,对判断「这段是不是定义」很关键,不能丢。
-    """
-    if not raw:
-        return ''
-    text = raw
-    text = re.sub(r"<span class='hl'>(.*?)</span>", hl[0] + r'\1' + hl[1], text, flags=re.S)
-    text = re.sub(r'<span class="bld">(.*?)</span>', bold + r'\1' + bold, text, flags=re.S)
-    # <code>M1.1</code> 是版本页码(M=缅甸版 V=VRI P=PTS T=泰版),标的是页在正文里
-    # 的起始位置,与段落不是一一对应,所以必须留在原位。直接去标签会让它粘到前一个
-    # 词上(Evaṃ M1.1 → EvaṃM1.1),看着像词形的一部分,加方括号隔开。
-    text = re.sub(r'<code>([^<]*)</code>', r'[\1]', text)
-    text = re.sub(r"<MdTpl[^>]*></MdTpl>", '', text)
-    text = re.sub(r'<[^>]+>', '', text)
-    text = html_mod.unescape(text)
-    return re.sub(r'\s+', ' ', text).strip()
-
-
-def snippet(text, width, around=None):
-    """截断文本;给了 around 就尽量把它所在的位置露出来。"""
-    if len(text) <= width:
-        return text
-    if around:
-        pos = text.find(around)
-        if pos > width // 2:
-            start = pos - width // 3
-            return '…' + text[start:start + width] + '…'
-    return text[:width] + '…'
-
-
-def emit(args, payload, render):
-    if getattr(args, 'json', False):
-        print(json.dumps(payload, ensure_ascii=False, indent=2))
-    else:
-        render()
-
-
-# ---------------------------------------------------------------------------
-# forms —— 词形展开,一切检索的前置
-# ---------------------------------------------------------------------------
-
-
-def fetch_forms(client, word):
-    """展开词形。**0 个词形的候选等同于没找到**——服务端对查无此词会返回一个
-    case 为空的行,若把它当成命中,后续 search 会拿到空 key 而返回整个语料库。"""
-    try:
-        data = client.call('GET', f'v2/case/{word}', timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, f'展开词形 {word}')
-    rows = (data or {}).get('rows') or []
-    return [r for r in rows if r.get('case')]
-
-
-def cmd_forms(args):
-    client = make_client(args)
-    rows = fetch_forms(client, args.word)
-    if not rows:
-        raise WpError(
-            f'「{args.word}」在语料里找不到任何词形。检查拼写(变音符号是否正确),'
-            '或换一个可能的词根再试。'
-        )
-
-    def render():
-        for idx, row in enumerate(rows[: args.limit], 1):
-            forms = row.get('case') or []
-            total = sum(int(f.get('count') or 0) for f in forms)
-            bold = sum(int(f.get('bold') or 0) for f in forms)
-            mark = '  ← 可能性最高' if idx == 1 else ''
-            print(f'[{idx}] {row.get("word")}  {len(forms)} 形 / 共 {total} 次(黑体 {bold}){mark}')
-            for f in sorted(forms, key=lambda x: -int(x.get('count') or 0)):
-                print(f'      {f.get("word"):<20} {f.get("count"):>5} 次   黑体 {f.get("bold")}')
-        print()
-        print('检索用(第一候选的全部词形):')
-        print('  ' + forms_arg(rows[0]))
-        if len(rows) > 1:
-            print('注意:还有其他候选词根。若目标概念同时有名词与动词两条线,两条都要展开。')
-
-    emit(args, rows, render)
-    return 0
-
-
-def forms_arg(row):
-    """把一个候选的全部词形拼成 search 要的逗号串。"""
-    return ','.join(f.get('word') for f in (row.get('case') or []) if f.get('word'))
-
-
-# ---------------------------------------------------------------------------
-# word —— 词典释义与形态分析,用来确认选对了词根
-# ---------------------------------------------------------------------------
-
-
-def cmd_word(args):
-    client = make_client(args)
-    try:
-        data = client.call('GET', 'v2/dict', query={'word': args.word, 'lang': args.lang},
-                           timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, f'查词典 {args.word}')
-    groups = (data or {}).get('words') or []
-    if not groups:
-        raise WpError(f'词典里没有「{args.word}」。')
-
-    def render():
-        for grp in groups:
-            for w in (grp.get('words') or [])[: args.limit]:
-                print(f'■ {w.get("word")}')
-                for g in (w.get('grammar') or [])[:6]:
-                    print(f'    ← {g.get("parent")}  {g.get("type")} {g.get("grammar")}'
-                          f'  ({g.get("factors")})')
-                for d in (w.get('dict') or [])[: args.dicts]:
-                    # 释义在 note;description 是词典本身的介绍,不是词条内容
-                    meaning = strip_markup(d.get('note') or '')
-                    if not meaning:
-                        continue
-                    print(f'    〔{d.get("shortname")}·{d.get("lang")}〕{snippet(meaning, 220)}')
-                print()
-
-    emit(args, groups, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# search —— 按词形检索段落
-# ---------------------------------------------------------------------------
-
-
-def resolve_key(client, args):
-    """确定检索用的词形串。--lemma 会先跑一次 forms,并把展开结果打出来。"""
-    if args.lemma:
-        rows = fetch_forms(client, args.lemma)
-        if not rows:
-            raise WpError(f'「{args.lemma}」展不出任何词形。')
-        key = forms_arg(rows[0])
-        if not key:
-            raise WpError(f'「{args.lemma}」展不出任何词形,无法检索。')
-        note(f'⚠ 已把词根「{args.lemma}」展开为 {len(key.split(","))} 个词形:{key}')
-        return key
-    key = ','.join(part.strip() for item in args.forms for part in item.split(',') if part.strip())
-    if not key:
-        # 空 key 会被服务端当成「不限」,返回整个语料库——决不能发出去
-        raise WpError('没有给出词形。用 --lemma <词根> 自动展开,或直接给逗号分隔的词形。')
-    return key
-
-
-def cmd_search(args):
-    client = make_client(args)
-    key = resolve_key(client, args)
-    query = {'key': key, 'limit': args.limit, 'offset': args.offset}
-    if args.bold:
-        query['bold'] = 'on'
-    if args.book:
-        query['book'] = args.book
-    if args.tags:
-        query['tags'] = args.tags
-    try:
-        data = client.call('GET', 'v2/search-pali-wbw', query=query, timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, '检索')
-    rows = (data or {}).get('rows') or []
-    total = (data or {}).get('count', 0)
-
-    def render():
-        scope = []
-        if args.bold:
-            scope.append('仅黑体')
-        if args.book:
-            scope.append(f'book={args.book}')
-        if args.tags:
-            scope.append(f'tags={args.tags}')
-        print(f'命中 {total} 段,本页 {len(rows)}(offset {args.offset})'
-              + (f'  [{" ".join(scope)}]' if scope else ''))
-        if not rows:
-            print('\n0 条。依次怀疑:词形没展开(用 --lemma)→ 词根选错 → 范围限太窄。')
-            return
-        print()
-        for idx, r in enumerate(rows, 1 + args.offset):
-            coord = fmt_coord(r.get('book'), r.get('paragraph'))
-            print(f'[{idx}] {coord}  {fmt_path(r.get("path"))}   rank {r.get("rank")}')
-            print(f'     {snippet(strip_markup(r.get("highlight")), args.width, "【")}')
-        print(f'\n引用时用坐标 book:paragraph,取原文用:wikipali get {rows[0].get("book")}:'
-              f'{rows[0].get("paragraph")}')
-
-    emit(args, {'count': total, 'rows': rows}, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# dist —— 出处分布
-# ---------------------------------------------------------------------------
-
-
-def cmd_dist(args):
-    client = make_client(args)
-    key = resolve_key(client, args)
-    query = {'key': key}
-    if args.tags:
-        query['tags'] = args.tags
-    try:
-        data = client.call('GET', 'v2/search-pali-wbw-books', query=query, timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, '统计出处分布')
-    rows = (data or {}).get('rows') or []
-
-    def render():
-        total = sum(int(r.get('count') or 0) for r in rows)
-        print(f'{len(rows)} 部书,共 {total} 次词命中\n'
-              '(注意:这里数的是词次,不是段落数。段落数用 search 的 count,'
-              '两者不相等——同一段里出现多次只算一段。)\n')
-        by_layer = {}
-        for r in sorted(rows, key=lambda x: -int(x.get('count') or 0))[: args.limit]:
-            layer = text_layer(r.get('tags'))
-            by_layer[layer] = by_layer.get(layer, 0) + int(r.get('count') or 0)
-            tags = ' '.join(t.get('name') for t in (r.get('tags') or []) if t.get('name'))
-            print(f'{r.get("count"):>5}  {str(r.get("paliTitle"))[:38]:<40} '
-                  f'--book {r.get("pcdBookId")}   [{tags}]')
-        print('\n按文献层次:', end='')
-        for layer in ('mūla', 'aṭṭhakathā', 'ṭīkā', ''):
-            if layer in by_layer:
-                print(f'  {layer or "未标层次"} {by_layer[layer]}', end='')
-        print('\n引用时必须标明层次——把义注的解释当成本文的说法是学术错误。')
-
-    emit(args, {'rows': rows}, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# get —— 按坐标取原文/译文
-# ---------------------------------------------------------------------------
-
-
-def cmd_get(args):
-    client = make_client(args)
-    grouped = parse_coords(args.coords)
-    channels = ','.join(args.channel) if args.channel else PALI_CHANNEL
-
-    collected = []
-    for book, paras in grouped.items():
-        # 服务端不带 channels 会 500,所以 channels 永远要给
-        query = {'view': 'paragraph', 'book': book, 'para': ','.join(str(p) for p in paras),
-                 'channels': channels, 'limit': args.limit}
-        try:
-            data = client.call('GET', 'v2/sentence', query=query, timeout=READ_TIMEOUT)
-        except ApiError as exc:
-            raise explain_api_error(exc, f'取 {book} 的段落')
-        collected.extend((data or {}).get('rows') or [])
-
-    def render():
-        if not collected:
-            print('这些坐标在指定 channel 下没有内容。')
-            print('注意:这是「该 channel 在此处没有文本」,不是「查询失败」——'
-                  '如实报告,不要拿相邻段落或别的译本凑。')
-            return
-        current = None
-        for r in collected:
-            ch = (r.get('channel') or {})
-            head = (r.get('book'), r.get('paragraph'), ch.get('uid'))
-            if head != current:
-                current = head
-                editor = (r.get('editor') or {})
-                who = editor.get('nickName') or editor.get('name') or ''
-                print(f'\n=== {fmt_coord(r.get("book"), r.get("paragraph"))}  '
-                      f'{ch.get("name")}({ch.get("lang")})'
-                      + (f'  作者:{who}' if who else '') + ' ===')
-            text = strip_markup(r.get('content'))
-            print(f'  [{r.get("word_start")}-{r.get("word_end")}] {text}')
-        print(f'\n共 {len(collected)} 句。')
-
-    emit(args, collected, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# toc —— 章节目录
-# ---------------------------------------------------------------------------
-
-
-def cmd_toc(args):
-    client = make_client(args)
-    book, para = parse_coord(args.coord)
-    try:
-        data = client.call('GET', 'v2/palitext', query={'view': 'book-toc', 'book': book, 'para': para},
-                           timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, f'取 {book}:{para} 的章节目录')
-    rows = (data or {}).get('rows') or []
-    # 服务端返回的是整套丛书的目录,默认只留当前这本,避免刷屏
-    shown = rows if args.all else [r for r in rows if r.get('book') == book]
-
-    def render():
-        print(f'{len(rows)} 条目录条目'
-              + ('' if args.all else f',其中 book {book} 有 {len(shown)} 条(--all 看整套丛书)'))
-        for r in shown:
-            level = int(r.get('level') or 1)
-            if level > args.depth:
-                continue
-            print(f'{"  " * (level - 1)}{r.get("book")}:{r.get("paragraph")}  {r.get("toc")}')
-
-    emit(args, shown, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# chapter —— 先报体量,再取整章
-# ---------------------------------------------------------------------------
-
-
-def fetch_meta(client, book, para):
-    try:
-        return client.call('GET', f'v2/palitext/{book}-{para}', timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, f'取 {book}:{para} 的段落元信息')
-
-
-def parse_path(raw):
-    """这个端点的 path 是 JSON 字符串,search 那边却是数组——两边都要能吃。"""
-    if isinstance(raw, str):
-        try:
-            return json.loads(raw)
-        except ValueError:
-            return []
-    return raw or []
-
-
-def resolve_chapter(client, book, para):
-    """给任意段号,向上找到它所属的章节节点。返回 (章节 meta, 走了几层)。
-
-    注意:正文段自己也带 chapter_len(值为 1),所以不能用「有没有这个字段」判断,
-    要看它是不是 > 1。向上一层优先取 path 的末项(那就是直接所属的章节),
-    没有 path 才退回 parent。
-    """
-    meta = fetch_meta(client, book, para)
-    hops = 0
-    while meta and int(meta.get('chapter_len') or 0) <= 1 and hops < 6:
-        up = None
-        path = parse_path(meta.get('path'))
-        if path:
-            last = path[-1]
-            if int(last.get('paragraph', -1)) != int(meta.get('paragraph', -1)):
-                up = int(last['paragraph'])
-        if up is None and meta.get('parent'):
-            up = int(meta['parent'])
-        if up is None:
-            break
-        meta = fetch_meta(client, book, up)
-        hops += 1
-    return meta, hops
-
-
-def cmd_chapter(args):
-    client = make_client(args)
-    book, para = parse_coord(args.coord)
-    meta, hops = resolve_chapter(client, book, para)
-    if not meta or not meta.get('chapter_len'):
-        raise WpError(f'{book}:{para} 向上找不到章节节点,无法确定章节范围。')
-
-    start = int(meta['paragraph'])
-    length = int(meta['chapter_len'])
-    strlen = int(meta.get('chapter_strlen') or 0)
-    end = start + length - 1
-    path = parse_path(meta.get('path'))
-    title = meta.get('toc') or meta.get('title') or (path[-1].get('title') if path else '')
-
-    print(f'章节   : {title}')
-    print(f'路径   : {fmt_path(path)}')
-    print(f'范围   : {book}:{start} – {book}:{end}({length} 段)'
-          + (f',约 {strlen} 字符' if strlen else ''))
-    if hops:
-        print(f'({book}:{para} 是正文段,向上 {hops} 层找到所属章节)')
-    if meta.get('prev_chapter') or meta.get('next_chapter'):
-        print(f'相邻   : 上一章 {book}:{meta.get("prev_chapter")}  下一章 {book}:{meta.get("next_chapter")}')
-
-    if not args.fetch:
-        print(f'\n只报体量,未取文。确认要读再加 --fetch;只要其中几段用:'
-              f'wikipali get {book}:{start} {book}:{start + 1} …')
-        return 0
-
-    if strlen > args.warn_at:
-        note(f'⚠ 本章约 {strlen} 字符,超过 {args.warn_at} 的提示阈值——注意上下文预算。')
-
-    if args.via == 'chapter-content':
-        return fetch_chapter_content(client, book, start, args)
-    return fetch_tipitaka_content(client, book, start, args)
-
-
-# tipitaka-content 返回的是一整串 HTML,每句包在 data-sid 里;sid 就是
-# book-para-wordStart-wordEnd,段落号从 sid 里就能取,不必解析外层的 data-para。
-SENTENCE_RE = re.compile(r"data-sid='([^']+)'\s*>(.*?)</div>", re.S)
-
-
-def fetch_tipitaka_content(client, book, para, args):
-    """整章取文:走 tipitaka-content(OpenSearch 预建文档)。
-
-    与 chapter-content 的区别:一次只取**一个** channel(参数是单数 channel),
-    返回已渲染好的 HTML 串而不是嵌套 JSON。没有该 channel 的预建文档时服务端
-    返回 400 且 message 里带 OpenSearch 的 found=false——那是「这一章没有该版本」,
-    不是服务故障,必须区分开。
-    """
-    query = {}
-    if args.channel:
-        if len(args.channel) > 1:
-            note('⚠ tipitaka-content 一次只接受一个 channel,已取第一个;'
-                 '要对读多个版本请分别调用。')
-        query['channel'] = args.channel[0]
-    try:
-        display = client.call('GET', f'v2/tipitaka-content/{book}-{para}', query=query,
-                              timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        if 'found' in str(exc) and 'false' in str(exc):
-            raise WpError(
-                f'{book}:{para} 这一章没有该版本的预建内容。\n'
-                '这是「该 channel 在本章无文本」,不是服务故障——如实报告,'
-                '不要拿别的版本顶替。\n'
-                f'用 wikipali versions {book}:{para} 看这一段实际有哪些版本。'
-            )
-        raise explain_api_error(exc, f'取 {book}:{para} 的整章内容')
-
-    if not isinstance(display, str):
-        raise WpError('整章内容的返回不是字符串,服务端返回形状可能变了。')
-
-    grouped = {}
-    order = []
-    for sid, body in SENTENCE_RE.findall(display):
-        text = strip_markup(body) if args.text else re.sub(r'\s+', ' ', body).strip()
-        if not text:
-            continue
-        try:
-            para_no = int(sid.split('-')[1])
-        except (IndexError, ValueError):
-            continue
-        if para_no not in grouped:
-            grouped[para_no] = []
-            order.append(para_no)
-        grouped[para_no].append({'id': sid, 'text': text})
-    out = [{'para': n, 'sentences': grouped[n]} for n in order]
-
-    def render():
-        total = sum(len(x['sentences']) for x in out)
-        src = f'channel {args.channel[0]}' if args.channel else '巴利原文'
-        if not total:
-            print(f'\n该版本在本章**没有句子内容**(服务端返回了文档但其中没有句子)。')
-            print(f'用 wikipali versions {book}:{para} 看这一段实际有哪些版本。')
-            return
-        print(f'\n{len(out)} 段 / {total} 句({src})')
-        for item in out:
-            print(f'\n## {book}:{item["para"]}')
-            for sent in item['sentences']:
-                print(f'  {sent["id"]}  {sent["text"]}')
-        print('\n句子 id 就是引用坐标(book-para-wordStart-wordEnd)。')
-
-    emit(args, out, render)
-    return 0
-
-
-def fetch_chapter_content(client, book, para, args):
-    """整章取文:走 chapter-content,一次拿回全章并按句对齐。
-
-    服务端返回的结构极厚(每句都带 channel / studio / editor / 各类计数,
-    24–36 KB),直接丢给模型是浪费。这里只留每句的 id 与 html——id 本身就是
-    可引用的坐标(book-para-wordStart-wordEnd),html 保留了 <strong> 黑体,
-    那是判断「这句是不是词条解释」的依据,不能丢。
-    """
-    query = {}
-    if args.channel:
-        query['channels'] = ','.join(args.channel)
-    try:
-        data = client.call('GET', f'v2/chapter-content/{book}-{para}', query=query,
-                           timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, f'取 {book}:{para} 的整章内容')
-
-    raw = (data or {}).get('content') or '[]'
-    try:
-        paragraphs = json.loads(raw) if isinstance(raw, str) else raw
-    except ValueError:
-        raise WpError('整章内容不是合法 JSON,服务端返回形状可能变了。')
-
-    wanted = set(args.channel or [])
-    out = []
-    placeholders = 0
-    for item in paragraphs:
-        sentences = []
-        for child in item.get('children') or []:
-            body = pick_body(child, wanted)
-            if body is None:
-                continue
-            if not body.strip():
-                # 请求的 channel 在这一句没有内容时,服务端仍返回等量的空占位条目。
-                # 照直输出会让人以为「有译文只是没显示」,必须滤掉并单独报数。
-                placeholders += 1
-                continue
-            sentences.append({
-                'id': child.get('id'),
-                'text': strip_markup(body) if args.text else body,
-            })
-        if sentences:
-            out.append({'para': int(item.get('para')), 'sentences': sentences})
-
-    def render():
-        total = sum(len(x['sentences']) for x in out)
-        src = f'channel {",".join(args.channel)}' if args.channel else '巴利原文'
-        if not total:
-            print(f'\n该 channel 在本章**没有任何内容**'
-                  + (f'(服务端返回了 {placeholders} 条空占位)' if placeholders else '') + '。')
-            print('如实报告「该译本在本章无文本」,不要拿别的版本或相邻章节顶替。')
-            print('用 wikipali versions <坐标> 看这一段实际有哪些译本。')
-            return
-        print(f'\n{len(out)} 段 / {total} 句({src})'
-              + (f' ⚠ 另有 {placeholders} 句该 channel 无内容,已略去' if placeholders else ''))
-        for item in out:
-            print(f'\n## {book}:{item["para"]}')
-            for sent in item['sentences']:
-                print(f'  {sent["id"]}  {sent["text"]}')
-        print('\n句子 id 就是引用坐标(book-para-wordStart-wordEnd)。')
-
-    emit(args, out, render)
-    return 0
-
-
-def pick_body(child, wanted_channels):
-    """取这一句要展示的正文:指定了 channel 就取该 channel 的译文,否则取原文。
-
-    **优先 content,为空才回退 html**——两者按 channel 类型互补:
-    - original(巴利原文)的 content 是空的,正文在 html 里(带 <strong> 黑体);
-    - nissaya 的 content 是 markdown 源码「巴利词= 缅文释义。」,既紧凑又保住了
-      「哪部分是巴利、哪部分是释义」这个区分;其 html 是同样内容的渲染结果,
-      体积十几倍且把两者拼在了一起。
-    """
-    sources = []
-    if wanted_channels:
-        for tran in child.get('translation') or []:
-            if ((tran.get('channel') or {}).get('id')) in wanted_channels:
-                sources.append(tran)
-        if not sources:
-            return None
-    else:
-        sources = child.get('origin') or []
-
-    for src in sources:
-        body = (src.get('content') or '').strip()
-        if body:
-            return body
-        html = (src.get('html') or '').strip()
-        if html:
-            return html
-    return ''  
-
-
-# ---------------------------------------------------------------------------
-# versions —— 某坐标有哪些译本,以及没有哪些
-# ---------------------------------------------------------------------------
-
-
-def cmd_versions(args):
-    client = make_client(args)
-    book, para = parse_coord(args.coord)
-    try:
-        data = client.call('GET', 'v2/channel',
-                           query={'view': 'paragraphs', 'book_id': book, 'para': para},
-                           timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        if exc.status and exc.status >= 500:
-            raise WpError(
-                f'查 {book}:{para} 的可用译本失败(HTTP {exc.status})。\n'
-                '稳定版站点上 channel?view=paragraphs 有已知缺陷,修复只在最新版代码上。\n'
-                '请切到最新版再试:wikipali endpoint next,或本次调用加 --api next。'
-            )
-        raise explain_api_error(exc, f'查 {book}:{para} 的可用译本')
-    rows = (data or {}).get('rows') or []
-
-    def render():
-        if not rows:
-            print(f'{book}:{para} 在任何 channel 下都没有内容。')
-            return
-        print(f'{book}:{para} 有 {len(rows)} 个 channel 存有内容:\n')
-        by_type = {}
-        for r in rows:
-            by_type.setdefault(r.get('type') or '?', []).append(r)
-        for typ in sorted(by_type):
-            print(f'  [{typ}]')
-            for r in sorted(by_type[typ], key=lambda x: str(x.get('lang'))):
-                name_l = (r.get('name') or '').lower()
-                ai = ' ⚠疑似机器译' if any(h in name_l for h in MACHINE_HINTS) else ''
-                print(f'    {str(r.get("lang")):<8} {str(r.get("name"))[:36]:<38} {r.get("uid")}{ai}')
-        langs = {str(r.get('lang')) for r in rows}
-        missing = [l for l in ('pali', 'my', 'zh-Hans', 'zh', 'en', 'th') if l not in langs]
-        if missing:
-            print(f'\n该段**没有**这些语言的内容:{", ".join(missing)}')
-            print('如实报告「无」,不要拿相邻段落或别的译本凑。')
-        print('\n标 ⚠疑似机器译 的按机器译文标注引用。**没标的不等于是人译**——'
-              '名字判断很脆弱,权威做法是 wikipali get 看作者是不是模型,见 conventions.md。')
-
-    emit(args, rows, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# count —— 词频合计
-# ---------------------------------------------------------------------------
-
-
-def cmd_count(args):
-    client = make_client(args)
-    out = []
-    for word in args.words:
-        rows = fetch_forms(client, word)
-        if not rows:
-            out.append({'word': word, 'found': False})
-            continue
-        top = rows[0]
-        forms = top.get('case') or []
-        out.append({
-            'word': word, 'found': True, 'lemma': top.get('word'),
-            'forms': len(forms),
-            'total': sum(int(f.get('count') or 0) for f in forms),
-            'bold': sum(int(f.get('bold') or 0) for f in forms),
-        })
-
-    def render():
-        print(f'{"词":<28}{"词根":<24}{"词形":>5}{"词次":>8}{"黑体":>7}')
-        for r in out:
-            if not r['found']:
-                print(f'{r["word"]:<28}{"(语料中未见)":<24}')
-                continue
-            print(f'{r["word"]:<28}{r["lemma"]:<24}{r["forms"]:>5}{r["total"]:>8}{r["bold"]:>7}')
-        print('\n这里数的是**词次**,不是段落数。段落数用 search 的 count。')
-
-    emit(args, out, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# terms —— 术语表(权威译名对照)
-# ---------------------------------------------------------------------------
-
-
-def cache_path(client, name):
-    """缓存文件按**站点分桶**存放。
-
-    线上四站共享同一个库,可以共用;但开发机(local)与任何自定义地址是**另一个
-    数据库**。不分桶的话,用过一次 --api local 之后,之后打线上会静默拿到开发机的
-    数据——看起来一切正常,数据却是错的。凭据早就是按桶存的,缓存同理。
-    """
-    import os
-    import re as _re
-    from creds import CREDS_DIR
-    bucket = _re.sub(r'[^A-Za-z0-9_.-]', '_', client.bucket_name)
-    return os.path.join(CREDS_DIR, 'cache', bucket, name)
-
-
-def cmd_terms(args):
-    import os
-    client = make_client(args)
-    path = cache_path(client, f'terms-{args.view}-{args.lang}.json')
-    rows = None
-    if os.path.exists(path) and not args.refresh:
-        try:
-            with open(path, encoding='utf-8') as fh:
-                rows = json.load(fh)
-        except (OSError, ValueError):
-            rows = None
-    if rows is None:
-        note('正在拉取术语表全表(服务端不支持按词查询,只能整表拉后本地过滤)…')
-        try:
-            data = client.call('GET', 'v2/term-vocabulary',
-                               query={'view': args.view, 'lang': args.lang}, timeout=120)
-        except ApiError as exc:
-            raise explain_api_error(exc, '取术语表')
-        rows = (data or {}).get('rows') or []
-        os.makedirs(os.path.dirname(path), exist_ok=True)
-        with open(path, 'w', encoding='utf-8') as fh:
-            json.dump(rows, fh, ensure_ascii=False)
-        note(f'已缓存 {len(rows)} 条到 {path}(--refresh 可强制更新)')
-
-    kw = (args.keyword or '').lower()
-    hits = [r for r in rows if kw in (r.get('word') or '').lower()] if kw else rows
-
-    def render():
-        if not hits:
-            print(f'术语表({args.view} / {args.lang},共 {len(rows)} 条)里没有含「{args.keyword}」的词条。')
-            print('注意:这只说明术语表没收录,不代表语料里没有这个词。')
-            return
-        print(f'{len(hits)} 条(全表 {len(rows)}):\n')
-        for r in hits[: args.limit]:
-            tag = f'  [{r["tag"]}]' if r.get('tag') else ''
-            other = f'  / {r["other_meaning"]}' if r.get('other_meaning') else ''
-            print(f'  {r.get("word"):<32} {r.get("meaning")}{other}{tag}')
-        if len(hits) > args.limit:
-            print(f'  …… 其余 {len(hits) - args.limit} 条(--limit 调整)')
-        print('\n术语表是**权威译名对照**,写译文或论文时的用词应与它一致;'
-              '与它不一致时要说明理由。')
-
-    emit(args, hits, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# related —— 本文 ↔ 义注 ↔ 复注的段落对应
-# ---------------------------------------------------------------------------
-
-
-def cmd_related(args):
-    client = make_client(args)
-    book, para = parse_coord(args.coord)
-    try:
-        data = client.call('GET', 'v2/related-paragraph', query={'book': book, 'para': para},
-                           timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        if exc.status and exc.status >= 500:
-            # 服务端在「查无关联」时会抛异常(修复已合并,未部署到稳定版站点)。
-            # 对使用者来说这多半就是「没有关联段落」,但不能替服务端断言,如实说明两种可能。
-            raise WpError(
-                f'查 {book}:{para} 的关联段落失败(HTTP {exc.status})。\n'
-                '最可能的原因是**该段没有关联段落**——稳定版站点在这种情况下会报 500,\n'
-                '服务端修复已合并但尚未部署。也可能是服务本身有问题。\n'
-                '两者无法从这里区分,**不要据此断言「该段有/没有注释」**;\n'
-                '可以换最新版试试:wikipali --api next related {0}:{1}'.format(book, para)
-            )
-        raise explain_api_error(exc, f'查 {book}:{para} 的关联段落')
-    rows = (data or {}).get('rows') or []
-
-    def render():
-        if not rows:
-            print(f'{book}:{para} 没有关联段落。')
-            print('约 2% 的段落没有 CST 锚点,这是正常结果,不是查询失败——'
-                  '如实报告,不要转而去注释书里搜关键词充数。')
-            return
-        print(f'{book}:{para} 关联到 {len(rows)} 部书:\n')
-        order = {'mūla': 0, 'aṭṭhakathā': 1, 'ṭīkā': 2}
-        rows.sort(key=lambda r: order.get(text_layer(r.get('tags')), 9))
-        for r in rows:
-            layer = text_layer(r.get('tags')) or '未标层次'
-            paras = r.get('para') or []
-            coords = ' '.join(f'{r.get("book")}:{p}' for p in paras[:8])
-            more = f' …共 {len(paras)} 段' if len(paras) > 8 else ''
-            here = '  ← 当前' if int(r.get('book', -1)) == book and para in paras else ''
-            print(f'  [{layer:<11}] {str(r.get("book_title_pali"))[:26]:<28}{here}')
-            print(f'      {coords}{more}')
-        first = rows[0]
-        print(f'\n取文:wikipali get {first.get("book")}:{(first.get("para") or [0])[0]}')
-        print('引用时必须标明层次——把义注的解释当成本文的说法是学术错误。')
-
-    emit(args, rows, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# articles / article / anthology —— 文章与文集
-# ---------------------------------------------------------------------------
-
-
-def cmd_articles(args):
-    client = make_client(args)
-    query = {'view': args.view, 'limit': args.limit, 'offset': args.offset}
-    if args.keyword:
-        query['search'] = args.keyword
-    if args.lang:
-        query['lang'] = args.lang
-    try:
-        data = client.call('GET', 'v2/article', query=query, timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, '列出文章')
-    rows = (data or {}).get('rows') or []
-
-    def render():
-        print(f'共 {(data or {}).get("count")} 篇,本页 {len(rows)}'
-              + (f'(关键词「{args.keyword}」)' if args.keyword else ''))
-        if not rows:
-            return
-        print()
-        for r in rows:
-            who = (r.get('editor') or {}).get('nickName') or ''
-            sub = f'  —— {r["subtitle"]}' if r.get('subtitle') else ''
-            print(f'  {str(r.get("lang")):<8} {str(r.get("title"))[:40]:<42}{sub}')
-            print(f'      {r.get("uid")}   {who}   {str(r.get("updated_at"))[:10]}')
-        print(f'\n读全文:wikipali article {rows[0].get("uid")}')
-
-    emit(args, rows, render)
-    return 0
-
-
-def cmd_article(args):
-    client = make_client(args)
-    try:
-        art = client.call('GET', f'v2/article/{args.uid}', timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, f'读文章 {args.uid}')
-    if not art:
-        raise WpError(f'读不到文章 {args.uid}。')
-
-    def render():
-        who = (art.get('editor') or {}).get('nickName') or ''
-        studio = (art.get('studio') or {}).get('nickName') or ''
-        print(f'# {art.get("title")}')
-        if art.get('subtitle'):
-            print(f'  {art["subtitle"]}')
-        print(f'  {art.get("lang")}  作者 {who}  studio {studio}  更新 {str(art.get("updated_at"))[:10]}')
-        print(f'  uid {art.get("uid")}\n')
-        body = art.get('content') or ''
-        if args.chars and len(body) > args.chars:
-            print(body[: args.chars])
-            print(f'\n……全文 {len(body)} 字符,此处截断(--chars 0 取全文)')
-        else:
-            print(body)
-        print('\n⚠ 文章是**二手研究**,不是原典。引用它的观点要标明作者,'
-              '不要把它的说法当成经律本身的说法。')
-
-    emit(args, art, render)
-    return 0
-
-
-def cmd_anthology(args):
-    client = make_client(args)
-    if args.uid:
-        try:
-            data = client.call('GET', f'v2/anthology/{args.uid}', timeout=READ_TIMEOUT)
-        except ApiError as exc:
-            raise explain_api_error(exc, f'读文集 {args.uid}')
-        arts = (data or {}).get('article_list') or []
-
-        def render():
-            print(f'# {data.get("title")}   {data.get("lang")}')
-            if data.get('summary'):
-                print(f'  {data["summary"]}')
-            print(f'  {len(arts)} 篇文章\n')
-            for a in arts[: args.limit]:
-                if isinstance(a, dict):
-                    print(f'  {str(a.get("title"))[:44]:<46} {a.get("uid")}')
-                else:
-                    print(f'  {a}')
-        emit(args, data, render)
-        return 0
-
-    try:
-        data = client.call('GET', 'v2/anthology',
-                           query={'view': args.view, 'limit': args.limit, 'offset': args.offset},
-                           timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, '列出文集')
-    rows = (data or {}).get('rows') or []
-
-    def render():
-        print(f'共 {(data or {}).get("count")} 个文集,本页 {len(rows)}\n')
-        for r in rows:
-            print(f'  {str(r.get("lang")):<8} {str(r.get("title"))[:40]:<42} '
-                  f'{r.get("childrenNumber")} 篇')
-            print(f'      {r.get("uid")}')
-        if rows:
-            print(f'\n看目录:wikipali anthology {rows[0].get("uid")}')
-
-    emit(args, rows, render)
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# books —— 分类目录:按 tag 找书
-# ---------------------------------------------------------------------------
-
-
-def fetch_books(client, refresh=False):
-    """书目清单整表拉一次缓存在本地。服务端也缓存 24 小时,这里再缓存一层是为了
-    让按 tag 筛选变成本地操作——281 条全量在手,筛什么都不用再请求。"""
-    import os
-    path = cache_path(client, 'book-titles.json')
-    if os.path.exists(path) and not refresh:
-        try:
-            with open(path, encoding='utf-8') as fh:
-                return json.load(fh)
-        except (OSError, ValueError):
-            pass
-    try:
-        data = client.call('GET', 'v2/book-title', timeout=READ_TIMEOUT)
-    except ApiError as exc:
-        raise explain_api_error(exc, '取书目清单')
-    rows = (data or {}).get('rows') or []
-    if rows and 'tags' not in rows[0]:
-        raise WpError(
-            '该站点返回的书目清单里没有 tags/toc 字段——服务端版本较旧,'
-            '分类目录功能尚未上线。\n'
-            '可以换最新版试试:wikipali --api next books …'
-        )
-    os.makedirs(os.path.dirname(path), exist_ok=True)
-    with open(path, 'w', encoding='utf-8') as fh:
-        json.dump(rows, fh, ensure_ascii=False)
-    return rows
-
-
-def cmd_books(args):
-    client = make_client(args)
-    rows = fetch_books(client, refresh=args.refresh)
-
-    if args.tag_list:
-        counter = {}
-        for r in rows:
-            for t in r.get('tags') or []:
-                counter[t] = counter.get(t, 0) + 1
-
-        def render_tags():
-            print(f'{len(counter)} 个 tag(后面是有该 tag 的书数):\n')
-            for name, n in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))[: args.limit]:
-                print(f'  {n:>4}  {name}')
-            print('\n多个 tag 用逗号连接是**且**的关系:'
-                  'wikipali books --tags dīghanikāya,ṭīkā')
-        emit(args, counter, render_tags)
-        return 0
-
-    hits = rows
-    if args.tags:
-        want = [t.strip() for t in args.tags.split(',') if t.strip()]
-        hits = [r for r in hits if all(t in (r.get('tags') or []) for t in want)]
-    if args.keyword:
-        kw = args.keyword.lower()
-        hits = [r for r in hits
-                if kw in str(r.get('title', '')).lower() or kw in str(r.get('toc', '')).lower()]
-
-    def render():
-        scope = []
-        if args.tags:
-            scope.append(f'tags={args.tags}')
-        if args.keyword:
-            scope.append(f'关键词={args.keyword}')
-        print(f'{len(hits)} 部书(全部 {len(rows)} 部)'
-              + (f'  [{" ".join(scope)}]' if scope else ''))
-        if not hits:
-            print('\n没有匹配的书。用 --tag-list 看有哪些 tag;多个 tag 之间是「且」。')
-            return
-        print()
-        for r in hits[: args.limit]:
-            cs = f'  {r["related_name"]}' if r.get('related_name') else ''
-            print(f'  {r.get("book")}:{r.get("paragraph"):<6} {str(r.get("toc"))[:38]:<40}{cs}')
-            if args.show_tags:
-                print(f'      {" ".join(r.get("tags") or [])}')
-        if len(hits) > args.limit:
-            print(f'  …… 其余 {len(hits) - args.limit} 部(--limit 调整)')
-        if hits:
-            first = hits[0]
-            print(f'\n看某本书的章节:wikipali toc {first.get("book")}:{first.get("paragraph")}')
-
-    emit(args, hits, render)
-    return 0

+ 0 - 99
plugins/wikipali/lib/cmd_site.py

@@ -1,99 +0,0 @@
-"""与站点和凭据状态有关的子命令:endpoint / whoami。"""
-
-import sys
-import time
-
-from client import fmt_ts, make_client, mask, note, token_expiry
-from creds import bucket_name_for, get_bucket, resolve_api_url, save_creds, load_creds, CREDS_PATH
-from errors import ApiError, WpError, explain_api_error
-from sites import SITES, expand_site_alias, normalize_api_url, site_label
-
-
-def cmd_endpoint(args):
-    creds = load_creds()
-    current_url, source = resolve_api_url(getattr(args, "api", None), creds)
-
-    if not args.target:
-        for idx, site in enumerate(SITES, 1):
-            mark = "  ← 当前" if site["url"] == current_url else ""
-            print(f"  {idx}) {site['url']:<32} {site['version']} · {site['domain']}{mark}")
-        if source in ("cli", "env"):
-            src = "--api" if source == "cli" else "WIKIPALI_API_URL"
-            note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
-        if current_url not in [s["url"] for s in SITES]:
-            print(f"  *) {current_url:<32} 自定义地址  ← 当前")
-        keys = "|".join(x["key"] for x in SITES)
-
-        # 只有在真正的终端里才提示选择。agent 的 Bash 调用、CI、管道都没有 tty,
-        # 那里必须保持纯展示——否则会卡在等输入上,且没人看得到提示符。
-        if args.list or not sys.stdin.isatty():
-            print(f"\n切换:wikipali endpoint <序号|{keys}|完整url>")
-            return 0
-
-        print(f"\n输入序号切换,直接回车不改(也可以 wikipali endpoint <{keys}|完整url>)")
-        try:
-            raw = input("选择:").strip()
-        except (EOFError, KeyboardInterrupt):
-            print()
-            return 0
-        if not raw:
-            print("未改动。")
-            return 0
-        args.target = raw
-
-    url = normalize_api_url(expand_site_alias(args.target))
-    name = bucket_name_for(url)
-    bucket = get_bucket(creds, name, url)
-    bucket["api_url"] = url
-    creds["current"] = name
-    save_creds(creds)
-    print(f"已切换到 {url}({site_label(url)})")
-    if name != "online":
-        note("提示:该地址是**另一个数据库**,与线上四站不共用数据与密钥。"
-             "凭据和本地缓存都是独立的一桶,可能需要在该站点重新登录。")
-    return 0
-
-
-def cmd_whoami(args):
-    client = make_client(args)
-    print(f"API      : {client.api_note()}")
-    print(f"凭据文件 : {CREDS_PATH}(桶:{client.bucket_name})")
-
-    user = client.bucket.get("user") or {}
-    if user.get("token"):
-        exp = token_expiry(user["token"])
-        expired = exp is not None and exp < time.time()
-        print(f"用户     : {user.get('username', '?')}  uid={user.get('uid', '?')}")
-        print(f"           token {mask(user['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
-    else:
-        print("用户     : 未登录(wikipali-login)")
-
-    model = client.bucket.get("model") or {}
-    if model.get("token"):
-        exp = token_expiry(model["token"])
-        expired = exp is not None and exp < time.time()
-        print(f"模型     : {model.get('name', '?')}  uid={model.get('uid', '?')}")
-        print(f"           token {mask(model['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
-    else:
-        print("模型     : 未建立(wikipali ensure-model --name <模型名>)")
-
-    tokens = client.bucket.get("access_tokens") or {}
-    if tokens:
-        print("access token:")
-        for uid, item in tokens.items():
-            exp = item.get("exp") or token_expiry(item.get("token", ""))
-            expired = exp is not None and exp < time.time()
-            book = item.get("book", 0)
-            scope = "全部 book" if book == 0 else f"book {book}"
-            name = item.get("channel_name") or ""
-            print(f"  {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
-    else:
-        print("access token:无(wikipali grant <channel>)")
-
-    if args.check:
-        try:
-            data = client.call("GET", "v2/auth/current", token=client.user_token)
-        except ApiError as exc:
-            raise explain_api_error(exc, "校验用户 token")
-        print(f"\n服务端确认:{data.get('nickName')} / realName={data.get('realName')}(studio_name 用它)")
-    return 0

+ 0 - 465
plugins/wikipali/lib/cmd_write.py

@@ -1,465 +0,0 @@
-"""写入路径的子命令:ensure-model / revoke / channels / grant / write。"""
-
-import json
-import sys
-import time
-from datetime import datetime, timezone
-
-from client import (WRITE_TIMEOUT, TOKEN_REFRESH_MARGIN, DEFAULT_BATCH,
-                    fmt_ts, make_client, mask, note, token_expiry)
-from errors import ApiError, WpError, explain_api_error
-
-
-def cmd_ensure_model(args):
-    client = make_client(args)
-    token = client.user_token
-
-    name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
-    if not name:
-        raise WpError(
-            "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
-            "该名字会成为句子的作者署名,不要用别的模型的名字。"
-        )
-
-    try:
-        current = client.call("GET", "v2/auth/current", token=token)
-    except ApiError as exc:
-        raise explain_api_error(exc, "取当前用户信息")
-    studio_name = current.get("realName")
-    if not studio_name:
-        raise WpError("服务端没有返回 realName,无法确定 studio_name。")
-
-    # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
-    try:
-        listed = client.call(
-            "GET", "v2/ai-model", token=token,
-            query={"view": "studio", "name": studio_name, "keyword": name},
-        )
-    except ApiError as exc:
-        raise explain_api_error(exc, "查询模型列表")
-    rows = (listed or {}).get("rows") or []
-    found = next((r for r in rows if r.get("name") == name), None)
-
-    if found:
-        print(f"已存在模型记录:{name}  uid={found['uid']}")
-    else:
-        body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
-        for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
-            if value is not None:
-                body[field] = value
-        try:
-            found = client.call("POST", "v2/ai-model", token=token, body=body)
-            print(f"已创建模型记录:{name}  uid={found['uid']}")
-        except ApiError as exc:
-            if exc.status != 409:
-                raise explain_api_error(exc, "创建模型记录")
-            # 并发或模糊匹配漏网:重查一次
-            listed = client.call(
-                "GET", "v2/ai-model", token=token,
-                query={"view": "studio", "name": studio_name, "keyword": name},
-            )
-            rows = (listed or {}).get("rows") or []
-            found = next((r for r in rows if r.get("name") == name), None)
-            if not found:
-                raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
-            print(f"已存在模型记录:{name}  uid={found['uid']}")
-
-    # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
-    patch = {}
-    for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
-        if value is not None and found.get(field) != value:
-            patch[field] = value
-    if args.privacy and found.get("privacy") != args.privacy:
-        patch["privacy"] = args.privacy
-    if patch:
-        try:
-            found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
-            print(f"已更新字段:{', '.join(patch)}")
-        except ApiError as exc:
-            raise explain_api_error(exc, "更新模型记录")
-
-    # 3) 取模型身份 token
-    try:
-        issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
-    except ApiError as exc:
-        raise explain_api_error(exc, "签发模型身份 token")
-
-    client.bucket["model"] = {
-        "uid": issued["uid"],
-        "name": issued["name"],
-        "token": issued["token"],
-        "issued_at": iso_now(),
-    }
-    client.save()
-    exp = token_expiry(issued["token"])
-    print(f"模型身份 token 已缓存:{mask(issued['token'])}  到期 {fmt_ts(exp)}")
-    print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
-    return 0
-
-
-def cmd_revoke(args):
-    client = make_client(args)
-    model = client.bucket.get("model") or {}
-    uid = args.uid or model.get("uid")
-    if not uid:
-        raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
-    if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
-        print("已取消。")
-        return 1
-    try:
-        data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
-    except ApiError as exc:
-        raise explain_api_error(exc, "撤销模型 token")
-    if model.get("uid") == uid:
-        client.bucket["model"] = {"uid": uid, "name": model.get("name")}
-        client.save()
-    print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
-    print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
-    return 0
-
-
-def fetch_channels(client, search=None):
-    try:
-        data = client.call(
-            "GET", "v2/channel", token=client.user_token,
-            query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
-        )
-    except ApiError as exc:
-        raise explain_api_error(exc, "获取可编辑 channel 列表")
-    return (data or {}).get("rows") or []
-
-
-def cmd_channels(args):
-    client = make_client(args)
-    rows = fetch_channels(client, args.search)
-    if args.json:
-        print(json.dumps(rows, ensure_ascii=False, indent=2))
-        return 0
-    if not rows:
-        print("当前账号没有任何可编辑的 channel。")
-        return 1
-    print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
-    for idx, ch in enumerate(rows, 1):
-        print(
-            f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
-            f"{ch.get('uid', '')[:8]}…  {ch.get('role', '')}"
-        )
-    return 0
-
-
-def pick_channel(client, given, interactive=True):
-    """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
-    rows = fetch_channels(client)
-    if not rows:
-        raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
-
-    if given:
-        for ch in rows:
-            if ch.get("uid") == given:
-                return ch["uid"], ch.get("name")
-        if given.isdigit() and 1 <= int(given) <= len(rows):
-            ch = rows[int(given) - 1]
-            return ch["uid"], ch.get("name")
-        matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
-        if len(matched) == 1:
-            return matched[0]["uid"], matched[0].get("name")
-        if len(matched) > 1:
-            names = ", ".join(c.get("name", "") for c in matched[:5])
-            raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
-        # 不在可编辑列表里的 uid:直接用,但回显不出名字
-        if len(given) >= 32:
-            note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
-            return given, None
-        raise WpError(f"找不到 channel:{given}")
-
-    if not (interactive and sys.stdin.isatty()):
-        raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wikipali channels` 再用 --channel 指定。")
-
-    print("可编辑 channel:")
-    for idx, ch in enumerate(rows, 1):
-        print(f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
-    raw = input("选择序号:").strip()
-    if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
-        raise WpError("输入无效。")
-    ch = rows[int(raw) - 1]
-    return ch["uid"], ch.get("name")
-
-
-def cached_access_token(client, channel_uid, book):
-    item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
-    if not item or not item.get("token"):
-        return None
-    # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
-    if item.get("book", 0) != 0 and item.get("book") != book:
-        return None
-    exp = item.get("exp") or token_expiry(item["token"])
-    if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
-        return None
-    return item
-
-
-def grant_access_token(client, channel_uid, channel_name, book, force=False):
-    if not force:
-        cached = cached_access_token(client, channel_uid, book)
-        if cached:
-            return cached
-
-    # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
-    payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
-    try:
-        data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
-    except ApiError as exc:
-        raise explain_api_error(exc, "签发 access token")
-    rows = (data or {}).get("rows") or []
-    if not rows:
-        # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
-        raise WpError(
-            f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
-            "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
-        )
-    row = rows[0]
-    item = {
-        "token": row["token"],
-        "book": int(book),
-        "exp": (row.get("payload") or {}).get("exp"),
-        "granted_at": iso_now(),
-    }
-    if channel_name:
-        item["channel_name"] = channel_name
-    client.bucket.setdefault("access_tokens", {})[channel_uid] = item
-    client.save()
-    return item
-
-
-def cmd_grant(args):
-    client = make_client(args)
-    uid, name = pick_channel(client, args.channel)
-    item = grant_access_token(client, uid, name, args.book, force=args.force)
-    scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
-    print(f"channel : {name or '(未知)'}  {uid}")
-    print(f"范围    : {scope}")
-    print(f"token   : {mask(item['token'])}  到期 {fmt_ts(item.get('exp'))}")
-    return 0
-
-
-SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
-
-
-def load_sentences(args):
-    if args.file == "-":
-        raw = sys.stdin.read()
-    else:
-        try:
-            with open(args.file, "r", encoding="utf-8") as fh:
-                raw = fh.read()
-        except OSError as exc:
-            raise WpError(f"读不了输入文件:{exc}")
-    try:
-        data = json.loads(raw)
-    except ValueError as exc:
-        raise WpError(f"输入不是合法 JSON:{exc}")
-
-    default_channel = None
-    if isinstance(data, dict):
-        default_channel = data.get("channel_uid") or data.get("channel")
-        data = data.get("sentences")
-    if not isinstance(data, list) or not data:
-        raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
-    return data, default_channel
-
-
-def normalize_sentences(rows, channel_uid, default_content_type):
-    out = []
-    for idx, row in enumerate(rows):
-        if not isinstance(row, dict):
-            raise WpError(f"第 {idx + 1} 条不是对象。")
-        missing = [f for f in SENT_REQUIRED if row.get(f) is None]
-        if missing:
-            raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
-        try:
-            sent = {
-                "book_id": int(row["book_id"]),
-                "paragraph": int(row["paragraph"]),
-                "word_start": int(row["word_start"]),
-                "word_end": int(row["word_end"]),
-                "content": str(row["content"]),
-                "content_type": row.get("content_type") or default_content_type,
-                "channel_uid": row.get("channel_uid") or channel_uid,
-            }
-        except (TypeError, ValueError) as exc:
-            raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
-        if not sent["channel_uid"]:
-            raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
-        out.append(sent)
-    return out
-
-
-def sent_key(sent):
-    return (
-        int(sent["book_id"]),
-        int(sent["paragraph"]),
-        int(sent["word_start"]),
-        int(sent["word_end"]),
-        sent["channel_uid"],
-    )
-
-
-def row_key(row):
-    channel = row.get("channel") or {}
-    return (
-        int(row.get("book", -1)),
-        int(row.get("paragraph", -1)),
-        int(row.get("word_start", -1)),
-        int(row.get("word_end", -1)),
-        channel.get("uid"),
-    )
-
-
-def confirm(question):
-    if not sys.stdin.isatty():
-        return False
-    answer = input(f"{question} [y/N] ").strip().lower()
-    return answer in ("y", "yes")
-
-
-def iso_now():
-    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
-
-
-def cmd_write(args):
-    client = make_client(args)
-    # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
-    client.user_token
-    model = client.model
-    rows, file_channel = load_sentences(args)
-
-    channel_hint = args.channel or file_channel
-    uid = name = None
-    if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
-        uid, name = pick_channel(client, channel_hint)
-    sentences = normalize_sentences(rows, uid, args.content_type)
-
-    channels = sorted({s["channel_uid"] for s in sentences})
-    books = sorted({s["book_id"] for s in sentences})
-    names = {uid: name} if uid else {}
-    for cuid in channels:
-        if cuid not in names:
-            names[cuid] = channel_display_name(client, cuid)
-
-    # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
-    print("=" * 72)
-    print(f"API      : {client.api_note()}")
-    for cuid in channels:
-        print(f"channel  : {names.get(cuid) or '(未知)'}  {cuid}")
-    print(f"book     : {', '.join(str(b) for b in books)}")
-    print(f"模型身份 : {model.get('name')}  uid={model.get('uid')}")
-    print(f"句子数   : {len(sentences)}(每批 {args.batch})")
-    print("-" * 72)
-    for sent in sentences[: args.preview]:
-        summary = sent["content"].replace("\n", " ")
-        if len(summary) > 50:
-            summary = summary[:50] + "…"
-        print(f"  {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']}  {summary}")
-    if len(sentences) > args.preview:
-        print(f"  …… 其余 {len(sentences) - args.preview} 条")
-    print("-" * 72)
-    print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
-    print("=" * 72)
-
-    if args.dry_run:
-        print("--dry-run:未发送任何请求。")
-        return 0
-    if not args.yes and not confirm("确认写入?"):
-        print("已取消,未写入任何内容。")
-        return 1
-
-    # 每个 channel 一张 access token(缓存命中就不重签)
-    tokens = {}
-    for cuid in channels:
-        book_scope = 0 if len(books) > 1 else books[0]
-        if args.book is not None:
-            book_scope = args.book
-        item = grant_access_token(client, cuid, names.get(cuid), book_scope)
-        tokens[cuid] = item["token"]
-
-    written = {}
-    failed = []
-    model_token = model["token"]
-    for start in range(0, len(sentences), args.batch):
-        batch = sentences[start : start + args.batch]
-        body = {
-            "sentences": [
-                {
-                    "book_id": s["book_id"],
-                    "paragraph": s["paragraph"],
-                    "word_start": s["word_start"],
-                    "word_end": s["word_end"],
-                    "channel_uid": s["channel_uid"],
-                    "content": s["content"],
-                    "content_type": s["content_type"],
-                    "access_token": tokens[s["channel_uid"]],
-                }
-                for s in batch
-            ]
-        }
-        try:
-            data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
-        except ApiError as exc:
-            if exc.status != 401:
-                raise explain_api_error(exc, "写入句子")
-            # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
-            model_token = refresh_model_token(client)
-            try:
-                data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
-            except ApiError as retry_exc:
-                raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
-        returned = (data or {}).get("rows") or []
-        for row in returned:
-            written[row_key(row)] = row
-        got = len(returned)
-        print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
-        if got < len(batch):
-            # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
-            for s in batch:
-                if sent_key(s) not in written:
-                    failed.append(s)
-
-    print("-" * 72)
-    print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
-    sample = next(iter(written.values()), None)
-    if sample:
-        editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
-        print(f"署名核对:第一条的 editor = {editor}")
-    if failed:
-        print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
-        for s in failed[:10]:
-            print(f"  {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']}  channel={s['channel_uid'][:8]}…")
-        if len(failed) > 10:
-            print(f"  …… 其余 {len(failed) - 10} 条")
-        return 1
-    return 0
-
-
-def channel_display_name(client, uid):
-    try:
-        data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
-    except (ApiError, WpError):
-        return None
-    if isinstance(data, dict):
-        return data.get("name")
-    return None
-
-
-def refresh_model_token(client):
-    note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
-    model = client.bucket.get("model") or {}
-    if not model.get("uid"):
-        raise WpError("缓存里没有模型 uid,无法重签。请跑:wikipali ensure-model --name <模型名>")
-    try:
-        issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
-    except ApiError as exc:
-        raise explain_api_error(exc, "重新签发模型 token")
-    model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
-    client.bucket["model"] = model
-    client.save()
-    return issued["token"]

+ 0 - 61
plugins/wikipali/lib/coords.py

@@ -1,61 +0,0 @@
-"""坐标与引用。
-
-WikiPali 的最小可引用单位是 (book, paragraph),句子再细分到 word_start/word_end。
-一切给用户看的内容都必须带得回坐标——这是研究型产出可信度的地基。
-
-坐标的书写形式统一为 `book:paragraph`,例如 `216:35`。
-"""
-
-import re
-
-from errors import WpError
-
-COORD_RE = re.compile(r'^\s*(\d+)\s*[:\-_]\s*(\d+)\s*$')
-
-
-def parse_coord(text):
-    """把 '216:35' 解析成 (216, 35)。也接受 216-35 / 216_35。"""
-    m = COORD_RE.match(str(text))
-    if not m:
-        raise WpError(f"坐标格式不对:{text}(应为 book:paragraph,如 216:35)")
-    return int(m.group(1)), int(m.group(2))
-
-
-def parse_coords(items):
-    """解析一串坐标,按 book 分组,返回 {book: [paragraph, ...]}(去重、保序)。"""
-    grouped = {}
-    for item in items:
-        for part in str(item).split(','):
-            if not part.strip():
-                continue
-            book, para = parse_coord(part)
-            paras = grouped.setdefault(book, [])
-            if para not in paras:
-                paras.append(para)
-    return grouped
-
-
-def fmt_coord(book, paragraph):
-    return f"{book}:{paragraph}"
-
-
-def fmt_path(path, sep=' › ', max_items=4):
-    """把检索结果的 path 数组压成一行章节路径。"""
-    if not path:
-        return ''
-    titles = [p.get('title', '') for p in path if isinstance(p, dict) and p.get('title')]
-    if len(titles) > max_items:
-        titles = [titles[0], '…'] + titles[-(max_items - 2):]
-    return sep.join(titles)
-
-
-def text_layer(tags):
-    """按 tags 判断文献层次:本文 / 义注 / 复注。引用时必须标明,混用是学术错误。"""
-    names = {t.get('name') for t in (tags or []) if isinstance(t, dict)}
-    if 'ṭīkā' in names:
-        return 'ṭīkā'
-    if 'aṭṭhakathā' in names:
-        return 'aṭṭhakathā'
-    if 'mūla' in names:
-        return 'mūla'
-    return ''

+ 0 - 84
plugins/wikipali/lib/creds.py

@@ -1,84 +0,0 @@
-"""凭据文件:~/.wikipali/credentials.json(0600)。
-
-只存 token,不存密码。线上四地址共用 online 桶,开发机 local,其余自成一桶。
-"""
-
-import json
-import os
-import stat
-
-from errors import WpError
-from sites import BUCKET_BY_URL, DEFAULT_API_URL, LOCAL_URL, expand_site_alias, normalize_api_url
-
-
-CREDS_DIR = os.path.join(os.path.expanduser("~"), ".wikipali")
-CREDS_PATH = os.path.join(CREDS_DIR, "credentials.json")
-
-
-def load_creds():
-    if not os.path.exists(CREDS_PATH):
-        return {"current": "online"}
-    try:
-        with open(CREDS_PATH, "r", encoding="utf-8") as fh:
-            data = json.load(fh)
-    except (OSError, ValueError) as exc:
-        raise WpError(f"凭据文件无法读取({CREDS_PATH}):{exc}")
-    if not isinstance(data, dict):
-        raise WpError(f"凭据文件格式不对({CREDS_PATH}),应为 JSON 对象")
-    data.setdefault("current", "online")
-    return data
-
-
-def save_creds(creds):
-    os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
-    tmp = CREDS_PATH + ".tmp"
-    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
-    fd = os.open(tmp, flags, 0o600)
-    try:
-        with os.fdopen(fd, "w", encoding="utf-8") as fh:
-            json.dump(creds, fh, ensure_ascii=False, indent=2)
-            fh.write("\n")
-    except Exception:
-        os.unlink(tmp)
-        raise
-    os.replace(tmp, CREDS_PATH)
-    os.chmod(CREDS_PATH, stat.S_IRUSR | stat.S_IWUSR)
-
-
-def bucket_name_for(api_url):
-    """凭据(与本地缓存)的桶名。
-
-    只有线上四个地址共享同一个库与密钥,共用 online 桶;staging、开发机、以及任何
-    自定义地址都是**另一个库**,各自一桶——混用会让凭据失效,更糟的是让查询静默
-    落到错误的数据上。
-    """
-    known = BUCKET_BY_URL.get(api_url)
-    if known:
-        return known
-    return "site:" + api_url
-
-
-def get_bucket(creds, name, api_url=None):
-    bucket = creds.setdefault(name, {})
-    bucket.setdefault("api_url", api_url or (DEFAULT_API_URL if name == "online" else LOCAL_URL))
-    bucket.setdefault("user", {})
-    bucket.setdefault("model", {})
-    bucket.setdefault("access_tokens", {})
-    return bucket
-
-
-def resolve_api_url(cli_api, creds):
-    """地址来源优先级:--api > 环境变量 > 凭据文件 > 内置默认。
-
-    前两者是一次性覆盖,不写回凭据文件——否则「上周试了一次 next」会一直粘着。
-    """
-    if cli_api:
-        return normalize_api_url(expand_site_alias(cli_api)), "cli"
-    env = os.environ.get("WIKIPALI_API_URL")
-    if env:
-        return normalize_api_url(expand_site_alias(env)), "env"
-    current = creds.get("current", "online")
-    bucket = creds.get(current)
-    if isinstance(bucket, dict) and bucket.get("api_url"):
-        return normalize_api_url(bucket["api_url"]), "creds"
-    return DEFAULT_API_URL, "default"

+ 0 - 38
plugins/wikipali/lib/errors.py

@@ -1,38 +0,0 @@
-"""面向用户的错误类型,以及把 HTTP 状态翻译成人话。"""
-
-
-class WpError(Exception):
-    """面向用户的错误:main() 捕获后只打印 message,不打印堆栈。"""
-
-
-class ApiError(WpError):
-    def __init__(self, status, message, url=None, body=None):
-        self.status = status
-        self.url = url
-        self.body = body
-        super().__init__(message)
-
-
-def explain_api_error(exc, what):
-    """把 HTTP 状态翻译成对操作者有意义的话(见 references/api.md 的错误约定)。"""
-    if exc.status == 401:
-        return WpError(
-            f"{what}:401 凭据失效或已被撤销。\n"
-            "  · 用户 token 失效 → 重新登录:wikipali-login\n"
-            "  · 模型 token 失效或被撤销 → 重跑:wikipali ensure-model\n"
-            "  不要自动重试。"
-        )
-    if exc.status == 403:
-        return WpError(f"{what}:403 无权限(不是 channel 的 owner/协作者,或不是模型 owner 本人)。")
-    if exc.status == 404:
-        return WpError(
-            f"{what}:404。三种可能,别只当成「资源不存在」:\n"
-            "  1. 该端点尚未部署到当前站点——各站代码版本不同,用 wikipali endpoint 换一个再试;\n"
-            "  2. **已经在最新版站点上仍 404**,说明它还没部署到任何站点,或本就没有这个端点;\n"
-            "  3. 才是资源真的不存在。"
-        )
-    if exc.status == 409:
-        return WpError(f"{what}:409 同名记录已存在。")
-    if exc.status == 422:
-        return WpError(f"{what}:422 参数校验失败——{exc}")
-    return WpError(f"{what}:HTTP {exc.status} {exc}")

+ 0 - 71
plugins/wikipali/lib/sites.py

@@ -1,71 +0,0 @@
-"""站点清单与地址解析。
-
-四个线上地址共享同一个数据库和同一把 jwt 密钥,凭据完全通用;
-.org / .cc 是地区可达性,www / next 是代码版本(不是数据环境)。
-开发机是另一个库、另一把密钥,故单独一桶,且永不作为自动 fallback 目标。
-"""
-
-import urllib.parse
-
-from errors import WpError
-
-
-# bucket 决定两件事:凭据/缓存存哪一桶,以及能不能作为自动 fallback 的目标。
-# 只有 bucket == "online" 的四个地址共享同一个数据库与 jwt 密钥,彼此可以互替。
-# staging 与开发机各是**另一个库**——实测 staging 的公开 channel 数与线上不同,
-# 同名 channel 的 uid 也不同——所以自成一桶,且绝不参与 fallback。
-SITES = [
-    {"key": "www", "url": "https://www.wikipali.org/api",
-     "version": "稳定版", "domain": ".org", "bucket": "online"},
-    {"key": "www.cc", "url": "https://www.wikipali.cc/api",
-     "version": "稳定版", "domain": ".cc", "bucket": "online"},
-    {"key": "next", "url": "https://next.wikipali.org/api",
-     "version": "最新版", "domain": ".org", "bucket": "online"},
-    {"key": "next.cc", "url": "https://next.wikipali.cc/api",
-     "version": "最新版", "domain": ".cc", "bucket": "online"},
-    {"key": "staging", "url": "https://staging.wikipali.org/api",
-     "version": "预发布", "domain": "独立库", "bucket": "staging"},
-    {"key": "local", "url": "http://127.0.0.1:8000/api",
-     "version": "开发机", "domain": "独立库", "bucket": "local"},
-]
-
-ONLINE_URLS = [s["url"] for s in SITES if s["bucket"] == "online"]
-LOCAL_URL = next(s["url"] for s in SITES if s["key"] == "local")
-BUCKET_BY_URL = {s["url"]: s["bucket"] for s in SITES}
-DEFAULT_API_URL = SITES[0]["url"]
-
-
-def normalize_api_url(url):
-    url = url.rstrip("/")
-    parsed = urllib.parse.urlparse(url)
-    if parsed.scheme not in ("http", "https"):
-        raise WpError(f"API 地址必须以 http:// 或 https:// 开头:{url}")
-    host = (parsed.hostname or "").lower()
-    if parsed.scheme == "http" and host not in ("127.0.0.1", "localhost", "::1"):
-        raise WpError(f"只有 127.0.0.1 / localhost 允许用 http://,其余必须 https://:{url}")
-    return url
-
-
-def expand_site_alias(value):
-    """把序号 / 简称展开成完整 url;已是 url 则原样返回。"""
-    value = value.strip()
-    if value.isdigit():
-        idx = int(value) - 1
-        if 0 <= idx < len(SITES):
-            return SITES[idx]["url"]
-        raise WpError(f"站点序号超出范围:{value}(可选 1-{len(SITES)})")
-    for site in SITES:
-        if value == site["key"]:
-            return site["url"]
-    if "://" in value:
-        return value
-    raise WpError(
-        f"无法识别的站点:{value}。可用简称:" + " / ".join(s["key"] for s in SITES) + ",或直接给完整 url"
-    )
-
-
-def site_label(api_url):
-    for site in SITES:
-        if site["url"] == api_url:
-            return f"{site['version']} · {site['domain']}"
-    return "自定义地址"

+ 0 - 242
plugins/wikipali/references/api-read.md

@@ -1,242 +0,0 @@
-# WikiPali API 参考(检索与阅读)
-
-读端**全部不需要凭据**。响应统一是 `{ ok, data, message }`。
-坐标、引用、层次等通用规矩见 `conventions.md`。
-
-## 1. 词形展开 —— `GET /v2/case/{词}`
-
-输入任意变格形或词根,返回候选词典原型,按可能性排序。
-
-```
-data: { rows: [ { word, count, case: [ {word, count, bold}, ... ] } ], count }
-```
-
-`rows[0]` 是可能性最高的候选;`case` 是**该词根在语料中实际出现过的全部词形**,
-每项带出现次数与其中的黑体次数。
-
-**这是一切检索的前置**:`wbw_templates` 索引的是变格形,拿词典形直接查会返回 0 条
-且不报错。
-
-## 2. 词典 —— `GET /v2/dict?word={词}&lang={zh|en|jp|…}`
-
-```
-data.words[].words[] = {
-  word, anchor, factors, parents,
-  grammar: [ {word, type, grammar, parent, factors, confidence} ],   ← 形态分析:形 → 根
-  dict:    [ {shortname, dictname, lang, note, dict_id} ]            ← 释义在 note
-}
-```
-
-⚠ **释义在 `note` 字段**,`description` 是词典本身的介绍("词数 7735"之类),不是词条
-内容。`note` 里可能夹着 `<MdTpl …></MdTpl>` 模板标记,要清掉。
-
-`case`/`grammar` 的方向是 **形 → 根**,用来确认"我选对了词根";**根 → 全部形**是
-`/v2/case` 的活。
-
-## 3. 检索 —— `GET /v2/search-pali-wbw`
-
-| 参数 | 说明 |
-|---|---|
-| `key` | **逗号分隔的词形列表**(不是词根)。多个词形之间是 OR |
-| `bold` | `on` 只要黑体命中、`off` 只要非黑体。黑体是注释书标出词条的地方 |
-| `book` | 限定书,值用 `search-pali-wbw-books` 返回的 `pcdBookId` |
-| `tags` | 范围限定,`tag1,tag2;tag3` —— 组间 OR、组内 AND |
-| `limit` / `offset` | 分页。`limit=200` 实测正常 |
-| ~~`view`~~ / ~~`type`~~ | **实测无影响**,源码也没读,可省略 |
-
-```
-data: { count: 命中段落数, rows: [ { book, paragraph, rank, path, paliTitle, highlight } ] }
-```
-
-- `rank` = `sum(weight)`,黑体权重更高;
-- `path` 是章节路径数组,每项 `{book, paragraph, title, level}`;
-- `highlight` 里命中词包在 `<span class='hl'>`,**并保留原文的 `<span class="bld">`**——
-  后者是黑体,判断"这段是不是定义"要靠它,清洗 HTML 时不能一并丢掉。
-
-## 4. 出处分布 —— `GET /v2/search-pali-wbw-books?key={词形,…}`
-
-```
-data.rows[] = { pcdBookId, count, book, paragraph, paliTitle, tags: [{name}] }
-```
-
-⚠ **`count` 数的是词次,不是段落数**。同一段里出现多次算多次。段落数要看
-`search-pali-wbw` 的 `count`。实测 `parivāsa`:词次 449、段落 281。方法论陈述里
-写错这两个数是硬伤。
-
-`tags` 含 `mūla` / `aṭṭhakathā` / `ṭīkā`,用来区分本文、义注、复注。
-
-## 5. 取文 —— `GET /v2/sentence`
-
-```
-view=paragraph&book={book}&para={p1,p2,…}&channels={uid,…}   按段落取
-view=chapter&book={book}&para={章节 para}&channels={uid,…}    取整章
-```
-
-⚠ **`channels` 参数是必需的**。不带 `channels` 会 **500**(实测;`lang` / `channel_type`
-单独用同样 500,它们只能与 `channels` 并用)。所以客户端必须永远给一个默认 channel。
-
-巴利原文的 channel:`_System_Pali_VRI_`,uid `00b577c0-13b9-11ee-a05a-b7307efd9ee6`。
-
-返回的每行是一个句子(不是整段):`{id, content, content_type, html, book, paragraph,
-word_start, word_end, editor, channel, updated_at}`。按 `word_start` 排序拼起来才是整段。
-
-⚠ 返回里字段名是 `book`(不是 `book_id`)。
-
-## 6. 目录 —— `GET /v2/palitext`
-
-`view=book-toc` / `chapter` / `chapter_children` / `children` / `paragraph`,
-参数 `book` / `para` / `series`。
-
-## 7. 章节目录 —— `GET /v2/palitext?view=book-toc&book={book}&para={任意段号}`
-
-`para` 给该书内任意段号即可,服务端自己往上找顶级目录。返回的是**整套丛书**的目录
-(如给 book 216 会连 213/214/… 一起返回,实测 951 条),要按 `book` 自行过滤。
-
-行字段:`book` / `paragraph` / `toc`(巴利标题)/ `level`(1–7,1 为顶层)。
-
-## 8. 段落与章节元信息 —— `GET /v2/palitext/{book}-{paragraph}`
-
-⚠ 是 **path 参数**(`/palitext/216-481`),不是 query。`data` 是**单个对象**,不是 rows。
-
-| 字段 | 说明 |
-|---|---|
-| `class` | `chapter` / `subhead` / `bodytext` … |
-| `chapter_len` | **章节段数**。⚠ 正文段自己也有这个字段且值为 1,所以判断「是不是章节」要看它 **> 1**,不能看有没有 |
-| `chapter_strlen` | 章节字符数——**取整章前报体量就靠它** |
-| `next_chapter` / `prev_chapter` / `parent` | 导航 |
-| `path` | 面包屑。⚠ **这个端点返回的是 JSON 字符串,而 search 返回的是数组**,客户端两种都要能吃 |
-| `pcd_book_id` | 另一套书号,**等于 `search-pali-wbw` 的 `book` 参数值**(实测 216 → 278,与 dist 输出的 `--book 278` 一致)|
-
-从正文段找所属章节:取 `path` 的**末项**,那就是直接父章节;没有 path 才退回 `parent`。
-
-## 9. 某段有哪些译本 —— `GET /v2/channel?view=paragraphs&book_id={book}&para={para}`
-
-返回该坐标下有内容的全部 channel(`uid` / `name` / `type` / `lang` / `status`)。
-
-⚠ **稳定版站点上这个分支会 500**,修复只在最新版代码里(`c392d33c3 :bug: view=paragraphs
-只显示一行`)。客户端遇到 5xx 要提示切到 `next`,不要当成「该段没有译本」。
-
-## 10. 术语表 —— `GET /v2/term-vocabulary?view=community&lang=zh-Hans`
-
-权威译名对照。行字段:`guid` / `word`(巴利词形)/ `tag` / `meaning` / `other_meaning`。
-
-⚠ **不支持按词查询**(带 `word=` 参数返回的不是 JSON),只能拉全表——实测 zh-Hans
-共 **17074 条**,客户端要缓存后本地过滤。
-
-## 其他 channel 视图
-
-`view` 取值:`public`(status=30)/ `studio` / `studio-all` / `user-edit` / `user-in-chapter`
-/ `system` / `paragraphs` / `id`。
-
-`status` 不止 10/30:实测 5(610 个,basic 用户新建的)、30(559,公开)、10(495)、0、1 都在用。
-
-## 11. 整章内容 —— `GET /v2/chapter-content/{book}-{para}`
-
-一次调用返回整章,带 `?channels={uid,…}` 时把译文一并返回,**服务端已按
-`wordStart/wordEnd` 与原文对齐**。比「`palitext` 报体量 + `sentence` 逐段取」少一次
-往返,也省了客户端自己配对。
-
-`data.content` 是**双重编码的 JSON 字符串**(`content_type: "json"`),要二次解析:
-
-```
-data.content → [ {book, para, channels, sentences: [[139,861,2,8], …], mode, children: [
-    { id: "139-861-2-8", book, para, wordStart, wordEnd,
-      origin: [...], translation: [...], commentaries: [...],
-      tranNum, nissayaNum, commNum, originNum, simNum }
-  ]} ]
-```
-
-`children[].id` 就是 `book-para-wordStart-wordEnd`,与平台文章里的引用格式
-`{{141-120-17-40}}` 一致——读到什么就能直接引用什么。
-
-不带 `channels` 时也返回 `tranNum` / `nissayaNum` / `commNum` / `simNum`,等于**免费给出
-章节级的「有哪些资源」**(`versions` 只能按段落查,这里补上了章节粒度)。
-
-### ⚠ content 与 html 按 channel 类型互补,不能只取一个
-
-| channel 类型 | `content` | `html` | 该用哪个 |
-|---|---|---|---|
-| `original`(巴利原文) | **空** | 正文在这里,带 `<strong>` 黑体 | `html` |
-| `nissaya`(缅文逐词) | markdown 源码 `巴利词= 缅文释义。` | 同内容的渲染,**体积十几倍** | **`content`** |
-
-所以取值规则是「**优先 `content`,为空才回退 `html`**」。
-
-nissaya 的 `html` 里每条 gloss 包在 `<MdTpl props="<base64>">` 里,base64 解出来是
-`{"pali": "…", "meaning": ["…"], "lang": "my"}`——**它把巴利词与释义分开标注**,而渲染
-出的 span 只是把两者拼接。这个区分是逐词解析的核心,**不要以为 props 是冗余而删掉**
-(本项目曾犯过这个错)。用 `content` 就天然保留了这个区分,`=` 左右分别是巴利与释义。
-
-### ⚠ 请求的 channel 无内容时会返回等量空占位
-
-指定 `channels=X` 而 X 在本章没有内容时,服务端**仍为每一句返回一条 `content` 与
-`html` 都是空字符串的条目**。照直显示会让人以为「有译文只是没渲染出来」。客户端必须
-滤掉空条目,并明确报告「该译本在本章无文本」——实测同一坐标下 `sentence` 端点返回
-`count: 0`,两处口径一致。
-
-### 体积
-
-整章原始返回 24 KB(仅原文)到 86 KB(带 nissaya)。只保留每句的 `id` 与正文后分别是
-3.3 KB 与 9.0 KB(**14% 与 10%**)。整章直接喂给模型是浪费,务必先过滤。
-
-## 11b. 整章内容(首选)—— `GET /v2/tipitaka-content/{book}-{para}`
-
-**取整章内容用这个,不用 `chapter-content`。** 走 OpenSearch 的预建文档
-(`tipitaka_chapter_{book}-{para}_{channelId}`),返回的 `data` 是一整串渲染好的 HTML。
-
-| | `tipitaka-content` | `chapter-content` |
-|---|---|---|
-| channel 参数 | `channel=<uuid>`(**单数,一次一个**) | `channels=<uuid,…>`(可多个)|
-| 缺省 | 巴利原文 `_System_Pali_VRI_` | 同 |
-| 返回 | 一整串 HTML | 嵌套 JSON,逐句带 origin/translation/各类计数 |
-| 用途 | 读——给人或模型看 | **写入侧要用**(需要逐句的多版本结构)|
-
-HTML 里每句包在 `data-sid='93-6-31-46'` 中,**sid 就是引用坐标**
-`book-para-wordStart-wordEnd`,段落号从 sid 里就能取,不必解析外层的 `data-para`。
-
-### 三种响应都要分开处理
-
-| 情况 | 表现 | 该怎么说 |
-|---|---|---|
-| 正常 | 200,HTML 里有 `data-sid` | — |
-| 有文档但没句子 | 200,但 `data-sid` 数为 0 | 「该版本在本章没有句子内容」 |
-| 没有该版本的预建文档 | **400**,`message` 里带 OpenSearch 的 `found:false` | 「该 channel 在本章无文本」,**不是服务故障** |
-
-第三种要靠 message 里的 `found` + `false` 判断。并非所有 channel 都有预建文档——
-实测 `93-5`:巴利原文、庄春江、北大-法胜、Punnacari 有;wbw 与 deepseek 没有。
-
-### ⚠ `<code>` 是版本页码,必须留在原位
-
-正文里夹着 `<code>M1.1</code><code>V1.1</code><code>P1.1</code><code>T1.1</code>:
-**M=缅甸版、V=VRI、P=PTS、T=泰版**。它标的是页在正文中的**起始位置**,与段落不是
-一一对应,所以**不能抽到单独的字段里**——抽走就丢了位置信息。
-
-去标签时也要留意:直接删会让页码粘到前一个词上(`Evaṃ M1.1` → `EvaṃM1.1`),看着
-像巴利词形的一部分。本项目转成 `[M1.1]` 保持可分辨。
-
-PTS 页码是西方巴利学界的标准引用依据,别丢。
-
-### 体积
-
-`93-5` 一章(7 段 37 句)原始返回 9.7 KB。这个端点的 `display` **本来就精简**,
-只有句子和最小包装,过滤后省不下多少(纯文本 8.7 KB)——与 `chapter-content` 完全
-不同,那边 24 KB 里绝大部分是每句重复的 channel/studio/editor 元数据。
-
-## 12. 章节元信息的两个等价端点
-
-`GET /v2/chapter/{book}-{para}` 与 `GET /v2/palitext/{book}-{para}` **返回完全一致**
-(实测字段与取值逐一相同,两个版本的站点上都是 200)。本项目用 `palitext`,没有偏好上的
-理由,换用 `chapter` 亦可。
-
-## 已知故障
-
-| 端点 | 现象 |
-|---|---|
-| `GET /v2/search?view=pali` | **500**。走 gRPC 的 `tulip` 服务,线上不可达 |
-| `GET /v2/search-book-list` | **500**,同上 |
-
-这两个是**词组/短语**检索(多词按全文匹配)。单词检索走 `search-pali-wbw`,不受影响。
-遇到需要短语检索时,把短语拆成词分别展开词形再检索。
-
-`GET /v2/search?view=title`(按标题)与 `view=page`(按页码)不走 gRPC,可用。
-
-⚠ `search` 的 `key` 以 `para` 开头、或首字母是 `M/P/T/V/O` 时会被劫持到页码检索分支。

+ 0 - 158
plugins/wikipali/references/api-write.md

@@ -1,158 +0,0 @@
-# WikiPali API 参考(写入路径)
-
-本文只记 Skill 用到的端点。契约以**稳定版**(`www.*`)为准;标注「最新版」的能力可能在稳定版上还是 404。
-
-基址记为 `{API}`,形如 `https://www.wikipali.org/api`。所有响应统一形如:
-
-```json
-{ "ok": true, "data": <任意>, "message": "" }
-```
-
-`ok: false` 时 `message` 是原因,HTTP 状态码同时表达语义。**HTTP 200 不等于全部成功**——见文末「静默跳过」。
-
-## 三种 token,职责不能混
-
-| Token | 从哪来 | 代表谁 | 用在哪 | 有效期 |
-|---|---|---|---|---|
-| userToken | `POST /v2/sign-in` | 人类操作者 | 查/建 ai-model、签 access token、列 channel | 365 天 |
-| modelToken | `GET /v2/ai-model-token/{uid}` | AI 模型身份 | **写句子时的 `Authorization`** | 30 天,可撤销 |
-| accessToken | `POST /v2/access-token` | 被委托的 channel 编辑权 | **写句子时的 body 字段** | 7 天 |
-
-写句子时两者同时出现:`Authorization: Bearer <modelToken>`,句子对象里带 `access_token: <accessToken>`。用错会让 `editor_uid` 落成人类用户,署名与审计就废了。
-
----
-
-## 1. 登录
-
-`POST {API}/v2/sign-in` body `{ "username": "<用户名或邮箱>", "password": "<明文>" }`
-
-- `data` 是 JWT 字符串本身(不是对象)。
-- 失败返回 **HTTP 400**,`message` 是 `invalid token`——措辞误导,实际含义是用户名或密码不对。
-
-`GET {API}/v2/auth/current`(Bearer = userToken)
-
-- `data: { id, nickName, realName, avatar, token, roles }`
-- **`realName` 就是后面 `studio_name` 参数要用的值**,不是 `nickName`。
-
-## 2. AI Model
-
-`GET {API}/v2/ai-model?view=studio&name={studio_name}&keyword={模型名}`(Bearer = userToken)
-
-- `data: { rows: [...], count }`;`keyword` 是 `like %kw%` **模糊**匹配,客户端必须自己做 `name` 精确比对。
-- `view` 只接受 `all` / `studio` / `usable` / `chat`。**传别的值会 500**(服务端 switch 缺 default 分支,已知缺陷)。
-- 行里的 `key` / `system_prompt` 只在请求者是 owner 本人时才返回。
-
-`POST {API}/v2/ai-model`(Bearer = userToken) body `{name, studio_name, model?, url?, key?, privacy?, description?, system_prompt?}`
-
-- 一次 POST 即可带全字段,不需要再 PUT 补。
-- 同一 studio 内 `name` 重复 → **409**,客户端据此判定「已存在」。
-- `name` / `studio_name` 缺失 → 422。
-- 鉴权要求 `studio_name` 就是操作者本人的 studio(个人 studio),group studio 一律 403。
-
-`PUT {API}/v2/ai-model/{uid}`(Bearer = userToken)
-
-- **增量更新**:只改请求里出现的字段,未提交的保持原值;显式传 `null` 才会清空。
-- 改名撞上同 studio 内已有名字 → 409。
-
-## 3. 模型身份 token
-
-`GET {API}/v2/ai-model-token/{uid}`(Bearer = userToken)→ `data: { uid, name, token }`
-
-- 只有模型 owner 本人能调;未登录 401,非 owner 403,模型不存在 404。
-- 签出的 token 有效期 30 天,payload 带 `typ: "ai-model"` 与 `ver`。
-
-`DELETE {API}/v2/ai-model-token/{uid}`(Bearer = userToken)→ `data: { uid, name, token_version }`
-
-- 语义是**作废该模型已签出的全部 token**,无法只废一张。撤销后旧凭据一律 401。
-- 这两个端点较新,稳定版站点上可能还没有 → 404 时先怀疑「站点代码版本旧」,而不是「模型不存在」。
-
-## 4. 可编辑 channel 列表
-
-`GET {API}/v2/channel?view=user-edit`(Bearer = userToken)→ `data: { rows: [...], count }`
-
-- 语义:owner 是自己的 channel ∪ 协作权限 power ≥ 20 的 channel。
-- 行内含 `uid / name / summary / type / owner_uid / lang / status / updated_at / created_at / role / studio`。
-- 支持 `order` / `dir` / `limit`(默认 200)/ `offset` / `search`。
-
-`GET {API}/v2/channel/{uid}` 可用于回显单个 channel 的名字。
-
-## 5. 签发 channel access token
-
-`POST {API}/v2/access-token`(Bearer = userToken)
-
-```json
-{ "payload": [ { "res_type": "channel", "res_id": "<channel uid>", "power": "edit", "book": 0 } ] }
-```
-
-→ `data: { rows: [ { payload, token } ], count }`
-
-- **无权时该条被静默跳过**,返回 `count: 0` 和空 rows,HTTP 仍是 200。必须判空,等同 403 处理,**不可继续写入**。
-- 返回的 `payload` 里含 `nbf` / `exp`,据此判断何时重签。
-- ⚠ **`book` 必须是整数**。服务端校验用 `$jwt->book !== $book` 严格比较,而 `$book` 已被转成 int,写成 `"1"` 会让 `"1" !== 1` 恒真而永远鉴权失败。`0` 表示不限 book。
-
-## 6. 写入句子
-
-`POST {API}/v2/sentence`(Bearer = **modelToken**)
-
-```json
-{
-  "sentences": [
-    {
-      "book_id": 1,
-      "paragraph": 10,
-      "word_start": 0,
-      "word_end": 12,
-      "channel_uid": "<channel uid>",
-      "content": "译文",
-      "content_type": "markdown",
-      "access_token": "<第 5 步签出的 JWT>"
-    }
-  ]
-}
-```
-
-→ `data: { rows: [ ... ], count }`
-
-- 语义:按 `(book_id, paragraph, word_start, word_end, channel_uid)` 做 `firstOrNew`——**存在即覆盖,不存在则新建**。天然幂等,但也意味着会静默覆盖别人写的同位置句子,写前必须向用户确认。
-- 副作用:写 `sent_histories`(可追溯)、清缓存、发进度消息。
-- 返回的 rows 用的是另一套字段名:`book`(不是 `book_id`)、`paragraph`、`word_start`、`word_end`、`channel.uid`、`editor`。核对写入结果要按这套字段匹配。
-- 缺 `sentences` 字段时返回 **HTTP 200** 且 `message: "no date"`——这是客户端 bug,不是成功。
-
-### 静默跳过
-
-`store()` 对**逐句**鉴权失败是 `continue` 掉的,不报错。所以:
-
-> 提交 N 条、HTTP 200、`count` 却小于 N,意味着有句子没写进去。
-
-必须把返回的 rows 与提交的句子逐条比对,把差集报给用户。
-
----
-
-## 错误约定
-
-| 现象 | 含义 | 处置 |
-|---|---|---|
-| 401 | token 失效/过期/被撤销 | 提示重新登录或重取模型 token,**不要自动重试** |
-| 403 | 无 channel 编辑权,或不是模型 owner | 指出缺哪一项权限 |
-| 404(较新端点) | 站点跑的是旧版代码 | 提示切到最新版站点,别当成「资源不存在」 |
-| 409 | 同 studio 内模型重名 | 当作「已存在」,回查列表取 uid |
-| 422 | 参数校验失败 | 看 message |
-| `access-token` 返回 `count: 0` | 对该 channel 无编辑权(静默跳过) | 当作 403,**中止写入** |
-| `sentence` 的 `count` < 提交条数 | 部分句子鉴权失败被跳过 | 逐条比对并报告 |
-| `message: "no date"` + 200 | 请求缺 `sentences` | 客户端 bug |
-
-## 站点
-
-四个线上地址**共享同一个数据库和同一把 JWT 密钥**,凭据完全通用,可随时切换:
-
-| 地址 | 地区 | 代码版本 |
-|---|---|---|
-| `https://www.wikipali.org/api` | .org | 稳定版 |
-| `https://www.wikipali.cc/api` | .cc | 稳定版 |
-| `https://next.wikipali.org/api` | .org | 最新版 |
-| `https://next.wikipali.cc/api` | .cc | 最新版 |
-| `http://127.0.0.1:8000/api` | 开发机 | 另一个库、另一把密钥 |
-
-- `.org` / `.cc` 是地区可达性;`www` / `next` 是**代码版本,不是数据环境**。
-- 线上四个之间可以自动 fallback(同一套数据),**但绝不能自动退到 127.0.0.1**。
-- 真正的风险不是写错库(写不错),而是写到不同版本的代码上,所以写入前要回显当前 api_url。

+ 0 - 120
plugins/wikipali/references/conventions.md

@@ -1,120 +0,0 @@
-# 通用约定
-
-**本文件的规则对本插件的所有 skill 一律有效**(研究、写入,以及以后加的任何流程)。
-单个 skill 只写自己流程特有的部分,共同的规矩都在这里——改一处,全部生效。
-
-## 坐标
-
-WikiPali 的最小可引用单位是 `book:paragraph`,例如 `216:35`。句子在段内再细分
-`word_start`–`word_end`。完整定位一条内容需要三样:
-
-```
-book : paragraph  +  word_start-word_end  +  channel_uid
-```
-
-`channel` 是**译本/版本**的载体:巴利原文、缅文逐词解析、各家汉译,都是同一坐标下
-的不同 channel。所以"取原文"和"取某语言译文"是同一个操作换 channel。
-
-读端与写端共用这一套坐标——检索到的位置,就是能写入的位置。
-
-## 引用格式
-
-> ⚠ **临时格式**,正式规范待定(见 `docs/wikipali-research-agent-design.md` §3.4)。
-> 规范给出后只改本节,所有 skill 自动跟上。
-
-```
-Cūḷavaggapāḷi, Pārivāsikakkhandhaka (VN 216:35)          ← 本文
-Samantapāsādikā, Pārivāsikavattakathā (SP-aṭṭ 141:63)     ← 义注,已标层次
-Nissaya(缅文,channel: nissaya)(216:35)                  ← 译文,标明语言与来源
-AI-汉译-Nissaya(**AI 生成**,deepseek-v3)(216:35)         ← 机器译文必须标注
-```
-
-书名与章节路径直接取检索结果的 `paliTitle` 与 `path` 字段,**不要自己拼**。
-
-## 文献层次必须标明
-
-`mūla`(本文)、`aṭṭhakathā`(义注)、`ṭīkā`(复注)是不同层次的权威。层次信息来自
-`dist` 输出里的 tags。
-
-**把义注的解释当成经律本身的说法是学术错误,不是措辞问题。** 引用时必须让读者看出
-这句话出自哪一层。
-
-## 译文来源的判定
-
-引用译文前必须判断人译还是机译。两个信号,**任一命中就按机器译文标注**:
-
-1. **作者是 AI 模型**——`get` 返回里的作者若是模型而非人类用户,该译文确定是机器
-   生成的,标注时连模型名一起写;
-2. **channel 名字像机器译**——库里存在人工用自己账号上传的机器译文(如
-   `Nissaya的AI翻译`、`Norbu AI Translations`),此时信号 1 不成立。但**光看 "AI" 两个字
-   会漏**:很多 channel 直接用模型名命名(`deepseek`、`qwen-max`、`grok-简体中文`、
-   `gemini`、`豆包`、`ChatGPT`),名字里根本没有 "AI"。`versions` 会按一份模型名清单
-   标出「⚠疑似机器译」,但那只是提醒,不是判定。
-
-两个都不命中时**不要主动断言"这是人译"**——只如实标出 channel 名与作者。信号 2 的清单
-永远追不上新出的模型名,所以**只有信号 1(作者是模型)是可靠的**,拿不准就用
-`wikipali get` 看作者。
-
-## 空结果要诚实
-
-区分三件事,对用户的下一步完全不同:
-
-| 现象 | 含义 |
-|---|---|
-| 检索 0 条 | 多半是词形没展开(见下),不是"没有材料" |
-| 某坐标取不到某 channel 的内容 | 该译本在此处没有文本。**如实说,不要拿相邻段落或别的译本凑** |
-| 请求报错 | 工具或服务的问题,不是语料的问题 |
-
-## 检索前必须展开词形
-
-语料索引的是**变格形**(`parivāsaṃ` / `parivāso` / …),不是词典形(`parivāsa`)。
-拿词典形直接检索会**返回 0 条且不报错**——看起来像"搜过了,没有"。
-
-所以任何检索都必须先 `wikipali forms <词>`(或给 `search --lemma`)。
-
-## 站点
-
-**线上四个地址**(`www`/`next` × `.org`/`.cc`)共享同一个数据库和密钥,凭据通用。
-**`staging` 与开发机 `local` 是另外的数据库**,各自一桶——实测 staging 的公开 channel
-数与线上不同(559 vs 561),同名 channel 的 uid 也不同,**在 staging 上查到的坐标不能
-直接拿到线上引用**。自动 fallback 只在线上四站之间发生,绝不会落到这两个上。
-
-`www` 是稳定版、`next` 是最新版**代码**,不是不同的数据环境。较新的端点在稳定版上
-返回 404,意思是"该站点代码版本还没到",不是"资源不存在"。
-
-`wikipali endpoint` 查看与切换,`--api` 只影响单次调用。
-
-### 用户要切换站点时,把选择权交回去
-
-用户说「切换服务器 / 换个站点」而**没指定目标**时:**把站点列表作为选择题呈现给他**,
-等他选定再执行。
-
-- **不要替他挑一个**——切换会写回凭据文件、改变之后所有操作的落点,那是他的决定;
-- **不要指望命令行弹出选单**——`wikipali endpoint` 的交互选择只在真实终端里出现,
-  agent 的 Bash 调用没有 tty,永远等不到;
-- 选项里要标明**哪些共享数据库、哪些是独立库**(见上),因为切到独立库意味着凭据、
-  缓存、乃至查到的坐标都换了一套。
-
-用户明确说了目标(`next`、`staging`、序号、完整 url)时直接执行,不必多问。
-
-
-## 凭据
-
-`~/.wikipali/credentials.json`(0600)。**任何 skill 都不得打印 token 全文,也不得
-`cat` 这个文件。**
-
-密码只由 `wikipali-login` 接触,且**只从三个地方进来**:真实终端的 `getpass`、操作系统的
-密码对话框、显式的 `--password-stdin` 管道。它永远不经过命令行参数,也不该经过与 AI 的
-对话——两者都会留痕(`ps` / shell history / 会话记录)。
-
-没有终端时(Claude Desktop、IDE、agent 代跑)`wikipali-login` 会自动弹系统密码框,所以
-**可以直接执行它**——密码不经过调用方,**不要以「要读密码」或「没有终端」为由拒绝执行**。
-只有既无终端又无图形界面时才需要用户自己开终端。Claude Desktop 的
-内置终端按 <kbd>Ctrl</kbd>+<kbd>`</kbd> 打开(仅本地会话)。
-
-登录是**一次性**的:用户 token 有效期 365 天,同一台机器上所有副本共用这一份凭据。
-
-**本地缓存(术语表、书目清单)与凭据一样按站点分桶存放**(`~/.wikipali/cache/<桶>/`)。
-线上四站共享同一个库可以共用;开发机(`local`)与任何自定义地址是**另一个数据库**,
-不分桶的话,用过一次 `--api local` 之后打线上会静默拿到开发机的数据——看起来一切
-正常,数据却是错的。

+ 0 - 250
plugins/wikipali/skills/research/SKILL.md

@@ -1,250 +0,0 @@
----
-name: research
-description: "Use this skill to research Pali Buddhist texts with WikiPali's corpus — finding where a term occurs across the Tipiṭaka and its commentaries, reading the Pali source, comparing translations, and producing cited scholarly writing. Trigger whenever the user asks to look up a Pali word or concept, find passages about a topic in the canon, analyse how a term is used, compare mūla / aṭṭhakathā / ṭīkā, check a translation against the Pali, or write a paper or summary grounded in Pali sources. Do not use for writing data into WikiPali (that is the write skill)."
----
-
-# WikiPali 研究
-
-用 WikiPali 的语料做巴利文献研究:定位 → 取证 → 展开 → 交叉验证 → 成文。
-
-命令是 `wikipali <子命令>`。检索与阅读全部只读,**不需要登录**。
-
-⚠ **刚装好或刚更新插件时,`wikipali` 可能还不在 PATH 上**——PATH 注入在会话启动时完成,装完要重启会话。若 `command -v wikipali` 为空,改用绝对路径 `${CLAUDE_PLUGIN_ROOT}/bin/wikipali`,并提醒用户重启会话。
-
-**坐标、引用格式、文献层次、译文来源判定见 `references/conventions.md`——那是所有
-skill 共用的规矩,必须遵守。** 端点细节见 `references/api-read.md`。
-
-## 铁律
-
-1. **每一条写进正文的引用,必须带得回坐标。** 手里没有坐标的内容,一个字都不许写进
-   产出。宁可说"未找到相关段落",也不要凭印象转述。
-2. **检索前必须先展开词形**(`wikipali forms`,或给 `search --lemma`)。直接拿词典形
-   去搜会**返回 0 条且不报错**。这是本工具最容易犯的错,因为它看起来像"搜过了,没有"。
-3. **0 条结果不等于"没有材料"。** 依次怀疑:词形没展开 → 词根选错 → 范围限太窄 →
-   才是真的没有。把怀疑过程说给用户听。
-4. **本文、义注、复注不能混。** 引用时必须标明层次(见 conventions.md)。
-5. **判断依据是巴利原文,译文只作佐证。** 机器生成的译文必须显式标注。
-6. **不要把整章往上下文里灌。** 先看体量再决定取多少。
-
-## 流程
-
-### 0a. 需要浏览语料结构时:先找书,再看章节
-
-```bash
-wikipali books --tag-list                      # 有哪些分类 tag
-wikipali books --tags dīghanikāya,ṭīkā         # 长部的复注有哪些(多个 tag 是「且」)
-wikipali toc 185:3                             # 那本书的章节目录
-```
-
-`books` 按 tag 筛(`mūla` / `aṭṭhakathā` / `ṭīkā` / 各部尼柯耶 / 各种论书),
-输出直接接 `toc` 看章节。适合「我想知道某类文献有哪些」这种起步阶段,
-而不是已经有明确关键词的检索。
-
-⚠ 该功能需要服务端较新版本;旧版会明确报「分类目录功能尚未上线」。
-
-### 0b. 先看有没有人写过
-
-```bash
-wikipali articles 别住          # 平台上的二手研究
-wikipali anthology              # 文集(成体系的系列文章)
-wikipali article <uid>          # 读全文
-```
-
-**几秒钟的事,能省掉重复劳动,也能发现你要处理的分歧点。** 但记住文章是二手研究:
-引用它的观点要标明作者,**不能把它的说法当成经律本身的说法**。
-
-### 1. 展开词形(永远的第一步)
-
-```bash
-wikipali forms parivāsa
-```
-
-输出候选词根,每个带该词根在语料中出现过的全部词形及频次、黑体数。取可能性最高的
-那个,但**要看一眼其余候选**:若目标概念同时有名词与动词两条线(`parivāsa` /
-`parivāseti`),两条都要展开。
-
-拿不准选哪个候选时:
-
-```bash
-wikipali word parivāsa          # 释义 + 形态分析,确认词根选对了
-wikipali terms parivāsa         # 术语表:该词的权威中文译名
-```
-
-**产出里的译名应与术语表一致**,不一致要说明理由。别自己从构词法编译名——实测
-`samodhānaparivāsa` 的权威译名是「合并别住」,凭字面容易写成别的。
-
-### 2. 看分布,再决定范围
-
-```bash
-wikipali dist --lemma parivāsa
-```
-
-输出每部书的命中数、`--book` 值和 tags,并按 `mūla` / `aṭṭhakathā` / `ṭīkā` 汇总。
-这一步决定后面所有工作的范围:
-
-- 命中集中在律藏 → 后续加 `--tags vinaya` 收窄;
-- 本文命中少而义注命中多 → 这是个**注释书概念**,论文结构要相应调整;
-- 总量太大 → 先收窄再取证,不要硬取。
-
-⚠ `dist` 数的是**词次**,`search` 数的是**段落数**,两个数不相等。方法论里别写混。
-
-### 3. 取定义:前 50 条,靠排序
-
-```bash
-wikipali search --lemma parivāsa --limit 50
-```
-
-结果按黑体加权排序,**注释书里作为词条解释的段落会自然排在前面**。从前 50 条里挑出
-讲定义和执行流程的,用来写定义部分。
-
-**前几名全是 aṭṭhakathā / ṭīkā 是正常的,不是检索出了问题。** 大部分名词解释在义注
-(aṭṭhakathā)与复注(ṭīkā)里,律藏的根本(pāḷi / mūla)中也有部分解释。
-
-所以:不必因为看不到本文命中就怀疑检索出了错;但也**不要断定本文没有解释**——本文
-里的那部分同样要引,按铁律 4 标明层次。
-
-命中总量特别大、前 50 条噪声明显时,可以加 `--bold` 只看黑体命中来收窄——那是收窄
-手段,不是默认做法,因为不加黑体的定义段落会被它漏掉。
-
-### 3b. 留意注疏有没有枚举子类
-
-注疏解释术语时常会枚举它的子类(`catubbidho X`、`duvidho X` 这类体例很常见,但**不是
-每个词都有**)。读定义段落时**留意有没有**这种枚举——有,它就是现成的分类框架,比自己
-归纳可靠;没有就跳过,**不要硬造分类**。
-
-**发现子类后,每一个都要单独再查一遍,不得凭名字推测含义。** 巴利复合词看起来像是
-可以拆开理解的(`paṭicchanna-parivāsa` 像"覆藏+别住"),而这种推测正是编造释义的
-入口——构词法给的是字面拼合,不是该词在律学里的实际所指。
-
-每个子类走一遍完整链路:
-
-```bash
-wikipali count samodhānaparivāsa paṭicchannaparivāsa …  # 一次看清各子类的词次
-wikipali terms samodhānaparivāsa                        # 权威译名
-wikipali search --lemma samodhānaparivāsa --limit 20    # 找它自己的解释段落
-wikipali get <坐标>                                      # 取原文
-```
-
-**拿到注疏对该子类的解释原文之前,不要写出它的含义。** 查不到解释就如实说"语料中
-未见对该子类的解释",不要用构词法去补——这是铁律 1 在子类上的具体化。
-
-**两个实测踩到的陷阱:**
-
-- **不同注疏的枚举可能不一样。** 实测 `parivāsa`:Pācityādiyojanā(202:1882)与
-  Kaṅkhāvitaraṇī-abhinavaṭīkā(212:1134)都说"四种",但列出的**不是同一组**——后者
-  含 `suddhantaparivāsa`(语料中 86 次),前者没有。所以**不要把某一处的列表当成
-  「标准分类」**:按出处分别记录,各家不一致就如实说明不一致。这本身往往是论文里
-  值得写的一笔。
-- **子类可能还有子类。** `samodhānaparivāsa` 自己又分三种(odhāna / aggha /
-  missaka,见 141:120)。要挖多深由研究问题决定,不必无限递归,但要说明你停在了
-  哪一层。
-
-频次**只报数字,不从中下判断**。例如 `parivāsa` 的四个子类:samodhāna 260 次、
-paṭicchanna 29、titthiya 18、appaṭicchanna 14。把这组数字连同出处交给用户即可——高频
-可能只是某部注疏反复提及,不等于该子类更重要,这个判断该由研究者做。
-
-### 4. 取案例:全量检索
-
-```bash
-wikipali search --lemma parivāsa --tags vinaya --limit 200
-```
-
-每条给出坐标、章节路径和高亮片段。**先用片段做初筛**,判断该段落属不属于目标案例
-类型,不要一上来就把每段全文取回来。
-
-**这一步要有意识地回到本文(mūla)。** 与定义相反,案例——谁、在什么情况下、如何
-执行、判定结果如何——在律藏本文里,注疏是对这些案例的解释。用 `dist` 输出里本文那
-几部书的 `--book` 值收窄,别让注疏的高命中密度把本文案例挤出视野。
-
-### 5. 取原文
-
-```bash
-wikipali get 216:35 216:36 216:41       # 按坐标精确取,缺省是巴利原文
-wikipali toc 216:512                    # 看这本书的章节结构
-wikipali chapter 216:512                # 只报体量:章节范围、段数、字符数
-wikipali chapter 216:512 --fetch        # 确认要读全章时才加 --fetch
-wikipali chapter 216:512 --fetch --channel <uid>   # 读某一个译本(一次一个)
-wikipali chapter 216:512 --fetch --text            # 纯文本,更省
-```
-
-`chapter` 给正文段也行,会自动向上找到所属章节。**不加 `--fetch` 就只报体量**——
-这是上下文预算的闸门,先看清多大再决定读不读。
-
-取文时每句只保留 **id + 正文**,服务端原始返回的十分之一左右。**句子 id 就是引用坐标**
-(`139-861-9-12` = book-para-wordStart-wordEnd),读到什么就能直接引用什么。
-
-若指定的 channel 在本章没有内容,命令会明确报「该译本在本章无文本」而不是显示一堆
-空行——服务端在这种情况下会返回等量的空占位条目。
-
-### 5b. 从本文跳到义注与复注
-
-```bash
-wikipali related 216:512        # 该段在义注、复注里的对应段落
-wikipali get 141:65 141:66      # 读义注怎么解释这一段
-```
-
-**这是找注释的正确方式,不要回头去注释书里搜关键词。** 关键词搜到的未必是在解释这一段,
-而注释书解释某段时也未必重复原词——两头都会错。`related` 走的是 CST 锚点的段落对应关系,
-是文献学上正确的对齐。
-
-输出按 mūla → aṭṭhakathā → ṭīkā 排序并标好层次,直接可用于引用。
-
-约 2% 的段落没有锚点,那时会明确报「没有关联段落」——**如实说,不要转而搜关键词充数**。
-
-### 6. 交叉验证
-
-```bash
-wikipali versions 216:512               # 该坐标有哪些译本,以及没有哪些
-wikipali get 216:512 --channel <uid>    # 取指定译本
-```
-
-`versions` 会把该段**没有**的语言明确列出来——某语言无译文时如实说"无",**不要拿
-相邻段落或别的译本凑**。
-
-⚠ `versions` 依赖的端点在稳定版站点上有缺陷,会提示你切到 `next`(`wikipali endpoint next`)。
-
-⚠ 用户要切换站点却**没指定目标**时,把站点列表**作为选择题呈现给他**再执行——不要替他挑,
-也不要指望命令行弹选单(agent 没有 tty,永远等不到)。见 `references/conventions.md`。
-
-⚠ 输出里标 **⚠疑似机器译** 的按机器译文标注引用;但**没标的不等于人译**——很多 channel
-直接用模型名命名(`deepseek`、`qwen-max`、`grok-简体中文`),判定要看 `get` 返回的作者,
-见 `references/conventions.md`。
-
-## 上下文预算
-
-| 操作 | 默认上限 | 超了怎么办 |
-|---|---|---|
-| `search` 摘要 | 一次不超过 50 条进上下文 | 用 `--tags` / `--book` 收窄,或分页逐批归纳 |
-| `get` 取段落 | 一次不超过 20 段 | 分批,每批处理完先记下结论再取下一批 |
-
-原则:**上下文里应该留下结论和坐标,而不是原文**。取回一批材料 → 归纳出结论并记下
-支撑坐标 → 再取下一批。不要把所有原文堆着等最后一起分析。
-
-`--width` 控制每条摘要的长度,`--json` 输出原始数据(需要自己处理时用)。
-
-## 按任务类型分档
-
-同一套检索,产出的详略要看用户要什么:
-
-| 用户要的 | 产出形态 |
-|---|---|
-| 快速查询("parivāsa 什么意思"、"哪几处提到 X") | 结论 + 坐标。**不报告检索方法**,别把查词变成论文 |
-| 综述 / 分析("X 在律藏里怎么用") | 结论 + 坐标 + 一句话交代范围 |
-| 论文 / 研究报告(用户明确说要写论文、要发表、要引用) | 完整方法论:展开了哪些词形(连同频次)、检索范围、总命中词次与段落数、实读段数、黑体与非黑体分布 |
-
-判断不了属于哪一档时按中间档走,并问用户要不要完整的检索方法说明。
-
-论文档的方法论不是修辞——"检索 parivāsa 的 13 个词形,得 281 段(449 词次),分布
-于 43 部书,其中律藏本文 159 词次、义注 28、复注 102"这样一句,是读者判断你的检索
-是否穷尽的唯一依据。
-
-## 常见错误
-
-| 现象 | 真正的原因 |
-|---|---|
-| 检索 0 条 | 多半是没展开词形,用了词典形 |
-| 查定义时结果全是义注、复注 | 正常,大部分名词解释在注疏里。但本文中也有部分解释,别据此断定本文没有 |
-| 查案例时找不到本文 | 这才是问题。用 `dist` 的 `--book` 值收窄到 mūla 再搜 |
-| 某段落取不到译文 | 该 channel 在该段落没有内容。如实报告 |
-| 想按短语检索 | 平台的词组检索目前不可用(服务端 500)。把短语拆成词,分别展开词形再检索 |
-| `get` 报 500 | 忘了 channel。`get` 缺省会带巴利原文的 channel,若你手动传了参数要确保 channel 在内 |

+ 0 - 122
plugins/wikipali/skills/write/SKILL.md

@@ -1,122 +0,0 @@
----
-name: write
-description: "Use this skill to write sentences (translations, commentary) into the WikiPali sentence database over its HTTP API, from any project. Trigger whenever the user asks to upload, push, publish, sync, or save translated Pali sentences to WikiPali / 巴利文 / wikipali.org, or mentions writing to a WikiPali channel, or asks about the wikipali CLI, wikipali-login, or ~/.wikipali/credentials.json. Handles login, AI-model identity tokens, channel selection, access tokens, and batched writes with attribution as the AI model rather than the human operator. Do not use for reading WikiPali data or for unrelated Laravel/API work."
-metadata:
-  author: mint
----
-
-# WikiPali 写入
-
-把句子写进 WikiPali 句子库,**署名为 AI 模型身份**(`editor_uid` = 模型 uid),而不是操作者本人。
-
-只依赖 Python 标准库,直接跑,不要建虚拟环境:
-
-- `wikipali-login` —— 唯一接触密码的程序,**必须由用户本人在真正的终端里执行**
-- `wikipali` —— 其余全部操作
-
-命令是 `wikipali <子命令>`,登录是独立的 `wikipali-login`。
-
-⚠ **刚装好或刚更新插件时,这两个命令可能还不在 PATH 上**——PATH 注入在会话启动时完成,装完要重启会话。若 `command -v wikipali` 为空,改用 `${CLAUDE_PLUGIN_ROOT}/bin/wikipali`,并提醒用户重启会话。
-
-**坐标、引用格式、译文来源判定、凭据规矩见 `references/conventions.md`——那是所有 skill 共用的,必须遵守。** 端点细节见 `references/api-write.md`。
-
-## 铁律
-
-1. **永远不要向用户索要密码。** 需要登录时**直接执行 `wikipali-login`**——密码由终端的
-   `getpass` 或**操作系统的密码对话框**收取,**不经过你**,你既看不到也无法读取。
-   绝不允许的三件事:向用户索要密码、把密码写进命令行参数、让用户把密码打进对话
-   (那会进入会话记录)。
-
-   ⚠ **不要以「这个命令要读密码 / 我不能处理凭据 / 本会话没有交互式终端」为由拒绝执行它。**
-   这三条都不成立:密码不经过你;无 TTY 时它自动改用系统对话框,那正是为这种环境设计的。
-   先跑,让它自己判断环境——只有它明确报错时才转达下面的办法。
-
-   若它报「既没有交互式终端也没有图形界面」,把这几条转达给用户:
-   - **Claude Desktop**:按 <kbd>Ctrl</kbd>+<kbd>`</kbd> 打开内置终端,在里面跑 `wikipali-login`(内置终端仅本地会话有);
-   - **SSH / 远程开发机**:凭据存在远端,要在**那台机器**上开终端登录;
-   - 自动化环境:`... | wikipali-login --username <名字> --password-stdin`。
-2. **写入前必须让用户确认。** `wikipali write` 默认会回显目标并等确认;只有用户已经明确同意本次写入时,才可以加 `-y`。
-3. **绝不打印 token 全文**(`~/.wikipali/credentials.json` 里的任何值)。脚本自己会打码,不要 `cat` 那个文件。
-4. **`count` 不等于提交条数就是有句子没写进去**,必须如实报告给用户,不要说「已全部写入」。
-5. **收到 401 不要自动重试**,按脚本的提示走。
-
-## 首次准备
-
-```bash
-wikipali whoami          # 先看缺什么
-```
-
-按缺什么补什么:
-
-```bash
-# 1) 登录(用户自己在另一个终端里跑,不要用 ! 前缀,也不要代跑)
-wikipali-login
-
-# 2) 建立模型身份并取 token;--name 必须是你自己的模型标识
-wikipali ensure-model --name claude-opus-5
-
-# 3) 看有哪些可写的 channel
-wikipali channels
-```
-
-`--name` 决定句子的作者署名,**不要冒用别的模型的名字**。同名记录已存在时会直接复用(幂等)。
-
-## 写入
-
-输入是一个 JSON 文件,两种形状都接受:
-
-```json
-{
-  "channel_uid": "可选,整批共用的 channel",
-  "sentences": [
-    { "book_id": 1, "paragraph": 10, "word_start": 0, "word_end": 12,
-      "content": "译文", "content_type": "markdown" }
-  ]
-}
-```
-
-或直接是句子数组(此时用 `--channel` 指定目标)。`content_type` 可省略,默认 `markdown`;`channel_uid` 可以逐句给,用于跨 channel 批量写。
-
-```bash
-wikipali write sentences.json --channel <uid或名字片段> --dry-run   # 先看回显
-wikipali write sentences.json --channel <uid或名字片段>            # 再真写
-```
-
-`write` 会自动完成:解析校验 → 确定 channel → 回显确认 → 按需签发/复用 access token → 每 50 条一批提交 → 核对 `count` 并报告漏写的句子。
-
-**写入是覆盖式的**:相同 `(book_id, paragraph, word_start, word_end, channel_uid)` 的已有句子会被替换。回显里那行警告要转达给用户。
-
-## 站点
-
-四个线上地址共享同一个数据库和密钥,凭据通用;`www` 是稳定版、`next` 是最新版代码,**不是**不同的数据环境。
-
-```bash
-wikipali endpoint            # 列出并标出当前
-wikipali endpoint next       # 改默认(唯一会写回凭据的方式)
-
-⚠ 用户要切换站点却**没指定目标**时,把站点列表**作为选择题呈现给他**再执行——不要
-替他挑,也不要指望命令行弹选单(agent 没有 tty,永远等不到)。见 conventions.md。
-wikipali --api next write …  # 只影响这一次调用
-```
-
-新端点在稳定版上返回 404 是「代码版本还没到」,不是「资源不存在」。
-
-## 出问题时
-
-| 现象 | 处置 |
-|---|---|
-| 401 | 用户 token 失效 → 重跑 `wikipali-login`;模型 token 失效或被撤销 → 重跑 `ensure-model` |
-| 403 | 不是 channel 的 owner/协作者,或不是模型 owner。指出缺哪项权限,别换个姿势重试 |
-| `count: 0`(签 access token) | 对该 channel 无编辑权。**中止写入**,不要继续 |
-| `count` 小于提交条数 | 逐条差集已由脚本列出,如实转达 |
-| 404(`ai-model-token` 等新端点) | 提示切到 `next` 或稍后再试 |
-
-凭据泄漏时撤销模型的全部 token:
-
-```bash
-wikipali revoke
-```
-
-## 更多
-
-端点字段、返回形状与各处陷阱见 `references/api-write.md`。若脚本行为与该文件对不上,多半是这份副本过期了——插件用户跑 `/plugin update`,手工安装的用户重新装一遍。