hcifootprint 0.2.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/LICENSE +20 -0
- package/README.md +524 -0
- package/dist/atom/types.d.ts +618 -0
- package/dist/atom/types.d.ts.map +1 -0
- package/dist/atom/types.js +2 -0
- package/dist/atom/types.js.map +1 -0
- package/dist/graph/builder.d.ts +31 -0
- package/dist/graph/builder.d.ts.map +1 -0
- package/dist/graph/builder.js +165 -0
- package/dist/graph/builder.js.map +1 -0
- package/dist/graph/guards.d.ts +31 -0
- package/dist/graph/guards.d.ts.map +1 -0
- package/dist/graph/guards.js +94 -0
- package/dist/graph/guards.js.map +1 -0
- package/dist/graph/skill-deps.d.ts +20 -0
- package/dist/graph/skill-deps.d.ts.map +1 -0
- package/dist/graph/skill-deps.js +15 -0
- package/dist/graph/skill-deps.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +15 -0
- package/dist/mcp.js.map +1 -0
- package/dist/presence/presence.d.ts +50 -0
- package/dist/presence/presence.d.ts.map +1 -0
- package/dist/presence/presence.js +126 -0
- package/dist/presence/presence.js.map +1 -0
- package/dist/registry/registry.d.ts +53 -0
- package/dist/registry/registry.d.ts.map +1 -0
- package/dist/registry/registry.js +76 -0
- package/dist/registry/registry.js.map +1 -0
- package/dist/serve/mcp-server.d.ts +40 -0
- package/dist/serve/mcp-server.d.ts.map +1 -0
- package/dist/serve/mcp-server.js +72 -0
- package/dist/serve/mcp-server.js.map +1 -0
- package/dist/serve/mcp.d.ts +12 -0
- package/dist/serve/mcp.d.ts.map +1 -0
- package/dist/serve/mcp.js +62 -0
- package/dist/serve/mcp.js.map +1 -0
- package/dist/serve/modes.d.ts +66 -0
- package/dist/serve/modes.d.ts.map +1 -0
- package/dist/serve/modes.js +347 -0
- package/dist/serve/modes.js.map +1 -0
- package/dist/testing/harness.d.ts +156 -0
- package/dist/testing/harness.d.ts.map +1 -0
- package/dist/testing/harness.js +416 -0
- package/dist/testing/harness.js.map +1 -0
- package/dist/testing/index.d.ts +35 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/lint.d.ts +20 -0
- package/dist/testing/lint.d.ts.map +1 -0
- package/dist/testing/lint.js +18 -0
- package/dist/testing/lint.js.map +1 -0
- package/dist/testing/model/check.d.ts +30 -0
- package/dist/testing/model/check.d.ts.map +1 -0
- package/dist/testing/model/check.js +89 -0
- package/dist/testing/model/check.js.map +1 -0
- package/dist/testing/model/lint.d.ts +54 -0
- package/dist/testing/model/lint.d.ts.map +1 -0
- package/dist/testing/model/lint.js +235 -0
- package/dist/testing/model/lint.js.map +1 -0
- package/dist/testing/model/satisfiable.d.ts +25 -0
- package/dist/testing/model/satisfiable.d.ts.map +1 -0
- package/dist/testing/model/satisfiable.js +101 -0
- package/dist/testing/model/satisfiable.js.map +1 -0
- package/dist/traverse/nav-session.d.ts +108 -0
- package/dist/traverse/nav-session.d.ts.map +1 -0
- package/dist/traverse/nav-session.js +637 -0
- package/dist/traverse/nav-session.js.map +1 -0
- package/dist/traverse/session.d.ts +239 -0
- package/dist/traverse/session.d.ts.map +1 -0
- package/dist/traverse/session.js +1305 -0
- package/dist/traverse/session.js.map +1 -0
- package/dist/tree/appmap.d.ts +8 -0
- package/dist/tree/appmap.d.ts.map +1 -0
- package/dist/tree/appmap.js +259 -0
- package/dist/tree/appmap.js.map +1 -0
- package/dist/tree/types.d.ts +151 -0
- package/dist/tree/types.d.ts.map +1 -0
- package/dist/tree/types.js +2 -0
- package/dist/tree/types.js.map +1 -0
- package/package.json +90 -0
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session — the traverse() driver: available / fire / sync.
|
|
3
|
+
*
|
|
4
|
+
* footprint's executor drives a chart to completion and its decide() picks one
|
|
5
|
+
* branch. A live UI is inverted: the user/agent drives edge-by-edge, and from
|
|
6
|
+
* any node the driver exposes all guard-passing edges and waits for the world
|
|
7
|
+
* to pick one. So this driver never touches footprint's engine. It reuses the
|
|
8
|
+
* transactional memory/commit/trace stack instead:
|
|
9
|
+
*
|
|
10
|
+
* one fired-or-stimulus transition
|
|
11
|
+
* → one fresh StageContext (runId '' = root namespace)
|
|
12
|
+
* → tracked reads (guard keys) + tracked writes (the settled delta)
|
|
13
|
+
* → commit() → one CommitBundle in the EventLog
|
|
14
|
+
*
|
|
15
|
+
* which makes footprint's whole post-hoc toolchain (causalChain, sliceForKey,
|
|
16
|
+
* arrayProvenance, commitValueAt) work on UI sessions unchanged. Guard
|
|
17
|
+
* evaluation itself is footprint's pure `evaluateFilter` — no scope, no
|
|
18
|
+
* commit, evidence-emitting, worker-safe.
|
|
19
|
+
*
|
|
20
|
+
* Longevity rules honored (from the footprint execution-model adjudication):
|
|
21
|
+
* fresh context per transition (never createNext — it retains every context),
|
|
22
|
+
* runId stays '' (non-empty runIds namespace writes and break slice matching),
|
|
23
|
+
* runtimeStageId uniqueness via a monotonic counter.
|
|
24
|
+
*/
|
|
25
|
+
import { EventLog, ScopeFacade, SharedMemory, StageContext, buildRuntimeStageId, createExecutionCounter, evaluateFilter, } from 'footprintjs/advanced';
|
|
26
|
+
import { formatSlice, keysReadFromMap, sliceForKey } from 'footprintjs/trace';
|
|
27
|
+
import { edgesToMCPTools, leaveSkillTool } from '../serve/mcp.js';
|
|
28
|
+
import { stepDependencies } from '../graph/skill-deps.js';
|
|
29
|
+
import { ToolRegistry } from '../registry/registry.js';
|
|
30
|
+
export class Session {
|
|
31
|
+
#spec;
|
|
32
|
+
#node;
|
|
33
|
+
#version = 0;
|
|
34
|
+
/** Committed state deltas only (settle / stimulus / inference). */
|
|
35
|
+
#stateVersion = 0;
|
|
36
|
+
/** Served-structure changes only (frames + coalesced registration/presence swaps). */
|
|
37
|
+
#structureVersion = 0;
|
|
38
|
+
/** Whether updateState() reports are expected (see SessionOptions.stateTap). */
|
|
39
|
+
#stateTap;
|
|
40
|
+
/** Whether handler return values are captured onto records (act → data back). */
|
|
41
|
+
#captureProduced;
|
|
42
|
+
/** Fingerprint of the served structure at the last coalesced flush. */
|
|
43
|
+
#structureFingerprint = '';
|
|
44
|
+
#structureFlushScheduled = false;
|
|
45
|
+
#heap;
|
|
46
|
+
#log;
|
|
47
|
+
#counter;
|
|
48
|
+
#redacted;
|
|
49
|
+
#commitValues;
|
|
50
|
+
#transitions = [];
|
|
51
|
+
#pending = [];
|
|
52
|
+
/** runtimeStageId → keys tracked-read, collected live via the scope channel. */
|
|
53
|
+
#readsByStep = new Map();
|
|
54
|
+
#recorder;
|
|
55
|
+
/** The one open skill frame (v0: one at a time). */
|
|
56
|
+
#frame = null;
|
|
57
|
+
/**
|
|
58
|
+
* Record id whose handler is executing its SYNCHRONOUS portion right now.
|
|
59
|
+
* updateState() called from inside that portion attributes directly to this
|
|
60
|
+
* record (like transitionId targeting) — the fix for the burst-fire race
|
|
61
|
+
* where another handler's report would FIFO-steal an earlier record.
|
|
62
|
+
*/
|
|
63
|
+
#invokingRecordId = null;
|
|
64
|
+
/** Closed frames (completed / cancelled / demoted), oldest first. */
|
|
65
|
+
#frames = [];
|
|
66
|
+
#registry;
|
|
67
|
+
#warn;
|
|
68
|
+
/** Unmet demand: rejected fires + explicitly reported unserved asks. */
|
|
69
|
+
#gaps = [];
|
|
70
|
+
/** Passive observer listeners, by event name (the recorder category, session grain). */
|
|
71
|
+
#listeners = new Map();
|
|
72
|
+
/** Monotonic counter for generated tool-group ids (never caller-supplied). */
|
|
73
|
+
#groupSeq = 0;
|
|
74
|
+
constructor(spec, opts) {
|
|
75
|
+
if (!spec.pages[opts.node]) {
|
|
76
|
+
throw new Error(`hcifootprint: unknown starting node '${opts.node}'. Known pages: ${Object.keys(spec.pages).join(', ')}.`);
|
|
77
|
+
}
|
|
78
|
+
this.#spec = spec;
|
|
79
|
+
this.#node = opts.node;
|
|
80
|
+
this.#stateTap = opts.stateTap ?? opts.state !== undefined;
|
|
81
|
+
this.#captureProduced = opts.captureProduced ?? true;
|
|
82
|
+
const initial = structuredClone(opts.state ?? {});
|
|
83
|
+
this.#log = new EventLog(initial);
|
|
84
|
+
this.#heap = new SharedMemory(undefined, initial);
|
|
85
|
+
this.#counter = createExecutionCounter();
|
|
86
|
+
this.#redacted = new Set(opts.redactedKeys ?? []);
|
|
87
|
+
this.#commitValues = opts.commitValues ?? 'delta';
|
|
88
|
+
this.#warn = opts.onWarn ?? ((message) => console.warn(message));
|
|
89
|
+
this.#registry = new ToolRegistry(this.#warn);
|
|
90
|
+
this.#recorder = {
|
|
91
|
+
id: 'hcifootprint-session',
|
|
92
|
+
onRead: (event) => {
|
|
93
|
+
if (!event.key || !event.runtimeStageId)
|
|
94
|
+
return;
|
|
95
|
+
const reads = this.#readsByStep.get(event.runtimeStageId) ?? [];
|
|
96
|
+
reads.push(event.key);
|
|
97
|
+
this.#readsByStep.set(event.runtimeStageId, reads);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
get node() {
|
|
102
|
+
return this.#node;
|
|
103
|
+
}
|
|
104
|
+
/** The compiled graph's id (namespaces MCP tool names). */
|
|
105
|
+
get graphId() {
|
|
106
|
+
return this.spec.id;
|
|
107
|
+
}
|
|
108
|
+
/** The one CAS/sinceVersion cursor: total order over ALL world motion. */
|
|
109
|
+
get version() {
|
|
110
|
+
return this.#version;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* D18 version split — `version` stays the single total-order cursor; these
|
|
114
|
+
* two say WHAT moved. A scrolling list must never staleness-fail a plan the
|
|
115
|
+
* way a closing modal must; consumers watching for re-render/replan can
|
|
116
|
+
* subscribe to the axis they care about.
|
|
117
|
+
*/
|
|
118
|
+
get stateVersion() {
|
|
119
|
+
return this.#stateVersion;
|
|
120
|
+
}
|
|
121
|
+
get structureVersion() {
|
|
122
|
+
return this.#structureVersion;
|
|
123
|
+
}
|
|
124
|
+
/** The compiled spec every lookup goes through — NavSession overlays mount-declared tools here. */
|
|
125
|
+
get spec() {
|
|
126
|
+
return this.#spec;
|
|
127
|
+
}
|
|
128
|
+
/** The live-binding registry (protected seam for NavSession's per-instance handlers). */
|
|
129
|
+
get registry() {
|
|
130
|
+
return this.#registry;
|
|
131
|
+
}
|
|
132
|
+
/** The session's dev-warning sink (protected seam for subclass layers). */
|
|
133
|
+
warn(message) {
|
|
134
|
+
this.#warn(message);
|
|
135
|
+
}
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
// Events — a PASSIVE observer surface (recorder category, session grain).
|
|
138
|
+
// Listeners never change what the session does; a throwing listener is
|
|
139
|
+
// isolated (caught + warned), never aborting the session.
|
|
140
|
+
// -------------------------------------------------------------------------
|
|
141
|
+
/** Subscribe to a session event. Returns an unsubscribe function. */
|
|
142
|
+
on(event, listener) {
|
|
143
|
+
const set = this.#listeners.get(event) ?? new Set();
|
|
144
|
+
set.add(listener);
|
|
145
|
+
this.#listeners.set(event, set);
|
|
146
|
+
return () => {
|
|
147
|
+
this.#listeners.get(event)?.delete(listener);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
#emit(event, payload) {
|
|
151
|
+
const set = this.#listeners.get(event);
|
|
152
|
+
if (!set)
|
|
153
|
+
return;
|
|
154
|
+
for (const listener of set) {
|
|
155
|
+
try {
|
|
156
|
+
listener(payload);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
// Observer rule (inherited from footprintjs recorders): a listener
|
|
160
|
+
// error never aborts the session.
|
|
161
|
+
this.#warn(`hcifootprint: '${event}' listener threw: ${String(error)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Copy a live record so a 'transition' listener (or a transitions() caller)
|
|
167
|
+
* can never mutate the log — INCLUDING the object-valued data channels
|
|
168
|
+
* (payload, produced), which are cloned defensively (fall back to the ref if
|
|
169
|
+
* a payload is not structured-cloneable).
|
|
170
|
+
*/
|
|
171
|
+
#copyRecord(t) {
|
|
172
|
+
return {
|
|
173
|
+
...t,
|
|
174
|
+
cause: { ...t.cause },
|
|
175
|
+
evidence: t.evidence ? t.evidence.map((c) => ({ ...c })) : undefined,
|
|
176
|
+
...(t.guardUnevaluated ? { guardUnevaluated: [...t.guardUnevaluated] } : {}),
|
|
177
|
+
...(t.payload !== undefined ? { payload: cloneSafe(t.payload) } : {}),
|
|
178
|
+
...(t.produced !== undefined ? { produced: cloneSafe(t.produced) } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
#emitTransition(record) {
|
|
182
|
+
this.#emit('transition', this.#copyRecord(record));
|
|
183
|
+
}
|
|
184
|
+
/** Increment the state axis and notify observers. (`+= 1` so global bump-replaces skip this.) */
|
|
185
|
+
#bumpState() {
|
|
186
|
+
this.#stateVersion += 1;
|
|
187
|
+
this.#emit('state', { version: this.#version, stateVersion: this.#stateVersion });
|
|
188
|
+
}
|
|
189
|
+
/** Increment the structure axis and notify observers. */
|
|
190
|
+
#bumpStructure() {
|
|
191
|
+
this.#structureVersion += 1;
|
|
192
|
+
this.#emit('structure', { version: this.#version, structureVersion: this.#structureVersion });
|
|
193
|
+
}
|
|
194
|
+
/** Generate an opaque group identity (never caller-supplied — see ToolGroup). */
|
|
195
|
+
nextGroupId(prefix = 'group') {
|
|
196
|
+
return `${prefix}#${(this.#groupSeq += 1)}`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Flip a registered tool between clickable and greyed-out (used by the
|
|
200
|
+
* ToolGroup handle). A real change is world motion — it bumps the structure
|
|
201
|
+
* axis so a stale plan is caught and the surface re-serves.
|
|
202
|
+
*/
|
|
203
|
+
setToolEnabled(affordanceId, enabled) {
|
|
204
|
+
if (this.#registry.setEnabled(affordanceId, enabled))
|
|
205
|
+
this.noteStructureChange();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Whether firing this tool should be refused as TOOL_DISABLED. Protected seam
|
|
209
|
+
* so InteractionSession can consult the INSTANCE-keyed registration first —
|
|
210
|
+
* a per-row disabled button ('id[instance]') must block, not just the base id.
|
|
211
|
+
*/
|
|
212
|
+
isToolDisabled(affordanceId, _opts) {
|
|
213
|
+
return this.#registry.isEnabled(affordanceId) === false;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Re-baseline the coalesced structure fingerprint. A subclass whose
|
|
217
|
+
* structureFingerprint() override reads its OWN fields must call this once
|
|
218
|
+
* at the end of its constructor (the base constructor cannot: a virtual
|
|
219
|
+
* call there would touch subclass fields before they initialize).
|
|
220
|
+
*/
|
|
221
|
+
resetStructureBaseline() {
|
|
222
|
+
this.#structureFingerprint = this.structureFingerprint();
|
|
223
|
+
}
|
|
224
|
+
/** Detached snapshot of the projected state (live state is immutable-after-swap; never hand out references). */
|
|
225
|
+
state() {
|
|
226
|
+
return structuredClone(this.#stateView());
|
|
227
|
+
}
|
|
228
|
+
// -------------------------------------------------------------------------
|
|
229
|
+
// available — the LLM's action space
|
|
230
|
+
// -------------------------------------------------------------------------
|
|
231
|
+
available() {
|
|
232
|
+
const flagMaterialized = this.#registry.hasAny();
|
|
233
|
+
const edges = [];
|
|
234
|
+
for (const aff of Object.values(this.spec.affordances)) {
|
|
235
|
+
if (!aff.on.includes(this.#node))
|
|
236
|
+
continue;
|
|
237
|
+
const { matched, conditions, unevaluable } = this.#evalGuard(aff.guard);
|
|
238
|
+
if (!matched)
|
|
239
|
+
continue;
|
|
240
|
+
edges.push({
|
|
241
|
+
affordanceId: aff.id,
|
|
242
|
+
description: aff.description,
|
|
243
|
+
role: aff.role,
|
|
244
|
+
...(flagMaterialized ? { materialized: this.#registry.isRegistered(aff.id) } : {}),
|
|
245
|
+
// A registered-but-disabled tool is served WITH the marker (a greyed
|
|
246
|
+
// button the agent can see), never silently hidden.
|
|
247
|
+
...(this.#registry.isEnabled(aff.id) === false ? { enabled: false } : {}),
|
|
248
|
+
evidence: conditions,
|
|
249
|
+
...(unevaluable.length > 0 ? { guardUnevaluated: unevaluable } : {}),
|
|
250
|
+
schema: aff.schema,
|
|
251
|
+
highEffect: aff.highEffect,
|
|
252
|
+
binding: aff.binding,
|
|
253
|
+
...(aff.descriptionSource === 'registration' ? { descriptionSource: 'registration' } : {}),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return { version: this.#version, node: this.#node, edges };
|
|
257
|
+
}
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
// registerTools — the live-binding wire (declare statically, bind dynamically)
|
|
260
|
+
// -------------------------------------------------------------------------
|
|
261
|
+
/**
|
|
262
|
+
* Build a ToolGroup handle for a generated group id. Protected: the PUBLIC
|
|
263
|
+
* registration entry points (registerToolGroup / registerTool) live on
|
|
264
|
+
* InteractionSession (the tree API — they take a node path). The flat/legacy
|
|
265
|
+
* graph registers via {@link registerTools}. `setEnabled` may be overridden
|
|
266
|
+
* for instance-aware key mapping.
|
|
267
|
+
*/
|
|
268
|
+
makeToolGroup(group, node, setEnabled) {
|
|
269
|
+
return {
|
|
270
|
+
id: group,
|
|
271
|
+
...(node !== undefined ? { node } : {}),
|
|
272
|
+
setEnabled: setEnabled ?? ((toolId, enabled) => this.setToolEnabled(toolId, enabled)),
|
|
273
|
+
unregister: () => this.unregisterGroup(group),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Register handlers on the FLAT graph (skillGraph — no node tree). Takes a
|
|
278
|
+
* caller `group` string; the tree API (InteractionSession.registerToolGroup)
|
|
279
|
+
* is preferred where you have a node path — it returns a handle so you never
|
|
280
|
+
* invent a group name.
|
|
281
|
+
*/
|
|
282
|
+
registerTools(opts) {
|
|
283
|
+
const unknown = Object.keys(opts.tools).filter((id) => !this.spec.affordances[id]);
|
|
284
|
+
if (unknown.length > 0) {
|
|
285
|
+
throw new Error(`hcifootprint: registerTools group '${opts.group}' includes undeclared affordance(s) ` +
|
|
286
|
+
`${unknown.map((u) => `'${u}'`).join(', ')} — declare them in the skill graph first ` +
|
|
287
|
+
`(known: ${Object.keys(this.spec.affordances).join(', ')}).`);
|
|
288
|
+
}
|
|
289
|
+
const triggers = {};
|
|
290
|
+
for (const [affordanceId, handler] of Object.entries(opts.tools)) {
|
|
291
|
+
this.#registry.register(opts.group, affordanceId, handler);
|
|
292
|
+
triggers[affordanceId] = (payload) => this.fire(affordanceId, { source: 'user', payload });
|
|
293
|
+
}
|
|
294
|
+
this.noteStructureChange();
|
|
295
|
+
return { triggers, unregister: () => this.unregisterGroup(opts.group) };
|
|
296
|
+
}
|
|
297
|
+
/** Remove every live binding currently owned by `group` (component unmount). */
|
|
298
|
+
unregisterGroup(group) {
|
|
299
|
+
const removed = this.#registry.unregisterGroup(group);
|
|
300
|
+
if (removed.length > 0)
|
|
301
|
+
this.noteStructureChange();
|
|
302
|
+
return removed;
|
|
303
|
+
}
|
|
304
|
+
/** Why an affordance is (or is not) available right now — per-condition evidence. */
|
|
305
|
+
explain(affordanceId) {
|
|
306
|
+
const aff = this.spec.affordances[affordanceId];
|
|
307
|
+
if (!aff) {
|
|
308
|
+
throw new Error(`hcifootprint: unknown affordance '${affordanceId}'. Known: ${Object.keys(this.spec.affordances).join(', ')}.`);
|
|
309
|
+
}
|
|
310
|
+
const offeredOnThisNode = aff.on.includes(this.#node);
|
|
311
|
+
const { matched, conditions, unevaluable } = this.#evalGuard(aff.guard);
|
|
312
|
+
return {
|
|
313
|
+
affordanceId,
|
|
314
|
+
node: this.#node,
|
|
315
|
+
offeredOnThisNode,
|
|
316
|
+
guardPassed: matched,
|
|
317
|
+
available: offeredOnThisNode && matched,
|
|
318
|
+
evidence: conditions,
|
|
319
|
+
...(unevaluable.length > 0 ? { guardUnevaluated: unevaluable } : {}),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/** Skill-level disclosure for the planning LLM (descriptions + feasibility, no tool detail). */
|
|
323
|
+
availableSkills() {
|
|
324
|
+
const skills = [];
|
|
325
|
+
for (const skill of Object.values(this.spec.skills)) {
|
|
326
|
+
const pre = this.#evalGuard(skill.precondition);
|
|
327
|
+
const entry = this.spec.affordances[skill.steps[0]];
|
|
328
|
+
const entryGuard = this.#evalGuard(entry.guard);
|
|
329
|
+
skills.push({
|
|
330
|
+
id: skill.id,
|
|
331
|
+
description: skill.description,
|
|
332
|
+
steps: [...skill.steps],
|
|
333
|
+
preconditionPassed: pre.matched,
|
|
334
|
+
evidence: pre.conditions,
|
|
335
|
+
...(pre.unevaluable.length > 0 ? { preconditionUnevaluable: pre.unevaluable } : {}),
|
|
336
|
+
entryAvailable: entry.on.includes(this.#node) && entryGuard.matched,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return { version: this.#version, node: this.#node, skills };
|
|
340
|
+
}
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
// Skill frames — on-demand disclosure: serve skills, expand tools on commit
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
/**
|
|
345
|
+
* Commit to a skill: opens a frame so toMCPTools()/contextBrief() serve ONLY
|
|
346
|
+
* that skill's currently-fireable steps plus escape tools — the token win
|
|
347
|
+
* (skills for planning, tools on commit). One frame at a time in v0.
|
|
348
|
+
*/
|
|
349
|
+
commitSkill(skillId, opts) {
|
|
350
|
+
const skill = this.spec.skills[skillId];
|
|
351
|
+
if (!skill) {
|
|
352
|
+
return { ok: false, reason: 'UNKNOWN_SKILL', known: Object.keys(this.spec.skills) };
|
|
353
|
+
}
|
|
354
|
+
if (opts?.expectedVersion !== undefined && opts.expectedVersion !== this.#version) {
|
|
355
|
+
return { ok: false, reason: 'STALE_CURSOR', version: this.#version };
|
|
356
|
+
}
|
|
357
|
+
if (this.#frame) {
|
|
358
|
+
return { ok: false, reason: 'FRAME_ALREADY_OPEN', skillId: this.#frame.skillId };
|
|
359
|
+
}
|
|
360
|
+
const pre = this.#evalGuard(skill.precondition);
|
|
361
|
+
if (!pre.matched) {
|
|
362
|
+
return { ok: false, reason: 'PRECONDITION_FAILED', evidence: pre.conditions };
|
|
363
|
+
}
|
|
364
|
+
this.#frame = {
|
|
365
|
+
skillId,
|
|
366
|
+
status: 'open',
|
|
367
|
+
principal: opts?.source ?? 'agent',
|
|
368
|
+
openedAt: Date.now(),
|
|
369
|
+
openedAtVersion: this.#version,
|
|
370
|
+
firedSteps: [],
|
|
371
|
+
inferredSteps: [],
|
|
372
|
+
};
|
|
373
|
+
this.#version++; // the served action space just changed
|
|
374
|
+
this.#bumpStructure();
|
|
375
|
+
return { ok: true, frame: this.#frameCopy(), plan: this.skillPlan(skillId), version: this.#version };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Close the open frame. Default reason: 'completed' if every step was
|
|
379
|
+
* committed while the frame was open, else 'cancelled'. Returns the closed
|
|
380
|
+
* frame, or null when none was open.
|
|
381
|
+
*/
|
|
382
|
+
leaveSkill(opts) {
|
|
383
|
+
if (!this.#frame)
|
|
384
|
+
return null;
|
|
385
|
+
const skill = this.spec.skills[this.#frame.skillId];
|
|
386
|
+
// Completion counts observed AND inferred steps; inferredSteps on the
|
|
387
|
+
// returned frame says which of them were guesses.
|
|
388
|
+
const allDone = skill.steps.every((step) => this.#frame.firedSteps.includes(step) || this.#frame.inferredSteps.includes(step));
|
|
389
|
+
this.#frame.status = opts?.reason ?? (allDone ? 'completed' : 'cancelled');
|
|
390
|
+
this.#frame.closedAtVersion = this.#version;
|
|
391
|
+
this.#frames.push(this.#frame);
|
|
392
|
+
const closed = this.#frameCopy(this.#frame);
|
|
393
|
+
this.#frame = null;
|
|
394
|
+
this.#version++; // back to skill-level disclosure
|
|
395
|
+
this.#bumpStructure();
|
|
396
|
+
return closed;
|
|
397
|
+
}
|
|
398
|
+
/** The open skill frame (snapshot), or null. */
|
|
399
|
+
skillFrame() {
|
|
400
|
+
return this.#frameCopy();
|
|
401
|
+
}
|
|
402
|
+
/** Frame history: every closed frame (completed / cancelled / demoted), oldest first. */
|
|
403
|
+
frames() {
|
|
404
|
+
return this.#frames.map((f) => this.#frameCopy(f));
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The DERIVED intra-skill dependency DAG with live status. Dependencies are
|
|
408
|
+
* computed, never authored: step B depends on step A when A's declared
|
|
409
|
+
* effect.writes overlap B's guard keys — the guard×effect atoms already
|
|
410
|
+
* encode the ordering, so it cannot drift from the graph.
|
|
411
|
+
*/
|
|
412
|
+
skillPlan(skillId) {
|
|
413
|
+
const skill = this.spec.skills[skillId];
|
|
414
|
+
if (!skill) {
|
|
415
|
+
throw new Error(`hcifootprint: unknown skill '${skillId}'. Known: ${Object.keys(this.spec.skills).join(', ')}.`);
|
|
416
|
+
}
|
|
417
|
+
const steps = skill.steps.map((stepId) => {
|
|
418
|
+
const aff = this.spec.affordances[stepId];
|
|
419
|
+
const dependsOn = stepDependencies(this.spec.affordances, skill.steps, stepId);
|
|
420
|
+
const { matched, conditions, unevaluable } = this.#evalGuard(aff.guard);
|
|
421
|
+
const frameForSkill = this.#frame?.skillId === skillId ? this.#frame : null;
|
|
422
|
+
const status = frameForSkill?.firedSteps.includes(stepId)
|
|
423
|
+
? 'done'
|
|
424
|
+
: frameForSkill?.inferredSteps.includes(stepId)
|
|
425
|
+
? 'inferred-done'
|
|
426
|
+
: !matched
|
|
427
|
+
? 'blocked'
|
|
428
|
+
: aff.on.includes(this.#node)
|
|
429
|
+
? 'ready'
|
|
430
|
+
: 'off-node';
|
|
431
|
+
return {
|
|
432
|
+
affordanceId: stepId,
|
|
433
|
+
description: aff.description,
|
|
434
|
+
status,
|
|
435
|
+
dependsOn,
|
|
436
|
+
onNodes: [...aff.on],
|
|
437
|
+
...(status === 'blocked' ? { blockedOn: conditions.filter((c) => !c.result) } : {}),
|
|
438
|
+
...(unevaluable.length > 0 ? { guardUnevaluated: unevaluable } : {}),
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
return { skillId, description: skill.description, steps };
|
|
442
|
+
}
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
// fire — apply a transition with provenance
|
|
445
|
+
// -------------------------------------------------------------------------
|
|
446
|
+
/** Returned transition records are LIVE views — settlement updates them in place. */
|
|
447
|
+
fire(affordanceId, opts) {
|
|
448
|
+
const aff = this.spec.affordances[affordanceId];
|
|
449
|
+
if (!aff) {
|
|
450
|
+
const available = this.available().edges.map((e) => e.affordanceId);
|
|
451
|
+
this.recordRejection(affordanceId, 'UNKNOWN_AFFORDANCE', opts.source, undefined, available);
|
|
452
|
+
return { ok: false, reason: 'UNKNOWN_AFFORDANCE', available };
|
|
453
|
+
}
|
|
454
|
+
if (opts.expectedVersion !== undefined && opts.expectedVersion !== this.#version) {
|
|
455
|
+
this.recordRejection(affordanceId, 'STALE_CURSOR', opts.source);
|
|
456
|
+
return { ok: false, reason: 'STALE_CURSOR', version: this.#version };
|
|
457
|
+
}
|
|
458
|
+
if (!aff.on.includes(this.#node)) {
|
|
459
|
+
this.recordRejection(affordanceId, 'NOT_ON_NODE', opts.source);
|
|
460
|
+
return { ok: false, reason: 'NOT_ON_NODE', node: this.#node };
|
|
461
|
+
}
|
|
462
|
+
// Guards are re-evaluated at fire time — plan-time guards are advisory.
|
|
463
|
+
const { matched, conditions, unevaluable } = this.#evalGuard(aff.guard);
|
|
464
|
+
if (!matched) {
|
|
465
|
+
this.recordRejection(affordanceId, 'GUARD_FAILED', opts.source, conditions);
|
|
466
|
+
return { ok: false, reason: 'GUARD_FAILED', evidence: conditions };
|
|
467
|
+
}
|
|
468
|
+
if (aff.schema !== undefined) {
|
|
469
|
+
const validation = validatePayload(aff.schema, opts.payload);
|
|
470
|
+
if (!validation.ok) {
|
|
471
|
+
this.recordRejection(affordanceId, 'PAYLOAD_INVALID', opts.source);
|
|
472
|
+
return { ok: false, reason: 'PAYLOAD_INVALID', issues: validation.issues };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// A greyed-out button: registered but not clickable. Only blocks EXECUTION
|
|
476
|
+
// fires (agent/user) — the record-only DOM sensor (invoke:false) still logs
|
|
477
|
+
// whatever actually happened. Retriable: the app may enable it next tick.
|
|
478
|
+
// Instance-aware via the protected seam (a disabled repeats-row button).
|
|
479
|
+
if (opts.invoke !== false && this.isToolDisabled(affordanceId, opts)) {
|
|
480
|
+
this.recordRejection(affordanceId, 'TOOL_DISABLED', opts.source);
|
|
481
|
+
return { ok: false, reason: 'TOOL_DISABLED', affordanceId };
|
|
482
|
+
}
|
|
483
|
+
const record = {
|
|
484
|
+
id: buildRuntimeStageId(affordanceId, this.#counter.value++),
|
|
485
|
+
cause: { kind: 'fired', affordanceId, principal: opts.source },
|
|
486
|
+
timestamp: Date.now(),
|
|
487
|
+
payload: opts.payload,
|
|
488
|
+
outcome: 'pending',
|
|
489
|
+
evidence: conditions,
|
|
490
|
+
// Unevaluated conditions are taken on faith (the app is the enforcer at
|
|
491
|
+
// L0/L1) — the record says so instead of pretending the guard passed.
|
|
492
|
+
...(unevaluable.length > 0 ? { guardUnevaluated: unevaluable } : {}),
|
|
493
|
+
fromNode: this.#node,
|
|
494
|
+
cursorVersion: this.#version,
|
|
495
|
+
};
|
|
496
|
+
this.#transitions.push(record);
|
|
497
|
+
this.#emitTransition(record);
|
|
498
|
+
this.#version++; // firing changes the world the next plan must see
|
|
499
|
+
const declaredWrites = aff.effect?.writes ?? [];
|
|
500
|
+
if (declaredWrites.length > 0 && this.#stateTap) {
|
|
501
|
+
// The app owns the real handler; the delta arrives via updateState().
|
|
502
|
+
this.#pending.push({ record, affordance: aff });
|
|
503
|
+
this.#invokeHandler(record, affordanceId, opts);
|
|
504
|
+
return { ok: true, transition: record, version: this.#version, settlement: 'awaiting-state' };
|
|
505
|
+
}
|
|
506
|
+
if (declaredWrites.length > 0) {
|
|
507
|
+
// No state tap: nothing will ever report a delta. A registered handler
|
|
508
|
+
// settles this record on ITS completion; with nothing to execute, settle
|
|
509
|
+
// now. Either way effectVerified is honestly 'unobservable'.
|
|
510
|
+
const willExecute = opts.invoke !== false && this.handlerFor(affordanceId, opts) !== undefined;
|
|
511
|
+
if (willExecute) {
|
|
512
|
+
this.#pending.push({ record, affordance: aff, settleOnCompletion: true });
|
|
513
|
+
this.#invokeHandler(record, affordanceId, opts);
|
|
514
|
+
return { ok: true, transition: record, version: this.#version, settlement: 'awaiting-state' };
|
|
515
|
+
}
|
|
516
|
+
this.#settle(record, aff, {}, { forceUnobservable: true });
|
|
517
|
+
this.#invokeHandler(record, affordanceId, opts);
|
|
518
|
+
return { ok: true, transition: record, version: this.#version, settlement: 'settled' };
|
|
519
|
+
}
|
|
520
|
+
this.#settle(record, aff, {});
|
|
521
|
+
this.#invokeHandler(record, affordanceId, opts);
|
|
522
|
+
return { ok: true, transition: record, version: this.#version, settlement: 'settled' };
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* D13: fire() executes when a live binding exists. Fire-and-forget — the
|
|
526
|
+
* app's state tap reports the real delta as usual; a throwing handler
|
|
527
|
+
* auto-rejects its still-pending transition (or rolls back an
|
|
528
|
+
* already-committed immediate settle) instead of leaving a lie in the log.
|
|
529
|
+
*
|
|
530
|
+
* Attribution safety: while the handler's synchronous portion runs,
|
|
531
|
+
* updateState() attributes to THIS record directly; while the handler is in
|
|
532
|
+
* flight (or failed), bare-FIFO skips this record so a neighbor's report
|
|
533
|
+
* can never steal it.
|
|
534
|
+
*/
|
|
535
|
+
#invokeHandler(record, affordanceId, opts) {
|
|
536
|
+
if (opts.invoke === false)
|
|
537
|
+
return; // record-only (the DOM sensor's mode)
|
|
538
|
+
const handler = this.handlerFor(affordanceId, opts);
|
|
539
|
+
if (!handler)
|
|
540
|
+
return;
|
|
541
|
+
const pendingEntry = this.#pending.find((p) => p.record.id === record.id);
|
|
542
|
+
if (pendingEntry)
|
|
543
|
+
pendingEntry.handlerInFlight = true;
|
|
544
|
+
void Promise.resolve()
|
|
545
|
+
.then(() => {
|
|
546
|
+
this.#invokingRecordId = record.id;
|
|
547
|
+
try {
|
|
548
|
+
return handler(opts.payload);
|
|
549
|
+
}
|
|
550
|
+
finally {
|
|
551
|
+
this.#invokingRecordId = null;
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
.then((returnValue) => {
|
|
555
|
+
// Act → get data back: whatever the handler returned (search results, a
|
|
556
|
+
// looked-up record) rides the DATA channel on the record — sanitized +
|
|
557
|
+
// capped so untrusted content can never become planner instructions.
|
|
558
|
+
if (this.#captureProduced && returnValue !== undefined && returnValue !== null) {
|
|
559
|
+
record.produced = sanitizeProduced(returnValue);
|
|
560
|
+
}
|
|
561
|
+
const entry = this.#pending.find((p) => p.record.id === record.id);
|
|
562
|
+
if (!entry)
|
|
563
|
+
return;
|
|
564
|
+
if (entry.settleOnCompletion) {
|
|
565
|
+
// Tapless session: the handler finishing IS the settlement signal.
|
|
566
|
+
this.#pending.splice(this.#pending.indexOf(entry), 1);
|
|
567
|
+
this.#settle(entry.record, entry.affordance, {}, { forceUnobservable: true });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
entry.handlerInFlight = false; // async app: the tap's later report may FIFO-settle it
|
|
571
|
+
})
|
|
572
|
+
.catch((error) => {
|
|
573
|
+
const index = this.#pending.findIndex((p) => p.record.id === record.id);
|
|
574
|
+
if (index >= 0) {
|
|
575
|
+
// Effect never landed: reject the pending so later deltas are not mis-attributed.
|
|
576
|
+
this.#pending.splice(index, 1);
|
|
577
|
+
record.outcome = 'rejected';
|
|
578
|
+
this.#version++;
|
|
579
|
+
this.#emitTransition(record); // observers see the settled (rejected) occurrence
|
|
580
|
+
}
|
|
581
|
+
else if (record.outcome === 'committed' && record.effectVerified === 'unobservable') {
|
|
582
|
+
// Immediate/tapless settle committed BEFORE the handler ran and the
|
|
583
|
+
// handler failed: the commit was a claim about an action that never
|
|
584
|
+
// happened. Roll it back and, if the settle moved the cursor on the
|
|
585
|
+
// navigation CLAIM, walk the cursor back honestly. A commit backed by
|
|
586
|
+
// REAL evidence (a state report settled it, effectVerified true) is
|
|
587
|
+
// stronger than the handler's failure — that one stands.
|
|
588
|
+
record.outcome = 'rolled-back';
|
|
589
|
+
this.#version++;
|
|
590
|
+
this.#emitTransition(record); // observers see the rolled-back occurrence
|
|
591
|
+
if (record.toNodeClaimed && record.toNode === this.#node && record.fromNode !== this.#node) {
|
|
592
|
+
this.sync(record.fromNode, { stimulus: 'navigation', principal: 'system' });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
this.#warn(`hcifootprint: handler for '${affordanceId}' threw: ${String(error)}`);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
// -------------------------------------------------------------------------
|
|
599
|
+
// updateState — the app reports reality; pending transitions settle
|
|
600
|
+
// -------------------------------------------------------------------------
|
|
601
|
+
/**
|
|
602
|
+
* Report a projected-state delta from the app (router/store tap).
|
|
603
|
+
*
|
|
604
|
+
* Attribution, in priority order:
|
|
605
|
+
* 1. `opts.transitionId` — settles that pending transition precisely (preferred).
|
|
606
|
+
* 2. `opts.stimulus`/`opts.principal` set — recorded as a stimulus transition,
|
|
607
|
+
* NEVER attributed to a pending fire (explicit attribution wins; a server
|
|
608
|
+
* push must not hijack a pending action's provenance).
|
|
609
|
+
* 3. Otherwise: FIFO to the oldest pending fired transition. With several
|
|
610
|
+
* pendings and out-of-order app handlers this can mis-attribute — pass
|
|
611
|
+
* transitionId from your tap when you can; effectVerified=false is the
|
|
612
|
+
* designed detector for key mismatches.
|
|
613
|
+
* 4. No pendings, no hints: recorded as a `stimulus:'unknown'` transition —
|
|
614
|
+
* state never moves silently.
|
|
615
|
+
*
|
|
616
|
+
* Undefined-valued entries are dropped from the report before anything else
|
|
617
|
+
* (uniformly — new and existing keys): a report cannot store undefined, and
|
|
618
|
+
* a declared write reported as undefined counts as missing
|
|
619
|
+
* (`effectVerified: false`).
|
|
620
|
+
*/
|
|
621
|
+
updateState(delta, opts) {
|
|
622
|
+
// Uniform undefined semantics: entries whose value is undefined are
|
|
623
|
+
// DROPPED from the report — new and existing keys alike, on every
|
|
624
|
+
// attribution path. Before this rule, a NEW key with undefined was
|
|
625
|
+
// dropped while an EXISTING key STORED undefined — and a stored
|
|
626
|
+
// undefined slips through value guards (`ne ''` matches undefined),
|
|
627
|
+
// which let a wrong-payload handler put a null item in a cart while
|
|
628
|
+
// the "item selected" guard passed. A JSON tap cannot even express
|
|
629
|
+
// undefined; from in-process handlers it is always an accident.
|
|
630
|
+
// Consequence: a declared write reported as undefined is a MISSING
|
|
631
|
+
// write — effectVerified flips false, the designed drift detector.
|
|
632
|
+
delta = Object.fromEntries(Object.entries(delta).filter(([, value]) => value !== undefined));
|
|
633
|
+
// Validate BEFORE consuming a pending: a non-cloneable value (function, DOM
|
|
634
|
+
// node) must reject loudly without destroying the attribution queue.
|
|
635
|
+
try {
|
|
636
|
+
structuredClone(delta);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
return { ok: false, reason: 'UNCLONEABLE_DELTA', issues: String(error) };
|
|
640
|
+
}
|
|
641
|
+
if (opts?.transitionId !== undefined) {
|
|
642
|
+
const index = this.#pending.findIndex((p) => p.record.id === opts.transitionId);
|
|
643
|
+
if (index < 0) {
|
|
644
|
+
return {
|
|
645
|
+
ok: false,
|
|
646
|
+
reason: 'UNKNOWN_TRANSITION',
|
|
647
|
+
pending: this.#pending.map((p) => p.record.id),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
const [pending] = this.#pending.splice(index, 1);
|
|
651
|
+
this.#settle(pending.record, pending.affordance, delta);
|
|
652
|
+
return { ok: true, attributed: true, transition: pending.record, version: this.#version };
|
|
653
|
+
}
|
|
654
|
+
const explicitStimulus = opts?.stimulus !== undefined || opts?.principal !== undefined;
|
|
655
|
+
// A handler reporting synchronously from inside its own invocation settles
|
|
656
|
+
// its OWN record — precise attribution, immune to the burst-fire race.
|
|
657
|
+
if (!explicitStimulus && this.#invokingRecordId !== null) {
|
|
658
|
+
const index = this.#pending.findIndex((p) => p.record.id === this.#invokingRecordId);
|
|
659
|
+
if (index >= 0) {
|
|
660
|
+
const [pending] = this.#pending.splice(index, 1);
|
|
661
|
+
this.#settle(pending.record, pending.affordance, delta);
|
|
662
|
+
return { ok: true, attributed: true, transition: pending.record, version: this.#version };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (!explicitStimulus && this.#pending.length > 0) {
|
|
666
|
+
// Bare FIFO skips records whose handler is still in flight — the handler
|
|
667
|
+
// has first claim on its own record (see #invokeHandler).
|
|
668
|
+
const index = this.#pending.findIndex((p) => !p.handlerInFlight);
|
|
669
|
+
if (index >= 0) {
|
|
670
|
+
const pending = this.#pending[index];
|
|
671
|
+
this.#settle(pending.record, pending.affordance, delta);
|
|
672
|
+
this.#pending.splice(index, 1);
|
|
673
|
+
return { ok: true, attributed: true, transition: pending.record, version: this.#version };
|
|
674
|
+
}
|
|
675
|
+
// Every pending is handler-in-flight. If the delta covers exactly ONE
|
|
676
|
+
// in-flight pending's declared writes, it is that handler's own report
|
|
677
|
+
// (arriving from its async portion, past the #invokingRecordId window) —
|
|
678
|
+
// settle THAT record precisely instead of stranding it forever.
|
|
679
|
+
const deltaKeys = Object.keys(delta);
|
|
680
|
+
const own = this.#pending.filter((p) => {
|
|
681
|
+
const writes = p.affordance.effect?.writes ?? [];
|
|
682
|
+
return writes.length > 0 && writes.every((key) => deltaKeys.includes(key));
|
|
683
|
+
});
|
|
684
|
+
if (own.length === 1) {
|
|
685
|
+
const pending = own[0];
|
|
686
|
+
this.#settle(pending.record, pending.affordance, delta);
|
|
687
|
+
this.#pending.splice(this.#pending.indexOf(pending), 1);
|
|
688
|
+
return { ok: true, attributed: true, transition: pending.record, version: this.#version };
|
|
689
|
+
}
|
|
690
|
+
// Ambiguous or non-matching: fall through to stimulus (never inference —
|
|
691
|
+
// guessing while fires are in flight fabricates duplicates).
|
|
692
|
+
}
|
|
693
|
+
// Tier-2 effect-signature inference — only with NO hints and NO pendings.
|
|
694
|
+
// The no-pendings condition is load-bearing: with an async handler still in
|
|
695
|
+
// flight, ITS own report would otherwise match its affordance's signature
|
|
696
|
+
// and fabricate a duplicate inferred transition while the real pending
|
|
697
|
+
// starves. In-flight world = wait for the pending machinery; guessing is
|
|
698
|
+
// for quiet moments only.
|
|
699
|
+
if (!explicitStimulus && this.#pending.length === 0) {
|
|
700
|
+
const inferred = this.#inferAffordanceForDelta(Object.keys(delta));
|
|
701
|
+
if (inferred) {
|
|
702
|
+
const guardEval = this.#evalGuard(inferred.guard);
|
|
703
|
+
const record = {
|
|
704
|
+
id: buildRuntimeStageId(inferred.id, this.#counter.value++),
|
|
705
|
+
cause: { kind: 'fired', affordanceId: inferred.id, principal: 'unknown', inferred: true },
|
|
706
|
+
timestamp: Date.now(),
|
|
707
|
+
outcome: 'pending',
|
|
708
|
+
evidence: guardEval.conditions,
|
|
709
|
+
...(guardEval.unevaluable.length > 0 ? { guardUnevaluated: guardEval.unevaluable } : {}),
|
|
710
|
+
fromNode: this.#node,
|
|
711
|
+
cursorVersion: this.#version,
|
|
712
|
+
};
|
|
713
|
+
this.#commitDelta(inferred.id, record.id, Object.keys(inferred.guard ?? {}), delta);
|
|
714
|
+
record.outcome = 'committed';
|
|
715
|
+
record.toNode = this.#node; // inference never moves the cursor — that would be guessing twice
|
|
716
|
+
record.effectVerified = true; // writes ⊆ delta by construction of the match
|
|
717
|
+
this.#transitions.push(record);
|
|
718
|
+
this.#emitTransition(record);
|
|
719
|
+
this.#version++;
|
|
720
|
+
this.#bumpState();
|
|
721
|
+
// A guessed completion never advances firedSteps, but it must be VISIBLE
|
|
722
|
+
// to the plan — 'inferred-done' — or the agent blind-refires the step.
|
|
723
|
+
if (this.#frame &&
|
|
724
|
+
this.spec.skills[this.#frame.skillId].steps.includes(inferred.id) &&
|
|
725
|
+
!this.#frame.firedSteps.includes(inferred.id) &&
|
|
726
|
+
!this.#frame.inferredSteps.includes(inferred.id)) {
|
|
727
|
+
this.#frame.inferredSteps.push(inferred.id);
|
|
728
|
+
}
|
|
729
|
+
this.#checkFrameAfterWorldChange();
|
|
730
|
+
return { ok: true, attributed: false, transition: record, version: this.#version };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const stimulus = opts?.stimulus ?? 'unknown';
|
|
734
|
+
const record = {
|
|
735
|
+
id: buildRuntimeStageId(`stimulus:${stimulus}`, this.#counter.value++),
|
|
736
|
+
cause: { kind: 'stimulus', stimulus, principal: opts?.principal ?? 'system' },
|
|
737
|
+
timestamp: Date.now(),
|
|
738
|
+
outcome: 'pending',
|
|
739
|
+
fromNode: this.#node,
|
|
740
|
+
cursorVersion: this.#version,
|
|
741
|
+
};
|
|
742
|
+
// No tracked reads: the causal layer will honestly flag untracked sources.
|
|
743
|
+
this.#commitDelta(`stimulus:${stimulus}`, record.id, [], delta);
|
|
744
|
+
record.outcome = 'committed';
|
|
745
|
+
record.toNode = this.#node;
|
|
746
|
+
record.effectVerified = 'unobservable';
|
|
747
|
+
this.#transitions.push(record);
|
|
748
|
+
this.#emitTransition(record);
|
|
749
|
+
this.#version++;
|
|
750
|
+
if (Object.keys(delta).length > 0)
|
|
751
|
+
this.#bumpState();
|
|
752
|
+
this.#checkFrameAfterWorldChange();
|
|
753
|
+
return { ok: true, attributed: false, transition: record, version: this.#version };
|
|
754
|
+
}
|
|
755
|
+
/** Exactly-one match rule: ambiguity refuses to guess (falls through to stimulus). */
|
|
756
|
+
#inferAffordanceForDelta(deltaKeys) {
|
|
757
|
+
const candidates = Object.values(this.spec.affordances).filter((aff) => {
|
|
758
|
+
if (!this.#registry.isRegistered(aff.id))
|
|
759
|
+
return false;
|
|
760
|
+
if (!aff.on.includes(this.#node))
|
|
761
|
+
return false;
|
|
762
|
+
const writes = aff.effect?.writes ?? [];
|
|
763
|
+
if (writes.length === 0)
|
|
764
|
+
return false;
|
|
765
|
+
if (!writes.every((key) => deltaKeys.includes(key)))
|
|
766
|
+
return false;
|
|
767
|
+
return this.#evalGuard(aff.guard).matched;
|
|
768
|
+
});
|
|
769
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
770
|
+
}
|
|
771
|
+
/** Fired transitions still awaiting their state report (oldest first). */
|
|
772
|
+
pending() {
|
|
773
|
+
return this.#pending.map((p) => ({
|
|
774
|
+
id: p.record.id,
|
|
775
|
+
affordanceId: p.affordance.id,
|
|
776
|
+
firedAt: p.record.timestamp,
|
|
777
|
+
}));
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* The app rejected/rolled back a transition's effect (optimistic UI).
|
|
781
|
+
* Works on a PENDING transition (effect never landed → no bundle) and on an
|
|
782
|
+
* already-SETTLED one (server rejected after the optimistic report): the
|
|
783
|
+
* record flips to rolled-back and the app's compensating delta should follow
|
|
784
|
+
* via updateState — the commit log keeps both writes, honestly.
|
|
785
|
+
*/
|
|
786
|
+
reject(transitionId, opts) {
|
|
787
|
+
const index = this.#pending.findIndex((p) => p.record.id === transitionId);
|
|
788
|
+
if (index >= 0) {
|
|
789
|
+
const [pending] = this.#pending.splice(index, 1);
|
|
790
|
+
pending.record.outcome = opts?.outcome ?? 'rejected';
|
|
791
|
+
this.#version++;
|
|
792
|
+
this.#emitTransition(pending.record);
|
|
793
|
+
return pending.record;
|
|
794
|
+
}
|
|
795
|
+
const settled = this.#transitions.find((t) => t.id === transitionId && t.outcome === 'committed');
|
|
796
|
+
if (settled) {
|
|
797
|
+
settled.outcome = opts?.outcome ?? 'rolled-back';
|
|
798
|
+
this.#version++;
|
|
799
|
+
this.#emitTransition(settled);
|
|
800
|
+
return settled;
|
|
801
|
+
}
|
|
802
|
+
throw new Error(`hcifootprint: no pending or committed transition '${transitionId}' to reject.`);
|
|
803
|
+
}
|
|
804
|
+
// -------------------------------------------------------------------------
|
|
805
|
+
// sync — reconcile the cursor when the world moved without an offered edge
|
|
806
|
+
// -------------------------------------------------------------------------
|
|
807
|
+
/**
|
|
808
|
+
* The observed node is runtime input from the world, so an unauthored page
|
|
809
|
+
* is NOT an error: the cursor follows reality (off-graph), available()
|
|
810
|
+
* honestly serves zero edges there, and the hop is still recorded.
|
|
811
|
+
*/
|
|
812
|
+
sync(observedNode, opts) {
|
|
813
|
+
if (observedNode === this.#node) {
|
|
814
|
+
return { changed: false, node: this.#node, version: this.#version };
|
|
815
|
+
}
|
|
816
|
+
const offGraph = !this.spec.pages[observedNode];
|
|
817
|
+
const record = {
|
|
818
|
+
id: buildRuntimeStageId(`sync:${observedNode}`, this.#counter.value++),
|
|
819
|
+
cause: {
|
|
820
|
+
kind: 'stimulus',
|
|
821
|
+
stimulus: opts?.stimulus ?? 'navigation',
|
|
822
|
+
principal: opts?.principal ?? 'system',
|
|
823
|
+
},
|
|
824
|
+
timestamp: Date.now(),
|
|
825
|
+
outcome: 'committed',
|
|
826
|
+
effectVerified: 'unobservable',
|
|
827
|
+
unverifiedEdge: true, // this hop passed no guard — slices treat it as inferred
|
|
828
|
+
fromNode: this.#node,
|
|
829
|
+
toNode: observedNode,
|
|
830
|
+
cursorVersion: this.#version,
|
|
831
|
+
};
|
|
832
|
+
// Empty commit — footprint's own idiom: empty commits are deliberate cursor stops.
|
|
833
|
+
this.#commitDelta(`sync:${observedNode}`, record.id, [], {});
|
|
834
|
+
this.#node = observedNode;
|
|
835
|
+
this.#transitions.push(record);
|
|
836
|
+
this.#emitTransition(record);
|
|
837
|
+
this.#version++;
|
|
838
|
+
this.#checkFrameAfterWorldChange();
|
|
839
|
+
return offGraph
|
|
840
|
+
? { changed: true, transition: record, node: this.#node, version: this.#version, offGraph: true }
|
|
841
|
+
: { changed: true, transition: record, node: this.#node, version: this.#version };
|
|
842
|
+
}
|
|
843
|
+
// -------------------------------------------------------------------------
|
|
844
|
+
// Trace surface — footprint's post-hoc toolchain over this session
|
|
845
|
+
// -------------------------------------------------------------------------
|
|
846
|
+
/** The footprintjs commit log: one bundle per SETTLED/stimulus/sync transition. */
|
|
847
|
+
commitLog() {
|
|
848
|
+
return [...this.#log.list()];
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* The interaction log. Settled/stimulus/sync rows join 1:1 to commitLog()
|
|
852
|
+
* bundles by TransitionRecord.id; pending and rejected/rolled-back rows
|
|
853
|
+
* exist only here (their effects never touched state). Rows are snapshots —
|
|
854
|
+
* live records are the ones returned by fire()/updateState()/reject().
|
|
855
|
+
*/
|
|
856
|
+
transitions() {
|
|
857
|
+
return this.#transitions.map((t) => this.#copyRecord(t));
|
|
858
|
+
}
|
|
859
|
+
/** "Why does this state key hold its value?" — footprint backward slice, formatted. */
|
|
860
|
+
why(key) {
|
|
861
|
+
const slice = sliceForKey(this.#log.list(), key, keysReadFromMap(this.#readsByStep));
|
|
862
|
+
return formatSlice(slice);
|
|
863
|
+
}
|
|
864
|
+
/** runtimeStageId → tracked read keys (feed to causalChain's keysRead lookup). */
|
|
865
|
+
readsByStep() {
|
|
866
|
+
return this.#readsByStep;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Data the given transition's handler RETURNED (search results, a looked-up
|
|
870
|
+
* record) — a fresh snapshot, safe to serialize into a tool result. Available
|
|
871
|
+
* once the handler has resolved, so read it AFTER awaiting the settlement.
|
|
872
|
+
* Returns undefined when the handler returned nothing (or capture is off).
|
|
873
|
+
*/
|
|
874
|
+
producedFor(transitionId) {
|
|
875
|
+
const record = this.#transitions.find((t) => t.id === transitionId);
|
|
876
|
+
if (record?.produced === undefined)
|
|
877
|
+
return undefined;
|
|
878
|
+
return sanitizeProduced(record.produced); // fresh copy — consumer mutation must not touch the record
|
|
879
|
+
}
|
|
880
|
+
// -------------------------------------------------------------------------
|
|
881
|
+
// Gap ledger — unmet demand, the input to "which skill should we build next"
|
|
882
|
+
// -------------------------------------------------------------------------
|
|
883
|
+
/**
|
|
884
|
+
* Report an ask that no available action or skill could serve (typically
|
|
885
|
+
* called by the agent's report_gap tool before it apologizes). The row is
|
|
886
|
+
* token-lean by design: the ask plus NAME lists, never descriptions.
|
|
887
|
+
*/
|
|
888
|
+
reportGap(opts) {
|
|
889
|
+
const row = {
|
|
890
|
+
kind: 'reported',
|
|
891
|
+
timestamp: Date.now(),
|
|
892
|
+
node: this.#node,
|
|
893
|
+
version: this.#version,
|
|
894
|
+
...this.#gapContext(),
|
|
895
|
+
request: opts.request.slice(0, 500),
|
|
896
|
+
reason: opts.reason ?? 'other',
|
|
897
|
+
...(opts.note !== undefined ? { note: opts.note.slice(0, 500) } : {}),
|
|
898
|
+
...(opts.principal !== undefined ? { principal: opts.principal } : {}),
|
|
899
|
+
};
|
|
900
|
+
this.#pushGap(row);
|
|
901
|
+
return structuredClone(row);
|
|
902
|
+
}
|
|
903
|
+
/** The unmet-demand ledger (DEEP copies) — export it to your analytics/triage pipeline. */
|
|
904
|
+
gaps() {
|
|
905
|
+
return this.#gaps.map((g) => structuredClone(g));
|
|
906
|
+
}
|
|
907
|
+
/** Live export hook: fires once per new gap row. Sugar for `on('gap', …)`. */
|
|
908
|
+
onGap(listener) {
|
|
909
|
+
return this.on('gap', listener);
|
|
910
|
+
}
|
|
911
|
+
/** Every refused fire becomes a gap-ledger row (protected: NavSession adds tree rejections). */
|
|
912
|
+
recordRejection(affordanceId, rejectionReason, principal, evidence, precomputedActions) {
|
|
913
|
+
this.#pushGap({
|
|
914
|
+
kind: 'fire-rejected',
|
|
915
|
+
timestamp: Date.now(),
|
|
916
|
+
node: this.#node,
|
|
917
|
+
version: this.#version,
|
|
918
|
+
availableActions: precomputedActions ?? this.available().edges.map((e) => e.affordanceId),
|
|
919
|
+
availableSkills: Object.keys(this.spec.skills),
|
|
920
|
+
affordanceId,
|
|
921
|
+
rejectionReason,
|
|
922
|
+
principal,
|
|
923
|
+
// Copy the CONDITION OBJECTS too — the same objects ride FireResult.evidence,
|
|
924
|
+
// and a caller annotating those must not rewrite the ledger.
|
|
925
|
+
...(evidence !== undefined ? { evidence: evidence.map((c) => ({ ...c })) } : {}),
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
/** Names only — token-lean and injection-safe context for triage. */
|
|
929
|
+
#gapContext() {
|
|
930
|
+
return {
|
|
931
|
+
availableActions: this.available().edges.map((e) => e.affordanceId),
|
|
932
|
+
availableSkills: Object.keys(this.spec.skills),
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
#pushGap(row) {
|
|
936
|
+
this.#gaps.push(row);
|
|
937
|
+
// Per-listener deep copy: exporter mutation must never touch the ledger,
|
|
938
|
+
// nor another listener's view. Routes through the 'gap' observer channel.
|
|
939
|
+
const set = this.#listeners.get('gap');
|
|
940
|
+
if (!set)
|
|
941
|
+
return;
|
|
942
|
+
for (const listener of set) {
|
|
943
|
+
try {
|
|
944
|
+
listener(structuredClone(row));
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
// Consumer export code must never break the session (recorder rule).
|
|
948
|
+
this.#warn(`hcifootprint: gap listener threw: ${String(error)}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Per-edge MCP tool descriptors for the CURRENT slice. Regenerated per call
|
|
954
|
+
* — never cached. With a skill frame open, serves ONLY the frame's
|
|
955
|
+
* currently-fireable steps + escape tools (authored cancel/back roles and a
|
|
956
|
+
* synthetic leave-skill) — the on-demand disclosure contract.
|
|
957
|
+
*/
|
|
958
|
+
toMCPTools(opts) {
|
|
959
|
+
const served = this.#servedEdges();
|
|
960
|
+
const tools = edgesToMCPTools(this.spec, served.edges, opts);
|
|
961
|
+
if (served.escape)
|
|
962
|
+
tools.push(leaveSkillTool(this.spec, this.#frame.skillId));
|
|
963
|
+
return tools;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Token-lean, prompt-ready session context for the next chat turn: current
|
|
967
|
+
* position, the open frame, and who did what since `sinceVersion` (the
|
|
968
|
+
* agent's last look). Built from AUTHORED strings and structural facts only
|
|
969
|
+
* — state values and payloads never enter the text.
|
|
970
|
+
*/
|
|
971
|
+
contextBrief(opts) {
|
|
972
|
+
const sinceVersion = opts?.sinceVersion;
|
|
973
|
+
const max = opts?.maxTransitions ?? 20;
|
|
974
|
+
const relevant = this.#transitions.filter((t) => sinceVersion === undefined || t.cursorVersion >= sinceVersion);
|
|
975
|
+
const omitted = Math.max(0, relevant.length - max);
|
|
976
|
+
const shown = relevant.slice(-max);
|
|
977
|
+
const changedKeysById = new Map(this.#log
|
|
978
|
+
.list()
|
|
979
|
+
.map((b) => [b.runtimeStageId, Object.keys({ ...(b.overwrite ?? {}), ...(b.updates ?? {}) })]));
|
|
980
|
+
const lines = [`You are on: ${this.#nodeLabel(this.#node)}.`];
|
|
981
|
+
if (this.#frame) {
|
|
982
|
+
const skill = this.spec.skills[this.#frame.skillId];
|
|
983
|
+
lines.push(`Open skill: ${this.#frame.skillId} — ${skill.description} ` +
|
|
984
|
+
`(${this.#frame.firedSteps.length}/${skill.steps.length} steps done).`);
|
|
985
|
+
}
|
|
986
|
+
for (const f of this.#frames) {
|
|
987
|
+
if (f.status !== 'demoted')
|
|
988
|
+
continue;
|
|
989
|
+
if (sinceVersion !== undefined && (f.closedAtVersion ?? 0) < sinceVersion)
|
|
990
|
+
continue;
|
|
991
|
+
lines.push(`Note: skill ${f.skillId} was demoted — its precondition no longer holds.`);
|
|
992
|
+
}
|
|
993
|
+
lines.push(sinceVersion !== undefined
|
|
994
|
+
? `Since version ${sinceVersion} (now ${this.#version}):`
|
|
995
|
+
: `Session so far (version ${this.#version}):`);
|
|
996
|
+
if (shown.length === 0)
|
|
997
|
+
lines.push(' (no actions)');
|
|
998
|
+
if (omitted > 0)
|
|
999
|
+
lines.push(` … ${omitted} earlier action(s) omitted.`);
|
|
1000
|
+
for (const t of shown)
|
|
1001
|
+
lines.push(` • ${this.#briefLine(t, changedKeysById)}`);
|
|
1002
|
+
const pend = this.pending();
|
|
1003
|
+
lines.push(pend.length
|
|
1004
|
+
? `Pending (awaiting app state): ${pend.map((p) => p.affordanceId).join(', ')}.`
|
|
1005
|
+
: 'Pending: none.');
|
|
1006
|
+
const served = this.#servedEdges();
|
|
1007
|
+
const names = served.edges.map((e) => e.affordanceId + (e.highEffect ? ' [high-effect]' : ''));
|
|
1008
|
+
if (served.escape)
|
|
1009
|
+
names.push('leave-skill');
|
|
1010
|
+
lines.push(`Available now: ${names.length > 0 ? names.join(', ') : '(nothing on this page)'}.`);
|
|
1011
|
+
return { node: this.#node, version: this.#version, frame: this.#frameCopy(), text: lines.join('\n') };
|
|
1012
|
+
}
|
|
1013
|
+
// -------------------------------------------------------------------------
|
|
1014
|
+
// Internals
|
|
1015
|
+
// -------------------------------------------------------------------------
|
|
1016
|
+
#stateView() {
|
|
1017
|
+
return (this.#heap.getState() ?? {});
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Resolve the live handler for a fire. Protected seam: NavSession keys
|
|
1021
|
+
* repeats-container handlers by instance ('cancel-order[o-123]').
|
|
1022
|
+
*/
|
|
1023
|
+
handlerFor(affordanceId, _opts) {
|
|
1024
|
+
return this.#registry.handlerFor(affordanceId);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* D18: registration/presence flips ARE world motion — but coalesced. Raw
|
|
1028
|
+
* registry edits apply immediately; the trace row + version bump flush once
|
|
1029
|
+
* per microtask, and a leave+enter of the same shape within one window
|
|
1030
|
+
* cancels to nothing (StrictMode double-mounts and HMR never pollute the
|
|
1031
|
+
* trace). This also fixes the verified v1 gap: registerTools never bumped
|
|
1032
|
+
* the version, so a plan made before a mount/unmount passed CAS after it.
|
|
1033
|
+
*/
|
|
1034
|
+
noteStructureChange() {
|
|
1035
|
+
if (this.#structureFlushScheduled)
|
|
1036
|
+
return;
|
|
1037
|
+
this.#structureFlushScheduled = true;
|
|
1038
|
+
queueMicrotask(() => {
|
|
1039
|
+
this.#structureFlushScheduled = false;
|
|
1040
|
+
const now = this.structureFingerprint();
|
|
1041
|
+
if (now === this.#structureFingerprint)
|
|
1042
|
+
return; // net-zero churn: no row, no bump
|
|
1043
|
+
this.#structureFingerprint = now;
|
|
1044
|
+
const record = {
|
|
1045
|
+
id: buildRuntimeStageId('stimulus:structure-swap', this.#counter.value++),
|
|
1046
|
+
cause: { kind: 'stimulus', stimulus: 'structure-swap', principal: 'system' },
|
|
1047
|
+
timestamp: Date.now(),
|
|
1048
|
+
outcome: 'committed',
|
|
1049
|
+
effectVerified: 'unobservable',
|
|
1050
|
+
fromNode: this.#node,
|
|
1051
|
+
toNode: this.#node,
|
|
1052
|
+
cursorVersion: this.#version,
|
|
1053
|
+
};
|
|
1054
|
+
// Empty commit — footprint's deliberate-cursor-stop idiom.
|
|
1055
|
+
this.#commitDelta('stimulus:structure-swap', record.id, [], {});
|
|
1056
|
+
this.#transitions.push(record);
|
|
1057
|
+
this.#emitTransition(record);
|
|
1058
|
+
this.#version++;
|
|
1059
|
+
this.#bumpStructure();
|
|
1060
|
+
this.#checkFrameAfterWorldChange();
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* What "the served structure" looks like right now — compared at flush time
|
|
1065
|
+
* against the last flushed value. NavSession extends this with the presence
|
|
1066
|
+
* set and visibility signals.
|
|
1067
|
+
*/
|
|
1068
|
+
structureFingerprint() {
|
|
1069
|
+
// Include enabled state so a setEnabled() flip is world motion (the served
|
|
1070
|
+
// surface changed), just like a mount/unmount.
|
|
1071
|
+
return this.#registry
|
|
1072
|
+
.registrations()
|
|
1073
|
+
.map((r) => r.affordanceId + (r.enabled ? '' : ':off'))
|
|
1074
|
+
.sort()
|
|
1075
|
+
.join('|');
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Guard evaluation with the D18 honesty split: conditions over keys the
|
|
1079
|
+
* state view has never contained are UNEVALUABLE, not false. Evaluable
|
|
1080
|
+
* conditions decide matched; unevaluable keys are returned as a marker so
|
|
1081
|
+
* edges are served-with-honesty instead of silently hidden — the rung-killer
|
|
1082
|
+
* fix that lets one authored graph work at every rung of the ladder.
|
|
1083
|
+
*/
|
|
1084
|
+
#evalGuard(guard) {
|
|
1085
|
+
if (!guard)
|
|
1086
|
+
return { matched: true, conditions: [], unevaluable: [] };
|
|
1087
|
+
const state = this.#stateView();
|
|
1088
|
+
// A key holding undefined is as unevaluable as an absent one: operators
|
|
1089
|
+
// like `ne ''` would MATCH undefined, so a guard authored to mean "a
|
|
1090
|
+
// value is set" would pass exactly when it is not. Honesty over guessing:
|
|
1091
|
+
// serve the edge with the guardUnevaluated marker instead. (updateState
|
|
1092
|
+
// drops undefined from reports; this also covers undefined handed in via
|
|
1093
|
+
// the initial state.)
|
|
1094
|
+
const evaluableKey = (key) => state[key] !== undefined;
|
|
1095
|
+
const unevaluable = Object.keys(guard).filter((key) => !evaluableKey(key));
|
|
1096
|
+
if (unevaluable.length === 0) {
|
|
1097
|
+
const { matched, conditions } = evaluateFilter((key) => state[key], (key) => this.#redacted.has(key), guard);
|
|
1098
|
+
return { matched, conditions, unevaluable };
|
|
1099
|
+
}
|
|
1100
|
+
const evaluable = Object.fromEntries(Object.entries(guard).filter(([key]) => evaluableKey(key)));
|
|
1101
|
+
// evaluateFilter deliberately never matches {} — an all-unevaluable guard
|
|
1102
|
+
// must not fall into that anti-vacuous-truth rule, so short-circuit.
|
|
1103
|
+
if (Object.keys(evaluable).length === 0)
|
|
1104
|
+
return { matched: true, conditions: [], unevaluable };
|
|
1105
|
+
const { matched, conditions } = evaluateFilter((key) => state[key], (key) => this.#redacted.has(key), evaluable);
|
|
1106
|
+
return { matched, conditions, unevaluable };
|
|
1107
|
+
}
|
|
1108
|
+
#settle(record, aff, delta, settleOpts) {
|
|
1109
|
+
// Read provenance = the guard keys this transition's availability rested on.
|
|
1110
|
+
this.#commitDelta(aff.id, record.id, Object.keys(aff.guard ?? {}), delta);
|
|
1111
|
+
const deltaKeys = Object.keys(delta);
|
|
1112
|
+
const declared = aff.effect?.writes;
|
|
1113
|
+
record.effectVerified = settleOpts?.forceUnobservable
|
|
1114
|
+
? 'unobservable' // tapless settlement: no report will ever exist to check against
|
|
1115
|
+
: declared && declared.length > 0
|
|
1116
|
+
? declared.every((key) => deltaKeys.includes(key))
|
|
1117
|
+
: 'unobservable';
|
|
1118
|
+
if (aff.effect?.navigatesTo) {
|
|
1119
|
+
// Declared target = expectation, flagged as a CLAIM; sync() records reality.
|
|
1120
|
+
record.toNode = aff.effect.navigatesTo;
|
|
1121
|
+
record.toNodeClaimed = true;
|
|
1122
|
+
// The claim moves the LIVE cursor only if nothing else moved it since
|
|
1123
|
+
// this transition fired — a weaker claim must never clobber a newer
|
|
1124
|
+
// sync() observation that interleaved while the fire was pending.
|
|
1125
|
+
if (this.#node === record.fromNode)
|
|
1126
|
+
this.#node = aff.effect.navigatesTo;
|
|
1127
|
+
}
|
|
1128
|
+
else {
|
|
1129
|
+
// A non-navigating affordance never moves the record's cursor — even if
|
|
1130
|
+
// an interleaved sync() moved the session's.
|
|
1131
|
+
record.toNode = record.fromNode;
|
|
1132
|
+
}
|
|
1133
|
+
record.outcome = 'committed';
|
|
1134
|
+
this.#version++;
|
|
1135
|
+
if (deltaKeys.length > 0)
|
|
1136
|
+
this.#bumpState(); // empty settles are cursor stops, not state motion
|
|
1137
|
+
if (this.#frame &&
|
|
1138
|
+
this.spec.skills[this.#frame.skillId].steps.includes(aff.id) &&
|
|
1139
|
+
!this.#frame.firedSteps.includes(aff.id)) {
|
|
1140
|
+
this.#frame.firedSteps.push(aff.id);
|
|
1141
|
+
}
|
|
1142
|
+
this.#emitTransition(record); // now committed — observers see the settled record
|
|
1143
|
+
this.#checkFrameAfterWorldChange();
|
|
1144
|
+
}
|
|
1145
|
+
/** The disclosure filter: full slice normally; frame steps + escape roles when a frame is open. */
|
|
1146
|
+
#servedEdges() {
|
|
1147
|
+
const edges = this.available().edges;
|
|
1148
|
+
if (!this.#frame)
|
|
1149
|
+
return { edges, escape: false };
|
|
1150
|
+
const steps = this.spec.skills[this.#frame.skillId].steps;
|
|
1151
|
+
return {
|
|
1152
|
+
edges: edges.filter((e) => steps.includes(e.affordanceId) || e.role === 'cancel' || e.role === 'back'),
|
|
1153
|
+
escape: true,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Demotion: after any world change, an open frame whose skill PRECONDITION
|
|
1158
|
+
* no longer holds is closed as 'demoted' — the served context re-collapses
|
|
1159
|
+
* to skill level and the agent replans. Step guards failing is normal DAG
|
|
1160
|
+
* progress and never demotes; skills without a precondition never demote.
|
|
1161
|
+
*/
|
|
1162
|
+
#checkFrameAfterWorldChange() {
|
|
1163
|
+
if (!this.#frame)
|
|
1164
|
+
return;
|
|
1165
|
+
const skill = this.spec.skills[this.#frame.skillId];
|
|
1166
|
+
if (!skill.precondition)
|
|
1167
|
+
return;
|
|
1168
|
+
if (this.#evalGuard(skill.precondition).matched)
|
|
1169
|
+
return;
|
|
1170
|
+
this.#frame.status = 'demoted';
|
|
1171
|
+
this.#frame.closedAtVersion = this.#version;
|
|
1172
|
+
this.#frames.push(this.#frame);
|
|
1173
|
+
this.#frame = null;
|
|
1174
|
+
this.#version++;
|
|
1175
|
+
this.#bumpStructure();
|
|
1176
|
+
}
|
|
1177
|
+
#frameCopy(frame = this.#frame) {
|
|
1178
|
+
return frame
|
|
1179
|
+
? { ...frame, firedSteps: [...frame.firedSteps], inferredSteps: [...frame.inferredSteps] }
|
|
1180
|
+
: null;
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* The brief's TEXT channel only carries authored strings. A page id is
|
|
1184
|
+
* authored; an OFF-GRAPH observed node name is runtime router text (an
|
|
1185
|
+
* attacker-influencable URL segment) — it renders as a constant label here
|
|
1186
|
+
* and stays available verbatim only in structured data fields.
|
|
1187
|
+
*/
|
|
1188
|
+
#nodeLabel(name) {
|
|
1189
|
+
return Object.hasOwn(this.spec.pages, name) ? name : '(an unmapped location, off the authored graph)';
|
|
1190
|
+
}
|
|
1191
|
+
/** One authored-strings-only line per transition for contextBrief(). */
|
|
1192
|
+
#briefLine(t, changedKeysById) {
|
|
1193
|
+
if (t.cause.kind === 'fired') {
|
|
1194
|
+
const aff = this.spec.affordances[t.cause.affordanceId ?? ''];
|
|
1195
|
+
const moved = t.toNode && t.toNode !== t.fromNode
|
|
1196
|
+
? ` (${this.#nodeLabel(t.fromNode)} → ${this.#nodeLabel(t.toNode)})`
|
|
1197
|
+
: '';
|
|
1198
|
+
const flags = [];
|
|
1199
|
+
if (t.cause.inferred)
|
|
1200
|
+
flags.push('inferred, not observed');
|
|
1201
|
+
if (aff?.highEffect)
|
|
1202
|
+
flags.push('high-effect');
|
|
1203
|
+
if (t.toNodeClaimed)
|
|
1204
|
+
flags.push('navigation claimed, unconfirmed');
|
|
1205
|
+
if (t.outcome === 'pending')
|
|
1206
|
+
flags.push('awaiting app state');
|
|
1207
|
+
if (t.outcome === 'rejected' || t.outcome === 'rolled-back' || t.outcome === 'superseded') {
|
|
1208
|
+
flags.push(t.outcome);
|
|
1209
|
+
}
|
|
1210
|
+
if (t.effectVerified === false)
|
|
1211
|
+
flags.push('declared effect not observed');
|
|
1212
|
+
const suffix = flags.length > 0 ? ` [${flags.join('; ')}]` : '';
|
|
1213
|
+
return `${t.cause.principal} fired ${t.cause.affordanceId} — ${aff?.description ?? ''}${moved}${suffix}`;
|
|
1214
|
+
}
|
|
1215
|
+
if (t.toNode && t.toNode !== t.fromNode) {
|
|
1216
|
+
return `${t.cause.principal} ${t.cause.stimulus}: cursor moved ${this.#nodeLabel(t.fromNode)} → ${this.#nodeLabel(t.toNode)} (unverified edge)`;
|
|
1217
|
+
}
|
|
1218
|
+
if (t.cause.stimulus === 'structure-swap') {
|
|
1219
|
+
return 'the served tool surface changed (something mounted, unmounted, or changed visibility)';
|
|
1220
|
+
}
|
|
1221
|
+
// Key NAMES are the designed disclosure (values never enter text) — but a
|
|
1222
|
+
// tap could relay hostile keys, so they are hardened before rendering.
|
|
1223
|
+
const keys = (changedKeysById.get(t.id) ?? []).map(
|
|
1224
|
+
// eslint-disable-next-line no-control-regex
|
|
1225
|
+
(key) => key.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 60));
|
|
1226
|
+
return `${t.cause.principal} ${t.cause.stimulus} changed: ${keys.length > 0 ? keys.join(', ') : '(nothing)'}`;
|
|
1227
|
+
}
|
|
1228
|
+
/** One transition = one fresh StageContext = one CommitBundle. */
|
|
1229
|
+
#commitDelta(stageName, runtimeStageId, readKeys, delta) {
|
|
1230
|
+
const ctx = new StageContext('', stageName, stageName, this.#heap, '', this.#log);
|
|
1231
|
+
ctx.runtimeStageId = runtimeStageId;
|
|
1232
|
+
ctx.useCommitValues(this.#commitValues);
|
|
1233
|
+
ctx.useWriteProvenance('reads-prefix');
|
|
1234
|
+
const scope = new ScopeFacade(ctx, stageName);
|
|
1235
|
+
scope.attachScopeRecorder(this.#recorder);
|
|
1236
|
+
for (const key of readKeys)
|
|
1237
|
+
scope.getValue(key);
|
|
1238
|
+
for (const [key, value] of Object.entries(delta)) {
|
|
1239
|
+
scope.setValue(key, value, this.#redacted.has(key));
|
|
1240
|
+
}
|
|
1241
|
+
ctx.commit();
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Bounded, firewall-safe copy of a handler's return value for the DATA channel.
|
|
1246
|
+
* Caps depth/breadth/string length (search results can be large), drops
|
|
1247
|
+
* functions, and tolerates cycles via the depth cap — so a handler return can
|
|
1248
|
+
* never blow up a tool result or smuggle live references into the record.
|
|
1249
|
+
*/
|
|
1250
|
+
function sanitizeProduced(value, depth = 0) {
|
|
1251
|
+
if (typeof value === 'function')
|
|
1252
|
+
return undefined;
|
|
1253
|
+
if (typeof value === 'string')
|
|
1254
|
+
return value.length > 200 ? `${value.slice(0, 200)}…` : value;
|
|
1255
|
+
if (value === null || typeof value !== 'object')
|
|
1256
|
+
return value; // number, boolean, undefined
|
|
1257
|
+
if (depth >= 4)
|
|
1258
|
+
return null; // deep enough — and a cycle backstop
|
|
1259
|
+
if (Array.isArray(value)) {
|
|
1260
|
+
return value.slice(0, 30).map((item) => sanitizeProduced(item, depth + 1));
|
|
1261
|
+
}
|
|
1262
|
+
const out = {};
|
|
1263
|
+
let count = 0;
|
|
1264
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1265
|
+
if (count++ >= 40)
|
|
1266
|
+
break;
|
|
1267
|
+
const clean = sanitizeProduced(child, depth + 1);
|
|
1268
|
+
if (clean !== undefined)
|
|
1269
|
+
out[key] = clean;
|
|
1270
|
+
}
|
|
1271
|
+
return out;
|
|
1272
|
+
}
|
|
1273
|
+
/** Detach a value defensively — structuredClone, or the ref if it can't be cloned. */
|
|
1274
|
+
function cloneSafe(value) {
|
|
1275
|
+
try {
|
|
1276
|
+
return structuredClone(value);
|
|
1277
|
+
}
|
|
1278
|
+
catch {
|
|
1279
|
+
return value; // non-cloneable payload (function/DOM node): best-effort, keep the ref
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
function validatePayload(schema, payload) {
|
|
1283
|
+
const validator = schema;
|
|
1284
|
+
if (typeof validator.safeParse === 'function') {
|
|
1285
|
+
try {
|
|
1286
|
+
const result = validator.safeParse(payload);
|
|
1287
|
+
return result.success ? { ok: true } : { ok: false, issues: String(result.error) };
|
|
1288
|
+
}
|
|
1289
|
+
catch (error) {
|
|
1290
|
+
return { ok: false, issues: String(error) };
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
if (typeof validator.parse === 'function') {
|
|
1294
|
+
try {
|
|
1295
|
+
validator.parse(payload);
|
|
1296
|
+
return { ok: true };
|
|
1297
|
+
}
|
|
1298
|
+
catch (error) {
|
|
1299
|
+
return { ok: false, issues: String(error) };
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// Plain JSON Schema: describes the payload to the LLM; v0 ships no JSON-Schema validator.
|
|
1303
|
+
return { ok: true };
|
|
1304
|
+
}
|
|
1305
|
+
//# sourceMappingURL=session.js.map
|