refactor(examples): extract reusable logic into tested packages

Logic that lived under examples/ was outside the per-file 100% coverage
gate (examples/ are not workspaces) and, in the stdio-UI case, duplicated
across two examples. Move it into packages/ so it is gated and de-duped.

- packages/ui-stdio (new): unify the two diverged stdio-chat.ts copies into
  one @deepseek-ai/dsh-ui-stdio plugin (welcome/agent Config). A test-only
  I/O seam (createStdioChat(ctx, config, runtime)) keeps process streams out
  of the serializable config and makes every render/EOF/disposal branch
  unit-testable. Per-file 100%. echo/coding cordis.yml now load the package;
  both src/stdio-chat.ts deleted.
- packages/llm-replay (new): move examples/acp-agent/src/llm-replay.ts (+ its
  spec) here so its derive/parse/replay branches fall under the coverage gate.
  cordis.snapshot.yml + README rewired to the package name; added apply/env
  /assertNever/abort tests to reach per-file 100%.
- examples/{echo,coding}-agent: keyless Loader-path e2e smokes that boot the
  real cordis.yml (no key) — the guard a hand-mounted unit test cannot be for
  the unwrapExports/export-shape class (postmortem 0001). examples/AGENTS.md
  codifies the keyless+with-key smoke convention (keyless-by-nature exception
  for echo-agent).
- AGENTS.md: a scoped, removal-triggered pre-release stance (foundation over
  blast radius). packages/README.md: new rows + a FIXME to later regroup ALL
  packages into a hierarchy. Wiring: tsconfig paths/refs, publint, knip,
  module-graph.

Verified: typecheck, lint, test:coverage (887 tests, 100%), build, hygiene,
doc-sync, test:snapshot (10), test:e2e (6 keyless pass, with-key self-skip).
This commit is contained in:
Tianyi Cui
2026-06-19 12:42:28 +08:00
parent 1e09ab7204
commit 072f97c184
29 changed files with 1121 additions and 195 deletions

View File

