@arcforge/cognet 2.0.113 → 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 +3 -3
- package/src/air/grammar.ts +24 -69
- package/src/air/index.ts +12 -0
- package/src/air/interpolate/index.ts +1 -0
- package/src/air/interpolate/interpolate.ts +178 -0
- package/src/air/parse/index.ts +82 -18
- package/src/air/parse/scan.ts +85 -13
- package/src/air/protocol/classic.ts +49 -0
- package/src/air/protocol/index.ts +1 -0
- package/src/air/protocol/protocol.ts +147 -0
- package/src/air/protocol/sfc.ts +96 -0
- package/src/air/render/blocks.ts +38 -37
- package/src/air/render/index.ts +1 -1
- package/src/air/repair/repair.ts +1 -1
- package/src/air/types.ts +20 -3
- package/src/host.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforge/cognet",
|
|
3
|
-
"version": "2.0.
|
|
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.
|
|
22
|
-
"@arcforge/types": "2.0.
|
|
21
|
+
"@arcforge/err": "2.0.115",
|
|
22
|
+
"@arcforge/types": "2.0.115",
|
|
23
23
|
"hookable": "^5.5.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
package/src/air/grammar.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { AirMode,
|
|
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
|
-
/**
|
|
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
|
|
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 <scope>
|
|
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 <scope> below. boot.vue rendered directly into <system>. 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 <done/> 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 <done/> 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 <scope> — 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 <typescript> blocks — they execute immediately and their result returns to you as a <stdout> block on your next wake, whether or not you emitted <done/>. The tag governs when your turn ends, never whether your code runs. You communicate with the user by writing <text> — outbound communication, not a scratchpad, not narration.
|
|
62
|
-
|
|
63
|
-
Your <typescript> 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
|
-
<scope> — src/tools/ compiled to declarations. Everything here is yours to call.
|
|
74
|
-
<system> — boot.vue, rendered. Your identity and instructions, as the user wrote them. Highest priority.
|
|
75
|
-
<timeline> — the sequence of events leading to now, drawn from this session's log. You are the next step.
|
|
76
|
-
<contract> — 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
|
|
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:
|
|
86
|
-
rules: [...
|
|
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.
|
|
95
|
-
*
|
|
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>
|
package/src/air/parse/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
22
|
+
* script → inside <script>, buffering silently
|
|
22
23
|
*
|
|
23
|
-
* Tags are fixed and non-nesting.
|
|
24
|
-
*
|
|
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" | "
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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 "
|
|
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
|
|
130
|
-
?
|
|
131
|
-
: findCloseTagOutsideStrings(
|
|
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(
|
|
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(
|
|
225
|
+
events.push(delta(tag, content))
|
|
162
226
|
}
|
|
163
227
|
return true
|
|
164
228
|
}
|
package/src/air/parse/scan.ts
CHANGED
|
@@ -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
|
|
5
|
+
* string literal or a comment.
|
|
6
6
|
*
|
|
7
|
-
* Used for code blocks (typescript/
|
|
8
|
-
* something like `const s = "</
|
|
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
|
-
*
|
|
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
|
-
//
|
|
40
|
+
// Block comment — runs 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
|
-
|
|
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 <scope>
|
|
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 <scope> below. boot.vue rendered directly into <system>. 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 <done/> 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 <done/> 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 <scope> — 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 <typescript> blocks — they execute immediately and their result returns to you as a <stdout> block on your next wake, whether or not you emitted <done/>. The tag governs when your turn ends, never whether your code runs. You communicate with the user by writing <text> — outbound communication, not a scratchpad, not narration.
|
|
34
|
+
|
|
35
|
+
Your <typescript> 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
|
+
<scope> — src/tools/ compiled to declarations. Everything here is yours to call.
|
|
46
|
+
<system> — boot.vue, rendered. Your identity and instructions, as the user wrote them. Highest priority.
|
|
47
|
+
<timeline> — the sequence of events leading to now, drawn from this session's log. You are the next step.
|
|
48
|
+
<contract> — 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 = `- \`<done/>\` — 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
|
+
`<typescript>// code here</typescript><done/>`,
|
|
63
|
+
"```",
|
|
64
|
+
`Replying — even a short one-line reply always ends with <done/>:`,
|
|
65
|
+
"```",
|
|
66
|
+
`<text>message here</text><done/>`,
|
|
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
|
+
`<script> always comes before <template>. 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 => 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 <template lang="json"> 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 => \`| \${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
|
+
`<script>await fs.write("notes.md", "hello")</script><done/>`,
|
|
96
|
+
"```",
|
|
97
|
+
`Replying — even a short one-line reply always ends with <done/>:`,
|
|
98
|
+
"```",
|
|
99
|
+
`<template>message here</template><done/>`,
|
|
100
|
+
"```",
|
|
101
|
+
`Computing a value and speaking it in one turn — the count is interpolated, never counted by hand:`,
|
|
102
|
+
"```",
|
|
103
|
+
`<script>const files = await fs.list("src")</script>`,
|
|
104
|
+
`<template>Found {{ files.length }} files in src.</template><done/>`,
|
|
105
|
+
"```",
|
|
106
|
+
`Returning a structured result — the object is built in the script and passed whole:`,
|
|
107
|
+
"```",
|
|
108
|
+
`<script>const result = { ok: true, files: await fs.list("src") }</script>`,
|
|
109
|
+
`<template lang="json">{{ result }}</template><done/>`,
|
|
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
|
+
* <done/> 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 <scope>
|
|
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 <scope> below. boot.vue rendered directly into <system>. 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 <done/> 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 <done/> 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 <scope> — 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: <script> runs first and computes, <template> renders and is what the user actually reads.
|
|
40
|
+
|
|
41
|
+
<script> — 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
|
+
<template> — 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 <stdout> 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
|
+
<script>
|
|
56
|
+
const files = await fs.list("src")
|
|
57
|
+
const pkg = JSON.parse(await fs.read("package.json"))
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
## {{ pkg.name }}
|
|
62
|
+
|
|
63
|
+
There are {{ files.length }} files in src.
|
|
64
|
+
</template>
|
|
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 <template lang="json">. It must contain exactly one interpolation and nothing else:
|
|
71
|
+
|
|
72
|
+
<script>
|
|
73
|
+
const entries = await fs.list("src")
|
|
74
|
+
const result = { count: entries.length, names: entries.map(e => e.name) }
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template lang="json">{{ result }}</template>
|
|
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 <script> 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
|
+
<scope> — src/tools/ compiled to declarations. Everything here is yours to call.
|
|
93
|
+
<system> — boot.vue, rendered. Your identity and instructions, as the user wrote them. Highest priority.
|
|
94
|
+
<timeline> — the sequence of events leading to now, drawn from this session's log. You are the next step.
|
|
95
|
+
<contract> — your output grammar below. Every word you emit must be inside one of its blocks.
|
|
96
|
+
`.trim()
|
package/src/air/render/blocks.ts
CHANGED
|
@@ -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 => `- \`<${m.type}>\` — ${grammar.describe(m)}`)
|
|
73
|
-
modeLines.push(
|
|
74
|
-
`- \`<done/>\` — MANDATORY at the end of every message. No exceptions, including a single short <text> 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
|
-
`<${execTag}>// code here</${execTag}><done/>`,
|
|
88
|
-
"```",
|
|
89
|
-
`Replying — even a short one-line reply always ends with <done/>:`,
|
|
90
|
-
"```",
|
|
91
|
-
`<text>message here</text><done/>`,
|
|
92
|
-
"```"
|
|
93
|
-
)
|
|
94
|
-
} else {
|
|
95
|
-
examples.push("```", `<text>message here</text><done/>`, "```")
|
|
96
|
-
}
|
|
90
|
+
modeLines.push(DONE_RULE)
|
|
97
91
|
|
|
98
|
-
const
|
|
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
|
-
|
|
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
|
|
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 <${
|
|
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"`
|
package/src/air/render/index.ts
CHANGED
|
@@ -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
|
package/src/air/repair/repair.ts
CHANGED
|
@@ -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", "
|
|
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
|
-
// ──
|
|
26
|
+
// ── Protocols (output grammar) ───────────────────────────────────────────────
|
|
27
27
|
|
|
28
|
-
|
|
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
|
-
|
|
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),
|