@arcforge/cognet 2.0.114 → 2.0.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -5
- package/src/host.ts +8 -6
- package/src/air/air.ts +0 -32
- package/src/air/grammar.ts +0 -103
- package/src/air/index.ts +0 -25
- package/src/air/parse/index.ts +0 -210
- package/src/air/parse/scan.ts +0 -35
- package/src/air/render/blocks.ts +0 -242
- package/src/air/render/index.ts +0 -51
- package/src/air/render/output.ts +0 -144
- package/src/air/render/text.ts +0 -85
- package/src/air/repair/index.ts +0 -1
- package/src/air/repair/repair.ts +0 -33
- package/src/air/types.ts +0 -71
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforge/cognet",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.116",
|
|
4
4
|
"description": "A cognitive engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"main": "./src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
|
-
"./ecs": "./src/ecs/index.ts"
|
|
11
|
-
"./air": "./src/air/index.ts"
|
|
10
|
+
"./ecs": "./src/ecs/index.ts"
|
|
12
11
|
},
|
|
13
12
|
"files": [
|
|
14
13
|
"src"
|
|
@@ -18,8 +17,8 @@
|
|
|
18
17
|
"deploy": "echo \"This package is published ONLY by apps/tui/scripts/release.ts, which pins workspace:* deps to concrete versions first. Publishing it directly ships an unresolvable dependency.\" && exit 1"
|
|
19
18
|
},
|
|
20
19
|
"dependencies": {
|
|
21
|
-
"@arcforge/err": "2.0.
|
|
22
|
-
"@arcforge/types": "2.0.
|
|
20
|
+
"@arcforge/err": "2.0.116",
|
|
21
|
+
"@arcforge/types": "2.0.116",
|
|
23
22
|
"hookable": "^5.5.3"
|
|
24
23
|
},
|
|
25
24
|
"devDependencies": {
|
package/src/host.ts
CHANGED
|
@@ -126,11 +126,11 @@ globals.definePlugin = (plugin: CognetPlugin): CognetPlugin => {
|
|
|
126
126
|
|
|
127
127
|
// run() is overloaded on input shape (string vs string[]) — a plain arrow
|
|
128
128
|
// can't carry two call signatures, so it's declared separately and spread in.
|
|
129
|
-
function run(code: string
|
|
130
|
-
function run(code: string[]
|
|
131
|
-
function run(code: string | string[]
|
|
132
|
-
if (Array.isArray(code)) return kernelOrThrow().run(code
|
|
133
|
-
return kernelOrThrow().run(code
|
|
129
|
+
function run(code: string): Promise<AxonRunResult>
|
|
130
|
+
function run(code: string[]): Promise<AxonRunResult[]>
|
|
131
|
+
function run(code: string | string[]) {
|
|
132
|
+
if (Array.isArray(code)) return kernelOrThrow().run(code)
|
|
133
|
+
return kernelOrThrow().run(code)
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// the syscall table, delegating — live from load() onward
|
|
@@ -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: {
|
|
@@ -185,10 +186,11 @@ globals.loop = (body: LoopBody): void => ambientOrThrow().loop(body)
|
|
|
185
186
|
globals.kernel = {
|
|
186
187
|
output: (type: keyof AxonOutputEvent, data: never) => ambientOrThrow().kernel.output(type, data),
|
|
187
188
|
stream: (req: never) => ambientOrThrow().kernel.stream(req),
|
|
188
|
-
run: ((code: string | string[]
|
|
189
|
+
run: ((code: string | string[]) => ambientOrThrow().kernel.run(code as never)) as KernelAbi["run"],
|
|
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),
|
package/src/air/air.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { Grammar, type AirOpts } from "./grammar"
|
|
2
|
-
import { Parser } from "./parse"
|
|
3
|
-
import { Render } from "./render"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Air — the AIR format contract as one handle.
|
|
7
|
-
*
|
|
8
|
-
* Render (what the model sees) and Parse (what the model emits back) are two
|
|
9
|
-
* halves of one grammar. Air() resolves that grammar once and hands it to
|
|
10
|
-
* both, so they cannot drift: enabling a mode changes the <contract> block
|
|
11
|
-
* and the parser's accepted tags together.
|
|
12
|
-
*
|
|
13
|
-
* The kernel constructs Air once per agent; render() runs every tick,
|
|
14
|
-
* parser() creates one stateful parser per engine call.
|
|
15
|
-
*/
|
|
16
|
-
export function Air(opts: AirOpts = {}) {
|
|
17
|
-
const grammar = Grammar(opts)
|
|
18
|
-
const render = Render({ grammar })
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
grammar,
|
|
22
|
-
|
|
23
|
-
/** Pure: context in → ordered messages out. */
|
|
24
|
-
render: render.render,
|
|
25
|
-
|
|
26
|
-
/** One parser per engine call: { feed(chunk), flush() }. */
|
|
27
|
-
parser: () => Parser({ grammar }),
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export type AirT = ReturnType<typeof Air>
|
|
32
|
-
export type AirParserT = ReturnType<AirT["parser"]>
|
package/src/air/grammar.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
// platform/air — Agent Intermediate Representation.
|
|
2
|
-
// Air() is the module's single export surface: one grammar, two halves.
|
|
3
|
-
//
|
|
4
|
-
// Render takes DOMAIN types (AxonTool[], AxonEntry[]) and owns the
|
|
5
|
-
// translation into protocol shape. The internal render vocabulary
|
|
6
|
-
// (render/blocks.ts's TimelineItem, tool-declaration shaping) is deliberately NOT exported —
|
|
7
|
-
// callers pass what they hold, never AIR internals.
|
|
8
|
-
|
|
9
|
-
export { Air, type AirT, type AirParserT } from "./air"
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The <scope> block renderer, exported because the CLI's typegen must render
|
|
13
|
-
* the SAME scope the model is shown — if the .d.ts and the <scope> block ever
|
|
14
|
-
* disagreed, the editor would describe capabilities the model does not have.
|
|
15
|
-
* One renderer, two consumers, no second implementation to drift.
|
|
16
|
-
*/
|
|
17
|
-
export { renderScope } from "./render/blocks"
|
|
18
|
-
export type { AirOpts } from "./grammar"
|
|
19
|
-
export type {
|
|
20
|
-
AirBlockEvent,
|
|
21
|
-
AirMessage,
|
|
22
|
-
AirMode,
|
|
23
|
-
AirModeType,
|
|
24
|
-
AirRenderInput,
|
|
25
|
-
} from "./types"
|
package/src/air/parse/index.ts
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
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>
|
package/src/air/parse/scan.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
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
|
-
}
|
package/src/air/render/blocks.ts
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
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
|
-
}
|
package/src/air/render/index.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import type { GrammarT } from "../grammar"
|
|
2
|
-
import type { AirMessage, AirRenderInput } from "../types"
|
|
3
|
-
import {
|
|
4
|
-
renderContract,
|
|
5
|
-
renderMeta,
|
|
6
|
-
renderScope,
|
|
7
|
-
renderSystem,
|
|
8
|
-
renderTimeline,
|
|
9
|
-
} from "./blocks"
|
|
10
|
-
|
|
11
|
-
type RenderOpts = {
|
|
12
|
-
grammar: GrammarT
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Render — domain in, ordered messages out. Pure.
|
|
17
|
-
*
|
|
18
|
-
* The caller passes what it holds (base string, AxonTool[], AxonEntry[]);
|
|
19
|
-
* the block renderers own every translation into protocol shape. System
|
|
20
|
-
* sections (meta, scope, system, contract) become individual system messages;
|
|
21
|
-
* the timeline becomes a single user message — proper conversation structure
|
|
22
|
-
* rather than one monolithic system prompt.
|
|
23
|
-
*
|
|
24
|
-
* Section order: <meta> → <scope> → <system> → <contract> → timeline
|
|
25
|
-
*/
|
|
26
|
-
export function Render(opts: RenderOpts) {
|
|
27
|
-
const { grammar } = opts
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
render(input: AirRenderInput): AirMessage[] {
|
|
31
|
-
const messages: AirMessage[] = []
|
|
32
|
-
|
|
33
|
-
const sys = (content: string) => {
|
|
34
|
-
if (content) messages.push({ role: "system", content })
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
sys(renderMeta(grammar))
|
|
38
|
-
if (input.scope) sys(renderScope(input.scope))
|
|
39
|
-
sys(renderSystem(input.base))
|
|
40
|
-
sys(renderContract(grammar))
|
|
41
|
-
|
|
42
|
-
if (input.history && input.history.length > 0) {
|
|
43
|
-
messages.push({ role: "user", content: renderTimeline(input.history) })
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return messages
|
|
47
|
-
},
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export type RenderT = ReturnType<typeof Render>
|
package/src/air/render/output.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { formatBytes } from "./text"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Capsule output projection — how raw capsule REPL output looks to the model.
|
|
5
|
-
*
|
|
6
|
-
* The capsule REPL emits a JSONL op-record envelope for every module function
|
|
7
|
-
* call, followed by the raw auto-logged return value. This detects that
|
|
8
|
-
* envelope, extracts the meaningful content per op type, and discards the
|
|
9
|
-
* noise.
|
|
10
|
-
*
|
|
11
|
-
* For content-returning ops (fs.read, fs.list, ...) — show the content.
|
|
12
|
-
* For mutation ops (fs.write, fs.mkdir, ...) — show a compact tick line.
|
|
13
|
-
* For anything else — pass through unchanged.
|
|
14
|
-
*
|
|
15
|
-
* The silent catch fall-throughs here are correct, not masked failures:
|
|
16
|
-
* this is a best-effort display projection where "show the raw string"
|
|
17
|
-
* is the honest fallback for anything that doesn't parse.
|
|
18
|
-
*/
|
|
19
|
-
export function formatCapsuleOutput(content: string): string {
|
|
20
|
-
const raw = content.trim()
|
|
21
|
-
|
|
22
|
-
// Plain JSON array — e.g. fs.list() return value serialised directly
|
|
23
|
-
if (raw.startsWith("[")) {
|
|
24
|
-
try {
|
|
25
|
-
const arr = JSON.parse(raw)
|
|
26
|
-
if (Array.isArray(arr)) {
|
|
27
|
-
if (arr.length === 0) return "(empty)"
|
|
28
|
-
// DirEntry[] — render as a compact directory listing
|
|
29
|
-
if (
|
|
30
|
-
arr.length > 0 &&
|
|
31
|
-
typeof arr[0] === "object" &&
|
|
32
|
-
arr[0] !== null &&
|
|
33
|
-
"name" in arr[0]
|
|
34
|
-
) {
|
|
35
|
-
return arr
|
|
36
|
-
.map((e: any) => `${e.type === "directory" ? "d" : "-"} ${e.name}`)
|
|
37
|
-
.join("\n")
|
|
38
|
-
}
|
|
39
|
-
// Generic array of primitives or unknown objects
|
|
40
|
-
return arr
|
|
41
|
-
.map((e: any) => (typeof e === "object" ? JSON.stringify(e) : String(e)))
|
|
42
|
-
.join("\n")
|
|
43
|
-
}
|
|
44
|
-
} catch {
|
|
45
|
-
/* not JSON — fall through to raw */
|
|
46
|
-
}
|
|
47
|
-
return raw
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (!raw.startsWith("{")) return raw
|
|
51
|
-
|
|
52
|
-
// Extract all leading JSON op-record objects.
|
|
53
|
-
// Everything after the last op record (the raw auto-logged return value)
|
|
54
|
-
// is discarded — the meaningful content comes from the op record's data field.
|
|
55
|
-
const opRecords: any[] = []
|
|
56
|
-
let i = 0
|
|
57
|
-
while (i < raw.length && raw[i] === "{") {
|
|
58
|
-
let depth = 0
|
|
59
|
-
let j = i
|
|
60
|
-
while (j < raw.length) {
|
|
61
|
-
const ch = raw[j]
|
|
62
|
-
if (ch === '"') {
|
|
63
|
-
// skip over string contents, respecting escape sequences
|
|
64
|
-
j++
|
|
65
|
-
while (j < raw.length) {
|
|
66
|
-
if (raw[j] === "\\") {
|
|
67
|
-
j += 2
|
|
68
|
-
continue
|
|
69
|
-
}
|
|
70
|
-
if (raw[j] === '"') {
|
|
71
|
-
j++
|
|
72
|
-
break
|
|
73
|
-
}
|
|
74
|
-
j++
|
|
75
|
-
}
|
|
76
|
-
continue
|
|
77
|
-
}
|
|
78
|
-
if (ch === "{") depth++
|
|
79
|
-
else if (ch === "}") {
|
|
80
|
-
depth--
|
|
81
|
-
if (depth === 0) {
|
|
82
|
-
j++
|
|
83
|
-
break
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
j++
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
const obj = JSON.parse(raw.slice(i, j))
|
|
90
|
-
if ("op" in obj) opRecords.push(obj)
|
|
91
|
-
else if ("procId" in obj && "command" in obj) {
|
|
92
|
-
const tail = obj.tail ? `\n${obj.tail}` : ""
|
|
93
|
-
return `spawned ${obj.command} procId=${obj.procId} pid=${obj.pid ?? "?"} status=${obj.status ?? "running"}${tail}`
|
|
94
|
-
} else break
|
|
95
|
-
} catch {
|
|
96
|
-
break
|
|
97
|
-
}
|
|
98
|
-
i = j
|
|
99
|
-
while (i < raw.length && (raw[i] === "\n" || raw[i] === "\r")) i++
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (opRecords.length === 0) return raw
|
|
103
|
-
|
|
104
|
-
return opRecords
|
|
105
|
-
.map(obj => {
|
|
106
|
-
const op: string = obj.op ?? ""
|
|
107
|
-
const d = obj.data ?? {}
|
|
108
|
-
|
|
109
|
-
if (!obj.ok) {
|
|
110
|
-
return `${op} ${d.path ?? ""} ✗ ${obj.error ?? d.message ?? "failed"}`
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
switch (op) {
|
|
114
|
-
case "fs.read":
|
|
115
|
-
case "fs.readLines":
|
|
116
|
-
return `read ${d.path} ${formatBytes(d.bytes ?? 0)}\n${d.content ?? ""}`
|
|
117
|
-
case "fs.list": {
|
|
118
|
-
const entries: any[] = d.entries ?? []
|
|
119
|
-
const lines = entries
|
|
120
|
-
.map((e: any) => {
|
|
121
|
-
const prefix = e.type === "directory" ? "d" : "-"
|
|
122
|
-
return `${prefix} ${e.name}`
|
|
123
|
-
})
|
|
124
|
-
.join("\n")
|
|
125
|
-
return `list ${d.path} ${entries.length} entries\n${lines}`
|
|
126
|
-
}
|
|
127
|
-
case "fs.write":
|
|
128
|
-
return `write ${d.path} ${formatBytes(d.bytes ?? 0)} ✓`
|
|
129
|
-
case "fs.mkdir":
|
|
130
|
-
return `mkdir ${d.path} ✓`
|
|
131
|
-
case "fs.delete":
|
|
132
|
-
return `rm ${d.path} ✓`
|
|
133
|
-
case "fs.move":
|
|
134
|
-
return `mv ${d.src} → ${d.dest} ✓`
|
|
135
|
-
case "fs.copy":
|
|
136
|
-
return `cp ${d.src} → ${d.dest} ✓`
|
|
137
|
-
case "fs.cd":
|
|
138
|
-
return `cd ${d.path} ✓`
|
|
139
|
-
default:
|
|
140
|
-
return `${op} ${JSON.stringify(d)} ✓`
|
|
141
|
-
}
|
|
142
|
-
})
|
|
143
|
-
.join("\n")
|
|
144
|
-
}
|
package/src/air/render/text.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/** Pure string utilities for AIR rendering. */
|
|
2
|
-
|
|
3
|
-
/** Escape XML text content (prose, stdout, user messages). */
|
|
4
|
-
export function esc(s: string): string {
|
|
5
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/** Escape a trusted value for a double-quoted XML attribute. */
|
|
9
|
-
export function escAttr(s: string): string {
|
|
10
|
-
return esc(s).replace(/"/g, """).replace(/'/g, "'")
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Escape XML inside code blocks — only & and <. Never > (breaks =>, generics). */
|
|
14
|
-
export function escCode(s: string): string {
|
|
15
|
-
return s.replace(/&/g, "&").replace(/</g, "<")
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Indent every line of s by `spaces` spaces. */
|
|
19
|
-
export function indent(s: string, spaces: number): string {
|
|
20
|
-
const pad = " ".repeat(spaces)
|
|
21
|
-
return s
|
|
22
|
-
.split("\n")
|
|
23
|
-
.map(line => pad + line)
|
|
24
|
-
.join("\n")
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function formatBytes(n: number): string {
|
|
28
|
-
return n >= 1024 ? `${(n / 1024).toFixed(1)}K` : `${n}B`
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Normalize code that may contain literal \n or \t escape sequences outside
|
|
33
|
-
* of string literals (a common model mistake). Replaces them with real
|
|
34
|
-
* whitespace so the timeline shows clean, executable code.
|
|
35
|
-
*
|
|
36
|
-
* Best-effort heuristic: only acts on \n/\t outside single-quoted,
|
|
37
|
-
* double-quoted, or template-literal strings.
|
|
38
|
-
*/
|
|
39
|
-
export function normalizeCode(code: string): string {
|
|
40
|
-
// Fast path: no escape sequences at all
|
|
41
|
-
if (!code.includes("\\n") && !code.includes("\\t")) return code
|
|
42
|
-
|
|
43
|
-
let result = ""
|
|
44
|
-
let i = 0
|
|
45
|
-
while (i < code.length) {
|
|
46
|
-
const c = code[i]
|
|
47
|
-
// Track string boundaries to avoid replacing inside strings
|
|
48
|
-
if (c === '"' || c === "'" || c === "`") {
|
|
49
|
-
const quote = c
|
|
50
|
-
result += c
|
|
51
|
-
i++
|
|
52
|
-
while (i < code.length) {
|
|
53
|
-
const sc = code[i]
|
|
54
|
-
if (sc === "\\") {
|
|
55
|
-
// Keep escape sequences inside strings as-is
|
|
56
|
-
result += code[i] + (code[i + 1] ?? "")
|
|
57
|
-
i += 2
|
|
58
|
-
} else if (sc === quote) {
|
|
59
|
-
result += sc
|
|
60
|
-
i++
|
|
61
|
-
break
|
|
62
|
-
} else {
|
|
63
|
-
result += sc
|
|
64
|
-
i++
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
} else if (c === "\\" && i + 1 < code.length) {
|
|
68
|
-
const next = code[i + 1]
|
|
69
|
-
if (next === "n") {
|
|
70
|
-
result += "\n"
|
|
71
|
-
i += 2
|
|
72
|
-
} else if (next === "t") {
|
|
73
|
-
result += "\t"
|
|
74
|
-
i += 2
|
|
75
|
-
} else {
|
|
76
|
-
result += c + next
|
|
77
|
-
i += 2
|
|
78
|
-
}
|
|
79
|
-
} else {
|
|
80
|
-
result += c
|
|
81
|
-
i++
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return result
|
|
85
|
-
}
|
package/src/air/repair/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { repair } from "./repair"
|
package/src/air/repair/repair.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic, pre-parse repair of raw model output.
|
|
3
|
-
*
|
|
4
|
-
* Scope is deliberately narrow: string-only fixes for mistakes that are
|
|
5
|
-
* common and unambiguous to correct. No semantic guessing, no model calls.
|
|
6
|
-
* If a fix isn't mechanically certain, leave the text alone — the parser's
|
|
7
|
-
* own incomplete/error reporting is the honest fallback, and that goes back
|
|
8
|
-
* to the model as an `agent:output:error` for it to correct itself.
|
|
9
|
-
*
|
|
10
|
-
* Applied to the full buffered response before it reaches the AIR parser.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const KNOWN_TAGS = ["text", "thinking", "typescript", "shell"] as const
|
|
14
|
-
|
|
15
|
-
export function repair(raw: string): string {
|
|
16
|
-
return normalizeTagCase(raw)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Lowercase tag names the model emitted in the wrong case
|
|
21
|
-
* (`<Text>`, `<TYPESCRIPT>`) — case is not semantically meaningful here,
|
|
22
|
-
* so normalizing it can never change intent, only make it parseable.
|
|
23
|
-
*/
|
|
24
|
-
function normalizeTagCase(raw: string): string {
|
|
25
|
-
let out = raw
|
|
26
|
-
for (const tag of KNOWN_TAGS) {
|
|
27
|
-
const open = new RegExp(`<(${tag})(\\s[^>]*)?>`, "gi")
|
|
28
|
-
const close = new RegExp(`</(${tag})>`, "gi")
|
|
29
|
-
out = out.replace(open, (match, _name, attrs) => `<${tag}${attrs ?? ""}>`)
|
|
30
|
-
out = out.replace(close, `</${tag}>`)
|
|
31
|
-
}
|
|
32
|
-
return out
|
|
33
|
-
}
|
package/src/air/types.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AIR — Agent Intermediate Representation.
|
|
3
|
-
*
|
|
4
|
-
* A general-purpose LLM protocol: hand the renderer Axon's domain (base
|
|
5
|
-
* context, declared tools, an event history) and it produces the ordered
|
|
6
|
-
* messages a model sees. AIR owns BOTH halves — render (what the model
|
|
7
|
-
* sees) and parse (what it emits back) — from one grammar, so they cannot
|
|
8
|
-
* drift.
|
|
9
|
-
*
|
|
10
|
-
* The render boundary is DOMAIN in, messages out: callers pass AxonTool[]
|
|
11
|
-
* and AxonEntry[], never AIR's internal render vocabulary. The
|
|
12
|
-
* timeline item shapes below are private to render/ — the exhaustive
|
|
13
|
-
* AxonEntry → item translation lives there, next to the parser it
|
|
14
|
-
* must agree with.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { AxonEntry, AxonScope } from "@arcforge/types"
|
|
18
|
-
|
|
19
|
-
// ── Messages ─────────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
export type AirMessage = {
|
|
22
|
-
role: "system" | "user" | "assistant"
|
|
23
|
-
content: string
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ── Modes (output grammar) ───────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
export type AirModeType = "text" | "typescript" | "shell"
|
|
29
|
-
|
|
30
|
-
export type AirMode = {
|
|
31
|
-
/** The output type this mode produces. */
|
|
32
|
-
type: AirModeType
|
|
33
|
-
/** Optional description override. Falls back to the default for the type. */
|
|
34
|
-
description?: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ── Render input ───────────────────────────────────────────────────────────
|
|
38
|
-
//
|
|
39
|
-
// DOMAIN in — the caller passes what it already holds. AIR owns every
|
|
40
|
-
// translation into protocol shape: tools → <scope> declarations, entries →
|
|
41
|
-
// <timeline> items. A cognet curates (which entries, what order, elided how)
|
|
42
|
-
// and hands the lists over; it never manufactures AIR-internal types.
|
|
43
|
-
|
|
44
|
-
export type AirRenderInput = {
|
|
45
|
-
/** Base context — the agent's identity contract. Rendered as <system>. */
|
|
46
|
-
base?: string
|
|
47
|
-
/** Capsule-implemented globals. Rendered as <scope lang="ts">. */
|
|
48
|
-
scope?: AxonScope
|
|
49
|
-
/** The event history to render, already curated by the cognet. Rendered as <timeline>. */
|
|
50
|
-
history?: readonly AxonEntry[]
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── Parser output ────────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Events emitted by the streaming AIR parser.
|
|
57
|
-
*
|
|
58
|
-
* *:delta — tokens inside a streamable block (<text>, <thinking>), real time.
|
|
59
|
-
* *:done — a block closed; content is the full inner text.
|
|
60
|
-
* `incomplete: true` means the stream ended without the closing tag —
|
|
61
|
-
* callers must treat these as format errors, never as valid actions.
|
|
62
|
-
* done — a <done/> self-closing tag was encountered.
|
|
63
|
-
*/
|
|
64
|
-
export type AirBlockEvent =
|
|
65
|
-
| { type: "text:delta"; content: string }
|
|
66
|
-
| { type: "text:done"; content: string; incomplete?: true }
|
|
67
|
-
| { type: "thinking:delta"; content: string }
|
|
68
|
-
| { type: "thinking:done"; content: string; incomplete?: true }
|
|
69
|
-
| { type: "typescript:done"; content: string; incomplete?: true }
|
|
70
|
-
| { type: "shell:done"; content: string; incomplete?: true }
|
|
71
|
-
| { type: "done" }
|