dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DeepSeek Harness 插件:让 agent 用 novel_* 工具写小说(章节 / SillyTavern 角色卡 / 世界观词条 / 写作预设 / 上下文组装 / 导出),数据落工作区文件
用工作区里的普通文件写小说:agent 直接用 novel_* 工具建作品、写正文、导角色卡、维护世界观词条、
检索与导出,续写由 harness 的模型承担,插件负责把作者的设定(世界观 / 命中词条 / 参与角色 /
写作预设 / 前文摘录)组装成写作简报交给它;方法层面的东西(文风、对话、节奏、结构)由自带的技能承载,
也可以导入你自己的。另外带一个侧栏面板:读起来像一本书,也能在面板上改章节正文、导入角色卡、设目标字数。
本仓库就是插件包(dsh.bundle.patch → cordis.patch.yml,向 profile 插入一行 id: novelnovel)。
领域逻辑(角色卡解析、预设解析、技能包解析、提示词组装、词条关键词匹配、章节排序、全文搜索、
PNG 卡读写、字数统计)集中在 src/domain/,与 harness 侧的存储/注册代码分离。
侧栏图标打开的面板(截图里是演示数据:3 章 / 4 张卡 / 3 条词条):

左栏是章节(字数、体量条、自检标记)与角色卡(参与开关直接点),右栏是正文;顶部一行给出这本书的 规模与目标字数,章节下方一行是本章目标与「本章注入了哪些词条」。
章节拖拽排序——拖到任意位置,落点画一条线;↑↓ 按钮保留给键盘与触屏。

正文编辑——直接改磁盘上的 Markdown 原文。保存会带上「你打开时的那一版」,如果模型(或另一个窗口) 在这期间写过这一章,保存会明确失败并让你重新加载,而不是静默覆盖。

