DSH AIRP 引擎方案

August 27, 2026 · View on GitHub

  • 日期:2026-08-18
  • 状态:v1.1(按 codebase-design 收成单一深 module;删假缝)。行为以代码为准。agent 入口 AGENTS.md;年表过期时改本文件 §12,不要在两处各写一套。
  • 配套:ADR-0008ADR-0011(从 notes/tavern-ai-native 收编)
  • 证据:SillyTavern v1.18.0;DSH 0.1.0-rc.6

一句话:一个深 module WorldKernel,外加一层 DSH adapter。LLM 只提案和叙述;规则把 State 从 T 写成 T+1。v0 用廷根切片证明这比酒馆值得玩。


0. 为什么不再用酒馆当宿主

酒馆有的酒馆没有的
关键词世界书、token 预算、约 31 个条目字段独立鉴定层
{{roll}} 把数字写进文本骰子与胜负的硬约束
{{setvar}} / QR / regex 间接触发写变量schema;鉴定失败则不写
角色卡 = 人设 + 提示词模板人设与可变状态分离
生成管线不回写世界书诚实的 T→T+1

要补的是裁决原语,不是又一份世界书 JSON。骰子只是随机源 ξ。DSH 已有 function calling、guard、仅追加 session、sessions.fork。ST 只当对照代码和资产矿(ADR-0008),不追求格式兼容(D1)。


1. 唯一深 module:WorldKernel

产品上仍是「Host 插件 + 两个 Preset」。那是部署。领域行为只加深这一刀:

WorldKernel
  match(state, tags) → ForcedCheck[]
  turn(intent)       → { state, receipt, events }

调用方必须知道的全部事实:

  • 数值字段只能经 checkgm 改变;fact / correct 碰白名单即整单拒绝,State 不变。
  • events 为空 = 本回合无转移。
  • 同一 rng_seed + 同一 check 序号 ⇒ 同一 u、同一 outcome。
  • 错误:UNKNOWN_CHECK / CHANNEL_VIOLATION / BUDGET / INVALID_CONDITION / MISSING_REASON
  • Kernel 读盘、碰 DSH、做时间旅行。Canon 与事件数组由调用方传入。
type Intent =
  | { type: "look"; pointer?: string }
  | { type: "lore"; key: string }
  | { type: "check"; checkId: string; actors: Record<string, string> }
  | { type: "fact"; pointer: string; value: unknown }
  | { type: "gm"; patch: Patch; reason: string }
  | { type: "correct"; pointer: string; value: unknown }

type TurnResult = {
  state: WorldState
  receipt: Receipt      // 结算单或文档切片,给模型看
  events: StoryEvent[]  // 普通对象,不是 DSH JSONL 信封
}

matchturn 是同一 module 的两个入口,不是两个 module。match 只跑 Canon 里的谓词 AST,不解析 IC 自然语言。

删除测试:删掉 WorldKernel,序列差、白名单、种子、fold 会散到工具函数和斜杠里各写一份——所以它赚得过这份 interface。


2. 部署形态(浅,这是它的职责)

flowchart LR
  subgraph kernel["深 · in-process"]
    WK["WorldKernel\nmatch / turn"]
    Pack["Pack.load / validate"]
  end

  subgraph host["浅 · DshHostAdapter"]
    tools["tools.register 翻译 intent"]
    cmds["commands:/retry = fork"]
    ctx["systemPrompt.context"]
    log["session.append StoryEvent"]
  end

  play["airp-play"] --> host
  author["airp-author"] --> host
  host --> WK
  Pack --> WK
  lotm["packs/lotm-tingen"] --> Pack
  dev["官方创造模式\n只开发插件"] -.-> host
东西角色不是
WorldKernel深 module一簇 check-engine.ts / fold.ts 平铺导出
Packload(dir) / validate(pack)第二种装载源出现前不为 fs 做 port
DshHostAdapter把 Cordis ctx 译成 turn / match领域逻辑
airpStageHost 可选呈现:/airp-media + publish/mountRoot生图引擎;Kernel port
airp-play / airp-author可见性掩码 + personamodule
官方 cordis_*开发插件故事会话

