@lazily-hub/lazily-js 0.4.0

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,214 @@
1
+ // Manufactured identity for text (cell-model.md § Manufactured identity for
2
+ // text, #lzstableid). Markdown has no inherent node ids, so reconciliation keys
3
+ // are *manufactured* from text in three layers:
4
+ //
5
+ // 1. in-band **anchors** (exact, survive a body rewrite),
6
+ // 2. **content-derived hashes** of whitespace-normalized text (survive
7
+ // reflow/reorder, change on edit),
8
+ // 3. **alignment** by word-LCS similarity (>= 0.5 => Edited/key-inherited;
9
+ // below => Inserted).
10
+ //
11
+ // This is the bridge to LIS keyed reconciliation (#lzkeyrecon): a new block
12
+ // that is `Same`/`Edited` inherits the matched predecessor's key, so the
13
+ // reconciler emits an `update` rather than remove+insert.
14
+
15
+ export const EDIT_THRESHOLD = 0.5;
16
+ export const ANCHOR_PREFIX = "a:";
17
+ export const CONTENT_PREFIX = "c:";
18
+
19
+ export class Block {
20
+ constructor(text, anchor = null) {
21
+ this.text = String(text);
22
+ this.anchor = anchor;
23
+ Object.freeze(this);
24
+ }
25
+ static text(text) {
26
+ return new Block(text, null);
27
+ }
28
+ static anchored(anchor, text) {
29
+ return new Block(text, String(anchor));
30
+ }
31
+ }
32
+
33
+ export class BlockKey {
34
+ constructor(kind, value) {
35
+ this.kind = kind; // "anchored" | "content"
36
+ this.value = value; // string (anchor) | bigint (content hash)
37
+ Object.freeze(this);
38
+ }
39
+ get isAnchored() {
40
+ return this.kind === "anchored";
41
+ }
42
+ get isContent() {
43
+ return this.kind === "content";
44
+ }
45
+ equals(other) {
46
+ if (this.kind !== other.kind) {
47
+ return false;
48
+ }
49
+ return this.value === other.value;
50
+ }
51
+ asString() {
52
+ if (this.kind === "anchored") {
53
+ return `${ANCHOR_PREFIX}${this.value}`;
54
+ }
55
+ return `${CONTENT_PREFIX}${this.value.toString(16).padStart(16, "0")}`;
56
+ }
57
+ }
58
+
59
+ // Whitespace normalization: split on any Unicode whitespace run, drop empties,
60
+ // rejoin with a single ASCII space. Matches Rust `split_whitespace().join(" ")`.
61
+ export function normalize(text) {
62
+ return String(text)
63
+ .split(/\s+/u)
64
+ .filter((token) => token.length > 0)
65
+ .join(" ");
66
+ }
67
+
68
+ // FNV-1a 64-bit content hash of the normalized text. Deterministic and
69
+ // cross-language stable (unlike Rust's per-process DefaultHasher); the
70
+ // ecosystem already uses FNV-1a-64 for ShmBlobArena checksums. The spec pins
71
+ // only "content-derived hashes of normalized text", not the algorithm.
72
+ const FNV_OFFSET = 0xcbf29ce484222325n;
73
+ const FNV_PRIME = 0x100000001b3n;
74
+ const MASK64 = 0xffffffffffffffffn;
75
+
76
+ export function contentHash(text) {
77
+ let hash = FNV_OFFSET;
78
+ for (const byte of new TextEncoder().encode(normalize(text))) {
79
+ hash ^= BigInt(byte);
80
+ hash = (hash * FNV_PRIME) & MASK64;
81
+ }
82
+ return hash;
83
+ }
84
+
85
+ // Layer dispatch: anchor wins over content (the controlled skeleton's in-band
86
+ // marker is the highest-certainty identity and is never overridden by text).
87
+ export function blockKey(block) {
88
+ if (block.anchor !== null && block.anchor !== undefined) {
89
+ return new BlockKey("anchored", String(block.anchor));
90
+ }
91
+ return new BlockKey("content", contentHash(block.text));
92
+ }
93
+
94
+ // Longest-common-subsequence length over two token arrays (rolling two-row DP).
95
+ function lcsLen(a, b) {
96
+ const dp = new Array(b.length + 1).fill(0);
97
+ for (const x of a) {
98
+ let prev = 0;
99
+ for (let j = 0; j < b.length; j++) {
100
+ const cur = dp[j + 1];
101
+ dp[j + 1] = x === b[j] ? prev + 1 : Math.max(dp[j + 1], dp[j]);
102
+ prev = cur;
103
+ }
104
+ }
105
+ return dp[b.length];
106
+ }
107
+
108
+ // Word-LCS similarity ratio in [0,1]: 2·|LCS| / (|a|+|b|). 1.0 = identical token
109
+ // sequence; both-empty => 1.0.
110
+ export function similarity(a, b) {
111
+ const aw = String(a).split(/\s+/u).filter((t) => t.length > 0);
112
+ const bw = String(b).split(/\s+/u).filter((t) => t.length > 0);
113
+ if (aw.length === 0 && bw.length === 0) {
114
+ return 1.0;
115
+ }
116
+ const lcs = lcsLen(aw, bw);
117
+ return (2 * lcs) / (aw.length + bw.length);
118
+ }
119
+
120
+ export class Match {
121
+ constructor(kind, oldIndex = -1, similarity = 0) {
122
+ this.kind = kind; // "same" | "edited" | "inserted"
123
+ this.oldIndex = oldIndex;
124
+ this.similarity = similarity;
125
+ Object.freeze(this);
126
+ }
127
+ static same(oldIndex) {
128
+ return new Match("same", oldIndex);
129
+ }
130
+ static edited(oldIndex, similarity) {
131
+ return new Match("edited", oldIndex, similarity);
132
+ }
133
+ static inserted() {
134
+ return new Match("inserted");
135
+ }
136
+ }
137
+
138
+ export class Alignment {
139
+ constructor(newMatches, removed) {
140
+ this.newMatches = newMatches;
141
+ this.removed = removed;
142
+ Object.freeze(this);
143
+ }
144
+ }
145
+
146
+ // Diff two block sequences by manufactured key, then by similarity.
147
+ // Pass 1: exact key match, lowest-unused-old-index first (left-to-right so
148
+ // duplicate identical blocks pair deterministically).
149
+ // Pass 2: word-LCS similarity for the remaining; >= EDIT_THRESHOLD => Edited
150
+ // (tiebreak: nearest index), else Inserted.
151
+ export function align(oldBlocks, newBlocks) {
152
+ const oldKeys = oldBlocks.map(blockKey);
153
+ const newKeys = newBlocks.map(blockKey);
154
+ const oldUsed = new Array(oldBlocks.length).fill(false);
155
+ const newMatches = new Array(newBlocks.length).fill(null);
156
+
157
+ for (let ni = 0; ni < newBlocks.length; ni++) {
158
+ for (let oi = 0; oi < oldBlocks.length; oi++) {
159
+ if (!oldUsed[oi] && newKeys[ni].equals(oldKeys[oi])) {
160
+ oldUsed[oi] = true;
161
+ newMatches[ni] = Match.same(oi);
162
+ break;
163
+ }
164
+ }
165
+ }
166
+
167
+ for (let ni = 0; ni < newBlocks.length; ni++) {
168
+ if (newMatches[ni] !== null) {
169
+ continue;
170
+ }
171
+ let bestOi = -1;
172
+ let bestSim = -1;
173
+ for (let oi = 0; oi < oldBlocks.length; oi++) {
174
+ if (oldUsed[oi]) {
175
+ continue;
176
+ }
177
+ const sim = similarity(newBlocks[ni].text, oldBlocks[oi].text);
178
+ const better =
179
+ sim > bestSim ||
180
+ (sim === bestSim && Math.abs(oi - ni) < Math.abs(bestOi - ni));
181
+ if (better) {
182
+ bestSim = sim;
183
+ bestOi = oi;
184
+ }
185
+ }
186
+ if (bestOi !== -1 && bestSim >= EDIT_THRESHOLD) {
187
+ oldUsed[bestOi] = true;
188
+ newMatches[ni] = Match.edited(bestOi, bestSim);
189
+ } else {
190
+ newMatches[ni] = Match.inserted();
191
+ }
192
+ }
193
+
194
+ const removed = [];
195
+ for (let oi = 0; oi < oldBlocks.length; oi++) {
196
+ if (!oldUsed[oi]) {
197
+ removed.push(oi);
198
+ }
199
+ }
200
+ return new Alignment(newMatches, removed);
201
+ }
202
+
203
+ // One stable string key per NEW block: Same/Edited inherit the matched old
204
+ // block's key; Inserted mints its own. This is what feeds keyed reconciliation.
205
+ export function assignStableKeys(oldBlocks, newBlocks) {
206
+ const oldKeyStrings = oldBlocks.map((b) => blockKey(b).asString());
207
+ const alignment = align(oldBlocks, newBlocks);
208
+ return alignment.newMatches.map((m, ni) => {
209
+ if (m.kind === "same" || m.kind === "edited") {
210
+ return oldKeyStrings[m.oldIndex];
211
+ }
212
+ return blockKey(newBlocks[ni]).asString();
213
+ });
214
+ }
@@ -0,0 +1,14 @@
1
+ import type { Context, CellHandle, EffectHandle, SignalHandle } from "./reactive.js";
2
+
3
+ export type TransitionFn<S, E> = (state: S, event: E) => S | null;
4
+
5
+ export type TransitionListener<S> = (old: S, next: S) => void;
6
+
7
+ export class StateMachine<S, E> {
8
+ constructor(ctx: Context, initial: S, transition: TransitionFn<S, E>);
9
+ get state(): S;
10
+ send(event: E): boolean;
11
+ stateHandle(): CellHandle<S>;
12
+ onTransition(handler: TransitionListener<S>): EffectHandle;
13
+ stateIs(target: S): SignalHandle<boolean>;
14
+ }
@@ -0,0 +1,78 @@
1
+ // Flat finite-state-machine kernel (lazily-spec/docs/state-machine.md) — the
2
+ // native JavaScript counterpart of lazily-kt's `StateMachine` and the Lean
3
+ // `LazilyFormal.StateMachine`. It is the kernel a single-region state chart
4
+ // compiles down to (state-machine.md: "a flat StateMachine is the degenerate
5
+ // chart with no nesting").
6
+ //
7
+ // A state machine is **compute, not protocol**: it is never serialized as a
8
+ // distinct wire kind — only its current state crosses IPC/FFI as an ordinary
9
+ // cell `Payload`. The state lives in a reactive `Cell` so any slot, signal, or
10
+ // effect that reads `state` is automatically invalidated when the machine
11
+ // transitions. The transition function is pure `(state, event) -> next | null`:
12
+ // a non-null result accepts the event; `null` rejects it (guard). A
13
+ // self-transition that returns an equal state is accepted but suppressed by the
14
+ // cell's `==` guard, so no downstream cascade fires.
15
+
16
+ import { SignalHandle } from "./reactive.js";
17
+
18
+ export class StateMachine {
19
+ #ctx;
20
+ #cell;
21
+ #transition;
22
+
23
+ constructor(ctx, initial, transition) {
24
+ if (typeof transition !== "function") {
25
+ throw new TypeError("StateMachine transition must be a function");
26
+ }
27
+ this.#ctx = ctx;
28
+ this.#transition = transition;
29
+ this.#cell = ctx.cell(initial);
30
+ }
31
+
32
+ // Send an event. Returns `true` if accepted (non-null), `false` if rejected
33
+ // (`null`/guard). An accepted transition to an equal state returns `true`
34
+ // but the `==` guard suppresses invalidation (no downstream cascade).
35
+ send(event) {
36
+ const current = this.#ctx.getCell(this.#cell);
37
+ const next = this.#transition(current, event);
38
+ if (next === null || next === undefined) {
39
+ return false;
40
+ }
41
+ this.#ctx.setCell(this.#cell, next);
42
+ return true;
43
+ }
44
+
45
+ get state() {
46
+ return this.#ctx.getCell(this.#cell);
47
+ }
48
+
49
+ // The underlying active-state cell handle, for reactive composition.
50
+ stateHandle() {
51
+ return this.#cell;
52
+ }
53
+
54
+ // Register an effect firing with `(old, new)` on each *real* state change.
55
+ // Not called on registration; only on subsequent changes. Returns a handle
56
+ // whose disposal stops further callbacks.
57
+ onTransition(handler) {
58
+ let prev = this.state;
59
+ return this.#ctx.effect(() => {
60
+ const current = this.state;
61
+ if (prev !== current) {
62
+ handler(prev, current);
63
+ }
64
+ prev = current;
65
+ return null;
66
+ });
67
+ }
68
+
69
+ // An eager signal that is `true` while in `target`, else `false`.
70
+ stateIs(target) {
71
+ const slot = this.#ctx.memo(() => this.state === target);
72
+ const effect = this.#ctx.effect(() => {
73
+ this.#ctx.get(slot);
74
+ return null;
75
+ });
76
+ return new SignalHandle(slot, effect);
77
+ }
78
+ }
@@ -0,0 +1,114 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ /**
4
+ * JS-friendly interface to the lazily / agent-doc state-projection FFI channel.
5
+ *
6
+ * `loadAgentDocFFI` returns an implementation; tests inject a mock.
7
+ */
8
+ export interface LazilyFFI {
9
+ /**
10
+ * Read the binary's `DocumentStateProjection` JSON for a document, or `null`
11
+ * when no events have been recorded.
12
+ */
13
+ stateProjection(documentHash: string): string | null;
14
+ /**
15
+ * Feed a `StateEvent` JSON object into the binary's state backbone.
16
+ * @returns `true` if accepted, `false` on parse/ledger failure.
17
+ */
18
+ recordStateEvent(documentHash: string, factJson: string): boolean;
19
+ }
20
+
21
+ export interface ProjectionSummary {
22
+ routeReadiness?: string;
23
+ routePaneId?: string;
24
+ latestTransportPatchId?: string;
25
+ latestTransportPhase?: string;
26
+ proofMarkers: number;
27
+ }
28
+
29
+ export interface StateBackboneEvent {
30
+ event_id: string;
31
+ fact: Record<string, unknown> & {
32
+ type: string;
33
+ document_hash: string;
34
+ };
35
+ }
36
+
37
+ /** Compute the canonical-path SHA-256 document key used by agent-doc. */
38
+ export function documentHash(filePath: string): string;
39
+
40
+ /** Build the Rust `StateEvent` JSON object for a typed fact. */
41
+ export function buildStateEvent(
42
+ documentHashValue: string,
43
+ type: string,
44
+ fields: Record<string, unknown>,
45
+ eventSuffix: string,
46
+ ): StateBackboneEvent;
47
+
48
+ /** Reduce a `DocumentStateProjection` JSON object into editor status fields. */
49
+ export function projectionSummary(projection: unknown): ProjectionSummary | null;
50
+
51
+ /** Render the compact editor-visible state projection summary. */
52
+ export function compactProjectionSummary(summary: ProjectionSummary): string;
53
+
54
+ /** Decode and free one `agent_doc_state_projection` pointer. */
55
+ export function decodeStateProjectionPointer(
56
+ koffi: unknown,
57
+ ptr: unknown,
58
+ freeString: (ptr: unknown) => void,
59
+ ): string | null;
60
+
61
+ /** Wrap an already-loaded native library with the lazily state-projection FFI. */
62
+ export function wrapAgentDocStateProjectionFFI(koffi: unknown, lib: unknown): LazilyFFI;
63
+
64
+ /**
65
+ * Lazily load the `agent_doc` native library and wrap its state-projection C
66
+ * ABI into the {@link LazilyFFI} interface. `koffi` is resolved only when
67
+ * called, not at module import.
68
+ */
69
+ export function loadAgentDocFFI(libPath?: string): LazilyFFI;
70
+
71
+ /** `"projection"` event payload: the new projection JSON, or `null`. */
72
+ export type ProjectionEvent = [projection: string | null];
73
+
74
+ /**
75
+ * EventEmitter-based holder for the binary's state projection, plus an event
76
+ * reporter. The JS analogue of lazily-kt's `StateProjectionClient`.
77
+ */
78
+ export class StateProjectionClient extends EventEmitter {
79
+ constructor(documentHash: string, ffi?: LazilyFFI);
80
+
81
+ readonly documentHash: string;
82
+
83
+ /** Current projection JSON, or `null` when no events have been recorded. */
84
+ get projection(): string | null;
85
+
86
+ /** `true` once a non-null projection has been observed. */
87
+ get isAvailable(): boolean;
88
+
89
+ /**
90
+ * Pull the latest state projection from the binary. Emits a `"projection"`
91
+ * event with the new JSON (or `null`).
92
+ * @returns The freshly-read projection JSON, or `null`.
93
+ */
94
+ refresh(): string | null;
95
+
96
+ /**
97
+ * Record a state event in the binary's backbone.
98
+ * @returns `true` if accepted, `false` on parse/ledger failure.
99
+ */
100
+ recordStateEvent(factJson: string): boolean;
101
+
102
+ on(event: "projection", listener: (projection: string | null) => void): this;
103
+ once(event: "projection", listener: (projection: string | null) => void): this;
104
+ off(event: "projection", listener: (projection: string | null) => void): this;
105
+ emit(event: "projection", projection: string | null): boolean;
106
+ addListener(
107
+ event: "projection",
108
+ listener: (projection: string | null) => void,
109
+ ): this;
110
+ removeListener(
111
+ event: "projection",
112
+ listener: (projection: string | null) => void,
113
+ ): this;
114
+ }
@@ -0,0 +1,256 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { createRequire } from "node:module";
3
+ import { realpathSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+
7
+ /**
8
+ * JS-friendly interface to the lazily / agent-doc state-projection FFI channel.
9
+ *
10
+ * The native loader (`loadAgentDocFFI`) returns an object implementing this
11
+ * interface; tests inject a plain-object mock instead (no native dependency).
12
+ *
13
+ * Mirrors the Kotlin `LazilyFFI` + `StateProjectionClient` pair from lazily-kt
14
+ * (#mes4), but adapts the pointer/free lifecycle into the loader boundary so
15
+ * the consumer never handles raw C pointers.
16
+ *
17
+ * @typedef {object} LazilyFFI
18
+ * @property {(documentHash: string) => (string | null)} stateProjection
19
+ * Read the binary's `DocumentStateProjection` JSON for a document, or `null`
20
+ * when no events have been recorded. The loader reads the NUL-terminated C
21
+ * string and frees the backing pointer before returning.
22
+ * @property {(documentHash: string, factJson: string) => boolean} recordStateEvent
23
+ * Feed a `StateEvent` JSON object into the binary's state backbone. Returns
24
+ * `true` if accepted, `false` on parse/ledger failure.
25
+ */
26
+
27
+ const nativeRequire = createRequire(import.meta.url);
28
+
29
+ /**
30
+ * Compute the canonical document key used by agent-doc snapshots and editor
31
+ * state-projection events.
32
+ *
33
+ * @param {string} filePath
34
+ * @returns {string}
35
+ */
36
+ export function documentHash(filePath) {
37
+ let canonical;
38
+ try {
39
+ canonical = realpathSync(filePath);
40
+ } catch {
41
+ canonical = resolve(filePath);
42
+ }
43
+ return createHash("sha256").update(canonical, "utf-8").digest("hex");
44
+ }
45
+
46
+ /**
47
+ * Build the Rust `StateEvent` JSON shape for a typed fact.
48
+ *
49
+ * @param {string} documentHashValue
50
+ * @param {string} type
51
+ * @param {Record<string, unknown>} fields
52
+ * @param {string} eventSuffix
53
+ * @returns {{ event_id: string, fact: Record<string, unknown> & { type: string, document_hash: string } }}
54
+ */
55
+ export function buildStateEvent(documentHashValue, type, fields, eventSuffix) {
56
+ return {
57
+ event_id: `${documentHashValue}:${eventSuffix}`,
58
+ fact: {
59
+ type,
60
+ document_hash: documentHashValue,
61
+ ...fields,
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Reduce a `DocumentStateProjection` JSON object into the compact editor status
68
+ * shape used by JetBrains and VS Code.
69
+ *
70
+ * @param {any} projection
71
+ * @returns {{ routeReadiness?: string, routePaneId?: string, latestTransportPatchId?: string, latestTransportPhase?: string, proofMarkers: number } | null}
72
+ */
73
+ export function projectionSummary(projection) {
74
+ if (!projection || typeof projection !== "object") return null;
75
+ const route = projection.route ?? {};
76
+ const transport = projection.transport ?? {};
77
+ const proof = projection.proof ?? {};
78
+ const patches = transport.patches && typeof transport.patches === "object"
79
+ ? Object.entries(transport.patches)
80
+ : [];
81
+ const sortedPatches = patches.sort(([a], [b]) => a.localeCompare(b));
82
+ const latest = sortedPatches.length > 0 ? sortedPatches[sortedPatches.length - 1] : undefined;
83
+ return {
84
+ routeReadiness: typeof route.readiness === "string" ? route.readiness : undefined,
85
+ routePaneId: typeof route.pane_id === "string" ? route.pane_id : undefined,
86
+ latestTransportPatchId: latest?.[0],
87
+ latestTransportPhase: typeof latest?.[1]?.phase === "string" ? latest[1].phase : undefined,
88
+ proofMarkers: proof.markers && typeof proof.markers === "object"
89
+ ? Object.keys(proof.markers).length
90
+ : 0,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Render a compact editor-visible status string for a projection summary.
96
+ *
97
+ * @param {{ routeReadiness?: string, routePaneId?: string, latestTransportPatchId?: string, latestTransportPhase?: string, proofMarkers: number }} summary
98
+ * @returns {string}
99
+ */
100
+ export function compactProjectionSummary(summary) {
101
+ return `route=${summary.routeReadiness ?? "unknown"} pane=${summary.routePaneId ?? "-"} `
102
+ + `transport=${summary.latestTransportPatchId ?? "-"}:${summary.latestTransportPhase ?? "-"} `
103
+ + `proof_markers=${summary.proofMarkers}`;
104
+ }
105
+
106
+ /**
107
+ * Decode and free one `agent_doc_state_projection` pointer.
108
+ *
109
+ * @param {any} koffi
110
+ * @param {any} ptr
111
+ * @param {(ptr: any) => void} freeString
112
+ * @returns {string | null}
113
+ */
114
+ export function decodeStateProjectionPointer(koffi, ptr, freeString) {
115
+ if (ptr === null || ptr === undefined) return null;
116
+ const address = typeof koffi.address === "function" ? koffi.address(ptr) : 1n;
117
+ if (address === 0n || address === 0) return null;
118
+ try {
119
+ const json = koffi.decode.string(ptr);
120
+ return json === "null" || json == null ? null : json;
121
+ } finally {
122
+ freeString(ptr);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Wrap an already-loaded native library with the lazily state-projection FFI
128
+ * interface. Exported so package and editor parity tests can cover pointer/free
129
+ * lifecycle without loading a real native library.
130
+ *
131
+ * @param {any} koffi
132
+ * @param {any} lib
133
+ * @returns {LazilyFFI}
134
+ */
135
+ export function wrapAgentDocStateProjectionFFI(koffi, lib) {
136
+ const state_projection = lib.func(
137
+ "void *agent_doc_state_projection(const char *document_hash)",
138
+ );
139
+ const record_state_event = lib.func(
140
+ "int agent_doc_record_state_event(const char *document_hash, const char *fact_json)",
141
+ );
142
+ const free_string = lib.func("void agent_doc_free_string(void *ptr)");
143
+ return {
144
+ stateProjection(documentHashValue) {
145
+ return decodeStateProjectionPointer(
146
+ koffi,
147
+ state_projection(documentHashValue),
148
+ free_string,
149
+ );
150
+ },
151
+ recordStateEvent(documentHashValue, factJson) {
152
+ return record_state_event(documentHashValue, factJson) === 1;
153
+ },
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Lazily load the `agent_doc` native library and wrap its state-projection C
159
+ * ABI into the {@link LazilyFFI} interface.
160
+ *
161
+ * Uses `createRequire` so `koffi` is resolved **only when this function is
162
+ * called**, not when the module is imported — keeping `make check` (pure-JS
163
+ * tests with injected mocks) free of any native build dependency. koffi ships
164
+ * prebuilt binaries (no native compile step), so — unlike the previous
165
+ * ffi-napi/ref-napi loader — it installs cleanly on Node >= 23 (ffi-napi's
166
+ * `napi_add_finalizer` ABI break made it uninstallable there).
167
+ *
168
+ * @param {string} [libPath] Library name or path (defaults to `agent_doc`,
169
+ * resolved from the process load path / `LD_LIBRARY_PATH`).
170
+ * @returns {LazilyFFI}
171
+ */
172
+ export function loadAgentDocFFI(libPath) {
173
+ const koffi = nativeRequire("koffi");
174
+ const lib = koffi.load(libPath ?? "agent_doc");
175
+ return wrapAgentDocStateProjectionFFI(koffi, lib);
176
+ }
177
+
178
+ /**
179
+ * Holds the raw JSON projection returned by the binary's
180
+ * `agent_doc_state_projection` FFI, exposed as an `EventEmitter` for reactive
181
+ * UI binding (VSCode status items, webviews), plus {@link recordStateEvent}
182
+ * for feeding facts into the binary's state backbone.
183
+ *
184
+ * `null` means no state events have been recorded for the document. The JSON
185
+ * contains document/queue/closeout/transport/supervisor/route/proof slices —
186
+ * consumers parse it with their preferred JSON library.
187
+ *
188
+ * This is the JS analogue of lazily-kt's `StateProjectionClient` (Kotlin
189
+ * `StateFlow` → JS `EventEmitter`): plugins become thin projection-renderer +
190
+ * event-reporter, NOT a reactive-core port (FFI-first rule).
191
+ *
192
+ * @example
193
+ * import { StateProjectionClient, loadAgentDocFFI } from "@lazily-hub/js/state-projection";
194
+ * const ffi = loadAgentDocFFI();
195
+ * const client = new StateProjectionClient(documentHash, ffi);
196
+ * client.on("projection", (json) => renderStatus(json));
197
+ * client.refresh(); // pull latest projection, emits "projection"
198
+ * client.recordStateEvent(JSON.stringify({ type: "BaselineSaved" }));
199
+ */
200
+ export class StateProjectionClient extends EventEmitter {
201
+ #documentHash;
202
+ #ffi;
203
+ #projection = null;
204
+
205
+ /**
206
+ * @param {string} documentHash
207
+ * @param {LazilyFFI} [ffi] Native FFI handle. When omitted the `agent_doc`
208
+ * library is loaded eagerly via {@link loadAgentDocFFI}. Tests pass a
209
+ * plain-object mock implementing {@link LazilyFFI}.
210
+ */
211
+ constructor(documentHash, ffi = loadAgentDocFFI()) {
212
+ super();
213
+ this.#documentHash = documentHash;
214
+ this.#ffi = ffi;
215
+ }
216
+
217
+ /** The document hash this client is bound to. */
218
+ get documentHash() {
219
+ return this.#documentHash;
220
+ }
221
+
222
+ /** Current projection JSON, or `null` when no events have been recorded. */
223
+ get projection() {
224
+ return this.#projection;
225
+ }
226
+
227
+ /** `true` once a non-null projection has been observed. */
228
+ get isAvailable() {
229
+ return this.#projection !== null;
230
+ }
231
+
232
+ /**
233
+ * Pull the latest state projection from the binary. Updates {@link projection}
234
+ * and emits a `"projection"` event with the new JSON (or `null`). Safe to call
235
+ * on any thread — the FFI is thread-safe.
236
+ *
237
+ * @returns {string | null} The freshly-read projection JSON, or `null`.
238
+ */
239
+ refresh() {
240
+ const json = this.#ffi.stateProjection(this.#documentHash);
241
+ this.#projection = json;
242
+ this.emit("projection", json);
243
+ return json;
244
+ }
245
+
246
+ /**
247
+ * Record a state event in the binary's backbone.
248
+ *
249
+ * @param {string} factJson JSON object deserializable as a `StateEvent`
250
+ * (internally tagged `{ "event_id": "...", "fact": { "type": "..." } }`).
251
+ * @returns {boolean} `true` if accepted, `false` on parse/ledger failure.
252
+ */
253
+ recordStateEvent(factJson) {
254
+ return this.#ffi.recordStateEvent(this.#documentHash, factJson);
255
+ }
256
+ }