@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.
- package/.github/workflows/release.yml +48 -0
- package/README.md +356 -0
- package/package.json +64 -0
- package/src/collections.d.ts +110 -0
- package/src/collections.js +413 -0
- package/src/index.d.ts +416 -0
- package/src/index.js +1109 -0
- package/src/reactive.d.ts +43 -0
- package/src/reactive.js +531 -0
- package/src/sem-tree.d.ts +21 -0
- package/src/sem-tree.js +130 -0
- package/src/seq-crdt.d.ts +44 -0
- package/src/seq-crdt.js +364 -0
- package/src/stable-id.d.ts +45 -0
- package/src/stable-id.js +214 -0
- package/src/state-machine.d.ts +14 -0
- package/src/state-machine.js +78 -0
- package/src/state-projection.d.ts +114 -0
- package/src/state-projection.js +256 -0
- package/src/statechart.d.ts +154 -0
- package/src/statechart.js +678 -0
- package/src/text-crdt.d.ts +23 -0
- package/src/text-crdt.js +317 -0
package/src/stable-id.js
ADDED
|
@@ -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
|
+
}
|