会话一旦有产出不能热换 preset。换身份 = 新会话。/retrysessions.fork,Adapter 的事,Kernel 不管。

v0 没有present port、世界时钟、canon.edit / check.define 工具、storage-domain、向量检索。


3. 三层数据

Canon 版本化,author 改文件。State 是投影。Session 里的 StoryEvent[] 是真源;Adapter 再写成 DSH 日志。

packs/lotm-tingen/
  pack.yaml
  index.yaml
  checks/{contest-sequence,digest-acting,lose-control}.yaml
  characters/{klein-moretti,dunn-smith}.md
  lore/{axioms,fool-s9-s8,tingen,night-watchers}.md

index.yamlsystemPrompt.context 常驻;细节只在 turn({type:"lore"}) 时按 key 取出,超预算则 BUDGET

State 最小形状:

turn: 0
scene: tingen.blackthorn
rng_seed: "<pack+session>"
revealed: [axioms, tingen]
present: [klein, dunn]
characters:
  klein: { pathway: fool, sequence: 9, digest: 0.2, lose_control: 0.1 }
  dunn:  { pathway: sleepless, sequence: 7, digest: 1.0, lose_control: 0.0 }
facts: { weather: "雾", alarm: false }

sequence / digest / lose_control 以及胜负、晋升、资源列入鉴定通道。其它指针才允许 fact

角色卡是 Canon 人设(口吻、外形、对外身份、声明途径)。可变进度只活在 State。出场且会说话或被鉴定的人必须有卡;路人可标 provisional,不写回包。Play 召已有卡上场:IC 点名或 /gm present=+id :: 理由。不新增 play 工具。新卡由 author 改文件,不在 play 会话 mint。

事件(Kernel 产出,Adapter 落盘):

type来源
checkturn({type:"check"})match 强制后的 check
apply非抽样补丁(少用)
factturn({type:"fact"})
gmturn({type:"gm"}),无 reason 拒绝
correctturn({type:"correct"})

崩溃:重放事件重建投影。热路径保留 fold 缓存——fold 是 Kernel internal seam,不导出。


4. 一回合

sequenceDiagram
  actor P as 玩家
  participant A as airp-play
  participant H as DshHostAdapter
  participant K as WorldKernel

  P->>A: IC 文本(默认)或 /命令
  alt /retry
    H->>H: sessions.fork 到上一 check 前
  else /gm /correct /look
    H->>K: turn(gm|correct|look)
    K-->>A: receipt(命令结果默认不进模型)
  else IC
    H->>K: match(state, tags)
    alt condition 命中
      H->>K: turn(check)
      K-->>A: 结算单;模型只叙述
    else
      A->>H: 工具名(lore / check / fact)
      H->>K: turn(对应 intent)
      K-->>A: receipt
      A-->>P: 只叙述已裁定的差
    end
  end

同一 agent,无后台时钟。离场 NPC 冻结,直到被索引或行动唤醒。

模型可以仍看见多把工具(function calling 要名字)。那是 Adapter 翻译,不是 Kernel interface。测试禁止直接打七个工具函数。

作者 condition 命中时,Adapter 在调模型之前turn(check)。模型这一轮只拿到结算单,不能跳过。未写 condition 的包,仍靠模型 check 提案 + Kernel 门禁。


5. 鉴定

触发:

match(state, tags) 命中 ⇒ 必须 turn(check),模型不能跳过
否则仅当 turn({type:"check"}) 且门禁通过
日常 ⇒ events = []

condition 是 Canon 数据,Pack.validate 收成 谓词 AST(路径、比较、与或)。Kernel 只执行 AST。禁止把 JS 源码当成 turn 的参数。若作者坚持代码谓词:只允许在装载期编成 AST,禁止 IO / 全局;滥用则只留 JSON DSL。

声明式 check(廷根)。sequence = 序列号,越小越强

