dsh-novel-writer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +110 -0
- package/README.md +132 -0
- package/cordis.patch.yml +8 -0
- package/lib/index.js +714 -0
- package/package.json +40 -0
- package/skills/novel-writing/SKILL.md +35 -0
package/INSTALL.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# dsh-novel-writer v0.2.0 — 跨设备安装指南
|
|
2
|
+
|
|
3
|
+
本目录是一个**完整的、自包含的插件分发件**,零第三方依赖(仅 Node 内置模块)。
|
|
4
|
+
将其复制到任何安装了 DSH(DeepSeek Harness)的设备即可使用,无需联网下载任何依赖。
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
dsh-novel-writer-v0.2.0/
|
|
8
|
+
├── lib/index.js # 插件本体(含全部修复,见下文"版本记录")
|
|
9
|
+
├── package.json # 包定义(bundle patch 指向 cordis.patch.yml)
|
|
10
|
+
├── cordis.patch.yml # 挂载插件到 loader 树
|
|
11
|
+
├── README.md # 功能与工具说明
|
|
12
|
+
└── skills/
|
|
13
|
+
└── novel-writing/
|
|
14
|
+
└── SKILL.md # 可选:小说写作技能(增强 AI 工作流)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 一、安装插件
|
|
20
|
+
|
|
21
|
+
### 方式 A:dsh CLI(推荐)
|
|
22
|
+
|
|
23
|
+
把整个文件夹放到目标设备的任意位置(例如 `D:/tools/dsh-novel-writer`),然后:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
dsh plugin --profile web add D:/tools/dsh-novel-writer
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 方式 B:手动等价操作
|
|
30
|
+
|
|
31
|
+
1. 把本文件夹(或链接)放入 profile 的 `node_modules`:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
# 在 <DSH_HOME>/profiles/<profile名> 目录下
|
|
35
|
+
npm install D:/tools/dsh-novel-writer
|
|
36
|
+
# 或手动建立 junction 链接(Windows):
|
|
37
|
+
# mklink /J <profile>\node_modules\dsh-novel-writer D:/tools/dsh-novel-writer
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
2. 编辑该 profile 的 `package.json`,在 `dsh.profile.bundles` 数组中加入:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
"dsh-novel-writer"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
3. 重启 web 应用,`novel_*` 系列工具即会注册到会话。
|
|
47
|
+
|
|
48
|
+
> 插件的 `cordis.patch.yml` 由 bundle 自动应用(`package.json` 中
|
|
49
|
+
> `dsh.bundle.patch` 已声明),无需手动往 profile 的 patch 文件里加条目。
|
|
50
|
+
|
|
51
|
+
## 二、(可选)安装技能 SKILL.md
|
|
52
|
+
|
|
53
|
+
技能用于增强 AI 的小说写作工作流提示(与插件工具配合使用):
|
|
54
|
+
|
|
55
|
+
- 把 `skills/novel-writing` 整个文件夹复制到工作区的 `.dsh/skills/` 下:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
<工作区>/.dsh/skills/novel-writing/SKILL.md
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- 新会话中即会出现 `novel-writing` 技能。
|
|
62
|
+
|
|
63
|
+
## 三、准备章节库
|
|
64
|
+
|
|
65
|
+
章节库存放于**会话工作区**(或插件 config `root` 指定的目录)下的 `novels/` 文件夹:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
<工作区>/novels/<书名>/
|
|
69
|
+
├── 第01章.md # 或 .txt / .markdown
|
|
70
|
+
└── 原稿件-单章-…第一章.txt # 序号在任意位置、中文数字均可识别
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
支持的文件命名与编码:
|
|
74
|
+
|
|
75
|
+
- 章号任意位置:`第01章.md`、`01-标题.md`、`原稿件-单章-…第一章.txt`、`原稿件-单章-第25章 .txt`
|
|
76
|
+
- 阿拉伯数字 + 中文数字(第一章/第十四章/第三十章),自动按章号排序
|
|
77
|
+
- 编码自动探测:UTF-8(含/不含 BOM)、UTF-16 LE/BE(含 BOM 或启发式)、GBK/GB18030(无 BOM)
|
|
78
|
+
|
|
79
|
+
## 四、验证安装
|
|
80
|
+
|
|
81
|
+
安装并重启后,在会话中应能看到以下工具:`novel_books`、`novel_chapters`、
|
|
82
|
+
`novel_read`、`novel_keywords`、`novel_new_chapter`。可先执行 `novel_books`
|
|
83
|
+
确认能列出书库。
|
|
84
|
+
|
|
85
|
+
若 `root` 需要指向其他目录(默认会话工作区),在 profile 的
|
|
86
|
+
`cordis.patch.yml` 中覆盖:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
- patch:
|
|
90
|
+
- id: novel-writer
|
|
91
|
+
config:
|
|
92
|
+
root: 'D:/我的小说库'
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 版本记录
|
|
98
|
+
|
|
99
|
+
### v0.2.0(本分发件)
|
|
100
|
+
- **修复**:章节序号提取支持任意位置(含中文数字"第一章/第十四章/第三十章"),
|
|
101
|
+
修复 `原稿件-单章-…第N章` 命名下章号全部丢失、排序混乱、
|
|
102
|
+
`novel_read(book,"1")` 误命中"第31章"、`novel_new_chapter` 自动取号错乱的问题。
|
|
103
|
+
- **新增**:文件编码自动探测(UTF-8 / UTF-16 LE/BE / GBK),
|
|
104
|
+
修复 UTF-16/GBK 章节文件读取乱码与字数统计错误的问题。
|
|
105
|
+
- 通过 37 章全量压力测试(463,475 字 / 13,231 行,全书读取+关键词统计 < 200ms)。
|
|
106
|
+
|
|
107
|
+
### v0.1.0(初始)
|
|
108
|
+
- 章节库管理:novel_books / novel_chapters / novel_read
|
|
109
|
+
- 分析:novel_keywords(中文二字词组 + 英文词)
|
|
110
|
+
- 续写:novel_new_chapter(自动取下一个章号)
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# 📚 dsh-novel-writer — 小说写作助手 / Novel Writing Assistant
|
|
2
|
+
|
|
3
|
+
**中文** | [**English**](#english)
|
|
4
|
+
|
|
5
|
+
一个零第三方依赖(仅 Node 内置模块)的 **DeepSeek Harness (DSH / Cordis) 插件**,为 AI 提供小说章节库管理、剧情分析与续写辅助的能力。
|
|
6
|
+
|
|
7
|
+
A **zero-dependency** (Node built-ins only) **DeepSeek Harness (DSH / Cordis) plugin** that gives the AI novel-chapter-library management, plot analysis, and continuation-writing assistance.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 功能 / Features
|
|
12
|
+
|
|
13
|
+
### 🇨🇳 中文
|
|
14
|
+
|
|
15
|
+
1. **章节库管理**:章节存放于 `novels/<书名>/第N章.md`(也支持 `.txt` / `.markdown`),
|
|
16
|
+
插件提供 `novel_books` / `novel_chapters` / `novel_read` 让你(AI)浏览库、列出章节并逐章阅读。
|
|
17
|
+
2. **智能章号识别**:章节文件名中的序号支持**任意位置**与多种写法,
|
|
18
|
+
例如 `第01章.md`、`01-标题.md`、`原稿件-单章-…第一章.txt`,
|
|
19
|
+
阿拉伯数字与中文数字(第一章/第十四章/第三十章)均可识别,并自动按章号排序。
|
|
20
|
+
3. **编码自动探测**:UTF-8(含/不含 BOM)、UTF-16 LE/BE(含 BOM 或启发式)、GBK/GB18030(无 BOM)均可直接读取,无需手动转码(如记事本另存的 Unicode 文本)。
|
|
21
|
+
4. **关键词分析**:`novel_keywords` 确定性提取全书/单章高频关键词(中文相邻二字词组 + 高频单字 + 英文词),
|
|
22
|
+
配合系统提示词引导 AI 分析剧情脉络、写作手法、词汇偏好与意象母题。
|
|
23
|
+
5. **续写辅助**:系统提示词规定续写工作流(先读后写、保持文风与伏笔一致),
|
|
24
|
+
`novel_new_chapter` 自动创建下一章文件。
|
|
25
|
+
|
|
26
|
+
### 🇬🇧 English
|
|
27
|
+
|
|
28
|
+
1. **Chapter-library management**: chapters live under `novels/<book>/第N章.md` (also `.txt` / `.markdown`).
|
|
29
|
+
`novel_books` / `novel_chapters` / `novel_read` let the AI browse, list, and read chapters.
|
|
30
|
+
2. **Smart chapter numbering**: chapter numbers may appear **anywhere** in the filename and in many forms,
|
|
31
|
+
e.g. `第01章.md`, `01-title.md`, `raw-…Chapter-1.txt`. Both Arabic and **Chinese numerals**
|
|
32
|
+
(第一章 / 第十四章 / 第三十章) are recognized and chapters are sorted by number automatically.
|
|
33
|
+
3. **Automatic encoding detection**: UTF-8 (with/without BOM), UTF-16 LE/BE (BOM or heuristic), and
|
|
34
|
+
GBK/GB18030 (no BOM) are decoded on the fly — no manual transcoding needed.
|
|
35
|
+
4. **Keyword analysis**: `novel_keywords` deterministically extracts high-frequency words from a whole book
|
|
36
|
+
or one chapter (Chinese bigrams + characters + English words), guiding the AI to analyze plot,
|
|
37
|
+
writing style, vocabulary preferences, and imagery motifs.
|
|
38
|
+
5. **Continuation writing**: a system prompt enforces the workflow (read first, then write; keep style and
|
|
39
|
+
foreshadowing consistent). `novel_new_chapter` creates the next chapter file automatically.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 提供的工具 / Provided Tools
|
|
44
|
+
|
|
45
|
+
| Tool | 中文说明 | English |
|
|
46
|
+
|------|----------|---------|
|
|
47
|
+
| `novel_books` | 列出章节库全部作品(章节数、总字数) | List all books in the library (chapter count, total chars) |
|
|
48
|
+
| `novel_chapters` | 列出某作品章节清单(章号/标题/字数/行数/更新时间) | List a book's chapters (number/title/chars/lines/updated) |
|
|
49
|
+
| `novel_read` | 阅读某章正文(行号 + 字数统计,offset/limit 分段) | Read a chapter's body (line numbers + char count, paginated) |
|
|
50
|
+
| `novel_keywords` | 提取高频关键词(可单章或全书) | Extract high-frequency keywords (chapter or whole book) |
|
|
51
|
+
| `novel_new_chapter` | 创建新章节文件(自动取下一个章号) | Create a new chapter file (auto-next number) |
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 安装 / Installation
|
|
56
|
+
|
|
57
|
+
> 现代安装请优先参考 `INSTALL.md`,或使用 DSH CLI:
|
|
58
|
+
|
|
59
|
+
> Preferred install via DSH CLI, see also `INSTALL.md`:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
dsh plugin --profile web add D:/path/to/dsh-novel-writer
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
或手动等价操作:在 profile 的 `package.json` 的 `dsh.profile.bundles` 中加入 `dsh-novel-writer`,
|
|
66
|
+
并在 profile `node_modules` 中链接本包(Windows 可建 junction:`mklink /J`)。安装后重启 web 应用生效。
|
|
67
|
+
|
|
68
|
+
Or the manual equivalent: add `dsh-novel-writer` to the `dsh.profile.bundles` array in the profile's
|
|
69
|
+
`package.json`, and link this package into the profile `node_modules` (on Windows, `mklink /J`).
|
|
70
|
+
Restart the web app after installing.
|
|
71
|
+
|
|
72
|
+
### (可选)安装技能 / (Optional) Install the Skill
|
|
73
|
+
|
|
74
|
+
把 `skills/novel-writing` 整个文件夹复制到工作区 `.dsh/skills/` 下,新会话即出现 `novel-writing` 技能:
|
|
75
|
+
|
|
76
|
+
Copy the whole `skills/novel-writing` folder into your workspace `.dsh/skills/`; a new session will surface the `novel-writing` skill:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
<workspace>/.dsh/skills/novel-writing/SKILL.md
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 配置 / Configuration
|
|
85
|
+
|
|
86
|
+
插件 `config` 支持 `root`:章节库根目录(默认取**会话工作区**,即 `novels/` 所在位置)。
|
|
87
|
+
在 profile 的 `cordis.patch.yml` 中可覆盖:
|
|
88
|
+
|
|
89
|
+
The plugin `config` supports `root`: the library root directory (defaults to the **session workspace** where `novels/` lives). Override it in the profile's `cordis.patch.yml`:
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
- patch:
|
|
93
|
+
- id: novel-writer
|
|
94
|
+
config:
|
|
95
|
+
root: 'D:/我的小说库'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 章节库约定 / Library Convention
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
<workspace or root>/novels/<书名>/
|
|
104
|
+
├── 第01章.md # 或 .txt / .markdown
|
|
105
|
+
└── 原稿件-单章-…第一章.txt # 序号在任意位置、中文数字均可识别
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
支持的命名与编码 / Supported naming & encoding:
|
|
109
|
+
|
|
110
|
+
- 章号任意位置:`第01章.md`、`01-标题.md`、`原稿件-单章-…第一章.txt`、`原稿件-单章-第25章 .txt`
|
|
111
|
+
- 阿拉伯数字 + 中文数字(第一章/第十四章/第三十章),自动按章号排序
|
|
112
|
+
- 编码自动探测:UTF-8(含/不含 BOM)、UTF-16 LE/BE(含 BOM 或启发式)、GBK/GB18030(无 BOM)
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 安全说明 / Security Notes
|
|
117
|
+
|
|
118
|
+
- 仅使用 Node 内置模块,无第三方运行时依赖、无网络请求、无 shell 执行。
|
|
119
|
+
- 所有文件读写均限定在章节库根目录下,并通过 `sanitizeSegment` 阻止路径穿越(拒绝 `.`/`..` 与 `\`/`/`)。
|
|
120
|
+
- 读取设有上限(单次最多 400 行 / 2 万字符),保护模型上下文。
|
|
121
|
+
|
|
122
|
+
- Uses only Node built-in modules: no third-party runtime deps, no network calls, no shell execution.
|
|
123
|
+
- All file reads/writes are confined to the library root, with `sanitizeSegment` blocking path traversal (rejects `.`/`..` and `\`/`/`).
|
|
124
|
+
- Reads are capped (400 lines / 20k chars per call) to protect the model's context window.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
<!-- ENGLISH -->
|
|
129
|
+
|
|
130
|
+
## English
|
|
131
|
+
|
|
132
|
+
`dsh-novel-writer` is a self-contained **DSH (Cordis) bundle plugin** for novel-writing assistance, with **no third-party dependencies** (Node built-ins only). See the sections above for features, tools, install, config, and security notes. MIT licensed.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-novel-writer bundle patch: mounts the novel-writing assistant plugin
|
|
2
|
+
# into the profile's loader tree. The entry's `name` is the plugin package
|
|
3
|
+
# itself, resolved from the profile's node_modules.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: novel-writer
|
|
6
|
+
name: 'dsh-novel-writer'
|
|
7
|
+
config:
|
|
8
|
+
root: ''
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-novel-writer — 小说写作助手插件
|
|
3
|
+
*
|
|
4
|
+
* 一个零第三方依赖的 cordis bundle 插件,为模型提供一套小说写作工具:
|
|
5
|
+
* - novel_books 列出章节库中的所有作品
|
|
6
|
+
* - novel_chapters 列出某部作品的章节清单(字数/行数/更新时间)
|
|
7
|
+
* - novel_read 阅读某个章节(带行号、字数统计、分段读取)
|
|
8
|
+
* - novel_keywords 确定性提取章节/全书高频关键词(中文词组+英文词)
|
|
9
|
+
* - novel_new_chapter 创建新章节文件
|
|
10
|
+
*
|
|
11
|
+
* 章节库约定:<root>/novels/<书名>/第N章.md(或 .txt/.markdown)
|
|
12
|
+
* root 默认取会话工作区(session cwd),可通过插件 config.root 覆盖。
|
|
13
|
+
*
|
|
14
|
+
* 本文件只使用 Node 内置模块,不依赖任何 @deepseek-ai/* 运行时包,
|
|
15
|
+
* 因此作为 file: 链接安装进 profile 后无需任何额外依赖下载。
|
|
16
|
+
*
|
|
17
|
+
* 注意:output.schema 必须使用标准 JSON Schema 子集(required 是对象层的
|
|
18
|
+
* 字符串数组),与 tools.register() 的 assertSupportedJsonSchema 一致。
|
|
19
|
+
*/
|
|
20
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
21
|
+
import { basename, extname, join, sep } from "node:path";
|
|
22
|
+
|
|
23
|
+
const name = "novel-writer";
|
|
24
|
+
const inject = ["tools", "systemPrompt"];
|
|
25
|
+
|
|
26
|
+
const CHAPTER_EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
|
|
27
|
+
/** 单次 novel_read 返回的最大行数(模型上下文保护)。 */
|
|
28
|
+
const READ_LIMIT = 400;
|
|
29
|
+
/** 单次 novel_read 返回的最大字符数。 */
|
|
30
|
+
const READ_MAX_CHARS = 20000;
|
|
31
|
+
/** 文件名里出现的章节序号前缀正则:第01章 / 01 / 01-标题 / 1.标题 等。 */
|
|
32
|
+
const LEADING_NUMBER = /^第?(\d+)[章回话]?[\s._\-—]*/;
|
|
33
|
+
/** 中文数字字符 → 数值。 */
|
|
34
|
+
const CJK_DIGITS = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
|
|
35
|
+
|
|
36
|
+
/** 中文数字(一~九十九,支持 十/百 组合)转阿拉伯数字;无法解析返回 void 0。 */
|
|
37
|
+
function cjkToNumber(text) {
|
|
38
|
+
const s = String(text).trim();
|
|
39
|
+
if (s === "") return void 0;
|
|
40
|
+
let total = 0;
|
|
41
|
+
let section = 0;
|
|
42
|
+
let hasUnit = false;
|
|
43
|
+
for (const ch of s) {
|
|
44
|
+
if (ch === "十") {
|
|
45
|
+
total += (section === 0 ? 1 : section) * 10;
|
|
46
|
+
section = 0;
|
|
47
|
+
hasUnit = true;
|
|
48
|
+
} else if (ch === "百") {
|
|
49
|
+
total += (section === 0 ? 1 : section) * 100;
|
|
50
|
+
section = 0;
|
|
51
|
+
hasUnit = true;
|
|
52
|
+
} else if (ch in CJK_DIGITS) {
|
|
53
|
+
section = CJK_DIGITS[ch];
|
|
54
|
+
} else {
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!hasUnit && section === 0) return void 0;
|
|
59
|
+
return total + section;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 从文件名/章节标识中提取章节序号(阿拉伯或中文数字,可出现在任意位置);失败返回 void 0。
|
|
63
|
+
* 支持:第25章 / 25章 / 01-标题 / 1.标题 / 第一章 / 第十四章 / 第1话。 */
|
|
64
|
+
function parseChapterNumber(name) {
|
|
65
|
+
const stem = String(name).replace(/\.[^.]+$/, "");
|
|
66
|
+
const arabicMark = /第?(\d{1,4})[章回话]/.exec(stem);
|
|
67
|
+
if (arabicMark) return Number(arabicMark[1]);
|
|
68
|
+
const leading = LEADING_NUMBER.exec(stem);
|
|
69
|
+
if (leading) return Number(leading[1]);
|
|
70
|
+
const cjkMark = /第([零一二三四五六七八九十百两]+)[章回话]/.exec(stem);
|
|
71
|
+
if (cjkMark) {
|
|
72
|
+
const n = cjkToNumber(cjkMark[1]);
|
|
73
|
+
if (n !== void 0) return n;
|
|
74
|
+
}
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 清理章节标题:去掉"第N章/N章"标记、行首序号与常见分隔符。 */
|
|
79
|
+
function cleanChapterTitle(stem) {
|
|
80
|
+
return stem
|
|
81
|
+
.replace(/第?(\d{1,4})[章回话]/g, "")
|
|
82
|
+
.replace(/第[零一二三四五六七八九十百两]+[章回话]/g, "")
|
|
83
|
+
.replace(LEADING_NUMBER, "")
|
|
84
|
+
.replace(/[_\-.]+/g, " ")
|
|
85
|
+
.trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 中文虚词/功能字黑名单:不参与关键词统计。 */
|
|
89
|
+
const CJK_STOP_CHARS = new Set(
|
|
90
|
+
"的了是在有和就都而于及与或这那之其以被把让向着过也还又但更最很从对为等啊吧呢吗嗯哦呀哈啦么个中上下前后左右来去出进到说想看要会能可没有不".split("")
|
|
91
|
+
);
|
|
92
|
+
/** 英文停用词。 */
|
|
93
|
+
const EN_STOP_WORDS = new Set(
|
|
94
|
+
"the and of to in a an is are was were be been being it its this that these those i you he she they we my your his her their our me him them us as at by for with on from or but not no so if then than when where which who whom what how why all any both each few more most other some such only own same too very just can could will would shall should may might must do does did done have has had having about into over under again further once here there".split(/\s+/)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
function assert(condition, message) {
|
|
98
|
+
if (!condition) throw new Error(message);
|
|
99
|
+
}
|
|
100
|
+
function requiredString(args, key) {
|
|
101
|
+
const value = args?.[key];
|
|
102
|
+
assert(typeof value === "string" && value.trim() !== "", `invalid arguments: "${key}" must be a non-empty string`);
|
|
103
|
+
return value.trim();
|
|
104
|
+
}
|
|
105
|
+
function optionalString(args, key) {
|
|
106
|
+
const value = args?.[key];
|
|
107
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
108
|
+
}
|
|
109
|
+
function optionalInt(args, key, min, max, fallback) {
|
|
110
|
+
const value = args?.[key];
|
|
111
|
+
if (value === void 0) return fallback;
|
|
112
|
+
assert(Number.isInteger(value) && value >= min && value <= max, `invalid arguments: "${key}" must be an integer between ${min} and ${max}`);
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
/** 清洗书名/章节名,防止路径穿越。 */
|
|
116
|
+
function sanitizeSegment(value, label) {
|
|
117
|
+
const cleaned = value.replace(/[\\/]/g, "");
|
|
118
|
+
assert(cleaned.length > 0 && !["", ".", ".."].includes(cleaned), `invalid arguments: ${label} contains invalid path characters`);
|
|
119
|
+
return cleaned;
|
|
120
|
+
}
|
|
121
|
+
function sessionCwd(exec) {
|
|
122
|
+
return exec?.agent?.session?.header?.cwd ?? process.cwd();
|
|
123
|
+
}
|
|
124
|
+
/** 解析章节库根目录:调用级 root 参数 > 插件 config.root > 会话工作区。 */
|
|
125
|
+
function resolveRoot(config, args, exec) {
|
|
126
|
+
const override = optionalString(args, "root");
|
|
127
|
+
if (override !== void 0) return override;
|
|
128
|
+
const configured = config?.root;
|
|
129
|
+
if (typeof configured === "string" && configured.trim() !== "") return configured.trim();
|
|
130
|
+
return sessionCwd(exec);
|
|
131
|
+
}
|
|
132
|
+
function novelsDir(root) {
|
|
133
|
+
return join(root, "novels");
|
|
134
|
+
}
|
|
135
|
+
function bookDir(root, book) {
|
|
136
|
+
return join(novelsDir(root), sanitizeSegment(book, "book"));
|
|
137
|
+
}
|
|
138
|
+
/** 读取一部作品的章节文件列表(按序号排序),返回带元信息的条目。 */
|
|
139
|
+
async function scanChapters(bookPath) {
|
|
140
|
+
const entries = await readdir(bookPath, { withFileTypes: true });
|
|
141
|
+
const chapters = [];
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
if (!entry.isFile()) continue;
|
|
144
|
+
const ext = extname(entry.name).toLowerCase();
|
|
145
|
+
if (!CHAPTER_EXTENSIONS.has(ext)) continue;
|
|
146
|
+
chapters.push({
|
|
147
|
+
file: entry.name,
|
|
148
|
+
number: parseChapterNumber(entry.name),
|
|
149
|
+
title: cleanChapterTitle(entry.name.slice(0, -ext.length)) || entry.name
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
chapters.sort((a, b) => (a.number ?? Number.MAX_SAFE_INTEGER) - (b.number ?? Number.MAX_SAFE_INTEGER) || a.file.localeCompare(b.file));
|
|
153
|
+
return chapters;
|
|
154
|
+
}
|
|
155
|
+
/** 读取文本文件并自动探测编码:UTF-8(含 BOM)、UTF-16 LE/BE(含 BOM 或无 BOM 启发式)、GBK/GB18030。
|
|
156
|
+
* 无法识别编码时抛错,提示将文件另存为 UTF-8。仅使用 Node 内置能力(TextDecoder)。 */
|
|
157
|
+
async function readTextFile(filePath, exec) {
|
|
158
|
+
const buf = await readFile(filePath, { signal: exec?.signal });
|
|
159
|
+
return decodeTextBuffer(buf, filePath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function decodeTextBuffer(buf, filePath) {
|
|
163
|
+
// 1) BOM 检测
|
|
164
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
165
|
+
return buf.subarray(3).toString("utf8"); // UTF-8 BOM
|
|
166
|
+
}
|
|
167
|
+
if (buf.length >= 2) {
|
|
168
|
+
if (buf[0] === 0xff && buf[1] === 0xfe) return buf.subarray(2).toString("utf16le"); // UTF-16 LE BOM
|
|
169
|
+
if (buf[0] === 0xfe && buf[1] === 0xff) return new TextDecoder("utf-16be").decode(buf.subarray(2)); // UTF-16 BE BOM
|
|
170
|
+
}
|
|
171
|
+
// 2) 无 BOM:先严格按 UTF-8 解码
|
|
172
|
+
try {
|
|
173
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
174
|
+
} catch {
|
|
175
|
+
/* 非 UTF-8,继续尝试 */
|
|
176
|
+
}
|
|
177
|
+
// 3) 0x00 字节分布启发式:高比例且集中在奇/偶位 → UTF-16 LE/BE
|
|
178
|
+
let evenNul = 0;
|
|
179
|
+
let oddNul = 0;
|
|
180
|
+
for (let i = 0; i < buf.length; i += 1) {
|
|
181
|
+
if (buf[i] === 0) {
|
|
182
|
+
if (i % 2 === 0) evenNul += 1;
|
|
183
|
+
else oddNul += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const nulRatio = (evenNul + oddNul) / Math.max(buf.length, 1);
|
|
187
|
+
if (nulRatio > 0.05) {
|
|
188
|
+
return evenNul >= oddNul
|
|
189
|
+
? new TextDecoder("utf-16le").decode(buf)
|
|
190
|
+
: new TextDecoder("utf-16be").decode(buf);
|
|
191
|
+
}
|
|
192
|
+
// 4) 中文 Windows 常见的 GBK/GB18030(TextDecoder 原生支持 gbk)
|
|
193
|
+
const gbk = new TextDecoder("gbk").decode(buf);
|
|
194
|
+
if (!gbk.includes("\uFFFD")) return gbk;
|
|
195
|
+
throw new Error(`cannot detect text encoding of "${filePath}": 不是有效的 UTF-8/UTF-16/GBK,请将文件另存为 UTF-8 编码`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function chapterStats(bookPath, chapter) {
|
|
199
|
+
const info = await stat(join(bookPath, chapter.file));
|
|
200
|
+
const text = await readTextFile(join(bookPath, chapter.file));
|
|
201
|
+
return {
|
|
202
|
+
chars: text.length,
|
|
203
|
+
lines: text.length === 0 ? 0 : text.split(/\r?\n/).length,
|
|
204
|
+
size: info.size,
|
|
205
|
+
updated: info.mtime.toISOString()
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** 解析"章节参数":可以是文件名、章号或标题子串。 */
|
|
209
|
+
function findChapter(chapters, chapterArg) {
|
|
210
|
+
const needle = String(chapterArg).trim();
|
|
211
|
+
const asNumber = parseChapterNumber(needle);
|
|
212
|
+
if (asNumber !== void 0) {
|
|
213
|
+
const byNumber = chapters.find((c) => c.number === asNumber);
|
|
214
|
+
if (byNumber) return byNumber;
|
|
215
|
+
}
|
|
216
|
+
const lower = needle.toLowerCase();
|
|
217
|
+
const byFile = chapters.find((c) => c.file.toLowerCase() === lower);
|
|
218
|
+
if (byFile) return byFile;
|
|
219
|
+
const byTitle = chapters.find((c) => c.title.toLowerCase().includes(lower) || c.file.toLowerCase().includes(lower));
|
|
220
|
+
if (byTitle) return byTitle;
|
|
221
|
+
return void 0;
|
|
222
|
+
}
|
|
223
|
+
/** 简单中文分词:单字频率 + 相邻双字(二元组)频率。 */
|
|
224
|
+
function extractKeywords(text, top) {
|
|
225
|
+
const cjkBigrams = new Map();
|
|
226
|
+
const cjkChars = new Map();
|
|
227
|
+
const enWords = new Map();
|
|
228
|
+
const cjkRun = [];
|
|
229
|
+
for (const ch of text) {
|
|
230
|
+
if (/[\u4e00-\u9fff]/.test(ch)) cjkRun.push(ch);
|
|
231
|
+
else if (cjkRun.length > 0) {
|
|
232
|
+
tallyCjkRun(cjkRun, cjkChars, cjkBigrams);
|
|
233
|
+
cjkRun.length = 0;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (cjkRun.length > 0) tallyCjkRun(cjkRun, cjkChars, cjkBigrams);
|
|
237
|
+
for (const match of text.toLowerCase().matchAll(/[a-z]{2,}/g)) {
|
|
238
|
+
const word = match[0];
|
|
239
|
+
if (!EN_STOP_WORDS.has(word)) enWords.set(word, (enWords.get(word) ?? 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
const keywords = [];
|
|
242
|
+
for (const [word, count] of cjkBigrams) {
|
|
243
|
+
if (count > 1) keywords.push({ word, count, kind: "cjk-bigram" });
|
|
244
|
+
}
|
|
245
|
+
for (const [word, count] of enWords) {
|
|
246
|
+
if (count > 1) keywords.push({ word, count, kind: "word" });
|
|
247
|
+
}
|
|
248
|
+
keywords.sort((a, b) => b.count - a.count || a.word.localeCompare(b.word));
|
|
249
|
+
return keywords.slice(0, top);
|
|
250
|
+
}
|
|
251
|
+
function tallyCjkRun(run, cjkChars, cjkBigrams) {
|
|
252
|
+
for (let i = 0; i < run.length; i += 1) {
|
|
253
|
+
const ch = run[i];
|
|
254
|
+
if (!CJK_STOP_CHARS.has(ch)) cjkChars.set(ch, (cjkChars.get(ch) ?? 0) + 1);
|
|
255
|
+
if (i + 1 < run.length) {
|
|
256
|
+
const bigram = run[i] + run[i + 1];
|
|
257
|
+
const meaningful = !CJK_STOP_CHARS.has(run[i]) || !CJK_STOP_CHARS.has(run[i + 1]);
|
|
258
|
+
if (meaningful) cjkBigrams.set(bigram, (cjkBigrams.get(bigram) ?? 0) + 1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---- 工具定义 ----
|
|
264
|
+
|
|
265
|
+
function registerNovelBooks(ctx, config) {
|
|
266
|
+
ctx.tools.register({
|
|
267
|
+
name: "novel_books",
|
|
268
|
+
description: "列出小说章节库中的全部作品(novels 文件夹下的子目录),含章节数与总字数。",
|
|
269
|
+
parameters: {
|
|
270
|
+
type: "object",
|
|
271
|
+
properties: {
|
|
272
|
+
root: { type: "string", description: "章节库根目录(含 novels 子目录)。默认取会话工作区。" }
|
|
273
|
+
},
|
|
274
|
+
additionalProperties: false
|
|
275
|
+
},
|
|
276
|
+
output: {
|
|
277
|
+
schema: {
|
|
278
|
+
type: "object",
|
|
279
|
+
additionalProperties: false,
|
|
280
|
+
properties: {
|
|
281
|
+
root: { type: "string" },
|
|
282
|
+
books: {
|
|
283
|
+
type: "array",
|
|
284
|
+
items: {
|
|
285
|
+
type: "object",
|
|
286
|
+
additionalProperties: false,
|
|
287
|
+
properties: {
|
|
288
|
+
name: { type: "string" },
|
|
289
|
+
chapters: { type: "integer" },
|
|
290
|
+
chars: { type: "integer" }
|
|
291
|
+
},
|
|
292
|
+
required: ["name", "chapters", "chars"]
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
required: ["root", "books"]
|
|
297
|
+
},
|
|
298
|
+
render: (_args, value) => [{
|
|
299
|
+
type: "text",
|
|
300
|
+
text: formatBooks(value)
|
|
301
|
+
}]
|
|
302
|
+
},
|
|
303
|
+
async execute(args, exec) {
|
|
304
|
+
const root = resolveRoot(config, args, exec);
|
|
305
|
+
const dir = novelsDir(root);
|
|
306
|
+
let entries;
|
|
307
|
+
try {
|
|
308
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
309
|
+
} catch {
|
|
310
|
+
return { root, books: [] };
|
|
311
|
+
}
|
|
312
|
+
const books = [];
|
|
313
|
+
for (const entry of entries) {
|
|
314
|
+
if (!entry.isDirectory()) continue;
|
|
315
|
+
const bookPath = join(dir, entry.name);
|
|
316
|
+
let chapters;
|
|
317
|
+
try {
|
|
318
|
+
chapters = await scanChapters(bookPath);
|
|
319
|
+
} catch {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
let chars = 0;
|
|
323
|
+
for (const chapter of chapters) {
|
|
324
|
+
try {
|
|
325
|
+
chars += (await chapterStats(bookPath, chapter)).chars;
|
|
326
|
+
} catch { /* 跳过无法统计的章节 */ }
|
|
327
|
+
}
|
|
328
|
+
books.push({ name: entry.name, chapters: chapters.length, chars });
|
|
329
|
+
}
|
|
330
|
+
books.sort((a, b) => b.chars - a.chars || a.name.localeCompare(b.name));
|
|
331
|
+
return { root, books };
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function formatBooks(value) {
|
|
337
|
+
const lines = [`<path>${value.root}/novels</path>`, `<type>novel-library</type>`, `<content>`, ""];
|
|
338
|
+
if (value.books.length === 0) lines.push("(暂无作品。请在 novels/<书名>/ 下存放章节文件,如 第01章.md)");
|
|
339
|
+
for (const book of value.books) lines.push(`- ${book.name}: ${book.chapters} 章, ${book.chars} 字`);
|
|
340
|
+
lines.push("", "</content>");
|
|
341
|
+
return lines.join("\n");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function registerNovelChapters(ctx, config) {
|
|
345
|
+
ctx.tools.register({
|
|
346
|
+
name: "novel_chapters",
|
|
347
|
+
description: "列出某部作品的全部章节:章号、标题、字数、行数、更新时间。",
|
|
348
|
+
parameters: {
|
|
349
|
+
type: "object",
|
|
350
|
+
properties: {
|
|
351
|
+
book: { type: "string", description: "书名(novels 下的子目录名)。" },
|
|
352
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
353
|
+
},
|
|
354
|
+
required: ["book"],
|
|
355
|
+
additionalProperties: false
|
|
356
|
+
},
|
|
357
|
+
output: {
|
|
358
|
+
schema: {
|
|
359
|
+
type: "object",
|
|
360
|
+
additionalProperties: false,
|
|
361
|
+
properties: {
|
|
362
|
+
book: { type: "string" },
|
|
363
|
+
chapters: {
|
|
364
|
+
type: "array",
|
|
365
|
+
items: {
|
|
366
|
+
type: "object",
|
|
367
|
+
additionalProperties: false,
|
|
368
|
+
properties: {
|
|
369
|
+
file: { type: "string" },
|
|
370
|
+
number: { type: "integer" },
|
|
371
|
+
title: { type: "string" },
|
|
372
|
+
chars: { type: "integer" },
|
|
373
|
+
lines: { type: "integer" },
|
|
374
|
+
updated: { type: "string" }
|
|
375
|
+
},
|
|
376
|
+
required: ["file", "title", "chars", "lines", "updated"]
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
required: ["book", "chapters"]
|
|
381
|
+
},
|
|
382
|
+
render: (_args, value) => [{
|
|
383
|
+
type: "text",
|
|
384
|
+
text: formatChapters(value)
|
|
385
|
+
}]
|
|
386
|
+
},
|
|
387
|
+
async execute(args, exec) {
|
|
388
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
389
|
+
const root = resolveRoot(config, args, exec);
|
|
390
|
+
const dir = bookDir(root, book);
|
|
391
|
+
const chapters = await scanChapters(dir);
|
|
392
|
+
const result = [];
|
|
393
|
+
for (const chapter of chapters) {
|
|
394
|
+
const stats = await chapterStats(dir, chapter);
|
|
395
|
+
result.push({
|
|
396
|
+
file: chapter.file,
|
|
397
|
+
...chapter.number === void 0 ? {} : { number: chapter.number },
|
|
398
|
+
title: chapter.title,
|
|
399
|
+
chars: stats.chars,
|
|
400
|
+
lines: stats.lines,
|
|
401
|
+
updated: stats.updated
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return { book, chapters: result };
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function formatChapters(value) {
|
|
410
|
+
const lines = [`<path>novels/${value.book}</path>`, `<type>novel-chapters</type>`, `<content>`, ""];
|
|
411
|
+
if (value.chapters.length === 0) lines.push("(该作品下没有章节文件)");
|
|
412
|
+
for (const chapter of value.chapters) {
|
|
413
|
+
const number = chapter.number === void 0 ? "?" : String(chapter.number).padStart(2, "0");
|
|
414
|
+
lines.push(`- 第${number}章 ${chapter.title} — ${chapter.chars} 字 / ${chapter.lines} 行 (${chapter.file}, 更新于 ${chapter.updated.slice(0, 10)})`);
|
|
415
|
+
}
|
|
416
|
+
lines.push("", "</content>");
|
|
417
|
+
return lines.join("\n");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function registerNovelRead(ctx, config) {
|
|
421
|
+
ctx.tools.register({
|
|
422
|
+
name: "novel_read",
|
|
423
|
+
description: "阅读某部作品的某个章节,返回带行号的正文(含字数统计)。可用 offset/limit 分段读取长章节。",
|
|
424
|
+
parameters: {
|
|
425
|
+
type: "object",
|
|
426
|
+
properties: {
|
|
427
|
+
book: { type: "string", description: "书名。" },
|
|
428
|
+
chapter: { type: "string", description: "章节标识:章号(如 1 或 01)、文件名(第01章.md)或标题子串。" },
|
|
429
|
+
offset: { type: "integer", description: "起始行号,从 1 开始。默认 1。" },
|
|
430
|
+
limit: { type: "integer", description: `最多返回行数。默认 ${READ_LIMIT}。` },
|
|
431
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
432
|
+
},
|
|
433
|
+
required: ["book", "chapter"],
|
|
434
|
+
additionalProperties: false
|
|
435
|
+
},
|
|
436
|
+
output: {
|
|
437
|
+
schema: {
|
|
438
|
+
type: "object",
|
|
439
|
+
additionalProperties: false,
|
|
440
|
+
properties: {
|
|
441
|
+
book: { type: "string" },
|
|
442
|
+
chapter: { type: "string" },
|
|
443
|
+
file: { type: "string" },
|
|
444
|
+
path: { type: "string" },
|
|
445
|
+
offset: { type: "integer" },
|
|
446
|
+
totalLines: { type: "integer" },
|
|
447
|
+
chars: { type: "integer" },
|
|
448
|
+
truncated: { type: "boolean" },
|
|
449
|
+
lines: {
|
|
450
|
+
type: "array",
|
|
451
|
+
items: {
|
|
452
|
+
type: "object",
|
|
453
|
+
additionalProperties: false,
|
|
454
|
+
properties: {
|
|
455
|
+
number: { type: "integer" },
|
|
456
|
+
text: { type: "string" }
|
|
457
|
+
},
|
|
458
|
+
required: ["number", "text"]
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
required: ["book", "chapter", "file", "path", "offset", "totalLines", "chars", "truncated", "lines"]
|
|
463
|
+
},
|
|
464
|
+
render: (_args, value) => [{
|
|
465
|
+
type: "text",
|
|
466
|
+
text: formatRead(value)
|
|
467
|
+
}]
|
|
468
|
+
},
|
|
469
|
+
async execute(args, exec) {
|
|
470
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
471
|
+
const chapterArg = requiredString(args, "chapter");
|
|
472
|
+
const offset = optionalInt(args, "offset", 1, Number.MAX_SAFE_INTEGER, 1);
|
|
473
|
+
const limit = optionalInt(args, "limit", 1, READ_LIMIT, READ_LIMIT);
|
|
474
|
+
const root = resolveRoot(config, args, exec);
|
|
475
|
+
const dir = bookDir(root, book);
|
|
476
|
+
const chapters = await scanChapters(dir);
|
|
477
|
+
const chapter = findChapter(chapters, chapterArg);
|
|
478
|
+
assert(chapter !== void 0, `cannot find chapter "${chapterArg}" in book "${book}" (可用 novel_chapters 查看章节列表)`);
|
|
479
|
+
const filePath = join(dir, chapter.file);
|
|
480
|
+
const text = await readTextFile(filePath, exec);
|
|
481
|
+
const allLines = text.split(/\r?\n/);
|
|
482
|
+
const totalLines = allLines.length;
|
|
483
|
+
assert(offset <= totalLines || (totalLines === 0 && offset === 1), `offset ${offset} is out of range for "${chapter.file}" (${totalLines} lines)`);
|
|
484
|
+
let chars = 0;
|
|
485
|
+
const lines = [];
|
|
486
|
+
for (let i = offset - 1; i < allLines.length && lines.length < limit; i += 1) {
|
|
487
|
+
const line = allLines[i];
|
|
488
|
+
chars += line.length + (i < allLines.length - 1 ? 1 : 0);
|
|
489
|
+
if (chars > READ_MAX_CHARS && lines.length > 0) {
|
|
490
|
+
return {
|
|
491
|
+
book,
|
|
492
|
+
chapter: chapter.file,
|
|
493
|
+
file: chapter.file,
|
|
494
|
+
path: filePath,
|
|
495
|
+
offset,
|
|
496
|
+
totalLines,
|
|
497
|
+
chars: text.length,
|
|
498
|
+
truncated: true,
|
|
499
|
+
lines
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
lines.push({ number: i + 1, text: line });
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
book,
|
|
506
|
+
chapter: chapter.file,
|
|
507
|
+
file: chapter.file,
|
|
508
|
+
path: filePath,
|
|
509
|
+
offset,
|
|
510
|
+
totalLines,
|
|
511
|
+
chars: text.length,
|
|
512
|
+
truncated: lines.length < limit && offset + lines.length - 1 < totalLines,
|
|
513
|
+
lines
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function formatRead(value) {
|
|
520
|
+
const endLine = value.lines.length > 0 ? value.lines[value.lines.length - 1].number : value.offset - 1;
|
|
521
|
+
const body = value.lines.map((line) => `${line.number}: ${line.text}`).join("\n");
|
|
522
|
+
let footer;
|
|
523
|
+
if (value.truncated) footer = `(输出截断。共 ${value.totalLines} 行 / ${value.chars} 字,已显示 ${value.offset}-${endLine} 行。用 offset=${endLine + 1} 继续阅读。)`;
|
|
524
|
+
else if (endLine < value.totalLines) footer = `(共 ${value.totalLines} 行 / ${value.chars} 字,已显示 ${value.offset}-${endLine} 行。用 offset=${endLine + 1} 继续阅读。)`;
|
|
525
|
+
else footer = `(本章共 ${value.totalLines} 行 / ${value.chars} 字)`;
|
|
526
|
+
return `<path>${value.path}</path>
|
|
527
|
+
<type>novel-chapter</type>
|
|
528
|
+
<content>
|
|
529
|
+
${body === "" ? "" : `${body}\n`}
|
|
530
|
+
${footer}
|
|
531
|
+
</content>`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function registerNovelKeywords(ctx, config) {
|
|
535
|
+
ctx.tools.register({
|
|
536
|
+
name: "novel_keywords",
|
|
537
|
+
description: "统计某部作品(或单个章节)中出现频率较高的关键词:中文相邻二字词组、高频单字与英文词。用于分析作者的词汇偏好与意象母题。",
|
|
538
|
+
parameters: {
|
|
539
|
+
type: "object",
|
|
540
|
+
properties: {
|
|
541
|
+
book: { type: "string", description: "书名。" },
|
|
542
|
+
chapter: { type: "string", description: "可选。只统计该章节;省略则统计全书。" },
|
|
543
|
+
top: { type: "integer", description: "返回的关键词数量。默认 20。" },
|
|
544
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
545
|
+
},
|
|
546
|
+
required: ["book"],
|
|
547
|
+
additionalProperties: false
|
|
548
|
+
},
|
|
549
|
+
output: {
|
|
550
|
+
schema: {
|
|
551
|
+
type: "object",
|
|
552
|
+
additionalProperties: false,
|
|
553
|
+
properties: {
|
|
554
|
+
book: { type: "string" },
|
|
555
|
+
scope: { type: "string" },
|
|
556
|
+
totalChars: { type: "integer" },
|
|
557
|
+
keywords: {
|
|
558
|
+
type: "array",
|
|
559
|
+
items: {
|
|
560
|
+
type: "object",
|
|
561
|
+
additionalProperties: false,
|
|
562
|
+
properties: {
|
|
563
|
+
word: { type: "string" },
|
|
564
|
+
count: { type: "integer" },
|
|
565
|
+
kind: { type: "string", enum: ["cjk-bigram", "word"] }
|
|
566
|
+
},
|
|
567
|
+
required: ["word", "count", "kind"]
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
required: ["book", "scope", "totalChars", "keywords"]
|
|
572
|
+
},
|
|
573
|
+
render: (_args, value) => [{
|
|
574
|
+
type: "text",
|
|
575
|
+
text: formatKeywords(value)
|
|
576
|
+
}]
|
|
577
|
+
},
|
|
578
|
+
async execute(args, exec) {
|
|
579
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
580
|
+
const chapterArg = optionalString(args, "chapter");
|
|
581
|
+
const top = optionalInt(args, "top", 1, 100, 20);
|
|
582
|
+
const root = resolveRoot(config, args, exec);
|
|
583
|
+
const dir = bookDir(root, book);
|
|
584
|
+
const chapters = await scanChapters(dir);
|
|
585
|
+
assert(chapters.length > 0, `book "${book}" has no chapter files`);
|
|
586
|
+
let selected;
|
|
587
|
+
let scope;
|
|
588
|
+
if (chapterArg !== void 0) {
|
|
589
|
+
selected = [findChapter(chapters, chapterArg)];
|
|
590
|
+
assert(selected[0] !== void 0, `cannot find chapter "${chapterArg}" in book "${book}"`);
|
|
591
|
+
scope = selected[0].file;
|
|
592
|
+
} else {
|
|
593
|
+
selected = chapters;
|
|
594
|
+
scope = `全书 ${chapters.length} 章`;
|
|
595
|
+
}
|
|
596
|
+
let text = "";
|
|
597
|
+
for (const chapter of selected) {
|
|
598
|
+
text += await readTextFile(join(dir, chapter.file), exec);
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
book,
|
|
602
|
+
scope,
|
|
603
|
+
totalChars: text.length,
|
|
604
|
+
keywords: extractKeywords(text, top)
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function formatKeywords(value) {
|
|
611
|
+
const lines = [`<path>novels/${value.book}</path>`, `<type>novel-keywords</type>`, `<content>`, `统计范围: ${value.scope} (共 ${value.totalChars} 字)`, ""];
|
|
612
|
+
if (value.keywords.length === 0) lines.push("(未提取到重复出现的关键词)");
|
|
613
|
+
for (const keyword of value.keywords) {
|
|
614
|
+
const kind = keyword.kind === "cjk-bigram" ? "词组" : "英文词";
|
|
615
|
+
lines.push(`- ${keyword.word} × ${keyword.count} (${kind})`);
|
|
616
|
+
}
|
|
617
|
+
lines.push("", "</content>");
|
|
618
|
+
return lines.join("\n");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function registerNovelNewChapter(ctx, config) {
|
|
622
|
+
ctx.tools.register({
|
|
623
|
+
name: "novel_new_chapter",
|
|
624
|
+
description: "为某部作品创建新章节文件(默认自动取下一个章号)。可指定标题与初始正文。",
|
|
625
|
+
parameters: {
|
|
626
|
+
type: "object",
|
|
627
|
+
properties: {
|
|
628
|
+
book: { type: "string", description: "书名。" },
|
|
629
|
+
chapter: { type: "integer", description: "可选。显式指定章号;省略则取现有最大章号 + 1。" },
|
|
630
|
+
title: { type: "string", description: "可选。章节标题,会写入 Markdown 一级标题。" },
|
|
631
|
+
content: { type: "string", description: "可选。章节初始正文。" },
|
|
632
|
+
root: { type: "string", description: "章节库根目录。默认取会话工作区。" }
|
|
633
|
+
},
|
|
634
|
+
required: ["book"],
|
|
635
|
+
additionalProperties: false
|
|
636
|
+
},
|
|
637
|
+
output: {
|
|
638
|
+
schema: {
|
|
639
|
+
type: "object",
|
|
640
|
+
additionalProperties: false,
|
|
641
|
+
properties: {
|
|
642
|
+
book: { type: "string" },
|
|
643
|
+
number: { type: "integer" },
|
|
644
|
+
file: { type: "string" },
|
|
645
|
+
path: { type: "string" },
|
|
646
|
+
chars: { type: "integer" }
|
|
647
|
+
},
|
|
648
|
+
required: ["book", "number", "file", "path", "chars"]
|
|
649
|
+
},
|
|
650
|
+
render: (_args, value) => [{
|
|
651
|
+
type: "text",
|
|
652
|
+
text: `<path>${value.path}</path>
|
|
653
|
+
<type>novel-chapter-created</type>
|
|
654
|
+
<content>
|
|
655
|
+
Created 第${String(value.number).padStart(2, "0")}章 (${value.chars} chars)
|
|
656
|
+
</content>`
|
|
657
|
+
}]
|
|
658
|
+
},
|
|
659
|
+
async execute(args, exec) {
|
|
660
|
+
const book = sanitizeSegment(requiredString(args, "book"), "book");
|
|
661
|
+
const explicit = optionalInt(args, "chapter", 1, 99999, void 0);
|
|
662
|
+
const title = optionalString(args, "title");
|
|
663
|
+
const content = optionalString(args, "content");
|
|
664
|
+
const root = resolveRoot(config, args, exec);
|
|
665
|
+
const dir = bookDir(root, book);
|
|
666
|
+
await mkdir(dir, { recursive: true });
|
|
667
|
+
let chapters = [];
|
|
668
|
+
try {
|
|
669
|
+
chapters = await scanChapters(dir);
|
|
670
|
+
} catch { /* 空目录 */ }
|
|
671
|
+
let number = explicit;
|
|
672
|
+
if (number === void 0) {
|
|
673
|
+
const maxNumber = chapters.reduce((max, c) => c.number !== void 0 ? Math.max(max, c.number) : max, 0);
|
|
674
|
+
number = maxNumber + 1;
|
|
675
|
+
}
|
|
676
|
+
const fileName = `第${String(number).padStart(2, "0")}章.md`;
|
|
677
|
+
const filePath = join(dir, fileName);
|
|
678
|
+
const parts = [];
|
|
679
|
+
if (title !== void 0) parts.push(`# ${title}`, "");
|
|
680
|
+
if (content !== void 0) parts.push(content, "");
|
|
681
|
+
const text = parts.join("\n");
|
|
682
|
+
await writeFile(filePath, text, { encoding: "utf8", signal: exec.signal });
|
|
683
|
+
return { book, number, file: fileName, path: filePath, chars: text.length };
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** 系统提示词:小说写作工作流(需求 1-3 的模型侧约定)。 */
|
|
689
|
+
const WORKFLOW_TEXT = [
|
|
690
|
+
"【小说写作助手 novel-writer】",
|
|
691
|
+
"1. 章节库约定:小说章节存放在工作区 novels/<书名>/ 文件夹下,每章一个 Markdown 文件(如 第01章.md、第02章.md)。",
|
|
692
|
+
" 优先使用 novel_books / novel_chapters / novel_read 工具浏览与阅读章节;也可以用 read/glob 等通用工具直接查看。",
|
|
693
|
+
"2. 分析小说时(剧情 / 写作手法 / 关键词):先 novel_books 找到作品,novel_chapters 了解章节结构,再 novel_read 逐章通读(长章节分段读完全文)。",
|
|
694
|
+
" 分析至少覆盖三方面:剧情脉络(冲突、转折、悬念、伏笔、人物关系与目标)、写作手法(叙事视角、节奏、对话、环境与细节描写、比喻意象、留白)、",
|
|
695
|
+
" 以及用 novel_keywords 提取的高频关键词(作者词汇偏好与意象母题),并说明这些手法/关键词在具体章节中的例证。",
|
|
696
|
+
"3. 续写 / 辅助写作时:先通读最近的章节(保持人物、时间线、情节伏笔、文风与已提炼的关键词一致),再动笔。",
|
|
697
|
+
" 新章节写入 novels/<书名>/ 文件夹:用 novel_new_chapter 创建章节文件,或直接用 write 工具写文件。",
|
|
698
|
+
"4. 每次续写前先简述:上一章结尾状态 → 本章目标 → 写作计划,再给出正文。"
|
|
699
|
+
].join("\n");
|
|
700
|
+
|
|
701
|
+
function apply(ctx, config = {}) {
|
|
702
|
+
registerNovelBooks(ctx, config);
|
|
703
|
+
registerNovelChapters(ctx, config);
|
|
704
|
+
registerNovelRead(ctx, config);
|
|
705
|
+
registerNovelKeywords(ctx, config);
|
|
706
|
+
registerNovelNewChapter(ctx, config);
|
|
707
|
+
ctx.systemPrompt.section({
|
|
708
|
+
name: "novel-writing",
|
|
709
|
+
order: 150,
|
|
710
|
+
text: WORKFLOW_TEXT
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export { apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-novel-writer",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "小说写作助手插件:章节库管理、剧情/写作手法/高频关键词分析、续写辅助(支持任意位置章号、中文数字、UTF-8/UTF-16/GBK 编码自动探测)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"dsh": {
|
|
8
|
+
"bundle": {
|
|
9
|
+
"patch": "./cordis.patch.yml"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"skills",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"INSTALL.md",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"cordis",
|
|
22
|
+
"deepseek-harness",
|
|
23
|
+
"plugin",
|
|
24
|
+
"novel",
|
|
25
|
+
"writing",
|
|
26
|
+
"fiction",
|
|
27
|
+
"chapters"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/siweina/dsh-novel-writer.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/siweina/dsh-novel-writer",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/siweina/dsh-novel-writer/issues"
|
|
36
|
+
},
|
|
37
|
+
"author": "siweina",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"dependencies": {}
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: novel-writing
|
|
3
|
+
description: 小说写作助手工作流:管理章节库、分析剧情/写作手法/高频关键词、保持文风续写。当用户提到“小说/章节/续写/剧情分析/写作手法/关键词”时使用。
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 小说写作助手 (novel-writing)
|
|
8
|
+
|
|
9
|
+
## 章节库约定
|
|
10
|
+
|
|
11
|
+
- 所有小说章节存放在工作区 `novels/<书名>/` 文件夹下,每章一个 Markdown 文件,命名如 `第01章.md`、`第02章.md`。
|
|
12
|
+
- 优先使用专用工具 `novel_books` / `novel_chapters` / `novel_read` 浏览与阅读;也可用 `glob`/`read` 直接查看。
|
|
13
|
+
|
|
14
|
+
## 分析工作流
|
|
15
|
+
|
|
16
|
+
1. `novel_books` 找到用户的作品;
|
|
17
|
+
2. `novel_chapters` 查看章节结构与各章字数;
|
|
18
|
+
3. `novel_read` 逐章通读(长章节用 offset/limit 分段读完);
|
|
19
|
+
4. `novel_keywords` 提取高频关键词(中文词组/单字 + 英文词),作为词汇偏好与意象母题的证据;
|
|
20
|
+
5. 输出分析,至少覆盖三方面:
|
|
21
|
+
- **剧情脉络**:冲突、转折、悬念、伏笔、人物关系与目标;
|
|
22
|
+
- **写作手法**:叙事视角、节奏、对话、环境与细节描写、比喻意象、留白;
|
|
23
|
+
- **关键词与意象**:结合 novel_keywords 的结果,指出作者反复使用的词汇与意象,并引用具体章节佐证。
|
|
24
|
+
|
|
25
|
+
## 续写工作流
|
|
26
|
+
|
|
27
|
+
1. 先通读最近的章节(必要时读完全书),保持:人物设定、时间线、情节伏笔、文风、已提炼的关键词一致;
|
|
28
|
+
2. 动笔前简述:上一章结尾状态 → 本章目标 → 写作计划;
|
|
29
|
+
3. 用 `novel_new_chapter` 创建新章节文件,或直接用 `write` 工具写入 `novels/<书名>/第NN章.md`;
|
|
30
|
+
4. 正文用中文,保持与原文一致的叙事风格与细节密度。
|
|
31
|
+
|
|
32
|
+
## 注意事项
|
|
33
|
+
|
|
34
|
+
- 用户放入章节文件的方式不限:可以自己复制进文件夹,也可以让 AI 用 `write`/`edit` 工具创建。
|
|
35
|
+
- 分析结果与续写正文都应使用中文呈现。
|