@@ -0,0 +1,42 @@
# @deepseek-ai/dsh-ui-stdio
A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it only consumes the `agent/*` event taxonomy plus the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface.
This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`.
## Config
| Key | Type | Default | Notes |
|---|---|---|---|
| `welcome` | string | `'ready.'` | Banner printed once on start, before the first `> ` prompt. |
| `agent` | string | `'main'` | Id of the agent to drive and render. |
```yaml
- id: ui-stdio
name: '@deepseek-ai/dsh-ui-stdio'
config:
welcome: 'coding-agent ready. Give it a coding task.'
```
## Rendering
- `agent/stream-chunk``text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on.
- `agent/turn-start` / `agent/turn-end` — a `[<agent> turn N]` header and a trailing `> ` prompt.
- `session/event``tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`.
## The I/O seam
The production entry point `apply(ctx, config)` binds the real `process` streams. The testable core is `createStdioChat(ctx, config, runtime)`, where `runtime: StdioRuntime` supplies `input` / `output` / `exit`. This seam is deliberately **not** part of the serializable `Config` (streams and functions do not belong in YAML config); it exists so the render, EOF, and disposal branches can be exercised with fakes instead of hijacking globals.
## Piped-stdin exit
On stdin EOF the plugin exits the process, but carefully:
- **No work submitted** (empty stdin, blank-only lines): exit immediately — no turn will ever start, so there is nothing to wait for. Gating on an observed `running` here would hang forever.
- **Work submitted**: exit the next time the agent settles to `idle` *after* having been observed `running`. `agent.send()` does not synchronously flip status to `running`, so requiring an observed `running` first (`sawRunning`) avoids exiting in the gap before the turn starts and dropping work; and the loop batches several queued messages into one turn, so the exit keys off the idle transition rather than counting sends.
Disposal (HMR or fiber teardown) closes the readline interface, which also fires `close` — a `disposed` guard ensures teardown never calls `process.exit`.
## Plugin export shape
Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../docs/postmortem/0001-acp-default-export-drops-inject.md)). The keyless Loader-path e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-ui-stdio",
"description": "Minimal stdio (readline) UI plugin: renders agent/* events to stdout and feeds stdin lines to the agent",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,190 @@
/**
* Minimal stdio UI plugin: reads lines from stdin → `agent.send()`/`steer()`,
* and renders the agent's stream chunks and tool activity to stdout. A UI is
* "just a plugin" — it only consumes the `agent/*` event taxonomy and the
* `agents` service, so the same plugin drives any example or product surface.
*
* Consolidates what were two near-identical copies under `examples/echo-agent`
* and `examples/coding-agent` (the latter a superset). This package IS that
* superset: dimmed chain-of-thought rendering plus the robust piped-stdin
* EOF→idle exit handling, configured per consumer via {@link Config}.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
* export — the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
* so a stray default would collapse the module to the bare function and drop
* the `inject` namespace (see docs/postmortem/0001). The keyless Loader-path
* e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end.
*
* @module @deepseek-ai/dsh-ui-stdio
*/
import { createInterface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
export const name = 'ui-stdio'
export const inject = ['agents']
/** Serializable plugin configuration (cordis-native, schemastery). */
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
/** Id of the agent to drive and render. Defaults to `'main'`. */
agent?: string
}
export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
agent: z.string().default('main'),
})
/**
* Process-I/O seam — the side-effecting handles the plugin would otherwise
* reach for as globals. Defaulted to the real `process` streams in
* {@link apply}; injected by tests so the EOF, render, and disposal branches
* are exercised without hijacking globals. Deliberately NOT part of the
* serializable {@link Config} (streams/functions don't belong in YAML config).
*/
export interface StdioRuntime {
/** Line source (default `process.stdin`). */
input: Readable
/** Render sink (default `process.stdout`). */
output: Writable
/** Process-exit hook (default `process.exit`); called once on stdin EOF. */
exit: (code: number) => void
}
/**
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
* production wrapper that binds the real `process` streams; tests call this
* directly with fakes. Returns nothing — all registration is via `ctx.on`/
* `ctx.effect`, so fiber disposal tears every listener and the readline
* interface down.
*/
export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
// schemastery `.default()` guarantees these are set after validation.
const welcome = config.welcome as string
const agentId = config.agent as string
const { input, output, exit } = runtime
let inReasoning = false
ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => {
if (chunk.type === 'reasoning-delta') {
// Dim the chain-of-thought so the final answer stands out.
if (!inReasoning) output.write('\x1B[2m')
inReasoning = true
output.write(chunk.text)
} else if (chunk.type === 'text-delta') {
if (inReasoning) output.write('\x1B[0m\n')
inReasoning = false
output.write(chunk.text)
}
})
ctx.on('agent/turn-start', (agent, turn) => {
output.write(`\n[${agent.id} turn ${turn}] `)
})
ctx.on('agent/turn-end', () => {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
output.write('\n> ')
})
ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/call') {
const { name: toolName, arguments: args } = event.data
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
output.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
const { content } = event.data
const text = content.filter(block => block.type === 'text').map(block => block.text).join('')
output.write(`\n [tool result] ${text}\n `)
}
})
ctx.effect(() => {
const reader = createInterface({ input })
// Piped-input exit, once stdin reaches EOF:
// - If no line ever submitted work (empty stdin, blank-only lines), exit
// immediately — no turn will ever start, so there is nothing to wait
// for. (Gating on an observed 'running' here would hang forever.)
// - If work WAS submitted, exit the next time the agent settles to idle
// AFTER having run. Two subtleties this handles: the loop batches
// several queued messages into ONE turn (one idle), so we don't count
// sends; and agent.send() does NOT synchronously flip status to
// 'running', so requiring an observed 'running' first (`sawRunning`)
// avoids exiting in the gap before the turn starts and dropping work.
let stdinClosed = false
let disposed = false
let submittedWork = false
let sawRunning = false
const maybeExit = (): void => {
if (disposed || !stdinClosed) return
// No work submitted: nothing will ever run, exit straight away.
// Work submitted: wait until a turn has run and the agent is idle.
if (submittedWork) {
if (!sawRunning) return
const agent = ctx.agents.get(agentId)
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let any final output flush, then exit.
setTimeout(() => { exit(0) }, 200)
}
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
if (subject.id !== agentId) return
if (status === 'running') sawRunning = true
if (status === 'idle') maybeExit()
})
reader.on('line', (line) => {
const text = line.trim()
if (!text) return
const agent = ctx.agents.get(agentId)
if (!agent) {
ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
return
}
submittedWork = true
if (agent.status === 'running') {
agent.steer([{ type: 'text', text }])
} else {
agent.send([{ type: 'text', text }])
}
})
reader.on('close', () => {
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
// `disposed` guards teardown so HMR/dispose never exits the process.
stdinClosed = true
maybeExit()
})
output.write(`${welcome}\n> `)
return () => {
disposed = true
disposeStatusListener()
reader.close()
}
}, 'ui-stdio')
}
/**
* Cordis entry point. Binds the real `process` streams and delegates to
* {@link createStdioChat}; the indirection keeps the side-effecting handles out
* of the testable core, which is why the unit suite drives `createStdioChat`
* directly. This thin wrapper is exercised end-to-end by the keyless
* Loader-path e2e smoke in `examples/echo-agent` (the real product entry).
*/
/* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */
export function apply(ctx: Context, config: Config): void {
createStdioChat(ctx, config, {
input: process.stdin,
output: process.stdout,
exit: code => process.exit(code),
})
}
/* v8 ignore stop */

