gen-doc-graphs.ts 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909
  1. /**
  2. * Generate (and verify) the relationship-diagram docs.
  3. *
  4. * This is the relationship layer above the existing catalogs:
  5. * - module-graph.md answers "which packages depend on which packages?"
  6. * - cordis-catalog/ answers "which events and services exist?"
  7. * - tool-catalog.md answers "which tools does the model see?"
  8. * - generated relationship diagrams answer "how do those pieces fit together?"
  9. *
  10. * Generated pages discover the enumerable facts from source. Hybrid pages use
  11. * discovered inventory plus small manifests for policy that source cannot infer
  12. * (for example, whether a package is an implementation or consumer in a seam).
  13. * Curated pages are still emitted here so the graph docs are one regenerated unit,
  14. * but their diagrams intentionally explain flow and ownership rather than
  15. * pretending to enumerate every source edge.
  16. *
  17. * `tsx scripts/gen-doc-graphs.ts` -> write generated diagram docs
  18. * `tsx scripts/gen-doc-graphs.ts --check` -> exit 1 if any file is stale
  19. */
  20. import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  21. import { dirname, relative, resolve } from 'node:path'
  22. import ts from 'typescript'
  23. import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
  24. const root = resolve(import.meta.dirname, '..')
  25. const SCOPE = '@deepseek-ai/dsh-'
  26. interface PkgJson {
  27. name: string
  28. peerDependencies?: Record<string, string>
  29. }
  30. interface Pkg {
  31. short: string
  32. name: string
  33. group: string
  34. rel: string
  35. deps: string[]
  36. }
  37. interface GraphDoc {
  38. rel: string
  39. content: string
  40. }
  41. interface ServiceRole {
  42. key: string
  43. pkg: string
  44. title: string
  45. mode: 'core' | 'seam' | 'bundle'
  46. implementations?: string[]
  47. consumers?: string[]
  48. companions?: string[]
  49. note: string
  50. }
  51. interface ExamplePlugin {
  52. id: string
  53. name: string
  54. }
  55. interface EventRelation {
  56. dispatchers: Map<string, Set<string>>
  57. listeners: Set<string>
  58. }
  59. const GROUP_ORDER = [
  60. 'util',
  61. 'llm',
  62. 'core',
  63. 'bash',
  64. 'sandbox',
  65. 'fs',
  66. 'skill',
  67. 'compact',
  68. 'subagent',
  69. 'web',
  70. 'todo',
  71. 'cordis',
  72. 'hooks',
  73. 'session-persistence',
  74. 'support',
  75. 'ui',
  76. ]
  77. const SERVICE_ROLES: ServiceRole[] = [
  78. {
  79. key: 'llm',
  80. pkg: 'llm',
  81. title: 'LLM adapter registry',
  82. mode: 'seam',
  83. implementations: ['llm-deepseek', 'llm-pi-ai', 'llm-replay'],
  84. consumers: ['agent-loop', 'compact-basic'],
  85. note: 'Adapters register provider implementations; the loop and compaction call the provider-neutral stream service.',
  86. },
  87. {
  88. key: 'sessions',
  89. pkg: 'session',
  90. title: 'In-memory session store',
  91. mode: 'core',
  92. consumers: ['agent-loop', 'agent', 'session-persistence', 'subagent-inprocess', 'invariants'],
  93. note: 'Owns append-only Session instances and emits the durable session event feed.',
  94. },
  95. {
  96. key: 'sessionPersistence',
  97. pkg: 'session-persistence',
  98. title: 'Durable session persistence seam',
  99. mode: 'seam',
  100. implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
  101. consumers: ['agent-loop', 'acp'],
  102. note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
  103. },
  104. {
  105. key: 'systemPrompt',
  106. pkg: 'system-prompt',
  107. title: 'System prompt assembly registry',
  108. mode: 'core',
  109. consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-web'],
  110. note: 'Collects prompt sections and model-facing tool schemas for each step.',
  111. },
  112. {
  113. key: 'tools',
  114. pkg: 'tools',
  115. title: 'Tool registry and guarded execution pipeline',
  116. mode: 'core',
  117. consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
  118. note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
  119. },
  120. {
  121. key: 'userInteraction',
  122. pkg: 'user-interaction',
  123. title: 'Human question/answer seam',
  124. mode: 'seam',
  125. implementations: ['stdio-agent', 'acp'],
  126. consumers: ['tool-ask-user', 'stdio-agent', 'acp'],
  127. note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
  128. },
  129. {
  130. key: 'skills',
  131. pkg: 'skill',
  132. title: 'Skill provider registry',
  133. mode: 'seam',
  134. implementations: ['skill-local'],
  135. consumers: ['tool-skill'],
  136. note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.',
  137. },
  138. {
  139. key: 'agents',
  140. pkg: 'agent',
  141. title: 'Agent registry',
  142. mode: 'core',
  143. consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-agent', 'invariants'],
  144. note: 'Owns live Agent handles and the create/resume factory seam.',
  145. },
  146. {
  147. key: 'agentLoop',
  148. pkg: 'agent-loop',
  149. title: 'Concrete loop driver',
  150. mode: 'bundle',
  151. consumers: ['agent-core'],
  152. note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.',
  153. },
  154. {
  155. key: 'bash',
  156. pkg: 'bash',
  157. title: 'Bash executor seam',
  158. mode: 'seam',
  159. implementations: ['bash-local', 'bash-sandbox'],
  160. consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
  161. note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them.',
  162. },
  163. {
  164. key: 'sandbox',
  165. pkg: 'sandbox',
  166. title: 'Process-sandbox seam',
  167. mode: 'seam',
  168. implementations: ['sandbox-local'],
  169. consumers: ['bash-sandbox'],
  170. note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.',
  171. },
  172. {
  173. key: 'approval',
  174. pkg: 'approval',
  175. title: 'Approval seam',
  176. mode: 'seam',
  177. implementations: ['acp'],
  178. consumers: ['tools', 'tool-bash'],
  179. note: 'One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`.',
  180. },
  181. {
  182. key: 'codeRuntime',
  183. pkg: 'code-runtime',
  184. title: 'Code-execution seam',
  185. mode: 'seam',
  186. implementations: ['code-runtime-worker'],
  187. consumers: ['tools'],
  188. note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode).',
  189. },
  190. {
  191. key: 'fs',
  192. pkg: 'fs',
  193. title: 'Filesystem provider seam',
  194. mode: 'seam',
  195. implementations: ['fs-local'],
  196. consumers: ['tool-fs'],
  197. companions: ['fs-policy'],
  198. note: 'tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate.',
  199. },
  200. {
  201. key: 'compact',
  202. pkg: 'compact',
  203. title: 'Compaction seam',
  204. mode: 'seam',
  205. implementations: ['compact-basic'],
  206. consumers: ['compact-basic'],
  207. note: 'The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred.',
  208. },
  209. {
  210. key: 'subagents',
  211. pkg: 'subagent',
  212. title: 'Subagent provider registry',
  213. mode: 'seam',
  214. implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-mock'],
  215. consumers: ['tool-subagent'],
  216. note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
  217. },
  218. {
  219. key: 'web',
  220. pkg: 'web',
  221. title: 'Web access provider registry',
  222. mode: 'seam',
  223. implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-local'],
  224. consumers: ['tool-web'],
  225. note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
  226. },
  227. {
  228. key: 'workflows',
  229. pkg: 'workflow',
  230. title: 'Workflow script engine',
  231. mode: 'seam',
  232. implementations: ['workflow-workerthread'],
  233. consumers: ['tool-workflow'],
  234. note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.',
  235. },
  236. ]
  237. const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
  238. // tools/result uses ctx.events.dispatch directly so the registry can await
  239. // every observer while containing each callback independently.
  240. { event: 'tools/result', pkg: 'tools', method: 'events.dispatch' },
  241. // Subagent lifecycle events intentionally bypass ctx.emit and call
  242. // ctx.events.dispatch directly so one throwing listener cannot starve later
  243. // listeners or strand an already-started child run.
  244. { event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' },
  245. { event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' },
  246. // provider-removed fires inside the provider registration's DISPOSER and
  247. // routes through the same contained dispatch (see emitLifecycle in
  248. // dsh-subagent), so the AST scan cannot attribute it either.
  249. { event: 'subagent/provider-removed', pkg: 'subagent', method: 'events.dispatch' },
  250. // The workflow/* lifecycle events dispatch the same way, for the same
  251. // per-listener-containment reason (WorkflowService.emitWorkflowEvent).
  252. { event: 'workflow/start', pkg: 'workflow', method: 'events.dispatch' },
  253. { event: 'workflow/phase', pkg: 'workflow', method: 'events.dispatch' },
  254. { event: 'workflow/log', pkg: 'workflow', method: 'events.dispatch' },
  255. { event: 'workflow/agent-start', pkg: 'workflow', method: 'events.dispatch' },
  256. { event: 'workflow/agent-end', pkg: 'workflow', method: 'events.dispatch' },
  257. { event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' },
  258. ]
  259. function generatedHeader(title: string): string[] {
  260. return [
  261. '<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
  262. ' Run `pnpm run gen-doc-graphs` to regenerate. -->',
  263. '',
  264. `# ${title}`,
  265. '',
  266. ]
  267. }
  268. function maintenanceFooter(source: string): string[] {
  269. return [`Maintenance mode: ${source}.`, '']
  270. }
  271. function graphIndexLink(rel: string): string {
  272. return relative('docs', rel).replaceAll('\\', '/')
  273. }
  274. function linkFromDoc(docRel: string, targetRel: string): string {
  275. return relative(dirname(docRel), targetRel).replaceAll('\\', '/')
  276. }
  277. function collectPackages(): Pkg[] {
  278. const pkgs: Pkg[] = []
  279. for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
  280. const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as PkgJson
  281. if (!json.name.startsWith(SCOPE)) continue
  282. const [, group, leaf] = rel.split('/')
  283. if (group === undefined || leaf === undefined) throw new Error(`gen-doc-graphs: unexpected package path ${rel}`)
  284. const deps = Object.keys(json.peerDependencies ?? {})
  285. .filter(dep => dep.startsWith(SCOPE))
  286. .map(dep => dep.slice(SCOPE.length))
  287. .sort()
  288. pkgs.push({
  289. short: json.name.slice(SCOPE.length),
  290. name: json.name,
  291. group,
  292. rel: dirname(rel),
  293. deps,
  294. })
  295. }
  296. return topoSort(pkgs)
  297. }
  298. function topoSort(pkgs: Pkg[]): Pkg[] {
  299. const remaining = new Map(pkgs.map(p => [p.short, p]))
  300. const placed = new Set<string>()
  301. const out: Pkg[] = []
  302. while (remaining.size > 0) {
  303. const ready = [...remaining.values()]
  304. .filter(pkg => pkg.deps.every(dep => placed.has(dep)))
  305. .sort(comparePackages)
  306. if (ready.length === 0) throw new Error(`gen-doc-graphs: dependency cycle among ${[...remaining.keys()].join(', ')}`)
  307. for (const pkg of ready) {
  308. out.push(pkg)
  309. placed.add(pkg.short)
  310. remaining.delete(pkg.short)
  311. }
  312. }
  313. return out
  314. }
  315. function comparePackages(a: Pkg, b: Pkg): number {
  316. const groupA = GROUP_ORDER.indexOf(a.group)
  317. const groupB = GROUP_ORDER.indexOf(b.group)
  318. const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
  319. const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
  320. return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
  321. }
  322. function nodeId(prefix: string, value: string): string {
  323. return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
  324. }
  325. function escLabel(value: string): string {
  326. return value.replace(/"/g, '\\"')
  327. }
  328. function mermaidCode(value: string): string {
  329. return `<code>${value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code>`
  330. }
  331. function repoLink(path: string, label: string, up = '..'): string {
  332. return `[${label}](${up}/${path})`
  333. }
  334. function sourceLink(source: string, up = '..'): string {
  335. return repoLink(source.split(':')[0] ?? source, `\`${source}\``, up)
  336. }
  337. function pkgLink(pkg: Pkg | undefined, fallback: string, up = '..'): string {
  338. return pkg ? repoLink(pkg.rel, `\`${pkg.short}\``, up) : `\`${fallback}\``
  339. }
  340. function pkgList(names: string[] | undefined, pkgsByShort: Map<string, Pkg>): string {
  341. if (!names || names.length === 0) return '-'
  342. return names.map(name => pkgLink(pkgsByShort.get(name), name)).join(', ')
  343. }
  344. function tableCell(value: string): string {
  345. return value.replace(/\|/g, '\\|').replace(/\n/g, '<br>')
  346. }
  347. function assertServiceRolesComplete(): void {
  348. const discovered = new Set(collectServices().map(service => service.key))
  349. const classified = new Set(SERVICE_ROLES.map(role => role.key))
  350. const missing = [...discovered].filter(key => !classified.has(key)).sort()
  351. const stale = [...classified].filter(key => !discovered.has(key)).sort()
  352. if (missing.length || stale.length) {
  353. throw new Error([
  354. missing.length ? `missing service role classification: ${missing.join(', ')}` : '',
  355. stale.length ? `stale service role classification: ${stale.join(', ')}` : '',
  356. ].filter(Boolean).join('; '))
  357. }
  358. }
  359. function renderCapabilitySeams(pkgs: Pkg[]): string {
  360. assertServiceRolesComplete()
  361. const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
  362. const maintenance = 'hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard'
  363. const nodes = new Map<string, string>()
  364. const edges = new Set<string>()
  365. const companionEdges = new Set<string>()
  366. const addNode = (id: string, label: string): void => {
  367. if (!nodes.has(id)) nodes.set(id, ` ${id}["${escLabel(label)}"]`)
  368. }
  369. const addEdge = (from: string, to: string): void => { edges.add(` ${from} --> ${to}`) }
  370. const lines = generatedHeader('Capability Seams And Core Services')
  371. lines.push(
  372. 'A service can be a core spine service, a swappable capability seam, or a bundle/composition point. The graph shows the package that owns the service declaration, known implementation packages, and packages that consume the service directly.',
  373. '',
  374. '```mermaid',
  375. 'flowchart LR',
  376. )
  377. for (const role of SERVICE_ROLES) {
  378. const svc = nodeId('svc', role.key)
  379. const owner = nodeId('pkg', role.pkg)
  380. addNode(owner, role.pkg)
  381. addNode(svc, `ctx.${role.key}<br/>${role.title}`)
  382. addEdge(owner, svc)
  383. for (const impl of role.implementations ?? []) {
  384. addNode(nodeId('pkg', impl), impl)
  385. addEdge(nodeId('pkg', impl), svc)
  386. }
  387. for (const consumer of role.consumers ?? []) {
  388. addNode(nodeId('pkg', consumer), consumer)
  389. addEdge(svc, nodeId('pkg', consumer))
  390. }
  391. for (const companion of role.companions ?? []) {
  392. addNode(nodeId('pkg', companion), companion)
  393. companionEdges.add(` ${svc} -. event gate .-> ${nodeId('pkg', companion)}`)
  394. }
  395. }
  396. lines.push(...nodes.values(), ...[...edges].sort(), ...[...companionEdges].sort())
  397. lines.push('```', '', '| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |', '| --- | --- | --- | --- | --- | --- | --- |')
  398. for (const role of SERVICE_ROLES) {
  399. lines.push(`| \`ctx.${role.key}\` | \`${role.mode}\` | ${pkgLink(pkgsByShort.get(role.pkg), role.pkg)} | ${pkgList(role.implementations, pkgsByShort)} | ${pkgList(role.consumers, pkgsByShort)} | ${pkgList(role.companions, pkgsByShort)} | ${tableCell(role.note)} |`)
  400. }
  401. lines.push('', ...maintenanceFooter(maintenance))
  402. return lines.join('\n')
  403. }
  404. function parseExampleCordis(rel: string): ExamplePlugin[] {
  405. const text = readFileSync(resolve(root, rel), 'utf8')
  406. const plugins: ExamplePlugin[] = []
  407. let current: { id: string; name?: string } | null = null
  408. const flush = (): void => {
  409. if (current?.name) plugins.push({ id: current.id, name: current.name })
  410. }
  411. for (const line of text.split('\n')) {
  412. const id = /^-\s+id:\s+(.+?)\s*$/.exec(line)
  413. if (id?.[1] !== undefined) {
  414. flush()
  415. current = { id: stripYamlScalar(id[1]) }
  416. continue
  417. }
  418. const name = /^\s+name:\s+(.+?)\s*$/.exec(line)
  419. if (name?.[1] !== undefined && current) current.name = stripYamlScalar(name[1])
  420. }
  421. flush()
  422. return plugins
  423. }
  424. function stripYamlScalar(value: string): string {
  425. return value.trim().replace(/^['"]|['"]$/g, '')
  426. }
  427. const APP_EXAMPLES = [
  428. {
  429. id: 'echo',
  430. rel: 'examples/echo-agent/composition.md',
  431. title: 'Echo Agent App Composition',
  432. label: 'examples/echo-agent',
  433. config: 'examples/echo-agent/cordis.yml',
  434. summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.',
  435. },
  436. {
  437. id: 'coding',
  438. rel: 'examples/coding-agent/composition.md',
  439. title: 'Coding Agent App Composition',
  440. label: 'examples/coding-agent',
  441. config: 'examples/coding-agent/cordis.yml',
  442. summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
  443. },
  444. {
  445. id: 'cordis',
  446. rel: 'examples/cordis-agent/composition.md',
  447. title: 'Cordis Agent App Composition',
  448. label: 'examples/cordis-agent',
  449. config: 'examples/cordis-agent/cordis.yml',
  450. summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it.',
  451. },
  452. {
  453. id: 'acp',
  454. rel: 'examples/acp-agent/composition.md',
  455. title: 'ACP Agent App Composition',
  456. label: 'examples/acp-agent',
  457. config: 'examples/acp-agent/cordis.yml',
  458. summary: 'The ACP demo exposes the same agent spine over JSON-RPC stdio, with no stdout logger and no pre-created agent; clients create sessions through the ACP bridge.',
  459. },
  460. ]
  461. type AppExample = typeof APP_EXAMPLES[number]
  462. function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void {
  463. const agentCore = nodeId('bundle', 'agent_core')
  464. const jsonl = nodeId('bundle', 'jsonl')
  465. lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-core"]`)
  466. lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
  467. if (pluginName === '@deepseek-ai/dsh-stdio-agent') {
  468. lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI<br/>console logger<br/>pre-created main agent"]`)
  469. } else if (pluginName === '@deepseek-ai/dsh-acp-agent') {
  470. lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>JSON-RPC stdio bridge<br/>sessions created by client"]`)
  471. }
  472. lines.push(
  473. ` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`,
  474. ` ${agentCore} --> ${nodeId('spine', 'sessions')}["ctx.sessions"]`,
  475. ` ${agentCore} --> ${nodeId('spine', 'tools')}["ctx.tools + tool-bash"]`,
  476. ` ${agentCore} --> ${nodeId('spine', 'loop')}["ctx.agents + ctx.agentLoop"]`,
  477. )
  478. }
  479. function renderAppComposition(example: AppExample): string {
  480. const plugins = parseExampleCordis(example.config)
  481. const maintenance = 'hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source'
  482. const lines = generatedHeader(example.title)
  483. lines.push(
  484. example.summary,
  485. '',
  486. '```mermaid',
  487. 'flowchart LR',
  488. ` cfg["${escLabel(example.label)}<br/>cordis.yml"]`,
  489. )
  490. for (const plugin of plugins) {
  491. const pluginNode = nodeId(`plugin_${example.id}`, plugin.id)
  492. lines.push(` ${pluginNode}["${escLabel(plugin.id)}<br/>${escLabel(plugin.name)}"]`)
  493. lines.push(` cfg --> ${pluginNode}`)
  494. if (plugin.name === '@deepseek-ai/dsh-stdio-agent' || plugin.name === '@deepseek-ai/dsh-acp-agent') {
  495. renderAppExpansion(lines, pluginNode, plugin.name)
  496. }
  497. }
  498. lines.push(
  499. '```',
  500. '',
  501. '| Plugin id | Package / module |',
  502. '| --- | --- |',
  503. ...plugins.map(plugin => `| \`${plugin.id}\` | \`${plugin.name}\` |`),
  504. '',
  505. `Source config: [\`${example.config}\`](${linkFromDoc(example.rel, example.config)}).`,
  506. )
  507. lines.push('', ...maintenanceFooter(maintenance))
  508. return lines.join('\n')
  509. }
  510. function collectEventRelations(): Map<string, EventRelation> {
  511. const out = new Map<string, EventRelation>()
  512. const ensure = (event: string): EventRelation => {
  513. const existing = out.get(event)
  514. if (existing) return existing
  515. const next = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
  516. out.set(event, next)
  517. return next
  518. }
  519. for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root }).sort()) {
  520. const [, , leaf] = rel.split('/')
  521. if (leaf === undefined) continue
  522. const text = readFileSync(resolve(root, rel), 'utf8')
  523. const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
  524. const visit = (node: ts.Node): void => {
  525. if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
  526. const method = node.expression.name.text
  527. if (!isCordisContextReceiver(node.expression, sf)) {
  528. ts.forEachChild(node, visit)
  529. return
  530. }
  531. if (method === 'on') {
  532. const event = eventArg(node.arguments, method)
  533. if (event) ensure(event).listeners.add(leaf)
  534. } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'strictSerial' || method === 'waterfall') {
  535. const event = eventArg(node.arguments, method)
  536. if (event) {
  537. const relation = ensure(event)
  538. const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
  539. methods.add(method === 'strictSerial' ? 'strictSerial (serial)' : method)
  540. relation.dispatchers.set(leaf, methods)
  541. }
  542. }
  543. }
  544. ts.forEachChild(node, visit)
  545. }
  546. visit(sf)
  547. }
  548. for (const entry of DYNAMIC_EVENT_DISPATCHERS) {
  549. const relation = ensure(entry.event)
  550. const methods = relation.dispatchers.get(entry.pkg) ?? new Set<string>()
  551. methods.add(entry.method)
  552. relation.dispatchers.set(entry.pkg, methods)
  553. }
  554. return out
  555. }
  556. function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean {
  557. // The chained fused-dispatch spelling: `agentEvents(ctx, agent).emit(…)` —
  558. // the receiver is a call expression, not an identifier.
  559. if (ts.isCallExpression(expr.expression) && expr.expression.expression.getText(sf) === 'agentEvents') {
  560. return true
  561. }
  562. const target = expr.expression.getText(sf)
  563. if (target === 'ctx' || target === 'this.ctx') return true
  564. // Scoped-dispatch spellings (the agent-scoping seam): the loop's fused
  565. // dispatcher (`events` from `agentEvents(ctx, agent)`), an agent's setup
  566. // context (`childCtx`), the agent's own context handle (`this.loopCtx`), and
  567. // the session store's captured dispatch context (`emitCtx`). Conventional
  568. // receiver names, pinned by the fused-dispatch convention; a rename here
  569. // must update this list (the producer/consumer matrix silently losing a
  570. // dispatcher or listener is the failure mode this list exists to prevent).
  571. return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
  572. }
  573. function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
  574. if (method === 'waterfall') {
  575. const arg = args.find(ts.isStringLiteralLike)
  576. return arg?.text
  577. }
  578. const first = args[0]
  579. if (first && ts.isStringLiteralLike(first)) return first.text
  580. // Scope-carrier dispatch: `emit(carrier, 'event/name', …)` puts the event
  581. // name second. Accept a string literal in position 1 when position 0 is a
  582. // non-literal expression (the carrier).
  583. const second = args[1]
  584. return second && ts.isStringLiteralLike(second) ? second.text : undefined
  585. }
  586. function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
  587. if (map.size === 0) return '-'
  588. return [...map.entries()]
  589. .sort(([a], [b]) => a.localeCompare(b))
  590. .map(([pkg, methods]) => `${pkgLink(pkgsByShort.get(pkg), pkg)} (${[...methods].sort().map(m => `\`${m}\``).join(', ')})`)
  591. .join(', ')
  592. }
  593. function listenerPackages(listeners: Set<string>, pkgsByShort: Map<string, Pkg>): string {
  594. if (listeners.size === 0) return '-'
  595. return [...listeners].sort().map(pkg => pkgLink(pkgsByShort.get(pkg), pkg)).join(', ')
  596. }
  597. function renderEventRelations(pkgs: Pkg[]): string {
  598. const events = collectEvents()
  599. const relations = collectEventRelations()
  600. const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
  601. const maintenance = 'hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`'
  602. const lines = generatedHeader('Event Producer And Consumer Matrix')
  603. lines.push(
  604. 'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
  605. '',
  606. '| Event | Mode | Declared in | Dispatchers | Listeners |',
  607. '| --- | --- | --- | --- | --- |',
  608. )
  609. for (const event of [...events].sort((a, b) => a.name.localeCompare(b.name))) {
  610. const relation = relations.get(event.name) ?? { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
  611. lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
  612. }
  613. // Completeness guard: every DECLARED event must have at least one dispatcher
  614. // edge — a zero-dispatcher row is either dead vocabulary or (the observed
  615. // failure mode) a dispatch spelling the AST scan does not recognize, silently
  616. // dropping the producer from the matrix. Fail the generation loud instead:
  617. // teach the scan the new spelling, add a DYNAMIC_EVENT_DISPATCHERS override,
  618. // or remove the dead event. Zero LISTENERS is deliberately legal — an event
  619. // dispatched for out-of-repo plugins is an ordinary extension point.
  620. const undispatched = [...events]
  621. .filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
  622. .map(event => event.name)
  623. .sort()
  624. if (undispatched.length > 0) {
  625. throw new Error(
  626. `event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} `
  627. + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch spelling the scan misses `
  628. + '(teach scripts/gen-doc-graphs.ts the spelling or add a DYNAMIC_EVENT_DISPATCHERS override)',
  629. )
  630. }
  631. const declared = new Set(events.map(event => event.name))
  632. const extra = [...relations.keys()].filter(event => !declared.has(event)).sort()
  633. if (extra.length > 0) {
  634. lines.push('', '## Non-harness or undeclared event strings seen in package source', '', '| Event string | Dispatchers | Listeners |', '| --- | --- | --- |')
  635. for (const event of extra) {
  636. const relation = relations.get(event)
  637. if (!relation) continue
  638. lines.push(`| \`${event}\` | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
  639. }
  640. }
  641. lines.push('', ...maintenanceFooter(maintenance))
  642. return lines.join('\n')
  643. }
  644. function renderLifecycle(): string {
  645. const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'
  646. return [
  647. ...generatedHeader('Agent Turn And Step Lifecycle'),
  648. 'This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
  649. '',
  650. '```mermaid',
  651. 'sequenceDiagram',
  652. ' participant User',
  653. ' participant Agent',
  654. ' participant Driver',
  655. ' participant Hooks as hook listeners',
  656. ' participant Prompt as ctx.systemPrompt',
  657. ' participant LLM as ctx.llm',
  658. ' participant Tools as ctx.tools',
  659. ' participant Session',
  660. ' participant Persistence',
  661. ' participant SDK as UI or SDK listener',
  662. ' User->>Agent: send(content)',
  663. ` Agent-->>SDK: ${mermaidCode('agent/queued')}`,
  664. ' Agent->>Driver: queued work wakes driver',
  665. ` Driver-->>SDK: ${mermaidCode('agent/status')} running`,
  666. ` Driver->>Session: ${mermaidCode('turn/start')}`,
  667. ` Driver->>Hooks: ${mermaidCode('agent/prompt-submit')} waterfall`,
  668. ' Hooks-->>Driver: allow, block, or add context',
  669. ` Driver->>Session: ${mermaidCode('user/message')} or rejected ${mermaidCode('turn/end')}`,
  670. ` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
  671. ` Driver-->>Driver: ${mermaidCode('agent/pre-step')} serial checkpoint`,
  672. ` Driver->>Session: ${mermaidCode('step/start')}`,
  673. ` Driver->>LLM: ${mermaidCode('agent/request')} waterfall, then ${mermaidCode('llm/stream')} waterfall`,
  674. ' LLM-->>Driver: StreamChunk*',
  675. ` Driver->>Session: ${mermaidCode('assistant/chunk')}*`,
  676. ` Session-->>SDK: ${mermaidCode('session/event')} ${mermaidCode('assistant/chunk')}*`,
  677. ` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
  678. ` Driver->>Session: ${mermaidCode('assistant/message')}`,
  679. ` Driver->>Session: ${mermaidCode('tool/call')}`,
  680. ' Driver->>Tools: execute through pre and post waterfalls',
  681. ' Tools-->>Session: tool-owned events when applicable',
  682. ` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
  683. ` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
  684. ` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
  685. ` Driver->>Session: ${mermaidCode('turn/end')}`,
  686. ` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
  687. ` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
  688. '```',
  689. '',
  690. 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
  691. '',
  692. ...maintenanceFooter(maintenance),
  693. ].join('\n')
  694. }
  695. function renderToolPipeline(): string {
  696. const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
  697. return [
  698. ...generatedHeader('Tool Execution Pipeline'),
  699. 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.',
  700. '',
  701. '```mermaid',
  702. 'flowchart TD',
  703. ' model["Assistant message contains tool-call block"]',
  704. ` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
  705. ' presentCall["UI pending card<br/>presentCall(args)"]',
  706. ` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
  707. ' guards["Registered monotonic guards<br/>deny or abstain; identity protected"]',
  708. ' denied["denied or approval refused<br/>tool body skipped"]',
  709. ` approval["${mermaidCode('ctx.approval')} one-shot prompt<br/>absent or unanswerable: deny"]`,
  710. ` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
  711. ' toolBody["Registered tool execute() body"]',
  712. ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
  713. ` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
  714. ` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
  715. ` final["${mermaidCode('tools/result')} parallel notification<br/>frozen authoritative outcome"]`,
  716. ' context["Buffered additionalContext<br/>context/message after all tool results"]',
  717. ` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
  718. ' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
  719. ' presentResult["UI completed card<br/>presentResult(args, result)"]',
  720. ' model --> toolCall',
  721. ' toolCall --> presentCall',
  722. ' toolCall --> pre',
  723. ' pre -->|allow| guards',
  724. ' guards -->|allow| around',
  725. ' guards -->|deny| denied',
  726. ' around --> toolBody',
  727. ' pre -->|deny| denied',
  728. ' pre -->|ask| approval',
  729. ' approval -->|allowed-once| guards',
  730. ' approval -->|rejected, cancelled, unavailable| denied',
  731. ' denied --> post',
  732. ' toolBody --> fsGate',
  733. ' fsGate --> toolBody',
  734. ' toolBody --> owned',
  735. ' toolBody --> around',
  736. ' around --> post',
  737. ' post --> final',
  738. ' final --> toolResult',
  739. ' toolResult --> presentResult',
  740. ' toolResult --> allResults',
  741. ' allResults --> context',
  742. '```',
  743. '',
  744. 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
  745. '',
  746. ...maintenanceFooter(maintenance),
  747. ].join('\n')
  748. }
  749. function renderSnapshotReplay(): string {
  750. const maintenance = 'curated Mermaid sequence based on the snapshot test harness'
  751. return [
  752. ...generatedHeader('ACP Snapshot Replay'),
  753. 'This graph explains what a snapshot scenario proves: recorded real-model session logs are replayed keylessly, ACP stdout is normalized and diffed, and scenario workspaces preserve tool side effects that the UI stream alone cannot prove.',
  754. '',
  755. '```mermaid',
  756. 'sequenceDiagram',
  757. ' participant Recorder as Real API recording',
  758. ' participant Fixture as snapshot fixture',
  759. ' participant Workspace',
  760. ' participant Replay as llm-replay adapter',
  761. ' participant ACP as acp-agent subprocess',
  762. ' participant Golden as stdout golden',
  763. ' Recorder->>Fixture: session.jsonl + workspace inputs',
  764. ' Fixture->>Workspace: seed files and hook configs',
  765. ' Fixture->>Replay: recorded StreamChunk script',
  766. ` Replay->>ACP: deterministic ${mermaidCode('llm/stream')} chunks`,
  767. ' ACP->>Workspace: bash, fs, and hook side effects',
  768. ' ACP->>Golden: normalized sessionUpdate stream',
  769. ' Golden-->>ACP: diff must be empty',
  770. '```',
  771. '',
  772. 'The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.',
  773. '',
  774. ...maintenanceFooter(maintenance),
  775. ].join('\n')
  776. }
  777. function renderDocs(): GraphDoc[] {
  778. const pkgs = collectPackages()
  779. const docs: GraphDoc[] = [
  780. { rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) },
  781. ...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
  782. { rel: 'docs/event-producer-consumer.md', content: renderEventRelations(pkgs) },
  783. { rel: 'docs/agent-lifecycle.md', content: renderLifecycle() },
  784. { rel: 'docs/tool-execution-pipeline.md', content: renderToolPipeline() },
  785. { rel: 'packages/ui/acp/snapshot-replay.md', content: renderSnapshotReplay() },
  786. ]
  787. docs.unshift({ rel: 'docs/graph-atlas.md', content: renderIndex(docs) })
  788. return docs
  789. }
  790. function renderIndex(docs: GraphDoc[]): string {
  791. const labels: Record<string, string> = {
  792. 'docs/capability-seams.md': 'capability seams and core services',
  793. 'examples/echo-agent/composition.md': 'echo-agent app composition',
  794. 'examples/coding-agent/composition.md': 'coding-agent app composition',
  795. 'examples/cordis-agent/composition.md': 'cordis-agent app composition',
  796. 'examples/acp-agent/composition.md': 'acp-agent app composition',
  797. 'docs/event-producer-consumer.md': 'event producer/consumer matrix',
  798. 'docs/agent-lifecycle.md': 'agent turn and step lifecycle',
  799. 'docs/tool-execution-pipeline.md': 'tool execution pipeline',
  800. 'packages/ui/acp/snapshot-replay.md': 'ACP snapshot replay',
  801. }
  802. const modes: Record<string, string> = {
  803. 'docs/capability-seams.md': 'hybrid generated',
  804. 'examples/echo-agent/composition.md': 'hybrid generated',
  805. 'examples/coding-agent/composition.md': 'hybrid generated',
  806. 'examples/cordis-agent/composition.md': 'hybrid generated',
  807. 'examples/acp-agent/composition.md': 'hybrid generated',
  808. 'docs/event-producer-consumer.md': 'hybrid generated',
  809. 'docs/agent-lifecycle.md': 'curated',
  810. 'docs/tool-execution-pipeline.md': 'curated',
  811. 'packages/ui/acp/snapshot-replay.md': 'curated',
  812. }
  813. const rows = [
  814. '| [module dependency graph](module-graph.md) | `generated` |',
  815. '| [tool schema catalog and package map](tool-catalog.md) | `generated` |',
  816. ...docs.map((doc) => {
  817. const link = graphIndexLink(doc.rel)
  818. return `| [${labels[doc.rel] ?? link}](${link}) | \`${modes[doc.rel] ?? 'generated'}\` |`
  819. }),
  820. ]
  821. const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode'
  822. return [
  823. ...generatedHeader('Documentation Graph Index'),
  824. 'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog.md](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md).',
  825. '',
  826. 'The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md).',
  827. '',
  828. '| Graph | Mode |',
  829. '| --- | --- |',
  830. ...rows,
  831. '',
  832. 'Regenerate with `pnpm run gen-doc-graphs`; verify freshness with `pnpm run verify-doc-graphs`.',
  833. '',
  834. ...maintenanceFooter(maintenance),
  835. ].join('\n')
  836. }
  837. function main(): void {
  838. const docs = renderDocs()
  839. if (process.argv.includes('--check')) {
  840. const stale: string[] = []
  841. for (const doc of docs) {
  842. const abs = resolve(root, doc.rel)
  843. const committed = existsSync(abs) ? readFileSync(abs, 'utf8') : null
  844. if (committed !== doc.content) stale.push(doc.rel)
  845. }
  846. if (stale.length === 0) {
  847. console.log(`gen-doc-graphs: ${docs.length} graph doc(s) are up to date.`)
  848. return
  849. }
  850. console.error(`gen-doc-graphs: stale graph doc(s): ${stale.join(', ')}. Run \`pnpm run gen-doc-graphs\` and commit the result.`)
  851. process.exit(1)
  852. }
  853. for (const doc of docs) {
  854. mkdirSync(dirname(resolve(root, doc.rel)), { recursive: true })
  855. writeFileSync(resolve(root, doc.rel), doc.content)
  856. }
  857. console.log(`gen-doc-graphs: wrote ${docs.length} graph doc(s).`)
  858. }
  859. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  860. main()
  861. }