@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
|
@@ -0,0 +1,51 @@
|
|
|
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>
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { repair } from "./repair"
|
|
@@ -0,0 +1,33 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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" }
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { err } from "@axon/err"
|
|
2
|
+
import type { KernelAbi } from "@arcforge/types"
|
|
3
|
+
|
|
4
|
+
/** Telemetry sink — the cognet's own `kernel.emit`. Fire-and-forget, never awaited. */
|
|
5
|
+
export type ClockEmit = KernelAbi["emit"]
|
|
6
|
+
|
|
7
|
+
export type ClockOpts = {
|
|
8
|
+
emit: ClockEmit
|
|
9
|
+
/**
|
|
10
|
+
* The wake's own abort signal. Lets tick/phase/system tell an intentional
|
|
11
|
+
* interrupt (Escape/Ctrl+C, engine abort) apart from a genuine failure.
|
|
12
|
+
*/
|
|
13
|
+
signal?: AbortSignal
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Clock — the world clock and the execution wrappers that drive it.
|
|
18
|
+
*
|
|
19
|
+
* Every cognet has one, whether or not it holds a world: `phase()` and
|
|
20
|
+
* `system()` are ambient globals, and the host wraps each loop iteration in a
|
|
21
|
+
* tick. This is deliberately separate from the ECS so a cognet that never
|
|
22
|
+
* queries an entity — a control loop, a perception stack — doesn't carry an
|
|
23
|
+
* entity store it never touches.
|
|
24
|
+
*
|
|
25
|
+
* tick/phase/system are the ONLY writers of the clock. Each brackets its
|
|
26
|
+
* callback with telemetry so the shape of a thought is recorded as it happens
|
|
27
|
+
* rather than reconstructed afterward.
|
|
28
|
+
*
|
|
29
|
+
* Interrupt is a THIRD outcome, not a failure: when `signal` is aborted,
|
|
30
|
+
* whatever fn() threw is cancellation surfacing through the call stack, not a
|
|
31
|
+
* bug in the phase's own work. Emitting *:failed for that would conflate a
|
|
32
|
+
* cancelled thought with a broken one, and telemetry full of meaningless red
|
|
33
|
+
* is telemetry nobody reads. Real failures still emit *:failed and rethrow —
|
|
34
|
+
* telemetry never swallows.
|
|
35
|
+
*/
|
|
36
|
+
export function Clock(opts: ClockOpts) {
|
|
37
|
+
const { emit, signal } = opts
|
|
38
|
+
|
|
39
|
+
let tick = 0
|
|
40
|
+
let phase: string | null = null
|
|
41
|
+
|
|
42
|
+
/** tick/phase stamp merged into every event payload below tick level. */
|
|
43
|
+
function stamp() {
|
|
44
|
+
return { tick, phase }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
get tick() {
|
|
49
|
+
return tick
|
|
50
|
+
},
|
|
51
|
+
get phase() {
|
|
52
|
+
return phase
|
|
53
|
+
},
|
|
54
|
+
stamp,
|
|
55
|
+
|
|
56
|
+
/** One iteration of the cognitive loop. Advances the clock. */
|
|
57
|
+
async runTick<T>(fn: () => Promise<T>): Promise<T> {
|
|
58
|
+
tick += 1
|
|
59
|
+
const current = tick
|
|
60
|
+
void emit("cognet:tick:start", { tick: current })
|
|
61
|
+
try {
|
|
62
|
+
const result = await fn()
|
|
63
|
+
void emit("cognet:tick:complete", { tick: current })
|
|
64
|
+
return result
|
|
65
|
+
} catch (cause) {
|
|
66
|
+
if (signal?.aborted) {
|
|
67
|
+
void emit("cognet:tick:interrupted", { tick: current })
|
|
68
|
+
throw cause
|
|
69
|
+
}
|
|
70
|
+
const failure = err(cause)
|
|
71
|
+
void emit("cognet:tick:failed", { tick: current, error: failure })
|
|
72
|
+
throw failure
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/** A named stage within a tick. Sets the current phase for its duration. */
|
|
77
|
+
async runPhase<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
78
|
+
phase = name
|
|
79
|
+
void emit("cognet:phase:start", { tick, phase: name })
|
|
80
|
+
try {
|
|
81
|
+
const result = await fn()
|
|
82
|
+
void emit("cognet:phase:complete", { tick, phase: name })
|
|
83
|
+
return result
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
if (signal?.aborted) {
|
|
86
|
+
void emit("cognet:phase:interrupted", { tick, phase: name })
|
|
87
|
+
throw cause
|
|
88
|
+
}
|
|
89
|
+
const failure = err(cause)
|
|
90
|
+
void emit("cognet:phase:failed", { tick, phase: name, error: failure })
|
|
91
|
+
throw failure
|
|
92
|
+
} finally {
|
|
93
|
+
phase = null
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** A unit of work within a phase. Timed for the flame graph. */
|
|
98
|
+
async runSystem<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
99
|
+
const started = Date.now()
|
|
100
|
+
void emit("cognet:system:start", { ...stamp(), system: name })
|
|
101
|
+
try {
|
|
102
|
+
const result = await fn()
|
|
103
|
+
void emit("cognet:system:complete", { ...stamp(), system: name, durationMs: Date.now() - started })
|
|
104
|
+
return result
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
if (signal?.aborted) {
|
|
107
|
+
void emit("cognet:system:interrupted", { ...stamp(), system: name })
|
|
108
|
+
throw cause
|
|
109
|
+
}
|
|
110
|
+
const failure = err(cause)
|
|
111
|
+
void emit("cognet:system:failed", { ...stamp(), system: name, error: failure })
|
|
112
|
+
throw failure
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ClockT = ReturnType<typeof Clock>
|
package/src/define.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CognetDefinition } from "@arcforge/types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The cognet authoring surface. Cognet projects (cognets/*) call this in
|
|
5
|
+
* their main.ts; the CLI bundle gets it as an injected global — the
|
|
6
|
+
* desugared form and the global form compile to the same thing, so there
|
|
7
|
+
* is never a side channel around the ABI.
|
|
8
|
+
*/
|
|
9
|
+
export function defineCognet(definition: CognetDefinition): CognetDefinition {
|
|
10
|
+
return definition
|
|
11
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { StateT } from "./state"
|
|
2
|
+
import type { ComponentRegistry, ComponentType, EntityId } from "./types"
|
|
3
|
+
import type { EcsEmit } from "./ecs"
|
|
4
|
+
|
|
5
|
+
type ComponentOpts = {
|
|
6
|
+
state: StateT
|
|
7
|
+
emit: EcsEmit
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Component — the single write path for component data.
|
|
12
|
+
* Every write emits kernel telemetry and fires watchers; Entity() delegates
|
|
13
|
+
* here so there is exactly one place a component can change.
|
|
14
|
+
*
|
|
15
|
+
* Telemetry goes to the runtime bus (→ tracing pipeline), never the session
|
|
16
|
+
* log. Bus emit never rejects (handler errors are re-emitted as
|
|
17
|
+
* axon:bus:error), so firing without await keeps writes synchronous.
|
|
18
|
+
*/
|
|
19
|
+
export function Component(opts: ComponentOpts) {
|
|
20
|
+
const { state, emit } = opts
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
add<K extends ComponentType>({
|
|
24
|
+
entity,
|
|
25
|
+
type,
|
|
26
|
+
data,
|
|
27
|
+
}: {
|
|
28
|
+
entity: EntityId
|
|
29
|
+
type: K
|
|
30
|
+
data: ComponentRegistry[K]
|
|
31
|
+
}) {
|
|
32
|
+
let store = state.components.get(type)
|
|
33
|
+
if (!store) {
|
|
34
|
+
store = new Map<EntityId, ComponentRegistry[K]>()
|
|
35
|
+
state.components.set(type, store)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const existed = store.has(entity)
|
|
39
|
+
store.set(entity, data)
|
|
40
|
+
|
|
41
|
+
void emit(existed ? "cognet:component:update" : "cognet:component:add", {
|
|
42
|
+
...state.stamp(),
|
|
43
|
+
entity,
|
|
44
|
+
component: type,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
state.notify(type, entity, data)
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
remove<K extends ComponentType>({ entity, type }: { entity: EntityId; type: K }) {
|
|
51
|
+
const store = state.components.get(type)
|
|
52
|
+
if (!store?.has(entity)) return
|
|
53
|
+
store.delete(entity)
|
|
54
|
+
|
|
55
|
+
void emit("cognet:component:remove", {
|
|
56
|
+
...state.stamp(),
|
|
57
|
+
entity,
|
|
58
|
+
component: type,
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
get<K extends ComponentType>({
|
|
63
|
+
entity,
|
|
64
|
+
type,
|
|
65
|
+
}: {
|
|
66
|
+
entity: EntityId
|
|
67
|
+
type: K
|
|
68
|
+
}): ComponentRegistry[K] | undefined {
|
|
69
|
+
return state.components.get(type)?.get(entity)
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
has<K extends ComponentType>({ entity, type }: { entity: EntityId; type: K }): boolean {
|
|
73
|
+
return state.components.get(type)?.has(entity) ?? false
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ComponentT = ReturnType<typeof Component>
|
package/src/ecs/ecs.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { KernelAbi } from "@arcforge/types"
|
|
2
|
+
import { Component } from "./component"
|
|
3
|
+
import { Entity } from "./entity"
|
|
4
|
+
import { State } from "./state"
|
|
5
|
+
|
|
6
|
+
/** Telemetry sink — cognets pass abi.emit; fire-and-forget, never awaited. Typed against cognet:*. */
|
|
7
|
+
export type EcsEmit = KernelAbi["emit"]
|
|
8
|
+
|
|
9
|
+
export type EcsOpts = {
|
|
10
|
+
emit: EcsEmit
|
|
11
|
+
/**
|
|
12
|
+
* The clock's stamp. Every world mutation is attributed to the tick and
|
|
13
|
+
* phase it happened in, so the mutation history replays against the clock
|
|
14
|
+
* rather than being a flat list of writes.
|
|
15
|
+
*/
|
|
16
|
+
stamp(): { tick: number; phase: string | null }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ecs — the wake-scoped world: entities, components, and queries over both.
|
|
21
|
+
*
|
|
22
|
+
* A WORKING SET, not memory. Persistence lives in the session log and durable
|
|
23
|
+
* cognitive state in kernel.store; if an entity matters beyond this wake, it
|
|
24
|
+
* was derived from the log and the next wake derives it again.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately opt-in. The world clock (tick/phase/system) is separate and
|
|
27
|
+
* always present — see ../clock.ts — so a control loop or perception stack
|
|
28
|
+
* that never queries an entity carries none of this.
|
|
29
|
+
*
|
|
30
|
+
* State() owns the store; Component() and Entity() are the write paths, and
|
|
31
|
+
* Entity delegates to Component so there is exactly one place a component can
|
|
32
|
+
* change — which is what makes telemetry and watchers reliable rather than
|
|
33
|
+
* best-effort.
|
|
34
|
+
*/
|
|
35
|
+
export function Ecs(opts: EcsOpts) {
|
|
36
|
+
const state = State({ stamp: opts.stamp })
|
|
37
|
+
|
|
38
|
+
const component = Component({ state, emit: opts.emit })
|
|
39
|
+
const entity = Entity({ state, component, emit: opts.emit })
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
state: state,
|
|
43
|
+
entity: entity,
|
|
44
|
+
component: component,
|
|
45
|
+
|
|
46
|
+
query: state.query,
|
|
47
|
+
watch: state.watch,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type EcsT = ReturnType<typeof Ecs>
|