Imported from wanrenhuifu/NovelNovel (
AGENTS.md). Install upstream withnpx skills add wanrenhuifu/NovelNovel. Copyright stays with the author.
AGENTS.md
DeepSeek Harness (dsh) 插件(npm 包 dsh-novelnovel):把小说写成工作区里的普通文件,让 harness 的
agent 直接用 novel_* 工具写作、导卡、维护设定、检索、导出。纯 Node 包——没有 UI、不发模型请求
(模型由 harness 提供),数据落 <工作目录>/<dataDir>/。领域逻辑集中在 src/domain/。
写作方法由技能承载:6 个技能随包注册(rank 250),用户自己的方法用 novel_skill 导入到项目根的
.dsh/skills/(rank 100,会覆盖同名内置技能)——那是唯一落在 <dataDir> 之外的写入。
命令
npm run build # esbuild → lib/index.js(lib/ 不入库;改完 src 必须重建,profile 加载的是产物)
npm run typecheck # tsc -p .(严格模式,noUnusedLocals/Parameters,零错误才过)
npm test # 5 个纯逻辑单测(下面 5 个脚本)
node scripts/test-card-import.mjs # 角色卡解析链路(PNG V2 / ccv3 双写 / JSON / V1 / 世界书并入)
node scripts/test-preset-import.mjs # 预设导入解析 + story_string 渲染 + 提示词组装 + 截断
node scripts/test-search.mjs # 章节全文搜索:命中/摘要/标题/上限/顺序
node scripts/test-reorder.mjs # 章节排序:边界/位移/规范化/不可变性
node scripts/test-skill-pack.mjs # 技能包解析 + SKILL.md frontmatter 往返 + 项目根祖先链
npm run test:dsh # 40 项端到端检查:真实 harness 服务上驱动全部工具(不调模型)
node tests/perf-probe.mjs # 性能探针(在 profile 目录里跑,带 ctx.fs 调用计数)
端到端验证统一走 npm run test:dsh:它把 cwd 切到 profile 目录再跑 tests/verify.mjs
(@deepseek-ai/* 由 profile 的 node_modules 提供),profile 名可用 DSH_PROFILE 覆盖。
无 lint 配置。仓库是 git 仓库(origin = wanrenhuifu/NovelNovel),提交信息沿用
feat: 中文摘要(、分隔) + - 主题:说明 列表体。
架构边界
src/index.ts插件入口:name/inject(硬依赖tools+fs)/resolveConfig/apply—— 注册工具、ctx.inject软挂载技能、命令与系统提示词段(order 4500)。src/contract.ts手写的 harness API 最小类型契约(只有类型、没有运行时);运行时真品由src/harness.ts解析(src/harness.ts的解析顺序见「坑」)。src/store.ts工作区文件存储(作品/章节/词条/角色卡/预设)+NovelStore的 patch 具名类型;src/skillStore.ts管项目级技能文件(项目根.dsh/skills/的列出/导入/导出/删除,加载仍由 harness 负责,不新增技能机制);src/fsx.ts封装ctx.fs(解析/读写/目录/删除/二进制)与会话构造;src/types.ts落盘结构。src/tools/<name>.ts一个工具一个文件,src/tools/index.ts注册;src/tools/shared.ts放统一输出 形状(TEXT_OUTPUT/textRender)、lines/preview/requireFields与ToolDeps。src/skills.ts枚举skills/*/SKILL.md并ctx.skills.register运行时注册(另导出bundledSkillNames供 novel_skill 判断同名覆盖);src/command.ts是/novel命令 (把当前作品状态以文本返回,不经过模型)。src/domain/纯逻辑,不 import harness、不碰文件系统(连node:*都不引,路径运算自己写):cardImport.ts(角色卡解析 + 世界书提取)、presetImport.ts、prompt.ts(系统提示词组装、宏替换、 词条注入、续写消息拼装)、search.ts/reorder.ts(id 泛化为string | number)、export.ts(正文拼装 + 角色卡 PNG 再导出)、png.ts(chunk 读写 + deflate)、skillFrontmatter.ts(SKILL.md 解析/渲染,两条投递路径共用)、skillPack.ts(技能包解析 + 项目根祖先链)、utils.ts、types.ts。tests/verify.mjs端到端;scripts/verify-dsh.mjs是它的启动器(切 cwd 到 profile);samples/preset-example.json供测试导入预设用。
约定
- 模型可见的字符串用英文:工具
description/参数说明/summary,以及 SKILL.md 的 frontmatter 与正文(description就是模型唯一能看到的路由判据,harness 对工具 schema 和技能都没有本地化 机制)。中文留给 README、AGENTS.md、代码注释;但whenToUse里要保留中文触发词(润色/断章这类),否则用户用中文提问时技能匹配不上。只读类工具声明isConcurrencySafe,会写文件的不要声明。 - 危险操作(删作品/章节/角色卡/导入的技能)必须
confirm=true才执行,错误信息提示先问用户; 会覆盖别人文件的(技能包导入)用overwrite=true同理。 update*的 patch 用store.ts导出的具名类型,不写Record<string, …>。- 新增工具:
src/tools/<name>.ts+ 在src/tools/index.ts注册;模型可见的字符串 (description/parameters/summary)改动要同步 README 的工具表。 - 注释只写约束性说明,不复述代码。
坑(改相关代码前必读)
- harness 依赖:
@deepseek-ai/*声明为 peer + esbuildexternal,绝不能内联——服务按模块 实例注册,产物里第二份会重复注册。以link:方式安装时包在工作区之外,Node 从包 realpath 找不到 harness 的依赖闭包,所以src/harness.ts按「普通 import → harness 进程入口process.argv[1]→$DSH_HOME/profiles与 cwd」依次解析,命中即用,保证与运行中的 harness 是同一模块实例。@lenml/char-card-reader是 AGPL,只做 external +dependencies,不打进产物。 - 工具参数名就是 schema 键:
defineTool的args类型由parameters推导(contract.ts的InferArgs),schema 里写author_note/prev_chapters这类 snake_case,代码里就必须同名访问 ——改 schema 键名不改进代码会直接 tsc 报错(有意的防漂移,别用any绕)。 - 写文件:
ctx.fs.writeText传{kind:'createIfAbsent'}命中已存在文件会报FS_NOT_OBSERVED(本地后端要求先读后写),所以fsx.ts先stat:已存在用replaceIfVersion(带 stale 校验), 不存在用createIfAbsent,写完emit('fs/observed')并把触发调用的 exec 作为第三参传入 (FsSession.actor)——observation policy 只对能解析出 session 的 actor 记录观察 (if (owner) this.set(...)),不传就等于这些写入对「先读后写」策略不存在,首方write/edit之后会被要求先读一遍。ctx.fs没有删除与二进制写入能力,删文件/写头像图片是node:fs+ctx.fs.processPath——这条路径不受沙箱约束,因此assertInsideWorkspace用ctx.fs.contains限定只能落在会话工作目录内(别删掉这个检查,novel_character action=export out_path=是 agent 可控参数)。 ctx.waterfall的末参是 fallback:ctx.waterfall(event, ...args, fallback)——不给 fallback 时, 最后一个业务参数会被当成 fallback 吞掉(表现为监听器收到的actor是 undefined,极易误判成 "策略不记录")。测试里要写成ctx.waterfall('fs/write-intent', target, exec, () => undefined)。- 损坏数据文件的处理分级:
project.json读不出来 → 跳过该作品目录并在novel_project action=list里点名;workspace.json读不出来 → 不致命,但多作品时解析「当前作品」必须显式传project=(宁可报错也不猜,避免写错作品);chapters/index.json读不出来 → 直接报错(静默当空索引会让 下次建章覆盖整份目录)。数据文件是给人手改的,readJson容忍 BOM/CRLF。 - 技能注册:不用
skill-filesystem的customSkillDirs——那是dsh-base的行 config,覆盖它要 重述整份 config(patch 是整行替换),随上游变化即失效;改走ctx.skills.register(rank 250, 项目级技能 rank 100/200 仍可覆盖同名插件技能)。SKILL.md 里disable-model-invocation/user-invocable是正规键,modelInvocable这类旧键会被 harness 直接拒绝。技能名必须是 kebab-case^[a-z0-9]+(?:-[a-z0-9]+)*$,不合规的候选会被本地 provider 判为 malformed——所以parseSkillFile在解析阶段就校验,而不是等 harness 静默丢掉它。 - 项目级技能的落点是「最近的含
.git的祖先目录」,不是 cwd——harness 的本地 provider 就这么解析 项目根。会话开在 git 仓库子目录里时项目根在 cwd 之外,而插件不往工作区外写,所以SkillStore.skillsRoot用ctx.fs.contains校验后拒绝并说明原因,而不是默默写到<cwd>/.dsh/skills让工具报成功、harness 却永远不加载(祖先链是skillPack.ancestorDirs, 纯字符串运算,\与/都容忍;漏了parent === current的终止条件会让单段相对路径死循环, 单测里专门盯了这条)。导入先全量校验再落盘;没有.novelnovel-skill.json标记的技能(用户手写的) 一律不覆盖、不删除。标记写在技能目录里当普通资源文件——harness 只把 SKILL.md 当目录变更。 - frontmatter 是逐行
key: value解析的:description/whenToUse里混进换行会静默吃掉后面 所有字段,所以技能包导入时就把多行值拦下来(requireSingleLine),渲染后还回读一次自证 (renderPackedSkill)。只按行内第一个冒号切分,所以值里带冒号是合法的。 slice(-0)陷阱:recent_chars/prev_chars为 0 时要显式判> 0再slice(-n), 否则slice(-0)等于slice(0),会取到整章(tools/context.ts与store.previousExcerpts各一处)。- 解析作品与统计字数分开:
resolveProjectId走listProjectRefs(每个作品只读project.json+chapters/index.json),读遍全书算字数的listProjects只服务novel_project action=list与/novel list;previousExcerpts先用listChapterMetas定位再按需读正文。别把正文读回解析路径 ——实测一次action=append的ctx.fs调用会从 39 涨到 1839(409ms→47ms);tests/perf-probe.mjs可复测(带调用计数)。 - 预设与文件名的不变量:同名预设重导入必须沿用原 id(否则
activePresetId悬空 → 简报静默 退回内置默认提示词,而列表仍显示有激活项);章节正文按<chapterId>.md、角色卡按<id>.json+ 头像按真实媒体类型的扩展名存放;id 一旦生成不再改名(ctx.fs没有 rename,重命名会牵动全部引用)。 - 角色卡:
@lenml/char-card-reader把 V1 卡的spec标为"unknown";cardImport.ts的specToVersion把非 v2/v3 归为 v1,别"修复"它。头像一律以字节表达(PNG 卡取原文件字节,JSON 卡把get_avatar()的 data URL 解成字节),落盘扩展名按媒体类型。rawData必须无损保留原始 JSON。 入口是parseCharacterBytes(字节 + 文件名 + 媒体类型),没有 File/Blob 版本。 - 世界书导入:
cardImport.ts的extractLorebookEntries把 character_book 词条并入项目 lorebook。{{char}}/{{user}}宏在导入时就按来源卡名/“主角”替换(replaceMacros)——项目 lorebook 混合多卡来源,留到组装时已无法确定{{char}}指向谁;关键词keys保持原文不替换 (要用于上下文匹配)。词条名优先entry_name→name→comment→首个关键词;空内容条目直接丢弃;enabled缺失视为启用。词条 id 由 store 生成后并入项目 lorebook。 - 角色卡 PNG 再导出:
domain/export.ts把任意来源的卡统一转 V2 结构写charachunk,有 V3 rawData 时另写ccv3(读取端 ccv3 优先);头像非 PNG(或缺失)时用纯色占位图。TS 5.8 下传给 Blob 的必须是Uint8Array<ArrayBuffer>。 - Lorebook 注入语义:
domain/prompt.ts的selectLoreEntries—— 词条 keys 为空 = 常驻注入; 有 keys 时仅当任一关键词(逗号分隔、小写比较)出现在续写上下文里才注入。改语义时同步更新skills/novel-cards/SKILL.md与 README 的说明。 - 写作预设:
presetImport.ts按字段特征识别裸预设(content→system、story_string→context、input_sequence→instruct),含context/sysprompt/instruct子对象则当合订信封;reasoning 直接 拒绝。instruct 只存档不参与提示词(对话格式由 harness 的消息结构承担)。buildSystemPrompt里:systemPrompt替换开场白(过replaceMacros),storyString走renderStoryString(仅支持{{#if}}/{{else}}/{{trim}}/{{var}}子集)替换默认设定区块;story_string的system变量映射本项目"写作要求",lorebook 统一放wiBefore。 - 安装与调试:改完
src/必须npm run build(lib/不入库,profile 加载的是产物);link:安装后改动只需重建 + 重开 dsh 会话,tarball 安装则要重新npm pack+dsh plugin add。 插件的观测口径(工具、技能、命令)都以npm run test:dsh为准。
参考
README.md:安装、工具表、配置、数据布局、设计说明。- dsh 插件开发文档(本机源码 checkout):
/d/DSH/deepseek-harness/docs/user/develop/**; 真实 API 以packages/**/src与 profile 里的@deepseek-ai/*为准。 - 技能子系统(rank 表、frontmatter 键、注册与覆盖语义):
/d/DSH/deepseek-harness/docs/subsystems/skills.md——注意docs/user/develop/**里 一次都没提过技能,外部作者无从得知这条扩展路径,改技能相关代码时以子系统文档为准。 - 角色卡规范:SillyTavern V2/V3 spec(character-card-spec-v2 / v3)。
