@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.
@@ -0,0 +1,52 @@
1
+ import type { ComponentT } from "./component"
2
+ import type { StateT } from "./state"
3
+ import type { ComponentRegistry, ComponentType, EntityId } from "./types"
4
+ import type { EcsEmit } from "./ecs"
5
+
6
+ type EntityOpts = {
7
+ state: StateT
8
+ component: ComponentT
9
+ emit: EcsEmit
10
+ }
11
+
12
+ /**
13
+ * Entity — membership in the world. Component writes delegate to Component()
14
+ * so telemetry and watchers fire through the single write path.
15
+ */
16
+ export function Entity(opts: EntityOpts) {
17
+ const { state, component, emit } = opts
18
+
19
+ return {
20
+ add<K extends ComponentType>({
21
+ entity,
22
+ components,
23
+ }: {
24
+ entity: EntityId
25
+ components?:
26
+ | { type: K; data: ComponentRegistry[K] }
27
+ | { type: K; data: ComponentRegistry[K] }[]
28
+ }) {
29
+ state.entities.add(entity)
30
+ void emit("cognet:entity:add", { ...state.stamp(), entity })
31
+
32
+ if (!components) return
33
+
34
+ const list = Array.isArray(components) ? components : [components]
35
+ for (const { type, data } of list) {
36
+ component.add({ entity, type, data })
37
+ }
38
+ },
39
+
40
+ remove({ entity }: { entity: EntityId }) {
41
+ // Remove components through the single write path so removal telemetry fires
42
+ for (const type of [...state.components.keys()]) {
43
+ component.remove({ entity, type })
44
+ }
45
+
46
+ state.entities.delete(entity)
47
+ void emit("cognet:entity:remove", { ...state.stamp(), entity })
48
+ },
49
+ }
50
+ }
51
+
52
+ export type EntityT = ReturnType<typeof Entity>
@@ -0,0 +1,13 @@
1
+ // ecs — the wake-scoped world. Opt-in: a cognet that never queries an entity
2
+ // doesn't need this module. The world clock lives in ../clock.ts and is
3
+ // always present.
4
+ export { Ecs, type EcsT, type EcsOpts, type EcsEmit } from "./ecs"
5
+ export type { StateOpts } from "./state"
6
+ export type {
7
+ ComponentRegistry,
8
+ ComponentType,
9
+ ComponentWatcher,
10
+ EntityId,
11
+ QueryDescriptor,
12
+ WorldQueryResult,
13
+ } from "./types"
@@ -0,0 +1,129 @@
1
+ import type {
2
+ ComponentRegistry,
3
+ ComponentStore,
4
+ ComponentType,
5
+ ComponentWatcher,
6
+ EntityId,
7
+ QueryDescriptor,
8
+ WorldQueryResult,
9
+ } from "./types"
10
+
11
+ export type StateOpts = {
12
+ /** The clock's stamp — world mutations are attributed to a tick and phase. */
13
+ stamp(): { tick: number; phase: string | null }
14
+ }
15
+
16
+ /**
17
+ * State — the single owner of everything shared inside the world:
18
+ * entity set, component stores, and watchers. Component() and Entity() are
19
+ * views over this; nothing else holds the maps.
20
+ *
21
+ * The world clock does NOT live here. tick/phase belong to Clock(), which
22
+ * every cognet has whether or not it holds a world — see ../clock.ts. State
23
+ * receives a stamp function so world mutations can be attributed to the tick
24
+ * and phase they happened in without owning the counters.
25
+ */
26
+ export function State(opts: StateOpts) {
27
+ const entities = new Set<EntityId>()
28
+ const components = new Map<ComponentType, ComponentStore>()
29
+ const watchers = new Map<ComponentType, Set<ComponentWatcher>>()
30
+
31
+ return {
32
+ entities,
33
+ components,
34
+
35
+ /** tick/phase stamp merged into every world event payload. */
36
+ stamp: opts.stamp,
37
+
38
+ /**
39
+ * Subscribe to writes on a specific component type.
40
+ * Called synchronously after every write of that component.
41
+ * Returns an unsubscribe function.
42
+ */
43
+ watch(type: ComponentType, handler: ComponentWatcher): () => void {
44
+ let set = watchers.get(type)
45
+ if (!set) {
46
+ set = new Set()
47
+ watchers.set(type, set)
48
+ }
49
+ set.add(handler)
50
+ return () => {
51
+ watchers.get(type)?.delete(handler)
52
+ }
53
+ },
54
+
55
+ /** Fire watchers for a component write. A throwing watcher is a bug — it propagates. */
56
+ notify(type: ComponentType, entity: EntityId, data: unknown) {
57
+ const set = watchers.get(type)
58
+ if (!set) return
59
+ for (const watcher of set) watcher(entity, data)
60
+ },
61
+
62
+ query<
63
+ const W extends readonly string[] = [],
64
+ const WO extends readonly string[] = [],
65
+ Reg extends Record<string, any> = ComponentRegistry,
66
+ >({
67
+ with: withComponents = [] as unknown as W,
68
+ without: withoutComponents = [] as unknown as WO,
69
+ where,
70
+ filter,
71
+ }: QueryDescriptor<W, WO, Reg>): WorldQueryResult<W, Reg> {
72
+ const stores = components as Map<string, Map<EntityId, any>>
73
+
74
+ const withStores: Map<EntityId, any>[] = []
75
+ for (const c of withComponents) {
76
+ const store = stores.get(c)
77
+ if (!store) return [] as WorldQueryResult<W, Reg>
78
+ withStores.push(store)
79
+ }
80
+
81
+ // Intersect starting from the smallest store
82
+ withStores.sort((a, b) => a.size - b.size)
83
+
84
+ let candidates: EntityId[] = []
85
+ if (withStores.length > 0) {
86
+ const [smallest, ...rest] = withStores
87
+ candidates = [...smallest.keys()].filter(e => rest.every(store => store.has(e)))
88
+ } else {
89
+ const all = new Set<EntityId>()
90
+ for (const store of stores.values()) {
91
+ for (const e of store.keys()) all.add(e)
92
+ }
93
+ candidates = [...all]
94
+ }
95
+
96
+ if (withoutComponents.length > 0) {
97
+ candidates = candidates.filter(e =>
98
+ withoutComponents.every(c => !stores.get(c)?.has(e))
99
+ )
100
+ }
101
+
102
+ if (where) {
103
+ candidates = candidates.filter(e =>
104
+ Object.entries(where).every(([component, expected]) => {
105
+ const store = stores.get(component)
106
+ if (!store?.has(e)) return false
107
+ return store.get(e) === expected
108
+ })
109
+ )
110
+ }
111
+
112
+ let results: WorldQueryResult<W, Reg> = candidates.map(e => {
113
+ const comps: Record<string, any> = {}
114
+ for (const c of withComponents) {
115
+ comps[c] = stores.get(c)!.get(e)
116
+ }
117
+ return { entity: e, components: comps as any }
118
+ })
119
+
120
+ if (filter) {
121
+ results = results.filter(entry => filter(entry as any))
122
+ }
123
+
124
+ return results
125
+ },
126
+ }
127
+ }
128
+
129
+ export type StateT = ReturnType<typeof State>
@@ -0,0 +1,46 @@
1
+
2
+ /**
3
+ * Base ComponentRegistry interface.
4
+ *
5
+ * Kernel systems extend this via module augmentation:
6
+ *
7
+ * declare module "@axon/core" {
8
+ * interface ComponentRegistry {
9
+ * "my-component": { value: string }
10
+ * }
11
+ * }
12
+ */
13
+ export interface ComponentRegistry {
14
+ // Base components (empty — kernels extend via module augmentation)
15
+ }
16
+
17
+ export type EntityId = string
18
+ export type ComponentType = keyof ComponentRegistry
19
+ export type ComponentStore<T = any> = Map<EntityId, T>
20
+
21
+ /** Fired synchronously after every component write of a watched type. */
22
+ export type ComponentWatcher = (entity: EntityId, data: unknown) => void
23
+
24
+ export type WorldQueryResult<
25
+ W extends readonly string[],
26
+ Reg extends Record<string, any> = ComponentRegistry,
27
+ > = {
28
+ entity: EntityId
29
+ components: {
30
+ [K in W[number]]: K extends keyof Reg ? Reg[K] : never
31
+ }
32
+ }[]
33
+
34
+ export type QueryDescriptor<
35
+ W extends readonly string[] = [],
36
+ WO extends readonly string[] = [],
37
+ Reg extends Record<string, any> = ComponentRegistry,
38
+ > = {
39
+ with?: W
40
+ without?: WO
41
+ where?: Partial<{ [K in keyof Reg]: Reg[K] }>
42
+ filter?: (entry: {
43
+ entity: EntityId
44
+ components: { [K in W[number]]: K extends keyof Reg ? Reg[K] : never }
45
+ }) => boolean
46
+ }
package/src/host.ts ADDED
@@ -0,0 +1,245 @@
1
+ import { Hookable } from "hookable"
2
+ import { AsyncLocalStorage } from "node:async_hooks"
3
+ import { err } from "@axon/err"
4
+ import type { AxonRunResult, CognetConfig, CognetDefinition, CognetHooks, CognetPlugin, CognetWake, KernelAbi } from "@arcforge/types"
5
+ import type { AxonOutputEvent } from "@arcforge/types"
6
+ import { Clock } from "./clock"
7
+
8
+ /**
9
+ * CognetHost — the runtime half of the cognet authoring surface.
10
+ *
11
+ * A cognet is authored as two clutter-free files:
12
+ * cognet.config.ts — identity: `export default defineCognet({ name, ... })`
13
+ * src/main.ts — a RAW SCRIPT: `loop(async ({ stop, ... }) => { ... })`
14
+ * with typed ambient globals (kernel, loop, phase,
15
+ * system) and normal imports.
16
+ *
17
+ * The CLI compile step wraps main.ts in a callable (imports hoisted out,
18
+ * body deferred) and generates an entry that composes this host with the
19
+ * config — so authors never see defineCognet-with-lifecycle boilerplate,
20
+ * and the desugared form is an ordinary CognetDefinition against the ABI:
21
+ * there is no side channel around it.
22
+ *
23
+ * IMPORTING THIS MODULE INSTALLS THE GLOBALS (that's why it is not exported
24
+ * from the cognet index — only generated bundle entries import it, first,
25
+ * so config/main evaluate with the globals present). One brain per process;
26
+ * each bundle carries its own inlined copy of this module's state.
27
+ *
28
+ * Global rules (the contract from the sketch):
29
+ * - process-lifetime things are ambient (kernel, loop)
30
+ * - the cognet learns NOTHING about its environment: no blueprint, no config,
31
+ * no paths. A mind that never knew what kind of world it was in doesn't
32
+ * need porting when the world changes.
33
+ * - wake-scoped things arrive as loop-body ARGS (stop, stimuli, signal,
34
+ * push); phase/system are ambient sugar bound to the current wake's
35
+ * world through AsyncLocalStorage. Several Axon instances share one JS
36
+ * global object, so plain global assignment is not an isolation boundary.
37
+ */
38
+
39
+ type LoopCtx = CognetWake & {
40
+ /** end the wake after this tick completes — the brain stays warm */
41
+ stop(): void
42
+ }
43
+
44
+ type LoopBody = (ctx: LoopCtx) => Promise<void>
45
+
46
+ // ── host state (module-scoped: one copy per compiled bundle) ────────────────
47
+
48
+ let registered: LoopBody | null = null
49
+ let boundKernel: KernelAbi | null = null
50
+ let currentClock: ReturnType<typeof Clock> | null = null
51
+ let loaded = false
52
+
53
+ type CognetAmbientScope = {
54
+ kernel: KernelAbi
55
+ loop(body: LoopBody): void
56
+ phase<T>(name: string, fn: () => Promise<T>): Promise<T>
57
+ system<T>(name: string, fn: () => Promise<T>): Promise<T>
58
+ }
59
+
60
+ // Every compiled cognet carries an inlined copy of this module, but all of
61
+ // those copies execute in the same process/globalThis. Symbol.for gives them
62
+ // one dispatcher while AsyncLocalStorage keeps nested and concurrent wakes
63
+ // bound to their own runtime across awaits.
64
+ const AMBIENT_SCOPE = Symbol.for("axon.cognet.ambient-scope")
65
+ const ambientStorage = (() => {
66
+ const shared = globalThis as typeof globalThis & { [AMBIENT_SCOPE]?: AsyncLocalStorage<CognetAmbientScope> }
67
+ return shared[AMBIENT_SCOPE] ??= new AsyncLocalStorage<CognetAmbientScope>()
68
+ })()
69
+
70
+ function ambientOrThrow(): CognetAmbientScope {
71
+ const scope = ambientStorage.getStore()
72
+ if (!scope) throw err("COGNET_ACCESSED_BEFORE_LOAD", { detail: "cognet globals are only available inside this brain's load, wake, and shutdown scope" })
73
+ return scope
74
+ }
75
+
76
+ // The cognet's own lifecycle hooks — a SEPARATE hookable from Axon's, one
77
+ // ring down. The kernel drives the brain's life through the host; the host
78
+ // fires these at the four fixed points. Awaited-to-completion, in order.
79
+ const hooks = new Hookable<CognetHooks>()
80
+
81
+ function kernelOrThrow(): KernelAbi {
82
+ if (!boundKernel) throw err("COGNET_ACCESSED_BEFORE_LOAD", { detail: "kernel is not available yet — it binds at load(); do work inside loop(), not at module top level" })
83
+ return boundKernel
84
+ }
85
+
86
+ function clockOrThrow(): ReturnType<typeof Clock> {
87
+ if (!currentClock) throw err("COGNET_ACCESSED_BEFORE_LOAD", { detail: "phase()/system() are wake-scoped — call them inside the loop body" })
88
+ return currentClock
89
+ }
90
+
91
+ // ── ambient globals ──────────────────────────────────────────────────────────
92
+
93
+ const globals = globalThis as Record<string, unknown>
94
+
95
+ globals.defineCognet = <T extends CognetConfig>(config: T): T => config
96
+
97
+ function registerLoop(body: LoopBody): void {
98
+ if (registered) throw err("COGNET_LOOP_ALREADY_DECLARED")
99
+ registered = body
100
+ }
101
+
102
+ // definePlugin — plumbing wired to lifecycle hooks. Runs at import time
103
+ // (the compile step imports plugins/*.ts into the bundle after this module),
104
+ // so a plugin's hooks.on(...) registrations land before load() fires "boot".
105
+ globals.definePlugin = (plugin: CognetPlugin): CognetPlugin => {
106
+ // hookable's InferCallback can't distribute over the generic hook name,
107
+ // so the handler is passed through — CognetPluginContext already typed it
108
+ // correctly at the call site; this is a TS limitation, not a shape gap.
109
+ plugin({ hooks: { on: (name, fn) => hooks.hook(name, fn as never) } })
110
+ return plugin
111
+ }
112
+
113
+ // run() is overloaded on input shape (string vs string[]) — a plain arrow
114
+ // can't carry two call signatures, so it's declared separately and spread in.
115
+ function run(code: string, opts?: { signal?: AbortSignal }): Promise<AxonRunResult>
116
+ function run(code: string[], opts?: { signal?: AbortSignal }): Promise<AxonRunResult[]>
117
+ function run(code: string | string[], opts?: { signal?: AbortSignal }) {
118
+ if (Array.isArray(code)) return kernelOrThrow().run(code, opts)
119
+ return kernelOrThrow().run(code, opts)
120
+ }
121
+
122
+ // the syscall table, delegating — live from load() onward
123
+ const localKernel = {
124
+ output: (type: keyof AxonOutputEvent, data: never) => kernelOrThrow().output(type, data),
125
+ stream: (req: never) => kernelOrThrow().stream(req),
126
+ run,
127
+ scope: () => kernelOrThrow().scope(),
128
+ base: () => kernelOrThrow().base(),
129
+ emit: (type: never, data: never) => kernelOrThrow().emit(type, data),
130
+ // store is a live sub-object on the bound ABI — delegate per call, same
131
+ // discipline as every other syscall (never captured before load())
132
+ store: {
133
+ session: {
134
+ get: (opts?: { after?: number }) => kernelOrThrow().store.session.get(opts),
135
+ },
136
+ get: (key: never) => kernelOrThrow().store.get(key),
137
+ set: (key: never, value: never) => kernelOrThrow().store.set(key, value),
138
+ },
139
+ } satisfies KernelAbi
140
+
141
+ const localScope: CognetAmbientScope = {
142
+ kernel: localKernel,
143
+ loop: registerLoop,
144
+ phase: <T>(name: string, fn: () => Promise<T>) => clockOrThrow().runPhase(name, fn),
145
+ system: <T>(name: string, fn: () => Promise<T>) => clockOrThrow().runSystem(name, fn),
146
+ }
147
+
148
+ // Stable process-wide facades. They never capture one cognet instance; every
149
+ // operation resolves the current async scope at the moment it is performed.
150
+ globals.loop = (body: LoopBody): void => ambientOrThrow().loop(body)
151
+ globals.kernel = {
152
+ output: (type: keyof AxonOutputEvent, data: never) => ambientOrThrow().kernel.output(type, data),
153
+ stream: (req: never) => ambientOrThrow().kernel.stream(req),
154
+ run: ((code: string | string[], opts?: { signal?: AbortSignal }) => ambientOrThrow().kernel.run(code as never, opts)) as KernelAbi["run"],
155
+ scope: () => ambientOrThrow().kernel.scope(),
156
+ base: () => ambientOrThrow().kernel.base(),
157
+ emit: (type: never, data: never) => ambientOrThrow().kernel.emit(type, data),
158
+ store: {
159
+ session: { get: (opts?: { after?: number }) => ambientOrThrow().kernel.store.session.get(opts) },
160
+ get: (key: never) => ambientOrThrow().kernel.store.get(key),
161
+ set: (key: never, value: never) => ambientOrThrow().kernel.store.set(key, value),
162
+ },
163
+ } satisfies KernelAbi
164
+
165
+ globals.phase = <T>(name: string, fn: () => Promise<T>) => ambientOrThrow().phase(name, fn)
166
+ globals.system = <T>(name: string, fn: () => Promise<T>) => ambientOrThrow().system(name, fn)
167
+
168
+ // ── composition (what the generated entry calls) ────────────────────────────
169
+
170
+ /**
171
+ * config + wrapped main → the definition the kernel loads. main() runs once
172
+ * at load(), with kernel already bound — its module/closure scope is the
173
+ * brain's resident RAM. It must declare exactly one loop().
174
+ */
175
+ export function CognetHost(config: CognetConfig, main: () => Promise<void>): CognetDefinition {
176
+ return {
177
+ ...config,
178
+
179
+ async load(abi) {
180
+ // A definition is a process-lifetime artifact. Reload plumbing may
181
+ // encounter the same hash-busted module instance again; exec is
182
+ // idempotent for that instance and must never register main twice.
183
+ if (loaded) {
184
+ if (boundKernel !== abi) throw err("COGNET_ALREADY_LOADED", { context: { name: config.name } })
185
+ return
186
+ }
187
+ boundKernel = abi
188
+ try {
189
+ await ambientStorage.run(localScope, main) // declares loop(); plugins already registered at import time
190
+ if (!registered) {
191
+ throw err("COGNET_NO_LOOP", { detail: `${config.name} ran main() without declaring loop()`, context: { name: config.name } })
192
+ }
193
+ await ambientStorage.run(localScope, () => hooks.callHook("boot"))
194
+ loaded = true
195
+ } catch (error) {
196
+ // A failed exec is not a half-loaded brain. Permit a clean
197
+ // retry after the caller has recorded/recovered the failure.
198
+ registered = null
199
+ boundKernel = null
200
+ throw error
201
+ }
202
+ },
203
+
204
+ async wake(wake) {
205
+ return ambientStorage.run(localScope, async () => {
206
+ const body = registered
207
+ if (!body) throw err("COGNET_NO_LOOP", { detail: `${config.name} woken before load()`, context: { name: config.name } })
208
+
209
+ const clock = Clock({ emit: (type, data) => kernelOrThrow().emit(type, data), signal: wake.signal })
210
+ currentClock = clock
211
+
212
+ await hooks.callHook("wake", wake)
213
+
214
+ try {
215
+ let stopped = false
216
+ const ctx: LoopCtx = { ...wake, stop: () => { stopped = true } }
217
+ const maxTicks = config.maxTicksPerWake ?? Infinity
218
+ while (!stopped && !wake.signal.aborted) {
219
+ if (clock.tick >= maxTicks) {
220
+ throw err("COGNET_MAX_TICKS", {
221
+ detail: `${config.name} exceeded ${maxTicks} ticks in one wake`,
222
+ context: { name: config.name, maxTicks },
223
+ })
224
+ }
225
+ // tick hook is on the hot path — plugins here must stay cheap
226
+ await clock.runTick(async () => {
227
+ await hooks.callHook("tick", { tick: clock.tick })
228
+ await body(ctx)
229
+ })
230
+ }
231
+ } finally {
232
+ currentClock = null
233
+ }
234
+ })
235
+ },
236
+
237
+ async unload() {
238
+ await ambientStorage.run(localScope, () => hooks.callHook("shutdown"))
239
+ hooks.removeAllHooks()
240
+ registered = null
241
+ boundKernel = null
242
+ loaded = false
243
+ },
244
+ }
245
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ // @arcforge/cognet — the cognet runtime.
2
+ //
3
+ // Everything that executes INSIDE a compiled brain. The kernel that loads the
4
+ // brain lives in @axon/core and is private; this is the public half, and it is
5
+ // what the generated entry imports.
6
+ //
7
+ // Root export is deliberately minimal: a cognet cannot exist without the host,
8
+ // but the world (./ecs) and the grammar (./air) are opt-in, so a control loop
9
+ // bundles neither.
10
+
11
+ // Importing ./host INSTALLS the ambient globals (loop, kernel, phase, system,
12
+ // defineCognet, definePlugin) as a side effect — that is why the generated
13
+ // entry imports it first, before config and main evaluate.
14
+ //
15
+ // definePlugin is deliberately global-only: it registers lifecycle hooks when
16
+ // called, so it is a side-effecting declaration rather than an identity
17
+ // function, and exporting it would invite calling it outside a brain.
18
+ export { CognetHost } from "./host"
19
+ export { defineCognet } from "./define"
20
+
21
+ export { Clock, type ClockT, type ClockOpts, type ClockEmit } from "./clock"