@arcforge/cognet 2.0.114 → 2.0.115

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforge/cognet",
3
- "version": "2.0.114",
3
+ "version": "2.0.115",
4
4
  "description": "A cognitive engine",
5
5
  "type": "module",
6
6
  "private": false,
@@ -18,8 +18,8 @@
18
18
  "deploy": "echo \"This package is published ONLY by apps/tui/scripts/release.ts, which pins workspace:* deps to concrete versions first. Publishing it directly ships an unresolvable dependency.\" && exit 1"
19
19
  },
20
20
  "dependencies": {
21
- "@arcforge/err": "2.0.114",
22
- "@arcforge/types": "2.0.114",
21
+ "@arcforge/err": "2.0.115",
22
+ "@arcforge/types": "2.0.115",
23
23
  "hookable": "^5.5.3"
24
24
  },
25
25
  "devDependencies": {
@@ -1,4 +1,5 @@
1
- import type { AirMode, AirModeType } from "./types"
1
+ import type { AirMode, AirProtocolName } from "./types"
2
+ import { MODE_DEFAULTS, resolveProtocol } from "./protocol"
2
3
 
3
4
  /**
4
5
  * Grammar — the single owner of the AIR format contract.
@@ -7,83 +8,33 @@ import type { AirMode, AirModeType } from "./types"
7
8
  * modes, the meta prose, the contract rules, and the tag set the parser
8
9
  * accepts. Render and Parse both consume this handle, so the promise made
9
10
  * to the model and the grammar accepted back can never drift.
11
+ *
12
+ * The variability point is `protocol` — a named grammar resolved as a unit
13
+ * (see protocol/). Modes are not chosen independently of the prose that
14
+ * describes them, so a caller picks a protocol and gets all three parts
15
+ * consistent, or overrides modes explicitly and takes responsibility for
16
+ * the pairing.
10
17
  */
11
18
 
12
19
  export type AirOpts = {
13
- /** Permitted output modes. Default: text + typescript (shell off, Helios stance). */
20
+ /** The output grammar. Default: classic (the two-block <typescript>/<text> contract). */
21
+ protocol?: AirProtocolName
22
+ /** Override the protocol's mode list. Rarely needed — prefer choosing a protocol. */
14
23
  modes?: AirMode[]
15
- /** Extra contract rules appended after the built-in rules. */
24
+ /** Extra contract rules appended after the protocol's rules. */
16
25
  extraRules?: string[]
17
26
  }
18
27
 
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
28
  export function Grammar(opts: AirOpts = {}) {
81
- const modes = opts.modes ?? [{ type: "text" }, { type: "typescript" }]
29
+ const protocol = resolveProtocol(opts.protocol ?? "classic")
30
+ const modes = opts.modes ?? protocol.modes
82
31
 
83
32
  return {
33
+ protocol: protocol.name,
84
34
  modes,
85
- meta: META,
86
- rules: [...RULES, ...(opts.extraRules ?? [])],
35
+ meta: protocol.meta,
36
+ rules: [...protocol.rules, ...(opts.extraRules ?? [])],
37
+ examples: protocol.examples,
87
38
 
88
39
  /** Default description for a mode, unless the mode overrides it. */
89
40
  describe(mode: AirMode): string {
@@ -91,8 +42,12 @@ export function Grammar(opts: AirOpts = {}) {
91
42
  },
92
43
 
93
44
  /**
94
- * Block tags the parser accepts. Thinking is always parseable —
95
- * models may emit reasoning even when it isn't a contract mode.
45
+ * Block tags the parser accepts.
46
+ *
47
+ * Thinking is always parseable but is never a contract mode: models
48
+ * do not choose to emit it, providers inline it. The parser must
49
+ * recognise the tag in order to strip it, which is a parse concern,
50
+ * not a promise made to the model.
96
51
  */
97
52
  tags(): string[] {
98
53
  return [...new Set(["thinking", ...modes.map(m => m.type)])]
package/src/air/index.ts CHANGED
@@ -16,10 +16,22 @@ export { Air, type AirT, type AirParserT } from "./air"
16
16
  */
17
17
  export { renderScope } from "./render/blocks"
18
18
  export type { AirOpts } from "./grammar"
19
+
20
+ /**
21
+ * The template renderer, exported because the SFC protocol's suspension
22
+ * point lives in the consumer: the parser emits script/template blocks, the
23
+ * consumer runs the script and resolves a scope, and this turns the template
24
+ * into what the user reads.
25
+ */
26
+ export { Interpolate, InterpolationError } from "./interpolate"
27
+ export type { InterpolateT, InterpolateOpts, Scope } from "./interpolate"
28
+
19
29
  export type {
20
30
  AirBlockEvent,
21
31
  AirMessage,
22
32
  AirMode,
23
33
  AirModeType,
34
+ AirProtocolName,
24
35
  AirRenderInput,
36
+ AirTemplateLang,
25
37
  } from "./types"
@@ -0,0 +1 @@
1
+ export { Interpolate, InterpolationError, type InterpolateT, type InterpolateOpts, type Scope } from "./interpolate"
@@ -0,0 +1,178 @@
1
+ import type { AirTemplateLang } from "../types"
2
+
3
+ /**
4
+ * Interpolate — the streaming template renderer.
5
+ *
6
+ * Feeds template source in and emits rendered output, replacing
7
+ * {{ expression }} with values from a resolved scope. Streaming is the whole
8
+ * point: literal text passes through the moment it arrives, so a template
9
+ * with no interpolations streams exactly as fast as raw text did.
10
+ *
11
+ * The scope arrives asynchronously — the script that produces it runs while
12
+ * the model is still generating the template. That race is resolved by
13
+ * blocking at the first brace and nowhere else: text before any {{ is
14
+ * released immediately, and only the segment that actually needs a value
15
+ * waits for one. When the script wins the race (the common case, since token
16
+ * generation is slower than a few file reads) nothing ever blocks.
17
+ *
18
+ * Rendered output is never patched after emission. A value the user has
19
+ * already read is never revised — if an expression cannot be resolved, that
20
+ * is an error the model must correct, not a glitch shown to the user.
21
+ */
22
+
23
+ export type Scope = Record<string, unknown>
24
+
25
+ export type InterpolateOpts = {
26
+ lang: AirTemplateLang
27
+ /**
28
+ * Resolves the script's bindings. Awaited at the first interpolation
29
+ * only — a template with no braces never calls it.
30
+ */
31
+ scope: () => Promise<Scope>
32
+ }
33
+
34
+ /** Thrown when an expression cannot be evaluated against the resolved scope. */
35
+ export class InterpolationError extends Error {
36
+ readonly expression: string
37
+ constructor(expression: string, cause: unknown) {
38
+ super(`could not evaluate {{ ${expression} }}: ${cause instanceof Error ? cause.message : String(cause)}`)
39
+ this.name = "InterpolationError"
40
+ this.expression = expression
41
+ }
42
+ }
43
+
44
+ const OPEN = "{{"
45
+ const CLOSE = "}}"
46
+
47
+ /**
48
+ * How much of a brace-free tail is safe to emit now.
49
+ *
50
+ * A trailing "{" may be the first half of a split "{{", and a trailing "\"
51
+ * may be the first character of a split "\{{" escape. Emitting either would
52
+ * commit to a rendering the next chunk contradicts.
53
+ */
54
+ function safeEnd(buffer: string): number {
55
+ if (buffer.endsWith("{") || buffer.endsWith("\\")) return buffer.length - 1
56
+ return buffer.length
57
+ }
58
+
59
+ export function Interpolate(opts: InterpolateOpts) {
60
+ let buffer = ""
61
+ let resolved: Scope | null = null
62
+ /** Held so a template with several interpolations awaits the script once. */
63
+ let pending: Promise<Scope> | null = null
64
+
65
+ async function scope(): Promise<Scope> {
66
+ if (resolved) return resolved
67
+ pending ??= opts.scope()
68
+ resolved = await pending
69
+ return resolved
70
+ }
71
+
72
+ /**
73
+ * In JSON mode the template is exactly one interpolation, so the rendered
74
+ * output IS the serialised value — never a string spliced into
75
+ * hand-written syntax. Markdown renders values for reading.
76
+ */
77
+ function render(value: unknown): string {
78
+ if (opts.lang === "json") return JSON.stringify(value, null, 2) ?? "null"
79
+ if (typeof value === "string") return value
80
+ if (value === null || value === undefined) return String(value)
81
+ if (typeof value === "object") return JSON.stringify(value)
82
+ return String(value)
83
+ }
84
+
85
+ async function evaluate(expression: string): Promise<string> {
86
+ const s = await scope()
87
+ const names = Object.keys(s)
88
+ try {
89
+ // Bindings become named parameters, so an expression sees exactly
90
+ // the script's top-level scope and nothing from this closure.
91
+ const fn = new Function(...names, `"use strict"; return (${expression});`)
92
+ return render(fn(...names.map(n => s[n])))
93
+ } catch (cause) {
94
+ throw new InterpolationError(expression, cause)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Drain everything currently resolvable. Literal text is emitted up to
100
+ * the next `{{`; a complete `{{ ... }}` is evaluated and emitted in place.
101
+ * A trailing partial brace or unterminated expression stays buffered for
102
+ * the next chunk — `flushing` marks the stream over, so a partial open
103
+ * brace is genuine trailing text and an unterminated expression is an
104
+ * error rather than something still arriving.
105
+ */
106
+ async function drain(flushing: boolean): Promise<string> {
107
+ let out = ""
108
+
109
+ while (buffer.length > 0) {
110
+ const open = buffer.indexOf(OPEN)
111
+
112
+ if (open === -1) {
113
+ // Hold back a lone trailing "{" or an escape prefix that may
114
+ // still be completed by the next chunk.
115
+ const safe = flushing ? buffer.length : safeEnd(buffer)
116
+ out += buffer.slice(0, safe)
117
+ buffer = buffer.slice(safe)
118
+ break
119
+ }
120
+
121
+ // `\{{ ... }}` is a literal, not a value — the escape an agent
122
+ // needs to write ABOUT interpolation without performing it.
123
+ // Without this, explaining the output format is impossible: the
124
+ // example is evaluated, fails on a binding that was never meant
125
+ // to exist, and takes the message with it.
126
+ if (open > 0 && buffer[open - 1] === "\\") {
127
+ out += buffer.slice(0, open - 1) + OPEN
128
+ buffer = buffer.slice(open + OPEN.length)
129
+ continue
130
+ }
131
+
132
+ out += buffer.slice(0, open)
133
+
134
+ const close = buffer.indexOf(CLOSE, open + OPEN.length)
135
+ if (close === -1) {
136
+ buffer = buffer.slice(open)
137
+ if (flushing) {
138
+ throw new InterpolationError(buffer.slice(OPEN.length).trim(), "unterminated interpolation")
139
+ }
140
+ break
141
+ }
142
+
143
+ const raw = buffer.slice(open + OPEN.length, close)
144
+ const expression = raw.trim()
145
+ buffer = buffer.slice(close + CLOSE.length)
146
+
147
+ // `{{ }}` with nothing in it is not an interpolation — it is an
148
+ // agent writing ABOUT interpolation, which it does whenever it
149
+ // explains its own output format. Evaluating an empty expression
150
+ // throws a syntax error and destroys the rest of the message, so
151
+ // the braces pass through verbatim: there is no value being asked
152
+ // for, and reproducing what was written is the only honest render.
153
+ if (expression.length === 0) {
154
+ out += OPEN + raw + CLOSE
155
+ continue
156
+ }
157
+
158
+ out += await evaluate(expression)
159
+ }
160
+
161
+ return out
162
+ }
163
+
164
+ return {
165
+ /** Feed template source. Returns rendered output ready to stream. */
166
+ feed(chunk: string): Promise<string> {
167
+ buffer += chunk
168
+ return drain(false)
169
+ },
170
+
171
+ /** End of template. Emits any held-back tail; throws on an unterminated expression. */
172
+ flush(): Promise<string> {
173
+ return drain(true)
174
+ },
175
+ }
176
+ }
177
+
178
+ export type InterpolateT = ReturnType<typeof Interpolate>
@@ -1,5 +1,5 @@
1
1
  import type { GrammarT } from "../grammar"
2
- import type { AirBlockEvent } from "../types"
2
+ import type { AirBlockEvent, AirTemplateLang } from "../types"
3
3
  import { repair } from "../repair"
4
4
  import { findCloseTagOutsideStrings } from "./scan"
5
5
 
@@ -16,29 +16,72 @@ import { findCloseTagOutsideStrings } from "./scan"
16
16
  * State machine:
17
17
  * idle → scanning for an opening tag or <done/>
18
18
  * text → inside <text>, streaming deltas token-by-token
19
+ * template → inside <template>, streaming deltas token-by-token
19
20
  * thinking → inside <thinking>, streaming deltas token-by-token
20
21
  * typescript → inside <typescript>, buffering silently
21
- * shell → inside <shell>, buffering silently
22
+ * script → inside <script>, buffering silently
22
23
  *
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).
24
+ * Tags are fixed and non-nesting. Attributes are tolerated on any tag, and
25
+ * `lang` is captured on <template> because it selects the interpolation
26
+ * serializer downstream — the only attribute that carries meaning.
27
+ *
28
+ * The parser stays synchronous and knows nothing about interpolation. Under
29
+ * the SFC protocol it emits script:done, then template:open, then template
30
+ * deltas — the consumer is what suspends between them to run the script.
31
+ * Keeping the suspension out here is what leaves the parser pure and
32
+ * testable against a fed string.
25
33
  */
26
34
 
27
- type BlockTag = "text" | "thinking" | "typescript" | "shell"
35
+ type BlockTag = "text" | "thinking" | "typescript" | "script" | "template"
28
36
 
29
37
  /** Blocks whose content streams as deltas. Code blocks buffer silently. */
30
- const STREAMABLE = new Set<BlockTag>(["text", "thinking"])
38
+ const STREAMABLE = new Set<BlockTag>(["text", "thinking", "template"])
39
+
40
+ /**
41
+ * How much of the buffer to hold back in idle mode so an opening tag split
42
+ * across chunks can still be completed by the next one.
43
+ *
44
+ * This must cover the longest OPENING tag including its attributes, not just
45
+ * the longest tag name: `<template lang="json">` is 22 chars, and holding
46
+ * back fewer would discard its prefix as stray text before the rest arrived,
47
+ * silently losing the lang and rendering JSON as markdown. Derived from the
48
+ * grammar rather than hand-counted so a new tag cannot outgrow it.
49
+ */
50
+ const ATTR_ALLOWANCE = 16
51
+
52
+ function maxTagLen(tags: string[]): number {
53
+ const longest = tags.reduce((n, t) => Math.max(n, t.length), 0)
54
+ return longest + "</>".length + ATTR_ALLOWANCE
55
+ }
31
56
 
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
57
+ /** Templates default to markdown; only an explicit lang="json" changes the serializer. */
58
+ function templateLang(attrs: string | undefined): AirTemplateLang {
59
+ return /lang\s*=\s*["']?json["']?/i.test(attrs ?? "") ? "json" : "md"
60
+ }
61
+
62
+ /**
63
+ * The delta event for a streamable tag. An explicit map rather than a
64
+ * template-literal cast: the compiler checks every STREAMABLE tag has one,
65
+ * so adding a streamable block cannot silently produce an unhandled event.
66
+ */
67
+ const DELTA = {
68
+ text: "text:delta",
69
+ thinking: "thinking:delta",
70
+ template: "template:delta",
71
+ } as const satisfies Record<"text" | "thinking" | "template", AirBlockEvent["type"]>
72
+
73
+ function delta(tag: BlockTag, content: string): AirBlockEvent {
74
+ return { type: DELTA[tag as keyof typeof DELTA], content }
75
+ }
35
76
 
36
77
  type ParserOpts = {
37
78
  grammar: GrammarT
38
79
  }
39
80
 
40
81
  export function Parser(opts: ParserOpts) {
41
- const openTag = new RegExp(`<(${opts.grammar.tags().join("|")})(?:\\s[^>]*)?>`)
82
+ const tags = opts.grammar.tags()
83
+ const openTag = new RegExp(`<(${tags.join("|")})(\\s[^>]*)?>`)
84
+ const MAX_TAG_LEN = maxTagLen(tags)
42
85
 
43
86
  let state: "idle" | BlockTag = "idle"
44
87
  let buffer = ""
@@ -50,7 +93,8 @@ export function Parser(opts: ParserOpts) {
50
93
  case "text": return { type: "text:done", content }
51
94
  case "thinking": return { type: "thinking:done", content }
52
95
  case "typescript": return { type: "typescript:done", content }
53
- case "shell": return { type: "shell:done", content }
96
+ case "script": return { type: "script:done", content }
97
+ case "template": return { type: "template:done", content }
54
98
  }
55
99
  }
56
100
 
@@ -69,14 +113,14 @@ export function Parser(opts: ParserOpts) {
69
113
  const openMatch = buffer.match(openTag)
70
114
 
71
115
  // Find the earliest match
72
- let earliest: { type: "done" | "open"; index: number; length: number; tag?: BlockTag } | null = null
116
+ let earliest: { type: "done" | "open"; index: number; length: number; tag?: BlockTag; attrs?: string } | null = null
73
117
 
74
118
  if (doneMatch && doneMatch.index !== undefined) {
75
119
  earliest = { type: "done", index: doneMatch.index, length: doneMatch[0].length }
76
120
  }
77
121
  if (openMatch && openMatch.index !== undefined) {
78
122
  if (!earliest || openMatch.index < earliest.index) {
79
- earliest = { type: "open", index: openMatch.index, length: openMatch[0].length, tag: openMatch[1] as BlockTag }
123
+ earliest = { type: "open", index: openMatch.index, length: openMatch[0].length, tag: openMatch[1] as BlockTag, attrs: openMatch[2] }
80
124
  }
81
125
  }
82
126
 
@@ -89,6 +133,12 @@ export function Parser(opts: ParserOpts) {
89
133
  } else {
90
134
  state = earliest.tag!
91
135
  blockContent = ""
136
+ // Announced at open, not at close: the consumer must know
137
+ // which serializer applies before the first delta arrives,
138
+ // since deltas are released as they stream.
139
+ if (state === "template") {
140
+ events.push({ type: "template:open", lang: templateLang(earliest.attrs) })
141
+ }
92
142
  }
93
143
  return true
94
144
  }
@@ -125,10 +175,24 @@ export function Parser(opts: ParserOpts) {
125
175
  function drainBlock(events: AirBlockEvent[], flushing: boolean): boolean {
126
176
  const tag = state as BlockTag
127
177
  const closeTag = `</${tag}>`
178
+ const streamable = STREAMABLE.has(tag)
179
+
180
+ /**
181
+ * Code blocks scan from the START of the block, not from the
182
+ * unconsumed tail. String-literal state is only meaningful over the
183
+ * whole block: scanning a mid-code fragment starts outside a string
184
+ * by assumption, so a chunk boundary falling inside one (`fs.list("`
185
+ * / `src")`) would leave the scanner permanently mis-synced and it
186
+ * would never find the real closing tag. Streamable blocks have no
187
+ * such state, so they scan the tail directly.
188
+ */
189
+ const scanned = streamable ? buffer : blockContent + buffer
190
+ const offset = streamable ? 0 : blockContent.length
128
191
 
129
- const closeIdx = STREAMABLE.has(tag)
130
- ? buffer.indexOf(closeTag)
131
- : findCloseTagOutsideStrings(buffer, closeTag)
192
+ const found = streamable
193
+ ? scanned.indexOf(closeTag)
194
+ : findCloseTagOutsideStrings(scanned, closeTag)
195
+ const closeIdx = found === -1 ? -1 : found - offset
132
196
 
133
197
  if (closeIdx !== -1) {
134
198
  // Found closing tag — extract content up to it
@@ -136,7 +200,7 @@ export function Parser(opts: ParserOpts) {
136
200
  buffer = buffer.slice(closeIdx + closeTag.length)
137
201
 
138
202
  if (STREAMABLE.has(tag) && content.length > 0) {
139
- events.push({ type: `${tag}:delta` as "text:delta" | "thinking:delta", content })
203
+ events.push(delta(tag, content))
140
204
  }
141
205
  blockContent += content
142
206
 
@@ -158,7 +222,7 @@ export function Parser(opts: ParserOpts) {
158
222
  blockContent += content
159
223
 
160
224
  if (STREAMABLE.has(tag)) {
161
- events.push({ type: `${tag}:delta` as "text:delta" | "thinking:delta", content })
225
+ events.push(delta(tag, content))
162
226
  }
163
227
  return true
164
228
  }
@@ -2,34 +2,106 @@
2
2
 
3
3
  /**
4
4
  * Find the first occurrence of `closeTag` in `src` that is not inside a
5
- * string literal (single-quote, double-quote, or template-literal).
5
+ * string literal or a comment.
6
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.
7
+ * Used for code blocks (typescript/script) where the model might write
8
+ * something like `const s = "</script>"` — that must not close the block.
9
9
  *
10
- * Returns -1 if no unquoted match is found.
10
+ * COMMENTS ARE PART OF THIS, not a refinement of it. Without them an
11
+ * apostrophe in ordinary prose — `// I'll read the files` — opens a string
12
+ * that never closes, and every subsequent close tag is treated as quoted.
13
+ * The block then runs to the end of the response and reports itself
14
+ * incomplete, silently destroying the whole message. Models write prose
15
+ * comments constantly, so this is not an edge case; it is the common case
16
+ * for any block that explains itself.
17
+ *
18
+ * The scan is deliberately lexical and shallow: strings, template literals
19
+ * (including their ${} holes, which can themselves contain strings and
20
+ * nested backticks), line comments, and block comments. It is not a parser
21
+ * and does not need to be — the only question is whether a given offset is
22
+ * code.
23
+ *
24
+ * Returns -1 if no unquoted, uncommented match is found.
11
25
  */
12
26
  export function findCloseTagOutsideStrings(src: string, closeTag: string): number {
13
27
  let i = 0
14
28
  while (i < src.length) {
15
29
  const ch = src[i]
30
+ const next = src[i + 1]
31
+
32
+ // Line comment — runs to the newline. Apostrophes inside are prose.
33
+ if (ch === "/" && next === "/") {
34
+ const nl = src.indexOf("\n", i)
35
+ if (nl === -1) return -1
36
+ i = nl + 1
37
+ continue
38
+ }
16
39
 
17
- // Enter a string literal skip until the matching unescaped quote.
40
+ // Block commentruns to the terminator.
41
+ if (ch === "/" && next === "*") {
42
+ const end = src.indexOf("*/", i + 2)
43
+ if (end === -1) return -1
44
+ i = end + 2
45
+ continue
46
+ }
47
+
48
+ // String or template literal.
18
49
  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
- }
50
+ i = skipString(src, i)
26
51
  continue
27
52
  }
28
53
 
29
- // Check for closing tag at this position.
30
54
  if (src.startsWith(closeTag, i)) return i
31
55
 
32
56
  i++
33
57
  }
34
58
  return -1
35
59
  }
60
+
61
+ /**
62
+ * Skip the string literal starting at `open`, returning the offset just past
63
+ * it (or src.length if it never terminates).
64
+ *
65
+ * Template literals recurse through `${}`: the hole is code, so it may hold
66
+ * strings, comments, and further template literals — and a close tag inside
67
+ * one is genuinely quoted, exactly as it would be anywhere else.
68
+ */
69
+ function skipString(src: string, open: number): number {
70
+ const quote = src[open]
71
+ let i = open + 1
72
+
73
+ while (i < src.length) {
74
+ const ch = src[i]
75
+
76
+ if (ch === "\\") { i += 2; continue }
77
+ if (ch === quote) return i + 1
78
+
79
+ // A ${} hole inside a template literal is code, not string content.
80
+ if (quote === "`" && ch === "$" && src[i + 1] === "{") {
81
+ i = skipHole(src, i + 2)
82
+ continue
83
+ }
84
+
85
+ i++
86
+ }
87
+ return src.length
88
+ }
89
+
90
+ /** Skip a template-literal `${...}` hole, honouring nesting and quoting inside it. */
91
+ function skipHole(src: string, start: number): number {
92
+ let depth = 1
93
+ let i = start
94
+
95
+ while (i < src.length && depth > 0) {
96
+ const ch = src[i]
97
+
98
+ if (ch === '"' || ch === "'" || ch === "`") {
99
+ i = skipString(src, i)
100
+ continue
101
+ }
102
+ if (ch === "{") depth++
103
+ else if (ch === "}") depth--
104
+ i++
105
+ }
106
+ return i
107
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The classic protocol's meta-block prose — the two-block grammar, where
3
+ * <typescript> acts and <text> speaks as independent blocks.
4
+ *
5
+ * NO BACKTICKS in this string — it's itself a template literal, and any
6
+ * backtick inside (even in a code example) closes it early, corrupting
7
+ * everything after it into broken JS the module fails to even load. Use
8
+ * plain text or single/double quotes for inline code references instead.
9
+ */
10
+ export const CLASSIC_META = `
11
+ 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.
12
+
13
+ AXON_HOME/
14
+ data/
15
+ knowledge/ — reference material you read
16
+ sessions/ — every session you've ever run, written by Axon
17
+ state/ — working state you read and write across sessions
18
+ server/ — HTTP routes, if this agent is exposed over the network
19
+ src/
20
+ boot.vue — who you are, in the user's own words
21
+ tools/ — everything you can call, becomes your &lt;scope&gt;
22
+ prompts/ — reusable prompt fragments
23
+ scripts/ — one-shot runs against your full runtime
24
+ .env — keys given to you by the user. yours to keep, yours to protect.
25
+ axon.config.ts — your identity, engine, and policy: the one file read at boot
26
+
27
+ 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.
28
+
29
+ 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.
30
+
31
+ 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.
32
+
33
+ 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.
34
+
35
+ Your &lt;typescript&gt; blocks use Bun's TypeScript REPL transform:
36
+ - TypeScript syntax is accepted, including type annotations, interfaces, assertions, enums, and top-level await. It is transpiled for execution, not typechecked.
37
+ - End a block with a bare expression to produce a value (for example a + b as the last line). It is echoed automatically.
38
+ - 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.
39
+ - Use dynamic await import("module") when you need a module; static import declarations are not valid REPL submissions.
40
+
41
+ Compose freely WITHIN a block — multiple independent tool calls in one block is the default, using Promise.all for parallel reads:
42
+ const [a, b] = await Promise.all([fs.read("tsconfig.json"), fs.read("package.json")])
43
+
44
+ How to read this context:
45
+ &lt;scope&gt; — src/tools/ compiled to declarations. Everything here is yours to call.
46
+ &lt;system&gt; — boot.vue, rendered. Your identity and instructions, as the user wrote them. Highest priority.
47
+ &lt;timeline&gt; — the sequence of events leading to now, drawn from this session's log. You are the next step.
48
+ &lt;contract&gt; — your output grammar below. Every word you emit must be inside one of its blocks.
49
+ `.trim()
@@ -0,0 +1 @@
1
+ export { resolveProtocol, MODE_DEFAULTS, DONE_RULE, type Protocol } from "./protocol"
@@ -0,0 +1,147 @@
1
+ import type { AirMode, AirModeType, AirProtocolName } from "../types"
2
+ import { CLASSIC_META } from "./classic"
3
+ import { SFC_META } from "./sfc"
4
+
5
+ /**
6
+ * Protocol — one named output grammar, resolved as a unit.
7
+ *
8
+ * A protocol owns everything that varies between output styles together:
9
+ * the meta prose (how the model is told to operate), the permitted modes
10
+ * (which tags it may emit), and the structural rules the contract states.
11
+ * These cannot be chosen independently — SFC's meta describes <script> and
12
+ * <template>, so it is meaningless next to classic's mode list.
13
+ *
14
+ * Bundling them is what makes switching a change of value. `Air({ protocol:
15
+ * "sfc" })` swaps the contract, the accepted tag set, and the prose in one
16
+ * move, with no possibility of a half-applied grammar.
17
+ *
18
+ * Adding a protocol is adding an entry to PROTOCOLS. Nothing else in AIR
19
+ * branches on the name.
20
+ */
21
+
22
+ export type Protocol = {
23
+ name: AirProtocolName
24
+ /** Meta-block prose — how this protocol tells the model to operate. */
25
+ meta: string
26
+ /** Permitted output modes, in contract-declaration order. */
27
+ modes: AirMode[]
28
+ /** Structural rules stated in the contract's Rules section. */
29
+ rules: string[]
30
+ /** Contract examples, already entity-escaped for display to the model. */
31
+ examples: string[]
32
+ }
33
+
34
+ /** Default descriptions for each output mode. */
35
+ export const MODE_DEFAULTS: Record<AirModeType, string> = {
36
+ text: "Plain language communication to the user.",
37
+ typescript:
38
+ "TypeScript executed immediately inside your persistent Bun process. Native runtime globals and declared tool namespaces are in scope.",
39
+ script:
40
+ "TypeScript executed immediately inside your persistent Bun process, before your template renders. Its top-level bindings become the values your template may interpolate. Native runtime globals and declared tool namespaces are in scope.",
41
+ template:
42
+ "Your message to the user. Written in markdown, or JSON when a structured result is required. May interpolate values from your script with double braces.",
43
+ }
44
+
45
+ const DONE_RULE = `- \`&lt;done/&gt;\` — MANDATORY at the end of every message. No exceptions, including a single short 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.`
46
+
47
+ /**
48
+ * classic — the two-block grammar: <typescript> acts, <text> speaks.
49
+ *
50
+ * Independent blocks with no data relationship. This is the protocol every
51
+ * Axon agent ran on before SFC existed, kept as a first-class value so
52
+ * reverting is a config change rather than a revert commit.
53
+ */
54
+ const CLASSIC: Protocol = {
55
+ name: "classic",
56
+ meta: CLASSIC_META,
57
+ modes: [{ type: "text" }, { type: "typescript" }],
58
+ rules: [],
59
+ examples: [
60
+ `Acting (no narration before — just the block):`,
61
+ "```",
62
+ `&lt;typescript&gt;// code here&lt;/typescript&gt;&lt;done/&gt;`,
63
+ "```",
64
+ `Replying — even a short one-line reply always ends with &lt;done/&gt;:`,
65
+ "```",
66
+ `&lt;text&gt;message here&lt;/text&gt;&lt;done/&gt;`,
67
+ "```",
68
+ ],
69
+ }
70
+
71
+ /**
72
+ * sfc — the single-file-component grammar: <script> computes, <template> speaks.
73
+ *
74
+ * The two blocks are one response with a data dependency: script runs first,
75
+ * its bindings resolve, and the template interpolates them. That ordering is
76
+ * not stylistic — it is what allows the template to stream, so it is stated
77
+ * as a hard rule rather than a convention.
78
+ */
79
+ const SFC: Protocol = {
80
+ name: "sfc",
81
+ meta: SFC_META,
82
+ modes: [{ type: "script" }, { type: "template" }],
83
+ rules: [
84
+ `&lt;script&gt; always comes before &lt;template&gt;. Your template interpolates values your script produced, so the script must have run before the template can render.`,
85
+ `Either block may be omitted. Script alone is a pure action — you are working, not speaking. Template alone is a pure message — you are speaking, not working. Emit both only when your message genuinely needs a computed value.`,
86
+ `Interpolate with double braces: {{ expression }}. Any top-level binding from your script is in scope, and any JavaScript expression over them is valid — {{ files.length }}, {{ name || "unknown" }}, {{ items.map(i =&gt; i.name).join(", ") }}.`,
87
+ `To write ABOUT the brace syntax without performing it — explaining your own output format, showing an example — escape it: \\{{ like this }} renders as literal braces.`,
88
+ `Interpolate rather than transcribe. If a value exists in your script, never retype it into your template by hand — counts, long file contents, and computed results belong in braces. This is the point of having a script.`,
89
+ `A &lt;template lang="json"&gt; must contain exactly one interpolation and nothing else — build the whole object in your script and pass it in. Never hand-write JSON syntax around holes.`,
90
+ `Templates are markdown or JSON only — no HTML, no components, and no template directives. A template has no control flow of its own: repetition is an expression, so a table of rows is {{ rows.map(r =&gt; \`| \${r.name} | \${r.count} |\`).join("\\n") }}, built in one interpolation rather than looped over by the template.`,
91
+ ],
92
+ examples: [
93
+ `Acting (no narration before — just the block):`,
94
+ "```",
95
+ `&lt;script&gt;await fs.write("notes.md", "hello")&lt;/script&gt;&lt;done/&gt;`,
96
+ "```",
97
+ `Replying — even a short one-line reply always ends with &lt;done/&gt;:`,
98
+ "```",
99
+ `&lt;template&gt;message here&lt;/template&gt;&lt;done/&gt;`,
100
+ "```",
101
+ `Computing a value and speaking it in one turn — the count is interpolated, never counted by hand:`,
102
+ "```",
103
+ `&lt;script&gt;const files = await fs.list("src")&lt;/script&gt;`,
104
+ `&lt;template&gt;Found {{ files.length }} files in src.&lt;/template&gt;&lt;done/&gt;`,
105
+ "```",
106
+ `Returning a structured result — the object is built in the script and passed whole:`,
107
+ "```",
108
+ `&lt;script&gt;const result = { ok: true, files: await fs.list("src") }&lt;/script&gt;`,
109
+ `&lt;template lang="json"&gt;{{ result }}&lt;/template&gt;&lt;done/&gt;`,
110
+ "```",
111
+ ],
112
+ }
113
+
114
+ /**
115
+ * raw — no grammar at all.
116
+ *
117
+ * For internal model calls that are not the cortex: classification,
118
+ * summarisation, a one-shot extraction. The model is handed the context and
119
+ * its reply is the whole message, with no blocks to comply with and no
120
+ * &lt;done/&gt; to remember. Text in, text out.
121
+ *
122
+ * An empty mode list renders an empty <contract> and gives the parser no
123
+ * tags, so every token flows straight through as text.
124
+ */
125
+ const RAW: Protocol = {
126
+ name: "raw",
127
+ meta: "",
128
+ modes: [],
129
+ rules: [],
130
+ examples: [],
131
+ }
132
+
133
+ const PROTOCOLS: Record<AirProtocolName, Protocol> = {
134
+ classic: CLASSIC,
135
+ sfc: SFC,
136
+ raw: RAW,
137
+ }
138
+
139
+ /** Resolve a protocol by name. Unknown names are a programming error, not input. */
140
+ export function resolveProtocol(name: AirProtocolName): Protocol {
141
+ const protocol = PROTOCOLS[name]
142
+ if (!protocol) throw new Error(`AIR: unknown protocol "${name}"`)
143
+ return protocol
144
+ }
145
+
146
+ /** The <done/> contract line, appended to every protocol that has modes. */
147
+ export { DONE_RULE }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The SFC protocol's meta-block prose — the single-file-component grammar,
3
+ * where <script> computes and <template> speaks as one response.
4
+ *
5
+ * The identity and environment sections are shared with classic verbatim;
6
+ * only the output-grammar sections differ. They are duplicated rather than
7
+ * templated because the prose is the contract — a shared fragment with holes
8
+ * punched in it would make both protocols harder to read and neither easy to
9
+ * change independently.
10
+ *
11
+ * NO BACKTICKS in this string — it's itself a template literal, and any
12
+ * backtick inside (even in a code example) closes it early, corrupting
13
+ * everything after it into broken JS the module fails to even load. Use
14
+ * plain text or single/double quotes for inline code references instead.
15
+ */
16
+ export const SFC_META = `
17
+ 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.
18
+
19
+ AXON_HOME/
20
+ data/
21
+ knowledge/ — reference material you read
22
+ sessions/ — every session you've ever run, written by Axon
23
+ state/ — working state you read and write across sessions
24
+ server/ — HTTP routes, if this agent is exposed over the network
25
+ src/
26
+ boot.vue — who you are, in the user's own words
27
+ tools/ — everything you can call, becomes your &lt;scope&gt;
28
+ prompts/ — reusable prompt fragments
29
+ scripts/ — one-shot runs against your full runtime
30
+ .env — keys given to you by the user. yours to keep, yours to protect.
31
+ axon.config.ts — your identity, engine, and policy: the one file read at boot
32
+
33
+ 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.
34
+
35
+ 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.
36
+
37
+ 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.
38
+
39
+ Your response is a single-file component. You have two blocks, and they work exactly as they do in a Vue SFC: &lt;script&gt; runs first and computes, &lt;template&gt; renders and is what the user actually reads.
40
+
41
+ &lt;script&gt; — where you act and where you compute. It executes immediately in your Bun process. Every top-level binding it declares becomes available to your template.
42
+ &lt;template&gt; — your message to the user. Markdown by default. It may interpolate any value your script produced.
43
+
44
+ Script always comes first. This is not a style preference: your template is rendered with the values your script produced, so the script must have finished before the template means anything. A template that arrives before its script cannot be rendered at all.
45
+
46
+ Either block may be omitted, and choosing to omit one is how you say what kind of turn this is:
47
+ - Script only — you are acting, not speaking. Its result returns to you as a &lt;stdout&gt; block on your next wake, exactly as before.
48
+ - Template only — you are speaking, not acting. The overwhelming majority of ordinary replies are this.
49
+ - Both — you needed a computed value inside something you are saying.
50
+
51
+ Do not reach for both out of habit. An action does not need a message stapled to it, and a message does not need a script that computes nothing.
52
+
53
+ Interpolation is the reason this shape exists. Write {{ expression }} anywhere in your template and it is replaced by the value:
54
+
55
+ &lt;script&gt;
56
+ const files = await fs.list("src")
57
+ const pkg = JSON.parse(await fs.read("package.json"))
58
+ &lt;/script&gt;
59
+
60
+ &lt;template&gt;
61
+ ## {{ pkg.name }}
62
+
63
+ There are {{ files.length }} files in src.
64
+ &lt;/template&gt;
65
+
66
+ Interpolate rather than transcribe. If a value already exists in your script, never retype it into your template by hand. Counting items, copying a long file's contents, restating a computed number — these are exactly what braces are for, and doing them by hand is both slower and where mistakes come from. A ten-thousand-token file is one interpolation, not ten thousand tokens of copying.
67
+
68
+ Any JavaScript expression over your script's bindings is valid inside braces, so a fallback is just {{ name || "unknown" }}. Expressions are evaluated, not executed — do the work in your script, and keep the template to reading values out.
69
+
70
+ When a structured result is required, use &lt;template lang="json"&gt;. It must contain exactly one interpolation and nothing else:
71
+
72
+ &lt;script&gt;
73
+ const entries = await fs.list("src")
74
+ const result = { count: entries.length, names: entries.map(e =&gt; e.name) }
75
+ &lt;/script&gt;
76
+
77
+ &lt;template lang="json"&gt;{{ result }}&lt;/template&gt;
78
+
79
+ Build the entire object in your script and pass it whole. Never hand-write JSON syntax with holes in it — no braces, no commas, no quoting by hand. Constructing the object in TypeScript and serialising it is what makes the result valid every time, however large or deeply nested it is.
80
+
81
+ Templates are markdown or JSON. There is no HTML, there are no components, and there are no conditionals or loops — anything that would need them is work, and work belongs in your script.
82
+
83
+ Your &lt;script&gt; blocks use Bun's TypeScript REPL transform:
84
+ - TypeScript syntax is accepted, including type annotations, interfaces, assertions, enums, and top-level await. It is transpiled for execution, not typechecked.
85
+ - 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.
86
+ - Use dynamic await import("module") when you need a module; static import declarations are not valid REPL submissions.
87
+
88
+ Compose freely WITHIN a block — multiple independent tool calls in one block is the default, using Promise.all for parallel reads:
89
+ const [a, b] = await Promise.all([fs.read("tsconfig.json"), fs.read("package.json")])
90
+
91
+ How to read this context:
92
+ &lt;scope&gt; — src/tools/ compiled to declarations. Everything here is yours to call.
93
+ &lt;system&gt; — boot.vue, rendered. Your identity and instructions, as the user wrote them. Highest priority.
94
+ &lt;timeline&gt; — the sequence of events leading to now, drawn from this session's log. You are the next step.
95
+ &lt;contract&gt; — your output grammar below. Every word you emit must be inside one of its blocks.
96
+ `.trim()
@@ -1,6 +1,7 @@
1
1
  import type { AxonEntry, AxonScope, AxonScopeModule } from "@arcforge/types"
2
2
  import { foldChunks } from "@arcforge/types"
3
3
  import type { GrammarT } from "../grammar"
4
+ import { DONE_RULE } from "../protocol"
4
5
  import { formatCapsuleOutput } from "./output"
5
6
  import { esc, escAttr, escCode, indent, normalizeCode } from "./text"
6
7
 
@@ -16,7 +17,15 @@ import { esc, escAttr, escCode, indent, normalizeCode } from "./text"
16
17
  * instruction, not as parseable XML. It looks like double-escaping; it isn't.
17
18
  */
18
19
 
20
+ /**
21
+ * <meta> — how the model is told to operate, owned by the protocol.
22
+ *
23
+ * A protocol with no prose (raw) renders nothing at all rather than an empty
24
+ * wrapper: an internal one-shot call should receive the caller's system
25
+ * block and nothing else.
26
+ */
19
27
  export function renderMeta(grammar: GrammarT): string {
28
+ if (!grammar.meta) return ""
20
29
  return `<meta>\n${indent(grammar.meta, 4)}\n</meta>`
21
30
  }
22
31
 
@@ -66,45 +75,30 @@ export function renderSystem(system?: string): string {
66
75
  return `<system>\n${system}\n</system>`
67
76
  }
68
77
 
78
+ /**
79
+ * <contract> — the output grammar, rendered from the resolved protocol.
80
+ *
81
+ * Blocks, rules, and examples all come from the grammar rather than being
82
+ * branched on here: a protocol states its own rules and shows its own
83
+ * examples, so adding one never edits this function. An empty mode list
84
+ * (the raw protocol) renders an empty contract — no grammar to comply with.
85
+ */
69
86
  export function renderContract(grammar: GrammarT): string {
70
87
  if (grammar.modes.length === 0) return `<contract></contract>`
71
88
 
72
89
  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
- }
90
+ modeLines.push(DONE_RULE)
97
91
 
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")
92
+ const sections = [`## Blocks`, modeLines.join("\n")]
106
93
 
107
- return `<contract>\n${indent(body, 4)}\n</contract>`
94
+ if (grammar.rules.length > 0) {
95
+ sections.push(`## Rules`, grammar.rules.map(r => `- ${r}`).join("\n"))
96
+ }
97
+ if (grammar.examples.length > 0) {
98
+ sections.push(`## Examples`, grammar.examples.join("\n"))
99
+ }
100
+
101
+ return `<contract>\n${indent(sections.join("\n\n"), 4)}\n</contract>`
108
102
  }
109
103
 
110
104
  /**
@@ -113,9 +107,17 @@ export function renderContract(grammar: GrammarT): string {
113
107
  * where a new entry-event type must decide its rendering, and it lives next
114
108
  * to the parser it has to agree with.
115
109
  */
116
- export function renderTimeline(entries: readonly AxonEntry[]): string {
110
+ export function renderTimeline(entries: readonly AxonEntry[], grammar: GrammarT): string {
117
111
  if (entries.length === 0) return `<timeline></timeline>`
118
112
 
113
+ // The model must read its own history in the grammar it was GIVEN — a
114
+ // timeline showing <typescript>/<text> to a model contracted for
115
+ // <script>/<template> teaches it, every tick, to emit tags its own
116
+ // contract forbids.
117
+ const sfc = grammar.protocol === "sfc"
118
+ const codeTag = sfc ? "script" : "typescript"
119
+ const speechTag = sfc ? "template" : "text"
120
+
119
121
  // chunked emissions fold to one turn each — the group is the fact
120
122
  // (AxonChunk standard); the model never sees transport granularity
121
123
  const items = foldChunks(entries).map(timelineItem).filter((i): i is TimelineItem => i !== null)
@@ -140,12 +142,11 @@ export function renderTimeline(entries: readonly AxonEntry[]): string {
140
142
  }
141
143
  if (item.type === "message") {
142
144
  const content = esc(item.content.trim())
143
- return ` <agent>\n <text>\n${indent(content, 12)}\n </text>\n </agent>`
145
+ return ` <agent>\n <${speechTag}>\n${indent(content, 12)}\n </${speechTag}>\n </agent>`
144
146
  }
145
147
  if (item.type === "execute") {
146
- const tag = item.lang === "sh" ? "shell" : "typescript"
147
148
  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
+ return ` <agent>\n <${codeTag} id="${id}">\n${indent(escCode(normalizeCode(item.code.trim())), 12)}\n </${codeTag}>\n </agent>`
149
150
  }
150
151
  if (item.type === "result") {
151
152
  const ok = item.ok ? ` ok="true"` : ` ok="false"`
@@ -40,7 +40,7 @@ export function Render(opts: RenderOpts) {
40
40
  sys(renderContract(grammar))
41
41
 
42
42
  if (input.history && input.history.length > 0) {
43
- messages.push({ role: "user", content: renderTimeline(input.history) })
43
+ messages.push({ role: "user", content: renderTimeline(input.history, grammar) })
44
44
  }
45
45
 
46
46
  return messages
@@ -10,7 +10,7 @@
10
10
  * Applied to the full buffered response before it reaches the AIR parser.
11
11
  */
12
12
 
13
- const KNOWN_TAGS = ["text", "thinking", "typescript", "shell"] as const
13
+ const KNOWN_TAGS = ["text", "thinking", "typescript", "script", "template"] as const
14
14
 
15
15
  export function repair(raw: string): string {
16
16
  return normalizeTagCase(raw)
package/src/air/types.ts CHANGED
@@ -23,9 +23,18 @@ export type AirMessage = {
23
23
  content: string
24
24
  }
25
25
 
26
- // ── Modes (output grammar) ───────────────────────────────────────────────────
26
+ // ── Protocols (output grammar) ───────────────────────────────────────────────
27
27
 
28
- export type AirModeType = "text" | "typescript" | "shell"
28
+ /**
29
+ * The named output grammars.
30
+ *
31
+ * classic — <typescript> acts, <text> speaks. Two independent blocks.
32
+ * sfc — <script> computes, <template> speaks. One response, script first.
33
+ * raw — no grammar; the whole reply is the message. For internal calls.
34
+ */
35
+ export type AirProtocolName = "classic" | "sfc" | "raw"
36
+
37
+ export type AirModeType = "text" | "typescript" | "script" | "template"
29
38
 
30
39
  export type AirMode = {
31
40
  /** The output type this mode produces. */
@@ -34,6 +43,9 @@ export type AirMode = {
34
43
  description?: string
35
44
  }
36
45
 
46
+ /** Template languages. Selects the interpolation serializer. */
47
+ export type AirTemplateLang = "md" | "json"
48
+
37
49
  // ── Render input ───────────────────────────────────────────────────────────
38
50
  //
39
51
  // DOMAIN in — the caller passes what it already holds. AIR owns every
@@ -67,5 +79,10 @@ export type AirBlockEvent =
67
79
  | { type: "thinking:delta"; content: string }
68
80
  | { type: "thinking:done"; content: string; incomplete?: true }
69
81
  | { type: "typescript:done"; content: string; incomplete?: true }
70
- | { type: "shell:done"; content: string; incomplete?: true }
82
+ // SFC. script:done is the suspension point — the consumer runs it and
83
+ // resolves a scope before template deltas may be released.
84
+ | { type: "script:done"; content: string; incomplete?: true }
85
+ | { type: "template:open"; lang: AirTemplateLang }
86
+ | { type: "template:delta"; content: string }
87
+ | { type: "template:done"; content: string; incomplete?: true }
71
88
  | { type: "done" }
package/src/host.ts CHANGED
@@ -141,6 +141,7 @@ const localKernel = {
141
141
  scope: () => kernelOrThrow().scope(),
142
142
  base: () => kernelOrThrow().base(),
143
143
  emit: (type: never, data: never) => kernelOrThrow().emit(type, data),
144
+ fault: (input: Parameters<KernelAbi["fault"]>[0]) => kernelOrThrow().fault(input),
144
145
  // store is a live sub-object on the bound ABI — delegate per call, same
145
146
  // discipline as every other syscall (never captured before load())
146
147
  store: {
@@ -189,6 +190,7 @@ globals.kernel = {
189
190
  scope: () => ambientOrThrow().kernel.scope(),
190
191
  base: () => ambientOrThrow().kernel.base(),
191
192
  emit: (type: never, data: never) => ambientOrThrow().kernel.emit(type, data),
193
+ fault: (input: Parameters<KernelAbi["fault"]>[0]) => ambientOrThrow().kernel.fault(input),
192
194
  store: {
193
195
  session: { get: (opts?: { after?: number }) => ambientOrThrow().kernel.store.session.get(opts) },
194
196
  get: (key: never) => ambientOrThrow().kernel.store.get(key),