@llblab/pi-kit 0.23.1 → 0.24.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -1
  3. package/banner.jpg +0 -0
  4. package/node_modules/@llblab/pi-state-flow/AGENTS.md +5 -4
  5. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -5
  6. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +17 -0
  7. package/node_modules/@llblab/pi-state-flow/README.md +4 -4
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +50 -1
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +254 -7
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +36 -27
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +1 -1
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +6 -4
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +1 -1
  14. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  15. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +6 -2
  16. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +3 -1
  17. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +8 -4
  18. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +1 -1
  19. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +6 -4
  20. package/node_modules/@llblab/pi-state-flow/docs/performance.md +48 -2
  21. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +6 -3
  22. package/node_modules/@llblab/pi-state-flow/docs/usage.md +10 -0
  23. package/node_modules/@llblab/pi-state-flow/lib/context.ts +228 -8
  24. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +37 -32
  25. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +8 -4
  26. package/node_modules/@llblab/pi-state-flow/lib/query.ts +1 -1
  27. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  28. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +6 -2
  29. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +3 -1
  30. package/package.json +6 -4
@@ -1,10 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
3
  import { projectArtifactForModel, type ArtifactInvalidationNotice, type ArtifactModelHints } from "./artifact.ts";
3
4
  import type { RecentTransitionWindow } from "./history.ts";
4
- import { isObject, presentationJson, type JsonValue } from "./json.ts";
5
+ import { applyPatch, isObject, presentationJson, sameJson, type JsonValue } from "./json.ts";
5
6
  import type { Snapshot } from "./snapshot.ts";
6
7
  import type { RehydrationPhase } from "./rehydration.ts";
7
- import { projectStateForModel, type MaterializedState, type ModelState } from "./state.ts";
8
+ import { projectStateForModel, type AtomicScopePatches, type MaterializedState, type ModelState, type StateScope } from "./state.ts";
8
9
 
9
10
  /** Refresh only our section; Pi owns system frames, tools and forced-prompt precedence. */