前置:dsh CLI 与 pnpm 都在 PATH 上——dsh plugin 是把参数转发给 profile 目录里的 pnpm。
桌面端安装里两者都不在 PATH 上,用应用自带的 wrapper:<安装目录>\resources\runtime\cli\bin\dsh.cmd
(它内部已经把 pnpm 指向 resources/runtime/pnpm/bin/pnpm.mjs)。下面统一写成 $DSH。
nn),别装进日常界面客户端半边是 fail-closed 的:一个包声明了 dsh.client 而产物读不到,宿主启动时会同步抛错,
结果是所有客户端插件(含首方 UI)一起加载不出来。开发迭代期把面板装进日常用的 profile,
一次构建失误就能让你连界面都打不开。所以:
$DSH = "<安装目录>\resources\runtime\cli\bin\dsh.cmd"
& $DSH --profile nn --from-default-profile web --dump-config # 建 profile,只打印组合树、不起服务
& $DSH plugin --profile nn add D:\NovelNovel # 装插件(只装进 nn)
& $DSH --profile nn # 起界面,终端打印带 token 的 URL
--from-default-profile web 这一步不能省。 自带模板只认 web / headless / acp / sdk /
sdk-minimal 这五个名字,别的名字不会被自动初始化;而 dsh plugin --profile nn add 对没有模板的
名字只给 @deepseek-ai/dsh-base——base 只是内核,没有 Host、没有 HTTP、没有 runner,装上也看不到界面。
--from-default-profile web 让新名字从 web 模板的组合包列表起步(只复制包清单,
不复制那个 profile 的任何本地状态)。加 --dump-config 是为了「只创建、不起服务」。
git clone https://github.com/wanrenhuifu/NovelNovel.git && cd NovelNovel
npm install
npm pack # prepack 会自动构建出 lib/(宿主 + 客户端两个产物)
dsh plugin --profile web add ./dsh-novelnovel-0.1.0.tgz
dsh web
npm install
npm run build
dsh plugin --profile web add . # 装成 link,指向这个工作树
改完 src/ 之后要 npm run build 再重开 dsh 会话——profile 加载的是构建产物,不是源码。
link: 装法下重建产物即生效,所以装了插件的 profile 会被工作树直接影响:
这也正是"开发迭代别装日常 profile"的另一半理由。
桌面端有自己的 profile,叫 desktop,由应用独占(dsh --profile desktop 启动会被拒),
但插件管理是支持的——桌面端自带的 CLI wrapper 就是以「允许管理 desktop」的方式跑起来的。
步骤:
# 1) 完全退出 DeepSeek Harness Desktop(它持有 profile 的 package.json 锁)
# 2) 用桌面端自带的 CLI 装(路径按你的安装位置改)
& $DSH plugin --profile desktop add D:\NovelNovel
# 3) 确认这条依赖进了清单,并且**同时**进了 dsh.profile.bundles
[System.IO.File]::ReadAllText("$env:USERPROFILE\.dsh\profiles\desktop\package.json")
# 4) 重开桌面端 —— 这一步不能省,理由见下
读 profile 的
package.json请走 .NET([System.IO.File]::ReadAllText): Windows PowerShell 5.1 的Get-Content会按 ANSI 解码无 BOM 的 UTF-8,中文变乱码。
第 4 步为什么不能省:插件只在启动时装载,装进 profile 之后不重开就不会生效。
加载只看
dsh.profile.bundles:清单里dependencies有、dsh.profile.bundles里没有 = 不加载 (app-boot 的loadProfileDirectory逐个解析这个列表)。用手动改清单装插件时最容易漏这一项。 反过来,在侧栏「插件」页主动取消选择某个组合包,产出的就是这个状态——那时是有意的,不是故障。装完怎么确认生效:让模型调一次
novel_project action=list或/novel。命令返回当前作品状态, 不经过模型,是最快的判据。
desktop profile 是 patchReload: live,但改 src/ 后仍然要 npm run build + 重开会话
(profile 加载的是 lib/ 产物,不是源码)。
⚠️ 桌面端的工作区根 = 那次会话选定的目录。打开桌面端后在你的小说目录里开会话,
数据才会落在那里(.novelnovel/);在别的目录开会话,novel_project action=list 会是空的。
还没发布到 npm。发布之后会是 dsh plugin --profile web add dsh-novelnovel,装的是预构建产物,
不需要任何构建授权。
| profile | 用途 |
|---|---|
web | 浏览器界面,交互式写作 |
headless | 一次性任务:dsh --profile headless "给《长夜将至》写第三章" |
desktop | DSH 桌面端(应用独占,只能按上面那节的方式装插件) |
nn / 任何自定名 | 开发迭代用:从 web 模板起步,与日常界面隔离 |
⚠️ 只有 web / headless / sdk / sdk-minimal / acp 这五个名字会从自带模板自动初始化。
自己起别的名字只会得到 dsh-base 一个 bundle,而 base 只是内核——没有 Host、没有 HTTP、
没有一次性 runner,装上也用不了。自定名要走 --from-default-profile web(见上面的推荐一节)。
desktop 不在其中:它由桌面端应用初始化,dsh 启动器会拒绝
dsh --profile desktop(但允许 dsh plugin --profile desktop,见上一节)。
# 浏览器端 / 一次性任务 / 自定 profile
$DSH --profile nn --dump-config | Select-String novelnovel # 应能看到 # == dsh-novelnovel 那一层
# 桌面端(要用自带 CLI)
& $DSH --profile desktop --dump-config | Select-String novelnovel
装完必须重开才算生效:客户端插件的注册表只在应用启动/插件层变动时构建一次,
之后是增量扫描。所以"装完不重开"的表现是:宿主半边(novel_* 工具)正常工作、
侧栏里却没有面板、浏览器 console 也没有报错——很难归因(实测踩过一次:
应用启动于 16:49、插件 17:12 装进 profile,注册表里根本没有这个包,直到重开才出现)。
面板没出现怎么办:先确认已经完全退出并重开过。若仍没有,打开 DevTools(F12) 看 console 有没有
ClientPackageCompositionError/MissingClientBundleError/missed the module table/slot entry crashed之一,按那个关键词排查。 宿主半边是否正常可以这样快速判断:随便让模型调一次novel_project action=list。
面板显示"尚未确定工作目录":这是设计如此,不是故障。HTTP 路由没有会话上下文, 工作目录只能由面板告知,而面板只认「本进程里真会话用过的工作目录」(否则等于把任意目录读取 开放出去)。所以先在本会话里让模型调用一次任意
novel_*工具(或用/novel),再点面板的刷新。
dsh plugin add github:wanrenhuifu/NovelNovel 装不起来:git 安装拉的是源码而不是构建产物,
本包没有 prepare 脚本,到手没有 lib/,加载会失败。请用上面的 tarball 路径。
dsh 把启动时所在的目录当作工作区根:小说数据落在 <那个目录>/.novelnovel/,项目级技能落在
「最近的含 .git 的祖先目录」的 .dsh/skills/。所以先 cd 到你的小说目录再启动(桌面端则是
在小说目录里开会话)。
当前结论:适配 DSH 桌面端 @deepseek-ai/dsh@0.2.1-rc.2(DSH Desktop,Node 24.21 / Electron 44.7)。
上一版验的是 0.2.0-rc.2(Node 24.18.1),升级后重跑过全量闸门。核查方式(都可复跑):
@deepseek-ai/dsh-app-boot 的 evaluatePluginCompatibility
跑本包 manifest + 运行时版本 → 无冲突项(不兼容会在安装期就被拒,不会等到运行期)。src/contract.ts 镜像的 API 面逐项对照:ToolDefinition/defineTool(仍支持
author-only 的 type:"json" 与 isConcurrencySafe)、ToolRunContext.agent.session.header.cwd、
ctx.fs 的读写与 writeText 五参签名、fs/observed 事件签名、ctx.skills.register 的
runtime rank 250、ctx.commands、ctx.systemPrompt —— 均无签名差异。dsh-fs-observation-policy 仍然从 actor.agent.session
推导 owner,所以插件写入照旧记在调用方会话名下。npm run test:primitives 会从安装里的前端产物把外壳的静态模块表挖出来,
核对我们的 require 说明符都在表里(0.2.1 起 dsh-client-ui-primitives / -ui-slots
不再是独立 npm 包,被打进了 dsh-web-frontend 的 bundle,但模块表里两个键都还在)。npm run test:dsh 68 项全过(其中一组走 ctx.tools.execute 真分发,参数与输出
schema 都由 harness 校验);真浏览器里也看过面板(见上面「侧栏面板」一节)。历史记录(保留,但已不是当前结论):0.2.0-rc.2、0.1.5-rc.1(npm latest,子包 0.1.5-rc.2)、
0.1.2-rc.1 CLI + 0.1.3-alpha.2 子包都验证过。
package.json 的 peerDependencies 用通配 *:插件运行时由 src/harness.ts 解析 harness 自己的包实例
(保证与运行中的 harness 同模块实例),不在依赖层面锁版本。src/contract.ts 是逐项比对后的手写副本——
上游改了签名要靠重新比对发现,所以升级 harness 后重跑一次 npm run test:dsh 与 npm run test:guard。
这一点与生态里多数插件的做法不同(它们写 >= 或 caret 区间,代价是 harness 一升级就可能装不上),
是本仓库有意的取舍:harness 还处在 developer preview,锁区间只会把上游的破坏性变更变成安装期的报错,
而真正的兼容性由 contract.ts 的逐项比对和端到端验证保证。
⚠️ 工作树里不要留 @deepseek-ai/* 的副本(node_modules/@deepseek-ai/ 里的旧版本就是这种情况):
解析链一旦命中它,插件就会加载第二份 harness——服务按模块实例注册,双份会重复注册。
src/harness.ts 现在会校验解析结果的版本是否与运行中的 harness 一致,不一致就报错拒绝,
并告诉你修法;npm run test:guard 就是这条行为的用例。
在装了插件的 profile 里开一个会话,直接用自然语言提写作需求即可;系统提示词会引导 agent 使用这些工具:
| 工具 | 作用 |
|---|---|
novel_project | 作品的新建 / 列表 / 详情 / 改设定(简介、世界观、写作要求、全书梗概 outline、全书目标字数 target_words)/ 切换当前作品 / 删除 |
novel_chapter | 章节列表(from/limit 分页、verbose=false 精简)、读取、新建(可带 goal)、追加(写正文的入口)、覆写、改名、打标签、写本章目标与摘要(meta)、排序、检索、删除 |
novel_character | SillyTavern 角色卡(PNG/JSON,V1/V2/V3)导入、查看、参与开关、再导出 PNG、移除 |
novel_source | 资料源(现代社区卡 / 独立世界书):导入(拆解 + 按通道分类:设定 / 文风 / 玩法规则 / 状态初值 / 角色档案 / 素材,可注入的并入世界书并记来源,状态与素材只读保留)、列表、详情、启用/停用,以及 entries 世界书浏览(按通道 / 是否常驻 / 关键词过滤、分页;给 chapter 时逐条说明为什么会/不会进这一章的简报) |
novel_lorebook | 世界观词条维护(带关键词=命中才注入,无关键词或 constant=常驻注入) |
novel_state | 状态层(MVU 兼容的变量树):查看、用某个资料源的 [initvar] 初始化、手工 set / apply(JSON Patch)、log(最近的变更)、clear(要 confirm)。模型写在正文里的 <UpdateVariable> 块会被自动剥离、应用并记账,渲染后的状态无条件进写作简报 |
novel_preset | 写作预设导入(SillyTavern JSON)/ 手写 / 编辑 / 激活 / 停用 |
novel_skill | 项目级写作方法技能:列出 / 导入技能包(JSON)/ 导出分享 / 删除 |
novel_context | 写作前必调:组装本次的写作简报(系统提示词 + 指令块 + 注入清单 + 参与角色 + 写作进度) |
novel_review | 写完必调:一章的判定闸——本地硬门槛(字数/格式/与前章重复)+ 只读子 agent 的语义自检(推进 goal 了吗、与大纲/设定/前文矛盾吗),返回 pass / fail / inconclusive 与具体改法 |
novel_export | 整书导出 Markdown / TXT,或全量备份 JSON |
另外自带 8 个技能,agent 按需加载(/novel-plan / /novel-prose 之类的手势也能直接触发):
| 技能 | 管什么 |
|---|---|
novel-plan | 入口:先规划后正文。产出三层大纲(全书梗概 / outline.md 细纲 / 每章目标),产出后停下来给人确认 |
novel-quick | 入口:跳过大纲直接进入写作循环;适合短篇与没有贯穿性线索(单元剧/日常)的作品 |
novel-writing | 工具流程与连续性检查(先读简报、写正文、落盘、记设定) |
novel-cards | 角色卡 / 预设的导入语义与宏规则 |
novel-prose | 文风与叙述:视角一致性、叙事距离、句式节奏、去 AI 味 |
novel-dialogue | 对话:潜台词、说话人辨识度、标签与动作拍、避免翻译腔 |
novel-scene | 场景与节奏:场景 vs 概述、目标/阻碍/转折、章末钩子 |
novel-outline | 结构与大纲、伏笔回收、通读一致性审计、中文网文套路 |
技能是按需加载的:模型平时只看得到每个技能的名字和一句描述,判断相关了才把正文拉进上下文。 所以这些方法平时不占上下文,也不会和你的写作预设打架——需要的时候它们才出现。
/novel-plan 与 /novel-quick它们是技能而不是斜杠命令,这是有意的:命令处理器不经过模型、只能返回文本,而这两个入口要让
模型去做事(建作品、产大纲、写正文)。技能用 /名称 触发时注入的是指令,模型随即行动。
(反差:/novel 是命令,它只打印当前作品状态,不经过模型。)
/novel-plan —— 两阶段的第一阶段:产出三层大纲(project.outline 全书梗概——这一层每章自动进简报、
项目目录里的 outline.md 细纲——太长不注入、每章的 ChapterMeta.goal——进简报的指令块),
然后停下来给人看。理由很直接:改大纲只要一条消息,
改已经写好的章节要重写它牵动的每一章(以及简报已经让模型保持一致的那些设定)。/novel-quick —— 跳过规划,直接进写作循环。判据不是长短而是"这本书有没有需要收束的伏笔/主线/关系弧";
没有就适合走这条,短篇也走这条。两条路径共用同一个写作循环:novel_context chapter=<ref> 取简报 → 写正文 →
novel_review 自检(没过就改,改完再自检)→ novel_chapter action=append 落盘 →
用 novel_lorebook action=add 记新设定。
novel_review自动写作最容易出的问题不是"写不出来",是"写出来了但这一章是废的"(没推进目标、跟上文矛盾、 跟上一章重复)。所以落盘前后各有一道闸,分两层,谁便宜谁先跑:
字数:1234 这类元话语、与上一章尾部连续重合超过 20 字的片段。
不过就直接判不合格,不会去起子 agent。返回三种结论,第三种是关键:
| 结论 | 含义 | 该怎么办 |
|---|---|---|
pass | 两层都过了 | 保留这一章,写下一章 |
fail | 有问题,附具体改法 | 按清单改,改完再 novel_review |
inconclusive | 本地过了,但语义没判成(这个组合没有 subagent 服务 / 子 agent 报错 / 返回坏形状) | 这不是通过:自己判连续性与任务契合度再决定 |
几条刻意如此的口径:
novel_review chapter=3 text="<草稿>" 在正文还没写进文件时就能判,
过了再 action=append。这样永远不会覆盖已经落盘的正文。chapters/index.json 每章的 review 字段)。
到上限(默认 3 次)报告里会出现 STOP:不再建议重写,而是把问题交给作者决定。
基础服务故障不计入、也不清零这个计数——它不是"这一章写坏了"。pass 会把摘要写进章节:判不合格的正文马上要重写,此时落一份摘要是给未来留假信息。novel_lorebook action=add(写权留在前台)。local_only=true 只跑第一层,用来在草稿阶段白嫖一次长度/格式/重复检查。novel_context 与 novel_project action=show 都会报一行进度
(progress: 12/24 chapters have text · next unwritten: 第十三章 [xxx] — goal: …),
所以"还剩几章、下一章是谁"不用模型自己数。整本书的循环靠 harness 的 goal 自动续轮驱动:
(用户在某一轮里说"把这本书写完")
create_goal objective="写完《长夜将至》全部 24 章,每章都要过 novel_review"
→ 之后每一轮写一章:novel_context → 写 → novel_review → action=append → 记设定 → 下一章
create_goal 只在真人开口的那一轮可用(harness 的硬闸),所以技能会要求在收到请求的那一轮
就把目标打上,而不是等到后面。
Web 界面(web / desktop / 任何带 Web 面的 profile)的侧栏会多一个 NovelNovel 图标,
点开是当前作品的章节列表 + 正文,按读一本书的样子排版:行宽行距按中文小说调过,
章节行带体量条(按最长章节归一化,不写死"多少字算长"),正文区显示参与写作的角色、命中的词条数
与每章的自检结论(通过 / 未过 ×N / 未判定)。
能改的都在这里(早期版本只读,现在写操作都补齐了):
| 位置 | 能做什么 |
|---|---|
| 章节列表 | 新建 / 改名 / 上移下移 / 删除(要二次确认) |
| 正文区 | 「编辑正文」→ 直接改 Markdown 原文 → 保存;草稿未保存时会提示 |
| 角色卡(左栏下半) | 导入本机的 PNG / JSON 卡(≤ 8 MB)、勾选「参与写作」、删除(二次确认) |
| 顶栏 | 设置全书目标字数,显示「已写 / 目标(百分比)」 |
面板写的是工作区里同一份 .novelnovel/ 文件,模型也在写它。所以每条写入都带版本校验:
?cwd= 由面板告知并按白名单校验),
未注册的工作区一律 403 unknown_workspace。Sec-Fetch-Site: cross-site,或 Origin 指向别的站点,
一律 403 cross_origin。读请求不需要(跨源读会被浏览器的 CORS 挡住)。confirm=true,面板上的「确认删除」就是它的来源。novel_* 工具,再点刷新。
(这条白名单不是多余的小心:没有它,任何拿到会话 cookie 的页面都能指定任意目录去读写。)novel_project action=update 写。novel_lorebook 只有工具面。connection/reset 会重取)。项目根的 .dsh/skills/ 是 harness 自己的技能目录:它会被自动扫描、改动实时生效,而且优先级高于
插件内置技能(项目 rank 100/200,插件 rank 250)——所以同名时生效的是你那一份。
你可以直接往里放 SKILL.md,也可以让 agent 用 novel_skill 导入一个技能包:
// 技能包:可以含多个技能;也可以去掉 skills,把单个技能写在顶层
{
"name": "我的爽文写法", // 包名,给人看的,可含中文
"author": "you",
"version": "1.0.0",
"skills": [
{
"name": "fast-payoff", // 必须是 kebab-case,其它形式 harness 会拒绝
"description": "Move the payoff closer to the setup.", // 模型唯一能看到的字段,写成路由判据
"whenToUse": "爽点 / 铺垫太长 / 节奏太慢", // 可选,补充触发场景(中文触发词要留在这里)
"content": "# 爽点前移\n\n正文,Markdown。" // 技能正文
}
]
}
novel_skill action=import path=my-pack.json 落到 .dsh/skills/,harness 实时收录
novel_skill action=list 现在装了哪些、哪些覆盖了内置技能
novel_skill action=export name=fast-payoff 导出成包,可以分享给别人
novel_skill action=remove name=fast-payoff confirm=true
导入的技能和你手写的技能走完全相同的加载路径——插件只负责摆文件,加载是 harness 自己的事,
所以插件里没有第二套技能机制。novel_skill 也只删自己导入的(按 .novelnovel-skill.json 标记识别),
手写的技能它一律不覆盖、不删除。
需要知道的一件事:harness 取的项目根是「最近的含 .git 的祖先目录,没有就用当前工作目录」。如果会话
开在仓库的子目录里,技能该放的地方就在工作区之外了——这时 novel_skill 会拒绝并说明原因,
而不是写到一个不会被扫描的位置。
斜杠命令 /novel:直接打印当前作品状态(/novel list 列出全部,/novel <作品> 切换当前作品),
不经过模型。
它要按两次回车:因为它带参数(
list/ 作品名),Web 端第一次回车是"认领这个命令、等你给参数" ——输入框会变成/novel并显示参数提示,再按一次回车才真的执行。也可以/novel后打一个空格 再回车。不带参数的命令(如/compact)才是一次回车就跑。
一小段系统提示词(order 4500),说明这个工作区里的小说该怎么写。
典型流程(agent 视角):
novel_context chapter=3 instruction="写林晚在城墙下遇到祭司" → 拿到简报
(按简报写正文)
novel_chapter action=append chapter=3 text="…" → 落到章节
novel_chapter action=meta chapter=3 goal="推进祭司线" → 记下这一章要推进什么(可选)
novel_lorebook action=add name=祭司 keys=夜祷 content=… → 记录新设定
要写整本书就走两个入口技能之一:/novel-plan(先产三层大纲、停下等你确认)或 /novel-quick
(直接进上面的循环)。两者都只是指令,真正的动作由模型执行。
novel_context 返回的系统提示词区块是设定与文风的权威来源:它就是给模型的 system prompt,
由 harness 自己的模型来遵守。
在 profile 的 cordis.patch.yml 里按行 id 覆盖,无需改插件包:
- id: novelnovel
name: dsh-novelnovel
config:
dataDir: .novelnovel # 数据根目录(相对工作目录,默认 .novelnovel)
defaultPrevChapterCount: 2 # 简报默认携带的前文章节数(默认 1)
defaultPrevChapterChars: 2000 # 每个前文章节摘取的尾部字数(默认 1500)
defaultRecentChars: 4000 # 当前章摘取的尾部字数(默认 3000)
maxPrevChapterCount: 5 # 模型单次能要的前文章节数上限(默认 10,硬上限 20)
maxPrevChapterChars: 8000 # 单章能摘取的字数上限(默认 20000)
minChapterChars: 800 # novel_review 的字数下限(默认 0 = 不设)
maxRepeatRunChars: 20 # 与上一章重合多少字算重复(默认 20,0 = 不查)
reviewFormatChecks: true # 是否检查正文里的标题/元话语(默认 true)
maxReviewAttempts: 3 # 同一章连败几次就停下来交给作者(默认 3,0 = 不设上限)
maxInjectedLoreEntries: 24 # 命中词条的注入条数上限(默认 24,0 = 不限;常驻条目不受它约束)
maxInjectedLoreChars: 24000 # 命中词条的注入字符预算(默认 24000,0 = 不限;常驻条目不受它约束)
reviewProvider: deepseek # 自检子 agent 的模型路由(默认继承当前会话)
reviewModel: deepseek-chat
非法配置(绝对路径、..、负数、超过硬上限、布尔/字符串写错)在加载期直接抛错,不静默取默认值。
maxPrevChapterChars / maxPrevChapterCount 是上下文预算的闸门:不设上限时模型可以要求把
整本书读进简报。maxInjectedLoreEntries / maxInjectedLoreChars 是词条侧的同类闸门(见下)。
novel_lorebook 的规则是"关键词命中就注入"。一本书 200 条词条时,某一章同时提到城墙、祭司、
夜祷,可能一次命中 30 条——全文进提示词,真正相关的那几条被淹掉。所以命中条目要过两道闸,
默认那一道永远是本地算的(毫秒级、零 token、结果稳定):
(left out by the injection limits: …)),需要时模型自己去读 lorebook.json。
无关键词的常驻条目不受限——那是作者显式要求每回合都带的。
单条词条就超预算时也至少放它一条:清空全部设定比多花几百 token 更糟。novel_context filter=true)。关键词召回是机械的
(命中 3 个词的不一定比命中 1 个的更相关),所以要更准时可以多花一次子 agent 往返。
它的权限是只准删不准加:返回的是"这些候选里哪些不要",候选池之外的 id 一律忽略并点名。
筛选失败就退回本地召回的结果(漏注入丢设定,比多注入严重得多),默认不开启。<工作目录>/.novelnovel/
workspace.json 当前作品
projects/<projectId>/
project.json 标题 / 简介 / 世界观 / 写作要求(+ 可选:全书梗概 `outline` 每章进简报、目标字数 `targetWords` 只看进度)
outline.md 可选:细纲(分卷/分幕、伏笔、关系变化)。**不进简报**,模型按需 `read`
lorebook.json 世界观词条
presets.json 写作预设与激活项
chapters/index.json 章节元数据(标题、标签、顺序;+ 可选的 `goal` / `summary` / `review`)
chapters/<chapterId>.md 章节正文(Markdown,可直接用 read/write 工具编辑)
characters/<id>.json 角色卡(rawData 无损保留)
characters/<id>.<ext> 原始头像(扩展名与真实媒体类型一致)
exports/ 导出结果
skillpacks/ novel_skill 导出的技能包
<项目根>/.dsh/skills/ 项目级技能(harness 自己扫描,含 novel_skill 导入的)
<技能名>/SKILL.md
<技能名>/.novelnovel-skill.json 导入标记(手写的技能没有它)
正文独立成文件是有意的:长篇小说用 read/write 工具直接改正文比走工具更顺手,
而 novel_chapter action=list 会重新读文件统计字数,所以绕过插件直接改文件也不会失同步。
(novel_project action=list / action=show 里的总字数走的是按文件大小校验的缓存——
只在直接改文件时省一次重读,改过就重算,所以你改完文件再列作品看到的仍然是真实字数。)
技能不放在数据目录里,因为它是整个工作区的写作方法,不属于某部作品——见「装你自己的写作方法」。
project.json 的 outline、chapters/index.json 里每章的 goal / summary 都是可选字段:
老作品缺了它们一切照旧(novel_context 只是在没有 outline 时少一段全书大纲、没有 goal 时少一行目标,
简报其余部分逐字不变)。数据文件本来就是给人手改的,现在也有了工具入口:
novel_project action=update outline="全书梗概…" # 大纲第一层;action=show 会回显它
novel_chapter action=create title="第三章" goal="…" # 建章时顺手写大纲第三层
novel_chapter action=meta chapter=3 goal="…" summary="…" # 之后补写或改写
novel_project action=update target_words=100000 # 全书目标字数(只看进度,不改变模型怎么写)
三层各走各的路(docs/design/auto-writer.md §4):
| 层 | 存哪 | 进简报吗 |
|---|---|---|
| 一 · 全书梗概(300–800 字) | project.json 的 outline | 进:系统提示词里的独立区块 ## 全书大纲,每章自动带上(Push,「不可遗漏」) |
| 二 · 细纲(分卷/分幕、伏笔、关系变化) | 项目目录里的 outline.md | 不进:太长,模型用首方 read 工具按需读那一小段(Pull) |
| 三 · 每章目标(一句话) | chapters/index.json 每章的 goal | 进:novel_context 放在简报指令块最前面(本章目标:…),并在 details.goal 里回显 |
第一层的区块是无条件附带的:它不依赖激活预设的 story_string 有没有引用它。
两者性质不同——预设管的是文风与设定怎么呈现,全书大纲是这本书的事实,
不能被一个没引用它的模板悄悄丢掉。第三层走指令块而不是系统提示词,则是因为
buildSystemPrompt 收不到章节元数据。
outline / goal / summary 的清空口径与其它 update 字段一致:传空串就是清空(不是"没传"),
清空后简报立刻退回旧形状(不再有 ## 全书大纲 区块 / 本章目标: 行),不会留一个空壳。
outline.md 是项目目录里的普通文件——有意不给工具:它太长,不该每轮进简报,
模型需要时用首方 read 工具按需读(§4、§8.3)。
summary 是唯一没有消费方的字段:故意不注入提示词——摘要有损,
写第 N 章时用它替换原文会悄悄丢掉细节(§3.1),所以只留给检索结果展示。
review,可选)novel_review 会把结论写回同一章的 chapters/index.json:
{
"id": "a1b2c3d4e5", "title": "第三章 祭坛", "goal": "…", "summary": "…",
"review": { "attempts": 2, "verdict": "fail", "at": 1760000000000, "issues": ["…"], "semantic": "ok" }
}
attempts 是连续判不合格的次数(通过时归零)。它必须落盘:模型自己数记不住,
数丢了就会无限重写同一章。到 maxReviewAttempts 就不再建议重写,改为交给作者。verdict 是三值的:pass / fail / inconclusive。写 pass 而实际没做语义检查,
等于在数据文件里说谎——所以"本地过了但没判成"如实记 inconclusive,且不改动 attempts。semantic 记录语义自检实际走到哪一步(ok / local-only / unavailable / failed),
人翻文件就能看出"这一章当时到底检查了什么"。ClientModuleRegistry
构造时会为每个声明了 dsh.client 的 loader 行读 exports["./client"] 指向的文件,
读不到就同步抛出(MissingClientBundleError 聚合成 ClientPackageCompositionError):
注册表不注册 → /plugins 路由与 window.__DSH_BOOT__ 注入都不发生 →
所有客户端插件(含首方 UI)一起加载不出来。现场看起来像"插件之间冲突",实际是启动即崩。
因此:① npm run build 两个产物必须同时成功;② scripts/test-manifest.mjs 在装机前把
两个抛点变成红灯;③ 开发迭代装在专用 profile(nn)里,不装日常界面。ctx.inject(["connection"], …) 软挂载,不在 apply 里直接 ctx.get:
插件行在 loader 里比 Web 那几行早,直接查是只查一次、拿不到就没了,
结果是三条路由永远不注册(面板每次取数 404),而宿主工具一切正常——
现场看起来像"面板坏了、插件是好的"。同一条形状的坑在 skills / commands / systemPrompt 上
各踩过一次。/api/novel.* 的 cwd 白名单:HTTP 请求没有会话上下文,工作目录只能由前端告知,
而"前端说什么就信什么"等于把任意目录读取开放给任何拿到会话 cookie 的页面。
所以只认本进程里真会话用过的工作目录(NovelStore.workspaceOf),并且复用当初那个真
Session 对象而不是 {header:{cwd}} 的伪造体——sandboxPolicy.resolve 要读会话的
projection,伪造对象会直接 TypeError 让三条路由全 500。没跑过任何 novel_* 工具时返回 403
并说明"先跑一次",而不是猜一个目录去读。ctx.fs(受 harness 沙箱与权限策略约束、与 write/edit
共用版本语义,并 emit('fs/observed') 让变更流与「先读后写」策略保持一致)。
写入用读取时拿到的版本做 CAS 基准,所以「先读后改」的窗口里被别处改过时会失败并提示重新读取,
而不是静默覆盖(索引、词条、预设、正文、工作区指针都走这条路;冲突会先自动重试几次)。
ctx.fs 没有删除与二进制写入能力,删文件与写 PNG 头像用 node:fs —— 这条路径不受沙箱约束,
所以插件自己兜住两道:目标必须落在会话工作目录内(out_path=<工作区外> 会报错),
且默认不覆盖已存在的文件(out_path 是模型可控参数,否则一个 action=export 就能用 PNG
字节盖掉任意工作区文件)。project.json 坏)→ 跳过并在
novel_project action=list 里点名;单张角色卡 / lorebook.json / presets.json 坏 →
跳过该文件、其余照常,写作简报仍然能组装;workspace.json 损坏 → 不致命,但多作品时解析
「当前作品」必须显式传 project=——宁可报错也不猜,避免把正文写进错误的作品;
chapters/index.json 损坏或缺失都要当心:损坏直接报错,缺失时若 chapters/ 里已有 .md
则拒绝写入(静默当空索引会让下一次建章覆盖掉整份大纲)。数据文件都是给人手改的,
读 JSON 时容忍 BOM 与 CRLF。project.json 里的 id 只是副本,
不一致时该作品会被判为不可读并点名);章节正文按 <chapterId>.md 命名,索引与正文要成对。@deepseek-ai/* 是 peer,运行时由 harness 自己提供——服务按模块实例注册,
内联第二份会重复注册。以 link: 方式安装时包位于工作区之外,Node 从包 realpath 找不到 harness
的依赖闭包,所以 src/harness.ts 会依次尝试:本包旁边 → harness 进程入口(process.argv[1])→
$DSH_HOME/profiles → 工作目录,并且每个候选都要自证身份:解析到的版本必须与运行中的
harness 一致,否则报错退出(工作树里残留一份旧 @deepseek-ai/* 时,静默加载它会让插件用上
第二份服务实例)。npm run test:guard 就是这条行为的用例。src/contract.ts 是手写的最小类型契约(只含本插件用到的成员),
依据 @deepseek-ai/dsh@0.2.0-rc.2 的真实签名整理。这样插件源码不依赖 harness
的包解析路径就能通过 tsc;运行时行为由真实 harness 与端到端验证保证。ctx.skills.register 运行时注册(rank 250),而不是去改 skill-filesystem
的 customSkillDirs——后者是别的插件行的 config,覆盖它要重述 dsh-base 的整份配置(patch 是整行替换),
极易随上游变化失效。副作用是项目级技能(rank 100/200)天然覆盖同名插件技能,正好成了
「装你自己的写作方法」那条路径。<dataDir> 之外:技能属于整个工作区,不属于某部作品,所以落在项目根的 .dsh/skills/
(harness 自己的目录)。项目根按 harness 的口径取「最近的含 .git 的祖先目录,没有则用工作目录」;
会话开在仓库子目录里时项目根在工作区之外,此时 novel_skill 拒绝并说明原因,而不是写到一个
不会被扫描的地方——「工具报成功、技能却不生效」是最难查的一类问题。这是插件唯一往数据目录之外
写文件的地方,范围由 ctx.fs.contains 收紧;导入的技能文件与手写技能走同一条 harness 加载路径,
插件只摆文件,不维护第二套机制。confirm=true,
插件会拒绝未确认的调用,提示先与用户确认。deleteChapter 在"删正文"与"改索引"之间被中断、有人挪走了文件、SKILL.md 没写 frontmatter)。
这类状态下工具一律点名并说明原因,而不是安静地当作空:
novel_chapter action=list 对索引里列着但正文不在的章节报
⚠ body file is missing on disk,novel_context 对取不到的前文报
could not be excerpted(否则续写会悄悄丢掉"上一章讲了什么");novel_skill action=list 对解析不了的文件报 could not be parsed and is NOT loaded
(否则表现为"技能明明放好了、工具说没有");ctx.subagents.start(provider, request) → { id, result, dispose }(dsh-base 提供 spawn
与 fork 两个 provider)。没有持久 agent 句柄、没有跨进程邮箱,ctx.jobs 也是进程内的
(官方 README:进程死则 job 死)。所以"记账"就是再起一个:每章自检起一个干净上下文的 spawn 子
agent,拿到结构化结论就销毁。三条细节都以真源码为准:
result 从不 reject:失败信息在 stopReason / diagnostic 里,判"有没有结论"要看
structured 在不在(`dsh-subagent-