@llblab/pi-kit 0.23.2 → 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.
@@ -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
@@ -127,13 +339,18 @@ export function runtimeContextMessage(
127
339
  rehydrationPhase?: RehydrationPhase,
128
340
  artifactHints: ArtifactModelHints = {},
129
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 {
130
347
  const recent = projectRecentForModel(recentTransitions);
131
348
  const context = {
132
349
  ...(snapshot.meta.specification === undefined ? {} : { specification: snapshot.meta.specification }),
133
- state: projectStateForModel(state, artifactHints),
134
- lazy_navigation: lazyNavigationHint(state),
135
- ...(rehydrationPhase === undefined ? {} : { knowledge_rehydration: { phase: rehydrationPhase } }),
136
- ...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason })) }),
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 })) }),
137
354
  ...(recent.length === 0 ? {} : { recent_transitions: recent }),
138
355
  };
139
356
  return syntheticUser(
@@ -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();
@@ -5,7 +5,9 @@ export type { StateDocument } from "./state.ts";
5
5
 
6
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
7
 
8
- 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}`;
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}`;
9
11
 
10
12
  const PATCH_DISPLAY_SECTION_KEYS = new Set(["global", "cwd", "session", "intents", "contract", "working", "artifacts", "response", "lazy"]);
11
13
 
@@ -98,7 +100,7 @@ SCOPES: Use the narrowest scope: session=branch/run continuation by default; cwd
98
100
 
99
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}
100
102
 
101
- 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}
102
104
 
103
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.
104
106
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.19.1",
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -45,7 +45,7 @@
45
45
  "@llblab/pi-clean-room": "0.2.0",
46
46
  "@llblab/pi-codex-usage": "0.10.0",
47
47
  "@llblab/pi-grow-loop": "0.8.2",
48
- "@llblab/pi-state-flow": "0.19.1",
48
+ "@llblab/pi-state-flow": "0.21.0",
49
49
  "@llblab/pi-telegram": "0.51.4",
50
50
  "@llblab/skills": "1.15.0"
51
51
  },