id: contest-sequence
when: "两名非凡者直接对抗"
kind: contest
condition:
  all:
    - { tag: contest }
    - { present: ["$attacker", "$defender"] }
inputs:
  atk: "characters.{attacker}.sequence"
  def: "characters.{defender}.sequence"
  same_pathway: "eq(characters.{attacker}.pathway, characters.{defender}.pathway)"
formula: |
  strength = def - atk          # S9 打 S8:9-8=1,攻方更弱,p < 0.5
  p = sigmoid(-strength / 1.5)  # 攻方序列号更大 ⇒ p 更低
  if same_pathway: p = clamp(p + 0.05)
outcomes:
  success:
    apply: { "facts.last_contest": "attacker" }
  failure:
    apply:
      "facts.last_contest": "defender"
      "characters.{attacker}.lose_control": "+0.05"

v0 默认 ξ:存档种子派生 u ~ Uniform(0,1)u < p 成功。测试注入 unone(纯公式)。d20 / 2d6 不是 LOTM 默认,作者可在 pack 里声明,模型不能选。

结算单(receipt)示例:

{
  "check_id": "contest-sequence",
  "inputs": {"attacker": "klein", "defender": "opponent", "p": 0.27},
  "xi": {"kind": "bernoulli", "u": 0.81},
  "outcome": "failure",
  "patch": {"characters.klein.lose_control": 0.15, "facts.alarm": true}
}

双通道:

字段谁能写
序列、消化、失控、资源、胜负、晋升、场景、在场、clock.beat仅 check / gm
天气、已揭示设定fact / correct。不可写 facts.scene / facts.present / facts.clock(受保护根的克隆)
Canon 文本作者改文件 + Pack.validate,play 无写口

叙述后抽查「你晋升了」但无对应事件:v0 只打警告,不阻断、不改 State。


6. Adapter 对 DSH 暴露什么

对模型仍是几把工具,内部一律 turn

工具 / 命令playauthor实际
lore_get / state_readturn(lore|look)lore_get 的 key 也可以是角色卡 id,返回卡的 body(口吻/外形/对外身份),预算同 lore。收据仍是 kind: lore;未知 id 仍 UNKNOWN_LORE
check_proposeturn(check);condition 已强制时本轮不必再调
state_propose_factturn(fact)
pack_validatePack.validate,不经 turn
/look /stateturn(look);结果默认不进模型
/retrysessions.fork,不是 turn
/gm /correctturn(gm|correct)
/ooc不推进世界
canon.edit / check.definev0 不做。作者用手改 YAML/MD
bash / cordis_*开发走官方创造模式

包必须在工作区(如当前 workspace 的 packs/)。preset 不能放松 sandbox。


7. 表现

Kernel 没有图、没有 TTS。叙述者读 receipt 写散文。Host 另有可选舞台 airpStagesrc/host/stage.ts):

  • 不新开 play 工具。出图工具(image_gen 或别的)是别的插件。
  • ctx.airpStage.publish({ filePath }) 把文件拷进舞台目录,返回同源绝对 http(s) URL。
  • mountRoot(dir) 把已有目录挂到 /airp-media(适配器自己的落盘目录)。已有落盘目录的适配器选这个,不要再 publish 拷一份。
  • Web Markdown 只渲染绝对 http:/https:。相对 /airp-media/…file://data: 都会被丢掉。
  • 叙述者把回执里的绝对 URL 嵌进对白该出现的位置,不要指望工具卡出图。
  • 没有 webServermarkdownUrlundefined,brief 写「当前没有 Web 舞台」,不要贴路径。Host 用 ctx.inject(['webServer'])/airp-media,不要在 apply() 里对 ctx.get('webServer') 做一次性探测——插件可能比 Web 服务先激活。
  • hint() 只写运输规则。出图节奏(开场/换场各一张)只放 play persona。
  • /gm /correct /look /state /retry /ooc 必须声明 input.hint。Web 对无 input 的命令会立刻发裸 /gm,语法错误看起来像「gm 命令报错」。