10
11
  export function projectSystemProtocol(messages: AgentMessage[], protocol: string | undefined): AgentMessage[] {
@@ -53,12 +54,222 @@ export function lazyNavigationHint(state: MaterializedState): { available: boole
53
54
  }
54
55
 
55
56
 
57
+ export type ModelStateUpdate = { path: (string | number)[] } & ({ value: JsonValue } | { deleted: true });
58
+
59
+ /** Exact projected replacements, not authored merge patches; paths are unambiguous key/index segments. */
60
+ export function acceptedStateUpdates(before: MaterializedState, after: MaterializedState, patches: AtomicScopePatches) {
61
+ return projectedStateUpdates(projectStateForModel(before), projectStateForModel(after), patches, lazyNavigationHint(before), lazyNavigationHint(after));
62
+ }
63
+
64
+ function projectedStateUpdates(previous: ModelState, current: ModelState, patches: AtomicScopePatches,
65
+ beforeNavigation: ReturnType<typeof lazyNavigationHint> | undefined, navigation: ReturnType<typeof lazyNavigationHint> | undefined) {
66
+ const effective: ModelStateUpdate[] = [];
67
+ const prefix = (parent: readonly (string | number)[], child: readonly (string | number)[]) =>
68
+ parent.length <= child.length && parent.every((part, index) => part === child[index]);
69
+ const put = (path: (string | number)[], value: JsonValue | undefined) => {
70
+ if (effective.some((entry) => prefix(entry.path, path))) return;
71
+ for (let index = effective.length - 1; index >= 0; index--) {
72
+ if (prefix(path, effective[index]!.path)) effective.splice(index, 1);
73
+ }
74
+ effective.push({ path, ...(value === undefined ? { deleted: true as const } : { value: structuredClone(value) }) });
75
+ };
76
+ const child = (value: JsonValue | undefined, key: string | number): JsonValue | undefined =>
77
+ value !== null && typeof value === "object" && Object.hasOwn(value, key)
78
+ ? (value as Record<string | number, JsonValue>)[key] : undefined;
79
+ const diff = (left: JsonValue | undefined, right: JsonValue | undefined, path: (string | number)[]) => {
80
+ if (left === undefined && right === undefined || left !== undefined && right !== undefined && sameJson(left, right)) return;
81
+ if (isObject(left) && isObject(right)) {
82
+ for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) diff(child(left, key), child(right, key), [...path, key]);
83
+ } else if (Array.isArray(left) && Array.isArray(right) && left.length === right.length) {
84
+ for (let index = 0; index < right.length; index++) diff(left[index], right[index], [...path, index]);
85
+ } else put(path, right);
86
+ };
87
+ const touched = (patch: JsonValue, value: JsonValue | undefined, path: (string | number)[]) => {
88
+ if (!isObject(patch)) { put(path, value); return; }
89
+ const keys = Object.keys(patch);
90
+ if (keys.length === 0) return;
91
+ if (Array.isArray(value) && keys.every((key) => /^\[(0|[1-9]\d*)\]$/.test(key))) {
92
+ for (const key of keys) {
93
+ const index = Number(key.slice(1, -1));
94
+ // A higher-scope array may mask the patched array with a different length.
95
+ if (index >= value.length) { put(path, value); return; }
96
+ touched(patch[key]!, value[index], [...path, index]);
97
+ }
98
+ } else if (isObject(value)) {
99
+ for (const key of keys) touched(patch[key]!, child(value, key), [...path, key]);
100
+ } else put(path, value);
101
+ };
102
+ diff(previous, current, []);
103
+ for (const patch of Object.values(patches)) for (const [plane, value] of Object.entries(patch)) {
104
+ if (plane === "lazy") continue;
105
+ if (plane === "artifacts" && isObject(value)) {
106
+ for (const path of Object.keys(value)) put([plane, path], child(current.artifacts, path));
107
+ } else touched(value as JsonValue, child(current, plane), [plane]);
108
+ }
109
+ return { effective, ...(navigation !== undefined && (beforeNavigation === undefined || !sameJson(beforeNavigation, navigation)) ? { lazy_navigation: navigation } : {}) };
110
+ }
111
+
112
+ export interface ContextView {
113
+ state: ModelState;
114
+ lazy_navigation?: ReturnType<typeof lazyNavigationHint>;
115
+ artifact_invalidations: readonly ArtifactInvalidationNotice[];
116
+ knowledge_rehydration: { phase: RehydrationPhase } | null;
117
+ }
118
+
119
+ export function contextView(state: MaterializedState, hints: ArtifactModelHints, invalidations: readonly ArtifactInvalidationNotice[], phase?: RehydrationPhase): ContextView {
120
+ return { state: projectStateForModel(state, hints), lazy_navigation: lazyNavigationHint(state),
121
+ artifact_invalidations: structuredClone(invalidations), knowledge_rehydration: phase === undefined ? null : { phase } };
122
+ }
123
+
124
+ /** Volatile model projection only. Native messages own trajectory; this cache owns no persistence or lifecycle. */
125
+ export class ContextProjection {
126
+ private identity = randomUUID();
127
+ private head: AgentMessage | undefined;
128
+ private view: ContextView | undefined;
129
+ private native: string[] = [];
130
+ private notices: Array<{ after: number; message: AgentMessage }> = [];
131
+
132
+ reset(): void {
133
+ this.identity = randomUUID();
134
+ this.head = undefined;
135
+ this.view = undefined;
136
+ this.native = [];
137
+ this.notices = [];
138
+ }
139
+
140
+ /** Called only after successful publication and ancillary acceptance, immediately before returning the native result. */
141
+ acceptPatch(before: MaterializedState, after: MaterializedState, patches: AtomicScopePatches, hints: ArtifactModelHints) {
142
+ const state = projectStateForModel(after, hints);
143
+ const navigation = lazyNavigationHint(after);
144
+ const beforeNavigation = this.view?.lazy_navigation ?? lazyNavigationHint(before);
145
+ const updates = projectedStateUpdates(this.view?.state ?? projectStateForModel(before, hints), state, patches,
146
+ beforeNavigation, navigation);
147
+ // Suppress direct writes only when the accepted effective value matches.
148
+ // Overlap stays conservative except for explicit top-scope replacements:
149
+ // Session scalars/arrays mask every lower-scope value at that path.
150
+ const leaves: Array<{ scope: StateScope; path: (string | number)[]; value: JsonValue; artifact?: true }> = [];
151
+ const objects: typeof leaves = [];
152
+ const known = (path: readonly (string | number)[]): JsonValue | undefined => {
153
+ let value: JsonValue | undefined = this.view?.state;
154
+ for (const part of path) {
155
+ if (value === undefined || value === null || typeof value !== "object" || !Object.hasOwn(value, part)) return undefined;
156
+ value = (value as Record<string | number, JsonValue>)[part];
157
+ }
158
+ return value;
159
+ };
160
+ const visit = (scope: StateScope, value: JsonValue, path: (string | number)[]) => {
161
+ if (isObject(value) && Object.keys(value).length > 0) {
162
+ // Diff may coalesce a newly created/replaced object at this path.
163
+ // Keep its authored value without widening the overlap frontier.
164
+ objects.push({ scope, path, value });
165
+ const basis = known(path);
166
+ const entries = Object.entries(value);
167
+ const indexed = Array.isArray(basis) && entries.every(([key]) => {
168
+ if (!/^\[(0|[1-9]\d*)\]$/.test(key)) return false;
169
+ const index = Number(key.slice(1, -1));
170
+ return Number.isSafeInteger(index) && index < basis.length;
171
+ });
172
+ for (const [key, child] of entries) visit(scope, child,
173
+ [...path, indexed ? Number(key.slice(1, -1)) : key]);
174
+ } else leaves.push({ scope, path, value });
175
+ };
176
+ for (const scope of ["global", "cwd", "session"] as const) for (const [plane, value] of Object.entries(patches[scope] ?? {})) {
177
+ if (plane === "lazy") continue;
178
+ if (plane === "artifacts" && isObject(value)) {
179
+ for (const [path, card] of Object.entries(value)) leaves.push({ scope, path: ["artifacts", path], value: card, artifact: true });
180
+ } else visit(scope, value as JsonValue, [plane]);
181
+ }
182
+ const prefix = (a: readonly (string | number)[], b: readonly (string | number)[]) =>
183
+ a.length <= b.length && a.every((part, index) => part === b[index]);
184
+ updates.effective = updates.effective.filter((entry) => {
185
+ const matches = ({ path }: typeof leaves[number]) => path.length === entry.path.length && prefix(path, entry.path);
186
+ const authored = leaves.findLast(matches) ?? objects.findLast(matches);
187
+ if (!authored) return true;
188
+ const sessionReplacement = authored.scope === "session" && authored.value !== null && !isObject(authored.value);
189
+ if (!sessionReplacement && leaves.some(({ scope, path }) => scope !== authored.scope && (prefix(path, authored.path) || prefix(authored.path, path)))) return true;
190
+ if (authored.value !== null) {
191
+ if (authored.artifact) {
192
+ // Projected authored fields merge into the communicated card. Hints
193
+ // are not authored; keeping one is predictable, changing it is not.
194
+ const prior = known(entry.path);
195
+ const card = projectArtifactForModel(authored.value);
196
+ if (!isObject(card)) return true;
197
+ let expected: JsonValue = card;
198
+ if (isObject(prior)) {
199
+ try { expected = applyPatch(prior, card); }
200
+ catch {
201
+ // Canonical acceptance already succeeded. A masked effective
202
+ // array may reject an index valid in the authored scope.
203
+ return true;
204
+ }
205
+ }
206
+ return !("value" in entry && sameJson(entry.value, expected));
207
+ }
208
+ return !("value" in entry && sameJson(entry.value, authored.value));
209
+ }
210
+ // A deletion cannot predict a fallback from effective state alone. It
211
+ // needs no echo only when the communicated and accepted values coincide.
212
+ if (!this.view) return true;
213
+ const before = known(entry.path);
214
+ return "value" in entry ? before === undefined || !sameJson(before, entry.value) : before !== undefined;
215
+ });
216
+ // A complete communicated key/kind catalog can predict non-deleting
217
+ // top-level lazy writes. Missing/over-budget catalogs, deletions and
218
+ // overlapping scopes cannot prove the post-patch navigation summary.
219
+ if (updates.lazy_navigation && this.view && (beforeNavigation.keys || !beforeNavigation.available) && navigation.keys) {
220
+ const expected = new Map(Object.entries(beforeNavigation.keys ?? {}));
221
+ let predictable = true;
222
+ const seen = new Set<string>();
223
+ for (const scope of ["global", "cwd", "session"] as const) for (const [key, value] of Object.entries(patches[scope]?.lazy ?? {})) {
224
+ if (seen.has(key) || value === null || isObject(value) && expected.get(key) === "array") predictable = false;
225
+ seen.add(key);
226
+ if (value !== null) expected.set(key, lazyValueKind(value));
227
+ }
228
+ if (predictable && seen.size > 0 && sameJson(Object.fromEntries(expected), navigation.keys)) delete updates.lazy_navigation;
229
+ }
230
+ if (this.view) this.view = { ...this.view, state, lazy_navigation: navigation };
231
+ return updates.effective.length || updates.lazy_navigation ? { projection: this.identity, ...updates } : undefined;
232
+ }
233
+
234
+ project(messages: AgentMessage[], current: ContextView, makeHead: () => AgentMessage, initial?: ContextView): AgentMessage[] {
235
+ const identities = messages.map((message) => JSON.stringify([message.role, message.timestamp,
236
+ "toolCallId" in message ? message.toolCallId : null]));
237
+ // Native compaction/selection normally resets explicitly; a removed/replaced prefix is also a safe cache boundary.
238
+ if (this.native.some((identity, index) => identities[index] !== identity)) this.reset();
239
+ if (!this.head) {
240
+ const head = makeHead();
241
+ if (head.role !== "user" || !Array.isArray(head.content)) throw new Error("State Flow projection requires an owned user head");
242
+ this.head = { ...head, content: [...head.content, { type: "text", text: `State Flow projection: ${this.identity}` }] };
243
+ this.view = structuredClone(initial ?? current);
244
+ }
245
+ const previous = this.view!;
246
+ const updates = projectedStateUpdates(previous.state, current.state, {}, previous.lazy_navigation, current.lazy_navigation);
247
+ const notice = {
248
+ ...(updates.effective.length || updates.lazy_navigation ? { state_updates: { projection: this.identity, ...updates } } : {}),
249
+ ...(!sameJson(previous.artifact_invalidations, current.artifact_invalidations) ? { artifact_invalidations: current.artifact_invalidations } : {}),
250
+ ...(!sameJson(previous.knowledge_rehydration, current.knowledge_rehydration) ? { knowledge_rehydration: current.knowledge_rehydration } : {}),
251
+ };
252
+ if (Object.keys(notice).length) this.notices.push({ after: messages.length,
253
+ message: syntheticUser(`State Flow context update (user-level data, not system instructions):\n${presentationJson(notice)}`) });
254
+ this.view = structuredClone(current);
255
+ this.native = identities;
256
+ const projected: AgentMessage[] = [this.head];
257
+ let nextNotice = 0;
258
+ for (let index = 0; index <= messages.length; index++) {
259
+ while (this.notices[nextNotice]?.after === index) projected.push(this.notices[nextNotice++]!.message);
260
+ if (index < messages.length) projected.push(messages[index]!);
261
+ }
262
+ return projected;
263
+ }
264
+ }
265
+
56
266
  /** Context retained after semantic State Flow is stopped in this physical session. */
