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