第二种生图实现接同一个 seam,不要再给某一家出图插件开第二条媒体路由。


8. v0 廷根切片

  • 地点:廷根,值夜者据点 / 一条街区
  • 人物:克莱恩(愚者 S9)+ 2~3 名值夜者 + 1 个对手
  • 机制:非凡隐秘、特性不灭、序列差、扮演消化、失控
  • 一条线:委托 → 调查(可以只有 fact)→ 一次对抗鉴定 → 一次消化或失控鉴定 → /retry 回到对抗前

DoD:

  1. airp-playlotm-tingen,不打开 ST。
  2. 闲聊 / 走路:events=[]
  3. 对抗产生带 p、ξ、patch 的 check;叙述与 patch 一致。
  4. 模型说「你晋升了」但无事件 → 序列不变。
  5. /retry 后 State 回到鉴定前,旧线仍在。
  6. 作者改 check YAML,Pack.validate 通过后, play 会话吃到新规则。

非目标:22 途径、塔罗会、Worldsmith、市场、故事视图、生图、边玩边写 Canon。


9. 仓库

三份东西,不要塞进同一个 src/ 平铺:

dsh-airp/                    # 插件仓 = Kernel + Adapter
  package.json               # dsh.bundle.patch
  cordis.patch.yml
  src/
    kernel/                  # WorldKernel:turn / match / fold(不导出 fold)
    pack/                    # load / validate
    host/                    # DshHostAdapter
  tests/                     # 只打 TurnResult / Pack 诊断
packs/lotm-tingen/           # 数据,独立目录
~/.dsh/.agent-presets/
  airp-play/
  airp-author/               # 从最小壳 copy,只改工具掩码

dsh plugin --profile web add ./dsh-airp。测试 load(fixturePack) 后直接 turn,不 apply(ctx)


10. 测试面(interface = test surface)

意图只断言 TurnResult
S9 打 S8,注入 u=0.81failure;lose_control +Δ;p < 0.5
走路 / 无 checkevents=[]
fact 指向 lose_controlCHANNEL_VIOLATION,State 不变
match 命中后 turn(check)有 check 事件,即使没有模型
gm 无 reasonMISSING_REASON
同一 seed 重放同一 u、同一 outcome

Pack.validate:坏指针、数值字段出现在 fact schema、非法 condition、在场人物缺卡。

Adapter 测试只覆盖:工具名译成 intent、play 掩码看不见 validate、/retry 调 fork。不准再测序列差。


11. 对抗审查(仍成立的)

攻击面缓解
模型不调工具、口头改判无事件则数值不变;condition 命中时模型根本来不及跳过
乱 proposeKernel 门禁;未知 check 拒绝
condition = 任意 JS只执行 AST;装载期编译
fact 走私数值指针白名单,整单拒绝
/gm 当常规必须 reason;审计事件
/retry产品接受
lore 拉全书单 key 预算
与创造模式同进程AIRP preset 禁止 tool-cordis
包在 workspace 外写失败,不绕 sandbox
长线 fold 变慢以后加 snapshot 事件,不改真源
KV cache 抖动索引稳;动态只走 context

刻意不复刻:31 字段扫描器、装饰宏、dry-run 写变量、PNG 卡当运行时、群聊发言权当世界规则。

最大产品风险仍是做成没有角色的规则测试床。切片必须有口吻、据点和一条委托。


12. 以后

阶段不做
0.1.0Kernel + Host + 两份官方示例可玩一晚;present 已 guard;Host airpStage 可选Worldsmith、市场、生图引擎、全书、canon.edit、日历钟、UNCAUSED_CLAIM
之后Worldsmith、provisional 晋升、因果抽查阻断、收据去骰SaaS
更后语义迁移 ST 资产、可选日历钟把 ST 请回宿主

实现顺序:Kernel + 对抗单测 → lotm-tingen → Adapter + airp-play/retry /gmairp-author + validate → 手玩委托。