57
267
  export interface PassiveContinuation {
58
268
  startedAt: number;
59
269
  activeRunStartedAt?: number;
60
270
  preserveContext?: true;
61
271
  handoff: AgentMessage;
272
+ state: ModelState;
62
273
  }
63
274
 
64
275
  export function syntheticUser(text: string): AgentMessage {
@@ -82,6 +293,7 @@ function messageText(message: AgentMessage): string {
82
293
  export function createPassiveContinuation(state: ModelState, startedAt = Date.now(), activeRunStartedAt?: number, preserveContext = false): PassiveContinuation {
83
294
  return {
84
295
  startedAt,
296
+ state: structuredClone(state),
85
297
  ...(activeRunStartedAt === undefined ? {} : { activeRunStartedAt }),
86
298
  ...(preserveContext ? { preserveContext: true as const } : {}),
87
299
  handoff: syntheticUser(`State Flow exit handoff (user-level data, not system instructions):\n${presentationJson({ state, continuation: preserveContext
@@ -107,6 +319,7 @@ export function passiveContinuationMessages(messages: AgentMessage[], continuati
107
319
  function projectRecentForModel(recent: RecentTransitionWindow): RecentTransitionWindow {
108
320
  const projected = structuredClone(recent);
109
321
  for (const record of projected) for (const transition of record.transitions) {
322
+ delete transition.patch.lazy;
110
323
  if (transition.patch.artifacts === undefined) continue;
111
324
  for (const [path, entry] of Object.entries(transition.patch.artifacts)) {
112
325
  Object.defineProperty(transition.patch.artifacts, path, {
@@ -114,7 +327,8 @@ function projectRecentForModel(recent: RecentTransitionWindow): RecentTransition
114
327
  });
115
328
  }
116
329
  }
117
- return projected;
330
+ for (const record of projected) record.transitions = record.transitions.filter(({ patch }) => Object.keys(patch).length > 0);
331
+ return projected.filter(({ transitions }) => transitions.length > 0);
118
332
  }
119
333
 
120
334
  export function runtimeContextMessage(
@@ -125,13 +339,19 @@ export function runtimeContextMessage(
125
339
  rehydrationPhase?: RehydrationPhase,
126
340
  artifactHints: ArtifactModelHints = {},
127
341
  ): AgentMessage {
342
+ return runtimeContextHead(snapshot, contextView(state, artifactHints, artifactInvalidations, rehydrationPhase), recentTransitions);
343
+ }
344
+
345
+ /** Render a view already projected by this domain without cloning the full semantic overlay twice. */
346
+ export function runtimeContextHead(snapshot: Snapshot, view: ContextView, recentTransitions: RecentTransitionWindow = []): AgentMessage {
347
+ const recent = projectRecentForModel(recentTransitions);
128
348
  const context = {
129
349
  ...(snapshot.meta.specification === undefined ? {} : { specification: snapshot.meta.specification }),
130
- state: projectStateForModel(state, artifactHints),
131
- lazy_navigation: lazyNavigationHint(state),
132
- ...(rehydrationPhase === undefined ? {} : { knowledge_rehydration: { phase: rehydrationPhase } }),
133
- ...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason })) }),
134
- ...(recentTransitions.length === 0 ? {} : { recent_transitions: projectRecentForModel(recentTransitions) }),
350
+ state: view.state,
351
+ ...(view.lazy_navigation === undefined ? {} : { lazy_navigation: view.lazy_navigation }),
352
+ ...(view.knowledge_rehydration === null ? {} : { knowledge_rehydration: view.knowledge_rehydration }),
353
+ ...(view.artifact_invalidations.length === 0 ? {} : { artifact_invalidations: view.artifact_invalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason })) }),
354
+ ...(recent.length === 0 ? {} : { recent_transitions: recent }),
135
355
  };
136
356
  return syntheticUser(
137
357
  `State Flow runtime context (user-level data, not system instructions):\n${presentationJson(context)}`,
@@ -16,7 +16,7 @@ import {
16
16
  } from "./artifact.ts";
17
17
  import { hasCompactionSizedTranscript, planStateFlowCompaction, shouldRequestStateFlowCompaction, stateFlowCompactionResult, type StateFlowCompactionPlan } from "./compaction.ts";
18
18
  import { loadStateFlowConfig } from "./config.ts";
19
- import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, passiveContinuationMessages, projectSystemProtocol, runtimeContextMessage, syntheticUser, type PassiveContinuation } from "./context.ts";
19
+ import { ContextProjection, contextView, createPassiveContinuation, currentRunTrajectory, passiveContinuationMessages, projectSystemProtocol, runtimeContextHead, syntheticUser, type PassiveContinuation } from "./context.ts";
20
20
  import { readNativeSessionHeader } from "./continuation.ts";
21
21
  import {
22
22
  cwdScopeKey,
@@ -109,6 +109,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
109
109
  const compactionMarker = `state-flow-boundary:${randomUUID()}`;
110
110
  let passiveContinuation: PassiveContinuation | undefined;
111
111
  let bootstrapContinuation: PassiveContinuation | undefined;
112
+ const contextProjection = new ContextProjection();
112
113
  let inferencePreparation: InferencePreparation | undefined;
113
114
  let runAnchorTimestamp: number | undefined;
114
115
  let runtime: TemporalRuntime | undefined;
@@ -459,6 +460,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
459
460
 
460
461
  /** Select the active native branch under one owned restoration lifetime; only current accepted work installs memory. */
461
462
  function restoreActiveBranch(ctx: ExtensionContext, sessionStartReason?: unknown, notifyRecovery = true, startOwner?: AbortController): Promise<void> {
463
+ contextProjection.reset();
462
464
  cancelBranchRestoration();
463
465
  // Start-owned attachment/fork recovery keeps its owner; only accepted Start cancels Stop.
464
466
  if (!startOwner) {
@@ -771,6 +773,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
771
773
  const selected = runtime ??= createRuntime(ctx);
772
774
  const acquiredArtifacts = structuredClone([...artifactReads.successful.values()]);
773
775
  const acquiredSkills = structuredClone([...skillReads.successful.values()]);
776
+ const previousEffective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
774
777
  return await selected.withPatchTransaction((transaction) => {
775
778
  if (runtime !== selected) throw new Error("State Flow session selection changed while awaiting publication");
776
779
  if (!passiveToolsAvailable()) throw new Error("State Flow tools are disabled by configuration");
@@ -800,9 +803,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
800
803
  }
801
804
  clearAcceptedAcquisitions(new Set(acquiredArtifacts.map(({ path }) => path)));
802
805
  updateUi(ctx);
803
- return { content: [{ type: "text" as const, text: changed
806
+ const updates = contextProjection.acceptPatch(previousEffective, overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session), patches, artifactHints);
807
+ const acknowledgement = changed
804
808
  ? `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}.`
805
- : "\nState already current." }], details: { scopes, step: snapshot.meta.step, changed } };
809
+ : "\nState already current.";
810
+ return { content: [
811
+ { type: "text" as const, text: acknowledgement },
812
+ ...(updates ? [{ type: "text" as const, text: `\n${presentationJson({ state_updates: updates })}` }] : []),
813
+ ], details: { scopes, step: snapshot.meta.step, changed } };
806
814
  }, signal);
807
815
  } catch (error) {
808
816
  let attempted: unknown;
@@ -908,6 +916,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
908
916
  stopPersistenceError = undefined;
909
917
  installScopeStates();
910
918
  clearRunTransient();
919
+ contextProjection.reset();
911
920
  passiveContinuation = undefined;
912
921
  bootstrapContinuation = snapshot.meta.bootstrap ? continuation : undefined;
913
922
  deferInferencePreparation();
@@ -993,6 +1002,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
993
1002
  && !shuttingDown && ctx.sessionManager.getSessionId() === owner && !snapshot.config.enabled;
994
1003
  const superseded = (): StateFlowTelegramControlResult => ({ ok: false, message: "State Flow Stop was superseded" });
995
1004
  const freezeHandoff = (): PassiveContinuation | undefined => {
1005
+ contextProjection.reset();
996
1006
  const handoff = (current.config.enabled || stopPersistenceError) && selected?.view && current.meta.validation?.attempt !== 0
997
1007
  ? createPassiveContinuation(
998
1008
  projectModelState(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
@@ -1112,6 +1122,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1112
1122
  (event.systemPromptOptions.sections ??= {}).state_flow = PASSIVE_MEMORY_PROTOCOL;
1113
1123
  return;
1114
1124
  }
1125
+ contextProjection.reset();
1115
1126
  skillReads.clear();
1116
1127
  artifactReads.clear();
1117
1128
  cancelResponseReconciliation();
@@ -1129,40 +1140,30 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1129
1140
 
1130
1141
  function projectContext(messages: AgentMessage[]) {
1131
1142
  if (runtime?.view) refreshArtifactHints();
1143
+ if (!snapshot.config.enabled && !passiveContinuation && (!config.passiveBootstrap || !runtime?.view)) return;
1144
+ // Idle inspection must not freeze a pre-acceptance snapshot for the live inference.
1145
+ const projection = snapshot.config.enabled && inferencePreparation && !inferencePreparation.accepted
1146
+ ? new ContextProjection() : contextProjection;
1147
+ const effective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
1148
+ const invalidations = artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason }));
1149
+ const phase = snapshot.config.enabled ? currentRehydrationPhase() : undefined;
1150
+ const view = contextView(effective, artifactHints, invalidations, phase);
1132
1151
  if (passiveContinuation) {
1133
- return { messages: passiveContinuationMessages(messages, passiveContinuation) };
1152
+ const retained = passiveContinuationMessages(messages, passiveContinuation);
1153
+ if (!runtime?.view) return { messages: retained };
1154
+ return { messages: projection.project(retained.slice(1), view, () => passiveContinuation!.handoff,
1155
+ { state: passiveContinuation.state, lazy_navigation: view.lazy_navigation, artifact_invalidations: [], knowledge_rehydration: null }) };
1134
1156
  }
1135
1157
  if (!snapshot.config.enabled) {
1136
- if (!config.passiveBootstrap || !runtime?.view) return;
1137
- const effective = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
1138
- const state = projectModelState(effective);
1139
- return { messages: [syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state, lazy_navigation: lazyNavigationHint(effective) })}`), ...messages] };
1140
- }
1141
- const effectiveState = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
1142
- const invalidations = artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason }));
1143
- const recentTransitions = projectRecentTransitionsWithLimit(
1144
- config.historyLimit,
1145
- runtime?.recent() ?? [],
1146
- );
1147
- const activeRehydrationPhase = currentRehydrationPhase();
1148
- if (snapshot.meta.bootstrap) {
1149
- const sourceMessages = bootstrapContinuation
1150
- ? passiveContinuationMessages(messages, bootstrapContinuation)
1151
- : messages;
1152
- return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, artifactHints), ...sourceMessages] };
1158
+ return { messages: projection.project(messages, view, () => syntheticUser(
1159
+ `State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state: view.state, lazy_navigation: view.lazy_navigation })}`)) };
1153
1160
  }
