# dsh-study-buddy 技术文档(v1.0 · 文档式笔记) > 版本基线:v1.1.1 | 读者:插件维护者、二次开发者、需要精确口径的使用者 > 配套:`docs/README.md`(文档地图与维护约定)、`docs/设计文档.md`(为什么这样设计)、`docs/refactor/架构选型.md`(本轮选型)、 > `docs/refactor/需求分析.md`(需求基线)、`docs/经验文档.md`(方法论与踩坑)、`docs/用户使用指南.md`(操作手册) --- ## 1. 环境与构建 | 项 | 值 | | :-- | :-- | | 运行时 | Node.js ≥ 22(`engines.node`) | | 包管理 | pnpm 11(`pnpm-workspace.yaml` + `pnpm-lock.yaml`) | | 依赖 | **零运行时依赖**(仅 `node:` 内置模块);devDeps 只有 esbuild / typescript / vitest / @types/node | | 构建 | `node build.mjs`:**先清 `lib/types`** → esbuild 打包 `src/index.ts` → `lib/index.js`(ESM, node22)→ `tsc -p tsconfig.json` 产出 `lib/types/*.d.ts` | | external | `@deepseek-ai/cordis`、`@deepseek-ai/dsh-*`(宿主提供) | | 交付形态 | 本包是**声明式 bundle**:`package.json` → `dsh.bundle.patch: ./presets/study.patch.yml` 声明「学习伙伴」预设(DSH 0.1.7 起目录式预设已删除,见 §9.2);根目录 `cordis.patch.yml` 是**默认不生效**的备用宿主行 | | 校验 | `pnpm run check` = `typecheck` → `test` → `build` | | 产物自检 | `lib/index.js` 中 `@deepseek-ai` 出现次数必须为 0;冒烟:`node -e "import('./lib/index.js').then(m => console.log(m.name, m.buildToolDefs({}).length))"` → `study-buddy 18` | > **为什么构建前要清 `lib/types`**:`tsc` 是增量输出,删掉的模块会留下 `.d.ts`, > 消费方仍能 `import` 到"已经不存在的 API",而编译期零信号(见 `docs/经验文档.md` 第二轮坑 3)。 --- ## 2. 模块地图(23 个) ``` src/ ├── index.ts VaultStore(业务编排)+ apply + re-export ├── host.ts **宿主适配层**:唯一接触面(HOST_CONTRACTS 契约表 + 特性探测/降级 + 可选 dsh-loader) ├── config.ts 配置来源链(行 config / DSH_STUDY_VAULT / /study-buddy.json)→ VaultLayout ├── tools.ts 18 个工具定义(ToolDef);ToolExecLike/sessionCwdOf 从 host.ts 转发 ├── note.ts 块契约:ID/校验/渲染/append·replace·definition/wikilink 关联 ├── notemodel.ts 段落模型(小节切分/渲染/标题匹配/块插入/围栏语言/块抹除) ├── frontmatter.ts 极简 YAML 解析与生成(六键 + 三文档键) ├── dirs.ts 目录模型:路径归一/DirIndex/层级校验/listDir/期望路径 ├── search.ts 分词、加权、多根索引(**不常驻正文**) ├── gate.ts 硬门禁:三连校验 + 提案渲染 ├── planstore.ts 规划记录状态机(.study/plans/.json) ├── archive.ts 历史存档(.study/archive/)+ archiveThenWrite 不变量 ├── store.ts .study/session.json(期望签名、已读规划) ├── overview.ts 覆盖度聚合与渲染(纯函数) ├── sourceSection.ts `来源章节` 语法与无损归一 ├── order.ts 排序口径:compareText(显式 zh-Hans-CN + 数值序,全序) ├── links.ts 关联增删/判重 + 改名/入链重写/断链/文件名计划 ├── lint.ts RULES 注册表(4 条)+ 报告 + 期望检查项解析 ├── lintrules.ts 会话残留/代码块语言/外部资源判定 + 共享正则 ├── vault.ts 扫描/跳过/路径安全/原子写/落盘解析/readNoteSource ├── state.ts .study/progress.json ├── memory.ts .study/memory.json └── opener.ts 开场门禁(提示段 + 预步提醒) ``` ### 2.1 `notemodel.ts` — 段落模型(地基) ```ts interface DocSection { level: number; title: string; body: string; start: number; end: number } splitSections(body): { lead: string; sections: DocSection[] } // matchAll,无共享正则状态 renderSections(lead, sections): string matchesTitle(heading, title): boolean // 精确相等 或 规范标题 + 括号/冒号/破折号 findSection(sections, title) / hasSection(sections, title) insertBlockBefore(body, block, beforeTitle = '关联'): string // 找不到则追加到末尾 inlineText(value): string // 换行折成空格 + 压缩空白(SEC-1 渲染兜底) makeLineOf(text): (index) => number // 下标 → 行号(1 基),二分查找(PERF-4) codeFenceLanguages(body): string[] // 逐行状态机,返回每个代码块的语言标注('' = 未标) blankOutBlocks(body): string // 代码块与
内容替换为空行(保留行号) ``` > `codeFenceLanguages` 用 `inFence` 状态机逐行扫描,**不能用正则全局匹配**——闭栅栏会被当成开栅栏(见 `docs/经验文档.md` 坑 1)。 > `inlineText` 是**纵深防御**,不替代校验:`validateBlock`/`validateSummary` 会直接拒绝含换行的字段。 > **迁移期残留**:`layerNumbers` / `countListItems` / `CardSection` 别名仍在文件中(模板类规则已删, > 这两函数目前只有测试引用);下次清理可一并删除。 ### 2.2 `frontmatter.ts` — 契约解析 ```ts interface CardMeta { id?; title?; domain?; source?; status?; sourceSection?; order?; summary?; template? } parseFrontmatter(raw): { meta: CardMeta | null; body: string; raw: string } renderFrontmatter(meta): string // 键序固定:ID→标题→领域→来源→状态→来源章节→顺序→简介 extractDefinition(body): string | null // 引用块提取(简介缺省来源) firstHeading(body): string | null ``` - `简介` 与 `定义` **双读**(同现时 `简介` 优先);`模板` 读入但**不渲染**(迁移兼容,见 §5.1)。 - 渲染对每个值过 `inlineText`(防换行截断 frontmatter);校验层另有一道 fail-loud。 ### 2.3 `note.ts` — 块契约 ```ts type LinkKind = 'prev' | 'next' | 'sibling' const LINKS_SECTION = '关联' const VALID_STATUS = ['草稿', '已确认', '需更新'] generateId(now?) / todayLocal(now?) validateSummary(summary): ValidateResult // 只拒换行;**无长度上限**(需求 R7) validateBlock(input): ValidateResult // 必填/状态枚举/标签空白/顺序数字/正文非空 renderNote(doc: BlockDoc): string // frontmatter → 引用块定位 → 正文 → 关联小节 appendToSection(body, section, changes): string // 命中小节追加;未命中新建小节 applyUpdate(raw, id, { action, changes?, section?, summary?, block? }): { text, warnings } inverseKind(kind): LinkKind // prev↔next 互反,sibling 同向 wikilinkTarget(name) / wikilinkOf(fileName) // 清洗 [[ ]] # | ^ parseLinkLine(line): { kind, target } | null ``` - `applyUpdate` 三动作:`append`(追加,不产生版本块)/ `replace`(重渲染,**旧正文由调用方先存档**)/ `definition`(只替换首个引用块)。 - `renderNote` 的 `简介` 缺省时从正文首个引用块提取(`extractDefinition`);**提取到就不再另写一遍引用块**—— 只有显式传 `summary` 才在正文前补 `> …`,否则"frontmatter 一遍 + 正文首行一遍"会让同一句话连着出现两次(2026-09-15 真机检查实测 4 篇全中)。 ### 2.4 `dirs.ts` — 目录模型 ```ts const TOC_FILE = '微目录.md' / const EXPECT_FILE = '笔记期望.md' / const MAX_NEST_HINT = 6 normRel / dirOfRel / parentDir / dirSegments / depthOf / isTocRel expectPathFor(vaultRoot) / dirPathFor(vaultRoot, dir) / assertDirPath(dir) // 越界与非法名 fail-loud nestHint(dir): string // 超过 6 层给人文案(软约束) interface DirStat { blocks; legacy; notes; hasToc } class DirIndex { add(rel, kind) / remove(rel, kind) // O(1) 增删(只记目录自身) statOf(dir) / countAt(dir) // countAt = 子树累加 childrenOf(dir) / dirs() / hasTocIn(dir) } buildDirIndex(files, kinds): DirIndex listDir(vaultRoot, dir, { headerBytes = 4096 }): Promise // 只读头,不读正文 compareOrder(a, b) // 顺序键 → 标题字典序 readNoteHeader(absPath, headerBytes) / parseHeaderFields(text) resolveNoteDir(layout, { plannedDir?, domain? }) // 规划 > domainFolders > fallbackDir vaultRelOf(vaultRoot, absPath) ``` - `listDir` 跳过 `.` 开头条目与微目录自身;`readNoteHeader` 在**头读失败(frontmatter 超长)时回退读全文一次**(架构选型 C4)。 - `DirIndex` 的计数与"有无微目录"都必须按**子树**判断(`countAt` / `hasTocIn`)——父级目录通常只放章节子目录。 ### 2.5 `search.ts` — 索引与召回 ```ts type NoteKind = 'block' | 'legacy' | 'note' indexNote(file, raw): IndexedCard // **不含正文**,只留元数据 + 四路 token 计数 kindOfNote(meta): NoteKind // 有 ID 且有来源章节 = block snippetOf(body, queryTokens): string // 调用方现读正文后调用 class SearchIndex { rebuild(cards, multiRoot) / all() / size / roots() byId(id) / byTitle(title) / candidatesForRef(ref) / underDir(dir) search(query, opts): SearchHit[] // opts: domain/status/kind/dirPath/limit static queryTokens(query): string[] } ``` - **字段权重**:标题 4 / 简介 3 / 领域 2 / 正文 1;单字 CJK token ×0.2。 - `kind` 排序:`block` → `legacy` → `note`,再按 `fullRel` 字典序。 - `SearchHit.snippet` 初值为 `null`,由调用方对**命中前 N 篇**现读正文填充(A4 的代价面)。 - `indexNote` 仍对整个正文做 token 化(读盘量不变),只是**不留原文**。 ### 2.6 `gate.ts` — 硬门禁 ```ts interface GateInput { vaultRoot; stateDir; session; expectSignature; requireExpect?; targetRel?; requirePlan?; planTtlHours? } checkExpect(input): GateResult // 文件缺失 / 未读 / 签名变化 三态 checkPlan(input): Promise // 无规划 / 记录不存在 / 未确认 / 已过期 checkPathInPlan(record, rel): GateResult checkWrite(input): Promise // 按序短路:期望 → 规划 → 路径 formatPlanProposal(record, { reusedDirs }): string // 对话内提案(不落盘) ``` 每条失败都返回 `{ ok: false, reason: '问题——修复步骤' }`。`note_write` 直接把 `reason` 拼进抛错文本。 ### 2.7 `planstore.ts` — 规划凭据 ```ts generatePlanId(now?) / planFileFor(vaultRoot, planId, stateDir) // id 格式 [0-9]{12}_[0-9a-f]{6}(各字段必须补零,见下) readPlan(file): Promise // 宽容:损坏返回 null(不抛错阻断) writePlan(file, record) buildPlanRecord(input, planId?): PlanRecord // 校验:路径在根下、标题唯一、items 非空;**confirmed 恒为 false** isPlanExpired(record, ttlHours, now?) consumePlanItem(file, title, rel, { ttlHours, now }): Promise // 消费记账(写盘失败即失败) abandonPlan(file) / pathInPlan(record, rel) ``` - `confirmed` 只由 `note_plan(action=confirm)` 置位:确认时写 `confirmed: true` + `confirmedAt`,并把 `createdAt` **重置为确认时刻** (有效期从凭据生效时刻起算;搁置超期的提案要先重新提案,不把过期确认做成"改了时间就能过")。 - **`generatePlanId` 的每个两位字段都必须补零**:`planFileFor` 的校验式要求恰好 12 位时间戳,漏一处(如小时位)就会让 00:00~09:59 生成的 id 只有 11 位,同一个 id 传给 confirm / note_write 被自己的校验拒绝——"凭据刚发出去就读不回来"。 ### 2.8 `archive.ts` — 历史存档 ```ts archiveStamp(now?) / archiveDirFor(vaultRoot, fromId, stateDir) // 时间戳 YYYYMMDDHHmmssSSS renderArchive(meta, original) / parseArchive(raw, id, file) / archiveBody(raw) writeArchive(vaultRoot, { fromId, oldRel, reason, title?, content, now? }): Promise listArchives(vaultRoot, fromId, stateDir) / readArchive(vaultRoot, fromId, archiveId, stateDir) archiveThenWrite(input): Promise // **先存档、后写正文**的唯一入口 ``` ### 2.9 `store.ts` — 会话门禁状态 ```ts interface SessionState { expect?: { signature; rel; readAt? }; activePlanId?; updatedAt? } sessionFileFor(vaultRoot, stateDir) / signatureOf(file) // mtime|ctime|size readSession(file) / writeSession(file, state) // 读取**永不抛错**(损坏回空态) markExpectRead(file, { signature, rel }) / clearExpectMark(file) ``` ### 2.10 `overview.ts` — 覆盖度 ```ts summarizeOverview(notes, { material? }): OverviewResult formatOverview(result, { scope? }): string ``` 返回 `{ chapters, unclassified, materials, counted }`;节号按**数值**排序,跳号只在同一父节下相邻间隔 >1 时提示。 ### 2.11 `sourceSection.ts` — 来源章节 ```ts normalizeSourceSection(input): { material; chapter; chapterTitle; section; raw } normalizeSourceSectionText(input): string isSourceRefComplete(ref): boolean ``` ### 2.12 `links.ts` — 关联与改名 ```ts addLink(raw, kind, label) / removeLink(raw, label) / listLinks(body) / parseLinkTarget(line) / linkTargetOf(label) replaceNoteTitle(raw, newTitle) / noteTitleOf(raw) rewriteLinkLines(body, { oldTitle, newTitle, targetId? }): LinkRewrite rewriteWikilinks(body, oldBase, newBase): LinkRewrite detectBrokenLinks(body, { oldTitle, oldId?, newTitle?, checkFormat? }): BrokenLinkHit[] planRename({ fileName, oldTitle, newTitle }): { renameFile, oldBase, newBase, reason } formatRenameReport(input): string ``` ### 2.13 `lint.ts` / `lintrules.ts` — 质量体检 ```ts interface Rule { id; title; severity: 'error'|'warn'|'info'; run(ctx: RuleCtx): LintFinding[] } export const RULES: Rule[] // 唯一权威:加规则 = 加一个数组元素 lintNote({ title?, summary?, body, kind? }, ctx?): LintReport interface LintReport { title; kind: 'block'|'legacy'|'note'; chars; findings; passed; notRun } parseExpectRules(text): ExpectRule[] // `- [检查] 禁止/必须 …`(支持 理由:/严重:) formatReport(report) / summarizeLint(reports) / formatBatch(batch, limit) ruleIds() / ruleTitle(id) / ruleCatalog() / ruleTable() ``` **规则与级别**(`RULES` 是唯一权威): | rule id | 中文 | 级别 | 判据 | | :-- | :-- | :-- | :-- | | `session-residue` | 会话残留 | warn(可配 off/error) | 6 类命中(路径/行号/第二人称/时间词/阶段代号/会话口吻),只扫代码块与折叠块之外 | | `code-language` | 代码块语言 | warn | 有未标语言的代码块(报块序号,不是行号) | | `external-resource` | 外部资源 | info | `