@arcforge/cognet 2.0.115 → 2.0.116

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,14 +1,13 @@
1
1
  {
2
2
  "name": "@arcforge/cognet",
3
- "version": "2.0.115",
3
+ "version": "2.0.116",
4
4
  "description": "A cognitive engine",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "main": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
- "./ecs": "./src/ecs/index.ts",
11
- "./air": "./src/air/index.ts"
10
+ "./ecs": "./src/ecs/index.ts"
12
11
  },
13
12
  "files": [
14
13
  "src"
@@ -18,8 +17,8 @@
18
17
  "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
18
  },
20
19
  "dependencies": {
21
- "@arcforge/err": "2.0.115",
22
- "@arcforge/types": "2.0.115",
20
+ "@arcforge/err": "2.0.116",
21
+ "@arcforge/types": "2.0.116",
23
22
  "hookable": "^5.5.3"
24
23
  },
25
24
  "devDependencies": {
package/src/host.ts CHANGED
@@ -126,11 +126,11 @@ globals.definePlugin = (plugin: CognetPlugin): CognetPlugin => {
126
126
 
127
127
  // run() is overloaded on input shape (string vs string[]) — a plain arrow
128
128
  // can't carry two call signatures, so it's declared separately and spread in.
129
- function run(code: string, opts?: { signal?: AbortSignal }): Promise<AxonRunResult>
130
- function run(code: string[], opts?: { signal?: AbortSignal }): Promise<AxonRunResult[]>
131
- function run(code: string | string[], opts?: { signal?: AbortSignal }) {
132
- if (Array.isArray(code)) return kernelOrThrow().run(code, opts)
133
- return kernelOrThrow().run(code, opts)
129
+ function run(code: string): Promise<AxonRunResult>
130
+ function run(code: string[]): Promise<AxonRunResult[]>
131
+ function run(code: string | string[]) {
132
+ if (Array.isArray(code)) return kernelOrThrow().run(code)
133
+ return kernelOrThrow().run(code)
134
134
  }
135
135
 
136
136
  // the syscall table, delegating — live from load() onward
@@ -186,7 +186,7 @@ globals.loop = (body: LoopBody): void => ambientOrThrow().loop(body)
186
186
  globals.kernel = {
187
187
  output: (type: keyof AxonOutputEvent, data: never) => ambientOrThrow().kernel.output(type, data),
188
188
  stream: (req: never) => ambientOrThrow().kernel.stream(req),
189
- run: ((code: string | string[], opts?: { signal?: AbortSignal }) => ambientOrThrow().kernel.run(code as never, opts)) as KernelAbi["run"],
189
+ run: ((code: string | string[]) => ambientOrThrow().kernel.run(code as never)) as KernelAbi["run"],
190
190
  scope: () => ambientOrThrow().kernel.scope(),
191
191
  base: () => ambientOrThrow().kernel.base(),
192
192
  emit: (type: never, data: never) => ambientOrThrow().kernel.emit(type, data),
package/src/air/air.ts DELETED
@@ -1,32 +0,0 @@
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"]>
@@ -1,58 +0,0 @@
1
- import type { AirMode, AirProtocolName } from "./types"
2
- import { MODE_DEFAULTS, resolveProtocol } from "./protocol"
3
-
4
- /**
5
- * Grammar — the single owner of the AIR format contract.
6
- *
7
- * Everything that defines what the model may emit lives here: the enabled
8
- * modes, the meta prose, the contract rules, and the tag set the parser
9
- * accepts. Render and Parse both consume this handle, so the promise made
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.
17
- */
18
-
19
- export type AirOpts = {
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. */
23
- modes?: AirMode[]
24
- /** Extra contract rules appended after the protocol's rules. */
25
- extraRules?: string[]
26
- }
27
-
28
- export function Grammar(opts: AirOpts = {}) {
29
- const protocol = resolveProtocol(opts.protocol ?? "classic")
30
- const modes = opts.modes ?? protocol.modes
31
-
32
- return {
33
- protocol: protocol.name,
34
- modes,
35
- meta: protocol.meta,
36
- rules: [...protocol.rules, ...(opts.extraRules ?? [])],
37
- examples: protocol.examples,
38
-
39
- /** Default description for a mode, unless the mode overrides it. */
40
- describe(mode: AirMode): string {
41
- return mode.description ?? MODE_DEFAULTS[mode.type]
42
- },
43
-
44
- /**
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.
51
- */
52
- tags(): string[] {
53
- return [...new Set(["thinking", ...modes.map(m => m.type)])]
54
- },
55
- }
56
- }
57
-
58
- export type GrammarT = ReturnType<typeof Grammar>
package/src/air/index.ts DELETED
@@ -1,37 +0,0 @@
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
-
11
- /**
12
- * The <scope> block renderer, exported because the CLI's typegen must render
13
- * the SAME scope the model is shown — if the .d.ts and the <scope> block ever
14
- * disagreed, the editor would describe capabilities the model does not have.
15
- * One renderer, two consumers, no second implementation to drift.
16
- */
17
- export { renderScope } from "./render/blocks"
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
-
29
- export type {
30
- AirBlockEvent,
31
- AirMessage,
32
- AirMode,
33
- AirModeType,
34
- AirProtocolName,
35
- AirRenderInput,
36
- AirTemplateLang,
37
- } from "./types"
@@ -1 +0,0 @@
1
- export { Interpolate, InterpolationError, type InterpolateT, type InterpolateOpts, type Scope } from "./interpolate"
@@ -1,178 +0,0 @@
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,274 +0,0 @@
1
- import type { GrammarT } from "../grammar"
2
- import type { AirBlockEvent, AirTemplateLang } 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
- * template → inside <template>, streaming deltas token-by-token
20
- * thinking → inside <thinking>, streaming deltas token-by-token
21
- * typescript → inside <typescript>, buffering silently
22
- * script → inside <script>, buffering silently
23
- *
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.
33
- */
34
-
35
- type BlockTag = "text" | "thinking" | "typescript" | "script" | "template"
36
-
37
- /** Blocks whose content streams as deltas. Code blocks buffer silently. */
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
- }
56
-
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
- }
76
-
77
- type ParserOpts = {
78
- grammar: GrammarT
79
- }
80
-
81
- export function Parser(opts: ParserOpts) {
82
- const tags = opts.grammar.tags()
83
- const openTag = new RegExp(`<(${tags.join("|")})(\\s[^>]*)?>`)
84
- const MAX_TAG_LEN = maxTagLen(tags)
85
-
86
- let state: "idle" | BlockTag = "idle"
87
- let buffer = ""
88
- let blockContent = ""
89
-
90
- function closeBlock(content: string): AirBlockEvent {
91
- const tag = state as BlockTag
92
- switch (tag) {
93
- case "text": return { type: "text:done", content }
94
- case "thinking": return { type: "thinking:done", content }
95
- case "typescript": return { type: "typescript:done", content }
96
- case "script": return { type: "script:done", content }
97
- case "template": return { type: "template:done", content }
98
- }
99
- }
100
-
101
- /**
102
- * In idle state, scan for opening tags or <done/>.
103
- * Returns true if progress was made (something consumed).
104
- */
105
- function drainIdle(events: AirBlockEvent[], flushing: boolean): boolean {
106
- // Repair only the region we could actually act on this pass — the
107
- // trailing MAX_TAG_LEN chars may still be an in-flight split tag
108
- // (unless flushing, where the whole buffer is final).
109
- const safeLen = flushing ? buffer.length : Math.max(0, buffer.length - MAX_TAG_LEN)
110
- if (safeLen > 0) buffer = repair(buffer.slice(0, safeLen)) + buffer.slice(safeLen)
111
-
112
- const doneMatch = buffer.match(/<done\s*\/>/)
113
- const openMatch = buffer.match(openTag)
114
-
115
- // Find the earliest match
116
- let earliest: { type: "done" | "open"; index: number; length: number; tag?: BlockTag; attrs?: string } | null = null
117
-
118
- if (doneMatch && doneMatch.index !== undefined) {
119
- earliest = { type: "done", index: doneMatch.index, length: doneMatch[0].length }
120
- }
121
- if (openMatch && openMatch.index !== undefined) {
122
- if (!earliest || openMatch.index < earliest.index) {
123
- earliest = { type: "open", index: openMatch.index, length: openMatch[0].length, tag: openMatch[1] as BlockTag, attrs: openMatch[2] }
124
- }
125
- }
126
-
127
- if (earliest) {
128
- // Consume everything up to and including the match
129
- buffer = buffer.slice(earliest.index + earliest.length)
130
-
131
- if (earliest.type === "done") {
132
- events.push({ type: "done" })
133
- } else {
134
- state = earliest.tag!
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
- }
142
- }
143
- return true
144
- }
145
-
146
- // No match found. If not flushing, hold back MAX_TAG_LEN chars
147
- // in case a tag is split across chunks.
148
- if (!flushing && buffer.length > MAX_TAG_LEN) {
149
- // Discard content before the holdback — it's bare text outside tags
150
- buffer = buffer.slice(buffer.length - MAX_TAG_LEN)
151
- return true // made progress by discarding
152
- }
153
-
154
- if (flushing) {
155
- buffer = ""
156
- return false
157
- }
158
-
159
- return false
160
- }
161
-
162
- /**
163
- * Inside a block, scan for the matching closing tag.
164
- * For streamable blocks (text, thinking), emit deltas as content arrives.
165
- * For code blocks (typescript, shell), string-aware scanning avoids
166
- * closing early on tags that appear inside string literals.
167
- *
168
- * The holdback (withholding the last `closeTag.length` chars, in case a
169
- * chunk boundary splits the tag) only makes sense mid-stream. On flush,
170
- * the stream is over — there is no next chunk to complete a split tag —
171
- * so the entire remaining buffer is genuine final content and must be
172
- * consumed in full, or the tail silently vanishes from the incomplete
173
- * block's reported content.
174
- */
175
- function drainBlock(events: AirBlockEvent[], flushing: boolean): boolean {
176
- const tag = state as BlockTag
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
191
-
192
- const found = streamable
193
- ? scanned.indexOf(closeTag)
194
- : findCloseTagOutsideStrings(scanned, closeTag)
195
- const closeIdx = found === -1 ? -1 : found - offset
196
-
197
- if (closeIdx !== -1) {
198
- // Found closing tag — extract content up to it
199
- const content = buffer.slice(0, closeIdx)
200
- buffer = buffer.slice(closeIdx + closeTag.length)
201
-
202
- if (STREAMABLE.has(tag) && content.length > 0) {
203
- events.push(delta(tag, content))
204
- }
205
- blockContent += content
206
-
207
- events.push(closeBlock(blockContent.trim()))
208
- state = "idle"
209
- blockContent = ""
210
- return true
211
- }
212
-
213
- // No closing tag yet. For streamable blocks, emit what we have but
214
- // hold back enough chars to detect a split closing tag — unless
215
- // flushing, where there's nothing left to arrive.
216
- const holdback = flushing ? 0 : closeTag.length
217
- const available = buffer.length - holdback
218
-
219
- if (available > 0) {
220
- const content = buffer.slice(0, available)
221
- buffer = buffer.slice(available)
222
- blockContent += content
223
-
224
- if (STREAMABLE.has(tag)) {
225
- events.push(delta(tag, content))
226
- }
227
- return true
228
- }
229
-
230
- return false
231
- }
232
-
233
- function drain(flushing = false): AirBlockEvent[] {
234
- const events: AirBlockEvent[] = []
235
-
236
- while (buffer.length > 0) {
237
- const consumed = state === "idle" ? drainIdle(events, flushing) : drainBlock(events, flushing)
238
- if (!consumed) break
239
- }
240
-
241
- return events
242
- }
243
-
244
- return {
245
- /** Feed a chunk of raw tokens. Returns any events that can be emitted. */
246
- feed(chunk: string): AirBlockEvent[] {
247
- buffer += chunk
248
- return drain()
249
- },
250
-
251
- /**
252
- * Signal end of stream. Flushes any remaining buffered content.
253
- * If inside an unclosed block, emits a done event with `incomplete: true` —
254
- * callers must treat these as format errors, never as valid actions.
255
- */
256
- flush(): AirBlockEvent[] {
257
- const events = drain(true)
258
-
259
- // Stream ended mid-block — emit what we have, flagged as incomplete.
260
- if (state !== "idle") {
261
- const event = closeBlock(blockContent) as AirBlockEvent & { incomplete?: true }
262
- event.incomplete = true
263
- events.push(event)
264
- }
265
-
266
- state = "idle"
267
- buffer = ""
268
- blockContent = ""
269
- return events
270
- },
271
- }
272
- }
273
-
274
- export type ParserT = ReturnType<typeof Parser>