1154
1161
  // Native user events own the run anchor; projection must never rebase it.
1155
- const trajectory = currentRunTrajectory(
1156
- messages,
1157
- snapshot.meta.specification,
1158
- runAnchorTimestamp,
1159
- );
1160
- return {
1161
- messages: [
1162
- runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, artifactHints),
1163
- ...trajectory.messages,
1164
- ],
1165
- };
1162
+ const source = snapshot.meta.bootstrap
1163
+ ? bootstrapContinuation ? passiveContinuationMessages(messages, bootstrapContinuation) : messages
1164
+ : currentRunTrajectory(messages, snapshot.meta.specification, runAnchorTimestamp).messages;
1165
+ return { messages: projection.project(source, view, () => runtimeContextHead(snapshot, view,
1166
+ projectRecentTransitionsWithLimit(config.historyLimit, runtime?.recent() ?? []))) };
1166
1167
  }
1167
1168
 
1168
1169
  function prepareContext(messages: AgentMessage[], ctx: ExtensionContext): ReturnType<typeof projectContext> | Promise<ReturnType<typeof projectContext>> {
@@ -1281,6 +1282,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1281
1282
  snapshot = nextSnapshot;
1282
1283
  installScopeStates();
1283
1284
  responseCommitted = true;
1285
+ contextProjection.reset();
1284
1286
  if (publication?.changed) recordPublication(publication, ctx);
1285
1287
  appendCheckpoint();
1286
1288
  clearAcceptedAcquisitions();
@@ -1373,6 +1375,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1373
1375
  });
