@arcforge/cognet 2.0.115 → 2.0.117
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 +4 -5
- package/src/host.ts +6 -6
- package/src/air/air.ts +0 -32
- package/src/air/grammar.ts +0 -58
- package/src/air/index.ts +0 -37
- package/src/air/interpolate/index.ts +0 -1
- package/src/air/interpolate/interpolate.ts +0 -178
- package/src/air/parse/index.ts +0 -274
- package/src/air/parse/scan.ts +0 -107
- package/src/air/protocol/classic.ts +0 -49
- package/src/air/protocol/index.ts +0 -1
- package/src/air/protocol/protocol.ts +0 -147
- package/src/air/protocol/sfc.ts +0 -96
- package/src/air/render/blocks.ts +0 -243
- package/src/air/render/index.ts +0 -51
- package/src/air/render/output.ts +0 -144
- package/src/air/render/text.ts +0 -85
- package/src/air/repair/index.ts +0 -1
- package/src/air/repair/repair.ts +0 -33
- package/src/air/types.ts +0 -88
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforge/cognet",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.117",
|
|
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.
|
|
22
|
-
"@arcforge/types": "2.0.
|
|
20
|
+
"@arcforge/err": "2.0.117",
|
|
21
|
+
"@arcforge/types": "2.0.117",
|
|
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
|
|
130
|
-
function run(code: string[]
|
|
131
|
-
function run(code: string | string[]
|
|
132
|
-
if (Array.isArray(code)) return kernelOrThrow().run(code
|
|
133
|
-
return kernelOrThrow().run(code
|
|
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[]
|
|
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"]>
|
package/src/air/grammar.ts
DELETED
|
@@ -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>
|
package/src/air/parse/index.ts
DELETED
|
@@ -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>
|