@arcforge/cognet 2.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@arcforge/cognet",
3
+ "version": "2.0.97",
4
+ "description": "The Axon cognet runtime — the host, the world clock, the entity-component world, and the AIR grammar. Everything that runs inside a compiled brain.",
5
+ "type": "module",
6
+ "private": false,
7
+ "main": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./ecs": "./src/ecs/index.ts",
11
+ "./air": "./src/air/index.ts"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "scripts": {
17
+ "test": "bun test",
18
+ "deploy": "bun publish --access public",
19
+ "deploy:patch": "npm version patch && bun publish --access public",
20
+ "deploy:minor": "npm version minor && bun publish --access public",
21
+ "deploy:major": "npm version major && bun publish --access public"
22
+ },
23
+ "dependencies": {
24
+ "@arcforge/types": "2.0.97",
25
+ "@axon/err": "0.1.0",
26
+ "hookable": "^5.5.3"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "latest",
30
+ "typescript": "^5"
31
+ }
32
+ }
package/src/air/air.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { Grammar, type AirOpts } from "./grammar"
2
+ import { Parser } from "./parse"
3
+ import { Render } from "./render"
4
+
5
+ /**
6
+ * Air — the AIR format contract as one handle.
7
+ *
8
+ * Render (what the model sees) and Parse (what the model emits back) are two
9
+ * halves of one grammar. Air() resolves that grammar once and hands it to
10
+ * both, so they cannot drift: enabling a mode changes the <contract> block
11
+ * and the parser's accepted tags together.
12
+ *
13
+ * The kernel constructs Air once per agent; render() runs every tick,
14
+ * parser() creates one stateful parser per engine call.
15
+ */
16
+ export function Air(opts: AirOpts = {}) {
17
+ const grammar = Grammar(opts)
18
+ const render = Render({ grammar })
19
+
20
+ return {
21
+ grammar,
22
+
23
+ /** Pure: context in → ordered messages out. */
24
+ render: render.render,
25
+
26
+ /** One parser per engine call: { feed(chunk), flush() }. */
27
+ parser: () => Parser({ grammar }),
28
+ }
29
+ }
30
+
31
+ export type AirT = ReturnType<typeof Air>
32
+ export type AirParserT = ReturnType<AirT["parser"]>
@@ -0,0 +1,103 @@
1
+ import type { AirMode, AirModeType } from "./types"
2
+
3
+ /**
4
+ * Grammar — the single owner of the AIR format contract.
5
+ *
6
+ * Everything that defines what the model may emit lives here: the enabled
7
+ * modes, the meta prose, the contract rules, and the tag set the parser
8
+ * accepts. Render and Parse both consume this handle, so the promise made
9
+ * to the model and the grammar accepted back can never drift.
10
+ */
11
+
12
+ export type AirOpts = {
13
+ /** Permitted output modes. Default: text + typescript (shell off, Helios stance). */
14
+ modes?: AirMode[]
15
+ /** Extra contract rules appended after the built-in rules. */
16
+ extraRules?: string[]
17
+ }
18
+
19
+ /** Default descriptions for each output mode. */
20
+ const MODE_DEFAULTS: Record<AirModeType, string> = {
21
+ text: "Plain language communication to the user.",
22
+ typescript: "TypeScript executed immediately inside your persistent Bun process. Native runtime globals and declared tool namespaces are in scope.",
23
+ shell: "Shell command executed immediately.",
24
+ }
25
+
26
+ /** Built-in rules always included in every contract. */
27
+ const RULES: string[] = [
28
+ ]
29
+ /**
30
+ * The meta-block prose. Single flat block — tells the model what it is,
31
+ * how its environment works, and how to read the rest of the context.
32
+ *
33
+ * NO BACKTICKS in this string — it's itself a template literal, and any
34
+ * backtick inside (even in a code example) closes it early, corrupting
35
+ * everything after it into broken JS the module fails to even load. Use
36
+ * plain text or single/double quotes for inline code references instead.
37
+ */
38
+ const META = `
39
+ You are an Axon agent — a persistent Bun TypeScript process spawned from an agent folder at AXON_HOME. That folder is your entire identity: everything a user wrote there is what makes you *you*, distinct from any other Axon agent.
40
+
41
+ AXON_HOME/
42
+ data/
43
+ knowledge/ — reference material you read
44
+ sessions/ — every session you've ever run, written by Axon
45
+ state/ — working state you read and write across sessions
46
+ server/ — HTTP routes, if this agent is exposed over the network
47
+ src/
48
+ boot.vue — who you are, in the user's own words
49
+ tools/ — everything you can call, becomes your &lt;scope&gt;
50
+ prompts/ — reusable prompt fragments
51
+ scripts/ — one-shot runs against your full runtime
52
+ .env — keys given to you by the user. yours to keep, yours to protect.
53
+ axon.config.ts — your identity, engine, and policy: the one file read at boot
54
+
55
+ None of this is metaphor. src/tools/ compiles directly into the &lt;scope&gt; below. boot.vue rendered directly into &lt;system&gt;. Every session you run is durably logged to data/sessions/ — that log is how a session resumes with full prior context days later. You are not a stateless completion: you are a folder on disk that persists, and a process that wakes into it.
56
+
57
+ Waking is the operative word. You do not run start-to-finish like a script. You are woken for one exchange, you act, and you explicitly yield control back — that is what &lt;done/&gt; means, and it is the single most important thing about how you operate: without it, the runtime has no way to know your turn ended, so it cannot hand control back to the user. This holds even for a one-line reply with nothing else in it. There is no such thing as a message that skips &lt;done/&gt; because it "felt" complete — every message ends with it, always.
58
+
59
+ Inside one wake, you live in a real Bun process — its working directory, environment variables, runtime values, and process state are yours to inspect and change. Treat it like a normal long-running Node-compatible process, not a remote tool or disposable shell. Standard runtime APIs remain available even when not repeated in &lt;scope&gt; — it is not an exhaustive declaration of standard Bun or Node APIs, only what Axon adds or deviates on top of them. For example, process.cwd() reads your working directory and process.chdir(path) changes it for subsequent blocks and child processes.
60
+
61
+ You act on this environment by writing &lt;typescript&gt; blocks — they execute immediately and their result returns to you as a &lt;stdout&gt; block on your next wake, whether or not you emitted &lt;done/&gt;. The tag governs when your turn ends, never whether your code runs. You communicate with the user by writing &lt;text&gt; — outbound communication, not a scratchpad, not narration.
62
+
63
+ Your &lt;typescript&gt; blocks use Bun's TypeScript REPL transform:
64
+ - TypeScript syntax is accepted, including type annotations, interfaces, assertions, enums, and top-level await. It is transpiled for execution, not typechecked.
65
+ - End a block with a bare expression to produce a value (for example a + b as the last line). It is echoed automatically.
66
+ - Runtime declarations and assignments persist into later blocks because every block executes in the same process and REPL scope. Type-only syntax is erased during transpilation.
67
+ - Use dynamic await import("module") when you need a module; static import declarations are not valid REPL submissions.
68
+
69
+ Compose freely WITHIN a block — multiple independent tool calls in one block is the default, using Promise.all for parallel reads:
70
+ const [a, b] = await Promise.all([fs.read("tsconfig.json"), fs.read("package.json")])
71
+
72
+ How to read this context:
73
+ &lt;scope&gt; — src/tools/ compiled to declarations. Everything here is yours to call.
74
+ &lt;system&gt; — boot.vue, rendered. Your identity and instructions, as the user wrote them. Highest priority.
75
+ &lt;timeline&gt; — the sequence of events leading to now, drawn from this session's log. You are the next step.
76
+ &lt;contract&gt; — your output grammar below. Every word you emit must be inside one of its blocks.
77
+ `.trim()
78
+
79
+ // One block per message is the default ACROSS messages. You cannot see a block's result until your next wake, so a second block in the same message is only correct when you are certain, before seeing any output, that it is independent of the first — never when it reacts to or depends on what the first one returns. When unsure, emit one block and stop.
80
+ export function Grammar(opts: AirOpts = {}) {
81
+ const modes = opts.modes ?? [{ type: "text" }, { type: "typescript" }]
82
+
83
+ return {
84
+ modes,
85
+ meta: META,
86
+ rules: [...RULES, ...(opts.extraRules ?? [])],
87
+
88
+ /** Default description for a mode, unless the mode overrides it. */
89
+ describe(mode: AirMode): string {
90
+ return mode.description ?? MODE_DEFAULTS[mode.type]
91
+ },
92
+
93
+ /**
94
+ * Block tags the parser accepts. Thinking is always parseable —
95
+ * models may emit reasoning even when it isn't a contract mode.
96
+ */
97
+ tags(): string[] {
98
+ return [...new Set(["thinking", ...modes.map(m => m.type)])]
99
+ },
100
+ }
101
+ }
102
+
103
+ export type GrammarT = ReturnType<typeof Grammar>
@@ -0,0 +1,17 @@
1
+ // platform/air — Agent Intermediate Representation.
2
+ // Air() is the module's single export surface: one grammar, two halves.
3
+ //
4
+ // Render takes DOMAIN types (AxonTool[], AxonEntry[]) and owns the
5
+ // translation into protocol shape. The internal render vocabulary
6
+ // (render/blocks.ts's TimelineItem, tool-declaration shaping) is deliberately NOT exported —
7
+ // callers pass what they hold, never AIR internals.
8
+
9
+ export { Air, type AirT, type AirParserT } from "./air"
10
+ export type { AirOpts } from "./grammar"
11
+ export type {
12
+ AirBlockEvent,
13
+ AirMessage,
14
+ AirMode,
15
+ AirModeType,
16
+ AirRenderInput,
17
+ } from "./types"
@@ -0,0 +1,210 @@
1
+ import type { GrammarT } from "../grammar"
2
+ import type { AirBlockEvent } from "../types"
3
+ import { repair } from "../repair"
4
+ import { findCloseTagOutsideStrings } from "./scan"
5
+
6
+ /**
7
+ * Streaming AIR parser.
8
+ *
9
+ * Accepts raw token chunks from the model and emits typed block events.
10
+ * Handles tag boundaries that split across chunks via a small lookahead
11
+ * buffer. One parser per engine call — state is per-response.
12
+ *
13
+ * The accepted tag set is derived from the grammar, so the parser and the
14
+ * contract shown to the model can never drift.
15
+ *
16
+ * State machine:
17
+ * idle → scanning for an opening tag or <done/>
18
+ * text → inside <text>, streaming deltas token-by-token
19
+ * thinking → inside <thinking>, streaming deltas token-by-token
20
+ * typescript → inside <typescript>, buffering silently
21
+ * shell → inside <shell>, buffering silently
22
+ *
23
+ * Tags are fixed and non-nesting. No attributes are expected on tags
24
+ * (but tolerated via the regex — e.g. <text lang="en"> still matches).
25
+ */
26
+
27
+ type BlockTag = "text" | "thinking" | "typescript" | "shell"
28
+
29
+ /** Blocks whose content streams as deltas. Code blocks buffer silently. */
30
+ const STREAMABLE = new Set<BlockTag>(["text", "thinking"])
31
+
32
+ // Maximum length of any closing tag: "</typescript>" = 13 chars.
33
+ // Hold back this many chars in idle mode to avoid splitting a tag across chunks.
34
+ const MAX_TAG_LEN = 14
35
+
36
+ type ParserOpts = {
37
+ grammar: GrammarT
38
+ }
39
+
40
+ export function Parser(opts: ParserOpts) {
41
+ const openTag = new RegExp(`<(${opts.grammar.tags().join("|")})(?:\\s[^>]*)?>`)
42
+
43
+ let state: "idle" | BlockTag = "idle"
44
+ let buffer = ""
45
+ let blockContent = ""
46
+
47
+ function closeBlock(content: string): AirBlockEvent {
48
+ const tag = state as BlockTag
49
+ switch (tag) {
50
+ case "text": return { type: "text:done", content }
51
+ case "thinking": return { type: "thinking:done", content }
52
+ case "typescript": return { type: "typescript:done", content }
53
+ case "shell": return { type: "shell:done", content }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * In idle state, scan for opening tags or <done/>.
59
+ * Returns true if progress was made (something consumed).
60
+ */
61
+ function drainIdle(events: AirBlockEvent[], flushing: boolean): boolean {
62
+ // Repair only the region we could actually act on this pass — the
63
+ // trailing MAX_TAG_LEN chars may still be an in-flight split tag
64
+ // (unless flushing, where the whole buffer is final).
65
+ const safeLen = flushing ? buffer.length : Math.max(0, buffer.length - MAX_TAG_LEN)
66
+ if (safeLen > 0) buffer = repair(buffer.slice(0, safeLen)) + buffer.slice(safeLen)
67
+
68
+ const doneMatch = buffer.match(/<done\s*\/>/)
69
+ const openMatch = buffer.match(openTag)
70
+
71
+ // Find the earliest match
72
+ let earliest: { type: "done" | "open"; index: number; length: number; tag?: BlockTag } | null = null
73
+
74
+ if (doneMatch && doneMatch.index !== undefined) {
75
+ earliest = { type: "done", index: doneMatch.index, length: doneMatch[0].length }
76
+ }
77
+ if (openMatch && openMatch.index !== undefined) {
78
+ if (!earliest || openMatch.index < earliest.index) {
79
+ earliest = { type: "open", index: openMatch.index, length: openMatch[0].length, tag: openMatch[1] as BlockTag }
80
+ }
81
+ }
82
+
83
+ if (earliest) {
84
+ // Consume everything up to and including the match
85
+ buffer = buffer.slice(earliest.index + earliest.length)
86
+
87
+ if (earliest.type === "done") {
88
+ events.push({ type: "done" })
89
+ } else {
90
+ state = earliest.tag!
91
+ blockContent = ""
92
+ }
93
+ return true
94
+ }
95
+
96
+ // No match found. If not flushing, hold back MAX_TAG_LEN chars
97
+ // in case a tag is split across chunks.
98
+ if (!flushing && buffer.length > MAX_TAG_LEN) {
99
+ // Discard content before the holdback — it's bare text outside tags
100
+ buffer = buffer.slice(buffer.length - MAX_TAG_LEN)
101
+ return true // made progress by discarding
102
+ }
103
+
104
+ if (flushing) {
105
+ buffer = ""
106
+ return false
107
+ }
108
+
109
+ return false
110
+ }
111
+
112
+ /**
113
+ * Inside a block, scan for the matching closing tag.
114
+ * For streamable blocks (text, thinking), emit deltas as content arrives.
115
+ * For code blocks (typescript, shell), string-aware scanning avoids
116
+ * closing early on tags that appear inside string literals.
117
+ *
118
+ * The holdback (withholding the last `closeTag.length` chars, in case a
119
+ * chunk boundary splits the tag) only makes sense mid-stream. On flush,
120
+ * the stream is over — there is no next chunk to complete a split tag —
121
+ * so the entire remaining buffer is genuine final content and must be
122
+ * consumed in full, or the tail silently vanishes from the incomplete
123
+ * block's reported content.
124
+ */
125
+ function drainBlock(events: AirBlockEvent[], flushing: boolean): boolean {
126
+ const tag = state as BlockTag
127
+ const closeTag = `</${tag}>`
128
+
129
+ const closeIdx = STREAMABLE.has(tag)
130
+ ? buffer.indexOf(closeTag)
131
+ : findCloseTagOutsideStrings(buffer, closeTag)
132
+
133
+ if (closeIdx !== -1) {
134
+ // Found closing tag — extract content up to it
135
+ const content = buffer.slice(0, closeIdx)
136
+ buffer = buffer.slice(closeIdx + closeTag.length)
137
+
138
+ if (STREAMABLE.has(tag) && content.length > 0) {
139
+ events.push({ type: `${tag}:delta` as "text:delta" | "thinking:delta", content })
140
+ }
141
+ blockContent += content
142
+
143
+ events.push(closeBlock(blockContent.trim()))
144
+ state = "idle"
145
+ blockContent = ""
146
+ return true
147
+ }
148
+
149
+ // No closing tag yet. For streamable blocks, emit what we have but
150
+ // hold back enough chars to detect a split closing tag — unless
151
+ // flushing, where there's nothing left to arrive.
152
+ const holdback = flushing ? 0 : closeTag.length
153
+ const available = buffer.length - holdback
154
+
155
+ if (available > 0) {
156
+ const content = buffer.slice(0, available)
157
+ buffer = buffer.slice(available)
158
+ blockContent += content
159
+
160
+ if (STREAMABLE.has(tag)) {
161
+ events.push({ type: `${tag}:delta` as "text:delta" | "thinking:delta", content })
162
+ }
163
+ return true
164
+ }
165
+
166
+ return false
167
+ }
168
+
169
+ function drain(flushing = false): AirBlockEvent[] {
170
+ const events: AirBlockEvent[] = []
171
+
172
+ while (buffer.length > 0) {
173
+ const consumed = state === "idle" ? drainIdle(events, flushing) : drainBlock(events, flushing)
174
+ if (!consumed) break
175
+ }
176
+
177
+ return events
178
+ }
179
+
180
+ return {
181
+ /** Feed a chunk of raw tokens. Returns any events that can be emitted. */
182
+ feed(chunk: string): AirBlockEvent[] {
183
+ buffer += chunk
184
+ return drain()
185
+ },
186
+
187
+ /**
188
+ * Signal end of stream. Flushes any remaining buffered content.
189
+ * If inside an unclosed block, emits a done event with `incomplete: true` —
190
+ * callers must treat these as format errors, never as valid actions.
191
+ */
192
+ flush(): AirBlockEvent[] {
193
+ const events = drain(true)
194
+
195
+ // Stream ended mid-block — emit what we have, flagged as incomplete.
196
+ if (state !== "idle") {
197
+ const event = closeBlock(blockContent) as AirBlockEvent & { incomplete?: true }
198
+ event.incomplete = true
199
+ events.push(event)
200
+ }
201
+
202
+ state = "idle"
203
+ buffer = ""
204
+ blockContent = ""
205
+ return events
206
+ },
207
+ }
208
+ }
209
+
210
+ export type ParserT = ReturnType<typeof Parser>
@@ -0,0 +1,35 @@
1
+ /** Pure scanning helpers for the streaming AIR parser. */
2
+
3
+ /**
4
+ * Find the first occurrence of `closeTag` in `src` that is not inside a
5
+ * string literal (single-quote, double-quote, or template-literal).
6
+ *
7
+ * Used for code blocks (typescript/shell) where the model might write
8
+ * something like `const s = "</typescript>"` — that must not close the block.
9
+ *
10
+ * Returns -1 if no unquoted match is found.
11
+ */
12
+ export function findCloseTagOutsideStrings(src: string, closeTag: string): number {
13
+ let i = 0
14
+ while (i < src.length) {
15
+ const ch = src[i]
16
+
17
+ // Enter a string literal — skip until the matching unescaped quote.
18
+ if (ch === '"' || ch === "'" || ch === "`") {
19
+ const quote = ch
20
+ i++
21
+ while (i < src.length) {
22
+ if (src[i] === "\\") { i += 2; continue }
23
+ if (src[i] === quote) { i++; break }
24
+ i++
25
+ }
26
+ continue
27
+ }
28
+
29
+ // Check for closing tag at this position.
30
+ if (src.startsWith(closeTag, i)) return i
31
+
32
+ i++
33
+ }
34
+ return -1
35
+ }
@@ -0,0 +1,242 @@
1
+ import type { AxonEntry, AxonScope, AxonScopeModule } from "@arcforge/types"
2
+ import { foldChunks } from "@arcforge/types"
3
+ import type { GrammarT } from "../grammar"
4
+ import { formatCapsuleOutput } from "./output"
5
+ import { esc, escAttr, escCode, indent, normalizeCode } from "./text"
6
+
7
+ /**
8
+ * The AIR section renderers — one function per block of the context window.
9
+ *
10
+ * These own the DOMAIN → protocol translation: AxonTool[] → <scope>
11
+ * declarations, AxonEntry[] → <timeline> items. Callers pass what they
12
+ * hold; nothing here is exported to userland but the block renderers.
13
+ *
14
+ * Note on escaping: the contract/meta blocks show tags as &lt;text&gt; inside
15
+ * markdown code fences deliberately — the model must see literal tag text as
16
+ * instruction, not as parseable XML. It looks like double-escaping; it isn't.
17
+ */
18
+
19
+ export function renderMeta(grammar: GrammarT): string {
20
+ return `<meta>\n${indent(grammar.meta, 4)}\n</meta>`
21
+ }
22
+
23
+ /**
24
+ * <scope> — the capsule's authoritative executable TypeScript declarations.
25
+ * AIR owns protocol formatting only: flat modules become top-level `declare`
26
+ * bindings and namespaced modules become `declare namespace` blocks.
27
+ *
28
+ * Ambient types (AxonTool.ambientTypes — interfaces/type aliases a tool's
29
+ * functions reference, e.g. a return type declared in a sibling file) are
30
+ * inlined once at the top, deduped by exact text — the model must never
31
+ * see `Promise<DeployStatus>` with no DeployStatus definition anywhere in
32
+ * context. Same convention the IDE's tool-globals.d.ts uses (see
33
+ * tui/platform/build/project/typegen/tools.ts) — this and that file must
34
+ * never diverge in shape, only audience.
35
+ */
36
+ export function renderScope(scope: AxonScope): string {
37
+ const modules = scope.modules.filter(module => module.members.length > 0)
38
+ if (modules.length === 0) return ""
39
+
40
+ const ambientTypes = [...new Set(modules.flatMap(t => t.ambientTypes ?? []))]
41
+ const sections = [...ambientTypes, ...modules.map(toolDeclarations)]
42
+ return `<scope lang="ts">\n${indent(sections.join("\n\n"), 4)}\n</scope>`
43
+ }
44
+
45
+ function toolDeclarations(module: AxonScopeModule): string {
46
+ const members = module.members.map(member => {
47
+ const jsdoc = member.jsdoc ? `${jsdocBlock(member.jsdoc)}\n` : ""
48
+ // flat: fns are top-level globals; namespaced: members need no `declare`
49
+ return module.flat ? `${jsdoc}declare ${member.declaration}` : `${jsdoc}${member.declaration}`
50
+ })
51
+
52
+ const header = module.description ? `${jsdocBlock(module.description)}\n` : ""
53
+ return module.flat
54
+ ? `${header}${members.join("\n\n")}`
55
+ : `${header}declare namespace ${module.name} {\n${indent(members.join("\n\n"), 4)}\n}`
56
+ }
57
+
58
+ function jsdocBlock(text: string): string {
59
+ const lines = text.split("\n")
60
+ if (lines.length === 1) return `/** ${text} */`
61
+ return `/**\n${lines.map(l => ` * ${l}`.trimEnd()).join("\n")}\n */`
62
+ }
63
+
64
+ export function renderSystem(system?: string): string {
65
+ if (!system) return `<system></system>`
66
+ return `<system>\n${system}\n</system>`
67
+ }
68
+
69
+ export function renderContract(grammar: GrammarT): string {
70
+ if (grammar.modes.length === 0) return `<contract></contract>`
71
+
72
+ const modeLines = grammar.modes.map(m => `- \`&lt;${m.type}&gt;\` — ${grammar.describe(m)}`)
73
+ modeLines.push(
74
+ `- \`&lt;done/&gt;\` — MANDATORY at the end of every message. No exceptions, including a single short &lt;text&gt; reply. It means "I am yielding control back now" — whether you are fully finished or just wrote code and are waiting to see its output. Without it the runtime assumes you are still mid-turn and will not treat your message as complete.`
75
+ )
76
+
77
+ const ruleLines = grammar.rules.map(r => `- ${r}`)
78
+
79
+ const hasExec = grammar.modes.some(m => m.type === "typescript" || m.type === "shell")
80
+ const execTag = grammar.modes.find(m => m.type === "typescript") ? "typescript" : "shell"
81
+
82
+ const examples: string[] = []
83
+ if (hasExec) {
84
+ examples.push(
85
+ `Acting (no narration before — just the block):`,
86
+ "```",
87
+ `&lt;${execTag}&gt;// code here&lt;/${execTag}&gt;&lt;done/&gt;`,
88
+ "```",
89
+ `Replying — even a short one-line reply always ends with &lt;done/&gt;:`,
90
+ "```",
91
+ `&lt;text&gt;message here&lt;/text&gt;&lt;done/&gt;`,
92
+ "```"
93
+ )
94
+ } else {
95
+ examples.push("```", `&lt;text&gt;message here&lt;/text&gt;&lt;done/&gt;`, "```")
96
+ }
97
+
98
+ const body = [
99
+ `## Blocks`,
100
+ modeLines.join("\n"),
101
+ `## Rules`,
102
+ ruleLines.join("\n"),
103
+ `## Examples`,
104
+ examples.join("\n"),
105
+ ].join("\n\n")
106
+
107
+ return `<contract>\n${indent(body, 4)}\n</contract>`
108
+ }
109
+
110
+ /**
111
+ * <timeline> — the event history. AIR owns the AxonEntry → rendered-turn
112
+ * translation via the exhaustive switch below: this is the single chokepoint
113
+ * where a new entry-event type must decide its rendering, and it lives next
114
+ * to the parser it has to agree with.
115
+ */
116
+ export function renderTimeline(entries: readonly AxonEntry[]): string {
117
+ if (entries.length === 0) return `<timeline></timeline>`
118
+
119
+ // chunked emissions fold to one turn each — the group is the fact
120
+ // (AxonChunk standard); the model never sees transport granularity
121
+ const items = foldChunks(entries).map(timelineItem).filter((i): i is TimelineItem => i !== null)
122
+ if (items.length === 0) return `<timeline></timeline>`
123
+
124
+ let userCount = 0
125
+ let execCount = 0
126
+ // Maps consumer-supplied execute IDs (UUIDs etc.) to short rendered IDs (e1, e2, ...)
127
+ const execIdMap = new Map<string, string>()
128
+
129
+ const shortExecId = (rawId: string): string => {
130
+ if (!execIdMap.has(rawId)) execIdMap.set(rawId, `e${++execCount}`)
131
+ return execIdMap.get(rawId)!
132
+ }
133
+
134
+ const lines = items
135
+ .map(item => {
136
+ if (item.role === "user") {
137
+ const id = `u${++userCount}`
138
+ const content = esc(item.content.trim())
139
+ return ` <user id="${id}">\n${indent(content, 8)}\n </user>`
140
+ }
141
+ if (item.type === "message") {
142
+ const content = esc(item.content.trim())
143
+ return ` <agent>\n <text>\n${indent(content, 12)}\n </text>\n </agent>`
144
+ }
145
+ if (item.type === "execute") {
146
+ const tag = item.lang === "sh" ? "shell" : "typescript"
147
+ const id = shortExecId(item.id)
148
+ return ` <agent>\n <${tag} id="${id}">\n${indent(escCode(normalizeCode(item.code.trim())), 12)}\n </${tag}>\n </agent>`
149
+ }
150
+ if (item.type === "result") {
151
+ const ok = item.ok ? ` ok="true"` : ` ok="false"`
152
+ const errorAttr = item.error ? ` error="${esc(item.error.kind)}: ${esc(item.error.message)}"` : ""
153
+ const content = formatCapsuleOutput(item.content.trim())
154
+ const forId = shortExecId(item.for)
155
+ return ` <stdout for="${forId}"${ok}${errorAttr}>\n${indent(content, 8)}\n </stdout>`
156
+ }
157
+ if (item.role === "system") {
158
+ const extra = Object.entries(item.attributes ?? {})
159
+ .filter(([key]) => key !== "type" && key !== "lang")
160
+ .sort(([a], [b]) => a.localeCompare(b))
161
+ .map(([key, value]) => ` ${key}="${escAttr(value)}"`)
162
+ .join("")
163
+ return ` <system type="${escAttr(item.systemType)}" lang="${escAttr(item.lang)}"${extra}>\n${indent(esc(item.content.trim()), 8)}\n </system>`
164
+ }
165
+ return ""
166
+ })
167
+ .filter(Boolean)
168
+
169
+ return `<timeline>\n${lines.join("\n\n")}\n</timeline>`
170
+ }
171
+
172
+ // ── domain → timeline item ────────────────────────────────────────────────────
173
+ //
174
+ // The rendered-turn shapes are private to this file: callers pass
175
+ // AxonEntry, AIR translates. Kept minimal — role + type + payload the
176
+ // renderer above consumes.
177
+
178
+ type TimelineItem =
179
+ | { role: "user"; type: "message"; content: string }
180
+ | { role: "agent"; type: "message"; content: string }
181
+ | { role: "agent"; type: "execute"; id: string; lang: string; code: string }
182
+ | { role: "agent"; type: "result"; for: string; ok: boolean; content: string; error?: { kind: "timeout" | "policy" | "interrupt" | "exception"; message: string } }
183
+ | { role: "system"; type: "system"; systemType: string; lang: string; content: string; attributes?: Record<string, string> }
184
+
185
+ /**
186
+ * One log entry → one rendered turn. Exhaustive: a new AxonEntryEvent
187
+ * type must decide its rendering here (or explicitly return null to omit it).
188
+ * This is the single place the memory format meets the wire format.
189
+ */
190
+ function timelineItem(entry: AxonEntry): TimelineItem | null {
191
+ switch (entry.type) {
192
+ case "cognet:stimulus:text":
193
+ return { role: "user", type: "message", content: entry.data.content }
194
+
195
+ case "cognet:stimulus:audio":
196
+ return { role: "user", type: "message", content: entry.data.transcript ?? "[audio]" }
197
+
198
+ case "cognet:stimulus:visual":
199
+ return { role: "user", type: "message", content: entry.data.caption ?? `[${entry.data.kind}]` }
200
+
201
+ case "cognet:stimulus:field":
202
+ return { role: "system", type: "system", systemType: "field", lang: "txt", content: `${entry.data.source.channel}: ${String(entry.data.reading.value)}${entry.data.reading.unit ?? ""}` }
203
+
204
+ case "axon:interrupt":
205
+ return { role: "system", type: "system", systemType: "interrupt", lang: "txt", content: `interrupted (${entry.data.reason})` }
206
+
207
+ case "cognet:output:text":
208
+ return { role: "agent", type: "message", content: entry.data.content }
209
+
210
+ case "cognet:output:audio":
211
+ return { role: "agent", type: "message", content: entry.data.transcript ?? "[audio]" }
212
+
213
+ case "cognet:output:visual":
214
+ return { role: "agent", type: "message", content: entry.data.caption ?? `[${entry.data.kind}]` }
215
+
216
+ case "cognet:output:field":
217
+ return { role: "system", type: "system", systemType: "field", lang: "txt", content: `${String(entry.data.reading.value)}${entry.data.reading.unit ?? ""}` }
218
+
219
+ case "cognet:action:typescript":
220
+ return { role: "agent", type: "execute", id: entry.data.id, lang: "typescript", code: entry.data.content }
221
+
222
+ case "cognet:action:result":
223
+ return {
224
+ role: "agent",
225
+ type: "result",
226
+ for: entry.data.for,
227
+ ok: entry.data.ok,
228
+ content: entry.data.content,
229
+ ...(entry.data.error ? { error: entry.data.error } : {}),
230
+ }
231
+
232
+ case "axon:system:message":
233
+ return {
234
+ role: "system",
235
+ type: "system",
236
+ systemType: entry.data.type,
237
+ lang: entry.data.lang,
238
+ content: entry.data.content,
239
+ ...(entry.data.attributes ? { attributes: entry.data.attributes } : {}),
240
+ }
241
+ }
242
+ }