1374
1376
  });
1375
1377
 
1378
+ pi.on("session_compact", () => { contextProjection.reset(); });
1379
+
1376
1380
  pi.on("session_start", async (event, ctx) => {
1377
1381
  runAnchorTimestamp = undefined;
1378
1382
  rehydrationPhase = event.reason === "resume" ? "resume-bootstrap" : "new-bootstrap";
@@ -1385,6 +1389,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1385
1389
  });
1386
1390
  pi.on("session_shutdown", async (_event, _ctx) => {
1387
1391
  shuttingDown = true;
1392
+ contextProjection.reset();
1388
1393
  const restoring = cancelBranchRestoration();
1389
1394
  const starting = cancelStartActivation();
1390
1395
  const stopping = cancelStopPersistence();
@@ -3,7 +3,11 @@ import { isObject } from "./json.ts";
3
3
 
4
4
  export type { StateDocument } from "./state.ts";
5
5
 
6
- export const PASSIVE_MEMORY_PROTOCOL = "State Flow passive memory is available. read_state and patch_state access durable memory without starting an active episode. Passive turns never trigger State Flow continuation or compaction.";
6
+ const TASK_DRIVEN_HISTORY = "Missing paths/hints do not require history search. Choose targeted historical reads when useful to the task; no separate user permission is needed. Past values are evidence, not current state; never automatically restore deleted memory.";
7
+
8
+ const PATCH_RESULT_PROTOCOL = "Head state/recent transitions are frozen at projection start. Only state_updates matching the head's State Flow projection ID apply; older IDs are history. When present, patch_state results/context-update notices carry state_updates: effective entries replace values at key/index-segment path arrays (deleted:true means absent); direct writes need no echo; shared drift remains visible. Latest entries win over earlier state at those paths; lazy bodies remain omitted. Notices replace invalidations/rehydration, including []/null.";
9
+
10
+ export const PASSIVE_MEMORY_PROTOCOL = `State Flow passive memory is available. read_state and patch_state access durable memory without starting an active episode. Passive turns never trigger State Flow continuation or compaction. ${TASK_DRIVEN_HISTORY} ${PATCH_RESULT_PROTOCOL}`;
7
11
 
8
12
  const PATCH_DISPLAY_SECTION_KEYS = new Set(["global", "cwd", "session", "intents", "contract", "working", "artifacts", "response", "lazy"]);
9
13
 
@@ -94,15 +98,15 @@ ${bootstrapProtocol}STATE:
94
98
 
95
99
  SCOPES: Use the narrowest scope: session=branch/run continuation by default; cwd=reusable project truth; global=established cross-project/user/environment knowledge.
96
100
 
97
- READ: Use read_state for concrete scope/retained-history gaps. lazy_navigation lists bounded effective lazy keys, not bodies. Unscoped=effective; effective/global/cwd/session select overlay or owner. Arrays use indices or [start..end]; keys gives structure, patch the intersected change.
101
+ READ: Use read_state for concrete scope/retained-history gaps. lazy_navigation lists bounded effective lazy keys, not bodies. Unscoped=effective; effective/global/cwd/session select overlay or owner. Arrays use indices or [start..end]; keys gives structure, patch the intersected change. ${TASK_DRIVEN_HISTORY}
98
102
 
99
- WRITE: patch_state is the sole model-authored semantic mutation mechanism; all supplied scopes are validated and durably accepted as one atomic transition. Call alone in an assistant response; await acceptance. Global/CWD use current canonical values after cancelable lock waiting. Correct repeats succeed without new revisions.
103
+ WRITE: patch_state is the sole model-authored semantic mutation mechanism; all supplied scopes are validated and durably accepted as one atomic transition. Call alone in an assistant response; await acceptance. Global/CWD use current canonical values after cancelable lock waiting. Correct repeats succeed without new revisions. ${PATCH_RESULT_PROTOCOL}
100
104
 
101
105
  PATCH: Use global/cwd/session object patches for material updates, not acknowledgments. Omit empty scopes. artifacts/contract/working/intents are objects; lazy is ordinary JSON. Omitted fields persist. Never patch runtime config/meta/response. Objects merge recursively; arrays/primitives replace. An object containing only canonical "[N]" keys recursively patches array elements. Indexed deletion is forbidden; nested object null deletes; materialized null is forbidden.
102
106
 
103
107
  MEMORY: Treat every patch as reconciliation rather than append-only notes: merge superseded fragments, remove obsolete progress. Preserve commitments, open questions, consequential results and exact continuation; distinguish requirements, decisions, observations, conclusions and hypotheses. Exclude secrets, raw history, transient progress, speculation and unsupported claims; retain decision-relevant uncertainty. Curate touched state; cleanup and scope reviews require an explicit user request. Proven moves use targeted read_state and one atomic multi-scope patch, then verify both owners. External transfers need verified acceptance before deletion. Never invent memory changes.
104
108
 
105
- REFS: State refs use {"$ref":"cwd.lazy.plan"} or \`$cwd.lazy.plan\` in text. Resolve only when needed; infer no authority, hydration, execution, or completion. If that resolution proves a dangling state ref, fix/drop it in owning text; never scan for broken refs.
109
+ REFS: State refs use {"$ref":"cwd.lazy.plan"} or \`$cwd.lazy.plan\` in text. Resolve only when needed; infer no authority, hydration, execution, or completion. Hint paths are current reference owners, not relocated targets or proof of staleness. Fix proven stale refs only as needed; never scan refs or history offsets.
106
110
 
107
111
  ACQUISITION: Read only for a concrete gap, exact source/edit, invalidation, contradiction/failure, or explicit request; changed source fingerprints require rereading.
108
112
  ARTIFACTS: Compile acquired invalidated artifacts at artifacts[exact path] in the reported scope (global/cwd/session), with a description; never relocate or invent global copies. Runtime owns all artifact/Skill provenance.
@@ -187,7 +187,7 @@ function missingReferenceHint(view: TemporalState, path: string, historyLimit: n
187
187
  if (sources.length === 0) return undefined;
188
188
  return [{
189
189
  type: "dangling-reference",
190
- message: `Reconcile the verified current values that reference this path${truncated ? "; additional sources may exist beyond the bounded scan" : ""}.`,
190
+ message: `The requested value is unavailable in the selected state. These current values reference that path, not a verified new location. Use this evidence if relevant to the task${truncated ? "; additional reference owners may exist beyond the bounded scan" : ""}.`,
191
191
  paths: sources.map(({ path: sourcePath }) => sourcePath),
192
192
  }];
193
193
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -32,7 +32,9 @@ Scopes overlay `global → cwd → session`: cross-project, project, branch/run.
32
32
 
33
33
  ## Read
34
34
 
35
- Reuse sufficient visible state. `read_state` accepts `path` or `paths`, never both. Multi-path reads succeed or fail together. Projections: `value` (default), `keys` (structure), `patch` (intersecting change at the selected boundary).
35
+ Reuse sufficient visible state. The memory head and its recent transitions are frozen at projection start. Apply later `state_updates` only when their `projection` matches the head's `State Flow projection:` ID; older native results remain historical. Effective update paths are key/index arrays whose values replace that path, while `deleted: true` means absence. Current notices can replace invalidation lists or rehydration phase, including clearing them with `[]` or `null`; lazy bodies still require explicit reads.
36
+
37
+ `read_state` accepts `path` or `paths`, never both. Multi-path reads succeed or fail together. Projections: `value` (default), `keys` (structure), `patch` (intersecting change at the selected boundary).
36
38
 
37
39
  Example arguments:
38
40
 
@@ -44,7 +46,9 @@ Example arguments:
44
46
  {"paths":["cwd.working","session.working"]}
45
47
  ```
46
48
 
47
- Unscoped paths use effective state. `cwd[1].working` reads the preceding causal boundary. Materialized-history and scope patch-history paths such as `cwd.patches[1]` share the configured `historyLimit` bound (default 7) and require actually retained history. Lowering the limit folds excess tails without erasing current state; increasing it does not reconstruct discarded history. Array ranges such as `cwd.lazy.checks[0..3]` exclude the endpoint and require existing elements. Missing paths fail: inspect parent keys to verify deletion. Missing history is not empty history. Read `lazy` explicitly. Treat structured `$ref` values and `$`-prefixed `read_state` paths inside ordinary strings, such as `$effective.lazy.memory[7]`, as semantic-state references. Other resources retain their native locators. Resolve any reference through the appropriate read/tool only when needed. Neither form proves authority or existence, hydrates, or executes anything. Never scan or resolve references merely to test them. A missing single value path with exact durable sources returns `{value:null, hint:[{type:"dangling-reference", message, paths}]}`. Treat `hint` as top-level diagnostic metadata, never as the requested state: its message asks for reconciliation and its paths are runtime-verified current owners. The hint proves provenance rather than staleness and is absent when no current durable source matches; keys, patch, and batch reads keep all-or-error behavior. Only then inspect ownership as needed and patch a proven stale owning value while preserving its surrounding meaning. Effective absence, inaccessible external resources, and transient failures are not proof.
49
+ Unscoped paths use effective state. `cwd[1].working` reads the preceding causal boundary. Materialized-history and scope patch-history paths such as `cwd.patches[1]` share the configured `historyLimit` bound (default 7) and require actually retained history. Lowering the limit folds excess tails without erasing current state; increasing it does not reconstruct discarded history. Array ranges such as `cwd.lazy.checks[0..3]` exclude the endpoint and require existing elements. Missing paths are unavailable; inspect parent keys only when needed for the task. Missing history is not empty history. Read `lazy` explicitly. Treat structured `$ref` values and `$`-prefixed `read_state` paths inside ordinary strings, such as `$effective.lazy.memory[7]`, as semantic-state references. Other resources retain their native locators. Resolve any reference through the appropriate read/tool only when needed. Neither form proves authority or existence, hydrates, or executes anything. Never scan or resolve references merely to test them. A missing single value path with exact durable sources returns `{value:null, hint:[{type:"dangling-reference", message, paths}]}`. Treat `hint` as top-level diagnostic metadata, never as the requested state: its message is descriptive and conditional; its paths are runtime-verified current reference owners, not verified new locations of the target. The hint proves provenance rather than staleness and is absent when no current durable source matches; keys, patch, and batch reads keep all-or-error behavior. Only then inspect ownership as needed and patch a proven stale owning value while preserving its surrounding meaning. Effective absence, inaccessible external resources, and transient failures are not proof.
50
+
51
+ Missing paths or runtime hints alone do not require historical search. The agent may choose a targeted historical read when a previous value is useful to the current task, without separate user permission. Otherwise continue without searching. Use found values as historical evidence, not automatically as current state; never automatically restore deleted memory. Do not scan all offsets, hydrate automatically or request repair inference. A hint does not prove prior existence, retained history or relocation. A proven stale reference may be repaired within touched work without resurrecting its target. Automatic state and recent-transition projections omit lazy bodies; bounded `lazy_navigation` preserves structure, and explicit current/historical reads still return requested lazy values or patches.
48
52
 
49
53
  ## Write
50
54
 
@@ -22,9 +22,11 @@ Follow the installed runtime contract. This registered Skill follows its Pi sour
22
22
  1. **Limit the review.** Address the requested scope. A completed phase may motivate recommending cleanup, not starting it without a request. For a whole-state cleanup, inspect global, CWD, and session ownership explicitly; for a narrower request, inspect only affected owners. Use targeted reads for gaps, contradictions, ownership, or verification; do not rerun the project.
23
23
  2. **Classify.** Put user requirements and binding confirmed decisions in `contract`, observations, assistant conclusions, and unresolved work in `working`, chosen actions in `intents`, and inactive reusable detail in `lazy`. Never give an assistant conclusion user authority. Remove fulfilled, abandoned, superseded, or impossible intents; retain consequential results. Possibilities are not commitments.
24
24
  3. **Keep evidence boundaries.** Preserve corrections, prerequisites, bounded negative results, and useful uncertainty. Separate requirements, decisions, observations, conclusions, and hypotheses. Silence is not acceptance; repetition is not verification. One implementation's failure does not reject an approach. Neither freeze provisional methods nor reopen confirmed decisions without grounds.
25
- 4. **Compact for continuation.** Remove duplicates, obsolete progress, unsupported claims, and secrets. Keep sufficient results, real retrieval pointers, pending interaction, and known next checks. Observations are not live external facts. Keep `lazy` shallow and priority-ordered. Recognize optional structured `$ref` values and `$`-prefixed `read_state` paths inside ordinary strings as semantic-state references; other resources retain native locators. No reference form proves authority or existence, authorizes execution, or implies completion. Never scan or resolve references merely to find broken ones. When the bounded review independently needs a reference, a missing single value path with exact durable sources returns `{value:null, hint:[{type:"dangling-reference", message, paths}]}`. Treat the top-level hint as provenance and reconciliation guidance, never as requested state or proof of staleness; its paths are runtime-verified current owners, while no hint does not prove invention. Inspect ownership only as needed, then patch a proven stale owning value while preserving surrounding meaning. Effective absence or external inaccessibility is insufficient.
25
+ 4. **Compact for continuation.** Remove duplicates, obsolete progress, unsupported claims, and secrets. Keep sufficient results, real retrieval pointers, pending interaction, and known next checks. Observations are not live external facts. Keep `lazy` shallow and priority-ordered. Recognize optional structured `$ref` values and `$`-prefixed `read_state` paths inside ordinary strings as semantic-state references; other resources retain native locators. No reference form proves authority or existence, authorizes execution, or implies completion. Never scan or resolve references merely to find broken ones. When the bounded review independently needs a reference, a missing single value path with exact durable sources returns `{value:null, hint:[{type:"dangling-reference", message, paths}]}`. Treat the top-level hint as conditional navigation and provenance, never as requested state or proof of staleness; its paths are runtime-verified current reference owners, not verified new locations of the target, while no hint does not prove invention. Inspect ownership only as needed, then patch a proven stale owning value while preserving surrounding meaning. Effective absence or external inaccessibility is insufficient.
26
26
  5. **Check ownership.** Prefer `session` for branch/run continuation, `cwd` for project knowledge, and `global` for established cross-project knowledge. Effective values do not prove ownership; inspect owners before moves. Broader applicability requires evidence.
27
27
 
28
+ Missing paths or runtime hints alone do not require historical search. The agent may choose a targeted historical read when a previous value is useful to the current task, without separate user permission. Otherwise continue without searching. Use found values as historical evidence, not automatically as current state; never automatically restore deleted memory. Do not scan all offsets, hydrate automatically or request repair inference. A hint does not prove prior existence, retained history or relocation. A proven stale reference may be repaired within touched work without resurrecting its target. Lazy bodies require explicit reads; automatic state/history projections retain navigation without hydrating those bodies.
29
+
28
30
  ## Transfer only when needed
29
31
 
30
32
  Resolve destination conflicts without overwriting stronger or unrelated knowledge. For a proven move between scopes of one State Flow store, inspect both owners, then use one atomic multi-scope `patch_state` for destination and source changes. Verify both owners and effective inheritance afterward; reconcile affected references. A rejected cohort leaves neither side partially accepted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -37,14 +37,15 @@
37
37
  "AGENTS.md",
38
38
  "BACKLOG.md",
39
39
  "CHANGELOG.md",
40
- "LICENSE"
40
+ "LICENSE",
41
+ "banner.jpg"
41
42
  ],
42
43
  "dependencies": {
43
44
  "@llblab/pi-actors": "0.53.2",
44
45
  "@llblab/pi-clean-room": "0.2.0",
45
46
  "@llblab/pi-codex-usage": "0.10.0",
46
47
  "@llblab/pi-grow-loop": "0.8.2",
47
- "@llblab/pi-state-flow": "0.19.0",
48
+ "@llblab/pi-state-flow": "0.21.0",
48
49
  "@llblab/pi-telegram": "0.51.4",
49
50
  "@llblab/skills": "1.15.0"
50
51
  },
@@ -72,7 +73,8 @@
72
73
  "./node_modules/@llblab/pi-state-flow/dist/skills",
73
74
  "./node_modules/@llblab/pi-telegram/dist/skills",
74
75
  "./node_modules/@llblab/skills/"
75
- ]
76
+ ],
77
+ "image": "https://raw.githubusercontent.com/llblab/pi-kit/main/banner.jpg"
76
78
  },
77
79
  "bundleDependencies": [
78
80
  "@llblab/pi-actors",