View File

@@ -0,0 +1,309 @@
import { Readable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts'
/**
* Unit tests for the stdio UI plugin. They drive the REAL plugin body
* (`createStdioChat`) with an injected {@link StdioRuntime} so every render,
* input, EOF, and disposal branch runs without touching the real `process`
* streams — the I/O seam is what makes the per-file gate reachable. The
* `agents` service is real (`@deepseek-ai/dsh-agent`); a minimal fake `Agent`
* stands in for the loop, since the loop is the genuinely expensive collaborator
* and we only need its `status` + `send`/`steer` surface here.
*/
/** A controllable stdin: a Readable we push lines into and can end on demand. */
function makeInput(): Readable & { feed(line: string): void; finish(): void } {
const stream = new Readable({ read() {} }) as Readable & { feed(line: string): void; finish(): void }
stream.feed = (line: string) => stream.push(`${line}\n`)
stream.finish = () => stream.push(null)
return stream
}
/** A stdout sink that accumulates everything written, for assertions. */
function makeOutput(): { write: (s: string) => boolean; text: () => string } {
let buf = ''
return { write: (s: string) => { buf += s; return true }, text: () => buf }
}
function makeRuntime(over: Partial<StdioRuntime> = {}): {
runtime: StdioRuntime
input: ReturnType<typeof makeInput>
out: ReturnType<typeof makeOutput>
exit: ReturnType<typeof vi.fn>
} {
const input = makeInput()
const out = makeOutput()
const exit = vi.fn()
return { runtime: { input, output: { write: out.write } as never, exit, ...over }, input, out, exit }
}
/** A minimal Agent fake exposing the surface the UI touches. */
function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & {
status: AgentStatus
sent: ContentBlock[][]
steered: ContentBlock[][]
} {
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
return {
id: id as Agent['id'],
status,
sent,
steered,
send: (content: ContentBlock[]) => void sent.push(content),
steer: (content: ContentBlock[]) => void steered.push(content),
} as never
}
const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const { runtime, input, out, exit } = makeRuntime(runtimeOver)
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, config, runtime)
}, { inject: ['agents'] }))
return { ctx, fiber, input, out, exit }
}
/** Drive a fake idle timer past the 200ms flush delay. */
function flushExit(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 250))
}
describe('createStdioChat rendering', () => {
it('writes the welcome banner and prompt on start', async () => {
const { out } = await setup()
expect(out.text()).toBe('hi there\n> ')
})
it('renders text-delta chunks verbatim', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'text-delta', index: 0, text: 'hello' })
expect(out.text()).toContain('hello')
})
it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'think' })
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'more' })
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'text-delta', index: 0, text: 'answer' })
expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer')
})
it('ignores stream-chunk types it does not render', async () => {
const { ctx, out } = await setup()
const before = out.text()
const agent = makeAgent('main')
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'block-start', index: 0, blockType: 'text' })
expect(out.text()).toBe(before)
})
it('renders turn-start and turn-end markers', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/turn-start', agent, 3)
expect(out.text()).toContain('[main turn 3] ')
ctx.emit('agent/turn-end', agent, 3, { kind: 'completed' })
expect(out.text()).toContain('\n> ')
})
it('resets dim styling at turn-end if a turn ends mid-reasoning', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'mid' })
ctx.emit('agent/turn-end', agent, 1, { kind: 'completed' })
expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
})
it('renders tool/call and tool/result session events', async () => {
const { ctx, out } = await setup()
const session = {} as Session
const callEvent = {
type: 'tool/call', seq: 1, time: 0,
data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' },
} as SessionEvent
ctx.emit('session/event', session, callEvent)
expect(out.text()).toContain('[tool call] bash({"command":"ls"})')
const resultEvent = {
type: 'tool/result', seq: 2, time: 0,
data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false },
} as SessionEvent
ctx.emit('session/event', session, resultEvent)
expect(out.text()).toContain('[tool result] file.txt')
})
it('resets dim styling when a tool/call interrupts reasoning', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'r' })
const session = {} as Session
ctx.emit('session/event', session, {
type: 'tool/call', seq: 1, time: 0,
data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' },
} as SessionEvent)
expect(out.text()).toContain('\x1B[2mr\x1B[0m')
})
it('ignores session events it does not render', async () => {
const { ctx, out } = await setup()
const before = out.text()
ctx.emit('session/event', {} as Session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } },
} as SessionEvent)
expect(out.text()).toBe(before)
})
})
describe('createStdioChat input', () => {
it('sends a typed line to an idle agent', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('do a thing')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]])
expect(agent.steered).toEqual([])
})
it('steers a typed line into a running agent', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main', 'running')
ctx.agents.register(agent)
input.feed('steer me')
await new Promise(r => setImmediate(r))
expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]])
expect(agent.sent).toEqual([])
})
it('ignores blank lines', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main')
ctx.agents.register(agent)
input.feed(' ')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([])
})
it('logs and drops a line when the target agent is not running', async () => {
const { ctx, input } = await setup()
const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
input.feed('nobody home')
await new Promise(r => setImmediate(r))
expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
})
it('drives the agent named in config, not a hardcoded id', async () => {
const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
const agent = makeAgent('worker')
ctx.agents.register(agent)
input.feed('hi')
await new Promise(r => setImmediate(r))
expect(agent.sent).toHaveLength(1)
})
})
describe('createStdioChat EOF exit', () => {
it('exits immediately on EOF when no work was submitted', async () => {
const { input, exit } = await setup()
input.finish()
await flushExit()
expect(exit).toHaveBeenCalledWith(0)
})
it('waits for the agent to settle idle after running before exiting', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
input.finish()
await new Promise(r => setImmediate(r))
// Work submitted but no 'running' observed yet — must NOT exit.
expect(exit).not.toHaveBeenCalled()
// The turn starts, then settles.
ctx.emit('agent/status', agent, 'running')
;(agent as { status: AgentStatus }).status = 'idle'
ctx.emit('agent/status', agent, 'idle')
await flushExit()
expect(exit).toHaveBeenCalledWith(0)
})
it('does not exit on an idle transition for a different agent', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
input.finish()
const other = makeAgent('other')
ctx.emit('agent/status', other, 'running')
ctx.emit('agent/status', other, 'idle')
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
it('does not exit while a turn is still running at EOF', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
ctx.emit('agent/status', agent, 'running')
;(agent as { status: AgentStatus }).status = 'running'
input.finish()
// sawRunning is true, but the agent is still running — the idle gate holds.
ctx.emit('agent/status', agent, 'idle') // a stale/duplicate signal while status stays 'running'
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
})
describe('createStdioChat disposal (HMR safety)', () => {
it('never exits the process when EOF arrives after fiber dispose', async () => {
const { fiber, input, exit } = await setup()
await fiber.dispose()
// A late EOF after disposal (reader.close() also fires 'close') must not exit.
input.finish()
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
it('stops handling input after dispose', async () => {
const { ctx, fiber, input } = await setup()
const agent = makeAgent('main')
ctx.agents.register(agent)
await fiber.dispose()
// The readline interface is closed on dispose; a late line reaches no handler.
input.feed('too late')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([])
})
it('removes the agent/status listener on dispose', async () => {
const { ctx, fiber, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
await fiber.dispose()
// After dispose, status transitions must neither throw nor schedule an exit
// (the listener and the EOF-exit path are both torn down).
expect(() => {
ctx.emit('agent/status', agent, 'running')
ctx.emit('agent/status', agent, 'idle')
}).not.toThrow()
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../../vendor/cosmokit" },
{ "path": "../../vendor/cordis" },
{ "path": "../../vendor/schemastery" },
{ "path": "../agent" },
{ "path": "../llm" },
{ "path": "../session" }
]
}