@llblab/pi-kit 0.37.0 → 0.38.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 (152) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +7 -3
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +679 -2
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +17 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +30 -14
  7. package/node_modules/@llblab/pi-state-flow/api/modules.ts +11 -0
  8. package/node_modules/@llblab/pi-state-flow/dist/api/modules.d.ts +3 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/api/modules.js +3 -0
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +7 -4
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +77 -36
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +2 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +26 -27
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +3 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +13 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +27 -21
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +144 -46
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +14 -18
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +6 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +60 -50
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +1049 -310
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +4 -4
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +15 -3
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +2 -0
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +2 -1
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +3 -1
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +7 -7
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +29 -0
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +186 -0
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/model-interface.d.ts +17 -0
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/model-interface.js +158 -0
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/module-registry.d.ts +21 -0
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/module-registry.js +108 -0
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/operation.js +3 -2
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/ownership.d.ts +4 -4
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/ownership.js +5 -5
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/planes.d.ts +29 -0
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/planes.js +84 -0
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +6 -3
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +33 -16
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +21 -4
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +53 -13
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +5 -2
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +3 -0
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +10 -1
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +84 -5
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +570 -20
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/selection.d.ts +10 -0
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/selection.js +32 -0
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/semantic-module.d.ts +79 -0
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/semantic-module.js +187 -0
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +98 -1
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +398 -3
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +7 -2
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +55 -3
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +67 -14
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +129 -9
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +26 -14
  59. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +61 -19
  60. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +14 -9
  61. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +59 -22
  62. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +111 -10
  63. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +728 -11
  64. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +10 -7
  65. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +50 -14
  66. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +40 -4
  67. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +247 -43
  68. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +41 -8
  69. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +132 -39
  70. package/node_modules/@llblab/pi-state-flow/dist/package.json +6 -1
  71. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +28 -16
  72. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +12 -8
  73. package/node_modules/@llblab/pi-state-flow/docs/README.md +9 -0
  74. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +76 -43
  75. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +9 -1
  76. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +6 -0
  77. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +23 -19
  78. package/node_modules/@llblab/pi-state-flow/docs/protocol-composition-decision.md +66 -0
  79. package/node_modules/@llblab/pi-state-flow/docs/public-module-contract.md +73 -0
  80. package/node_modules/@llblab/pi-state-flow/docs/scope-selection-contract.md +814 -0
  81. package/node_modules/@llblab/pi-state-flow/docs/semantic-compatibility-decision.md +212 -0
  82. package/node_modules/@llblab/pi-state-flow/docs/semantic-extensibility-decision.md +75 -0
  83. package/node_modules/@llblab/pi-state-flow/docs/semantic-handoff-contract.md +174 -0
  84. package/node_modules/@llblab/pi-state-flow/docs/semantic-memory-evaluation.md +166 -0
  85. package/node_modules/@llblab/pi-state-flow/docs/semantic-module-contract.md +84 -0
  86. package/node_modules/@llblab/pi-state-flow/docs/single-scope-interface-contract.md +201 -0
  87. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +22 -1
  88. package/node_modules/@llblab/pi-state-flow/docs/usage.md +32 -18
  89. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +68 -31
  90. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +21 -22
  91. package/node_modules/@llblab/pi-state-flow/lib/config.ts +14 -1
  92. package/node_modules/@llblab/pi-state-flow/lib/context.ts +142 -46
  93. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +14 -17
  94. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +53 -44
  95. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +897 -297
  96. package/node_modules/@llblab/pi-state-flow/lib/history.ts +16 -7
  97. package/node_modules/@llblab/pi-state-flow/lib/json.ts +2 -1
  98. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +10 -9
  99. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +189 -0
  100. package/node_modules/@llblab/pi-state-flow/lib/model-interface.ts +136 -0
  101. package/node_modules/@llblab/pi-state-flow/lib/module-registry.ts +119 -0
  102. package/node_modules/@llblab/pi-state-flow/lib/operation.ts +3 -2
  103. package/node_modules/@llblab/pi-state-flow/lib/ownership.ts +6 -6
  104. package/node_modules/@llblab/pi-state-flow/lib/planes.ts +98 -0
  105. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +37 -16
  106. package/node_modules/@llblab/pi-state-flow/lib/query.ts +59 -18
  107. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +5 -2
  108. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +10 -1
  109. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +574 -21
  110. package/node_modules/@llblab/pi-state-flow/lib/selection.ts +39 -0
  111. package/node_modules/@llblab/pi-state-flow/lib/semantic-module.ts +239 -0
  112. package/node_modules/@llblab/pi-state-flow/lib/session.ts +387 -3
  113. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +45 -4
  114. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +156 -16
  115. package/node_modules/@llblab/pi-state-flow/lib/state.ts +69 -28
  116. package/node_modules/@llblab/pi-state-flow/lib/status.ts +65 -31
  117. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +770 -18
  118. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +58 -20
  119. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +267 -50
  120. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +184 -52
  121. package/node_modules/@llblab/pi-state-flow/package.json +6 -1
  122. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +28 -16
  123. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +12 -8
  124. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  125. package/node_modules/@llblab/pi-telegram/BACKLOG.md +8 -10
  126. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +10 -0
  127. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +20 -18
  128. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +6 -6
  129. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +23 -65
  130. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +3 -3
  131. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +1 -1
  132. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +1 -1
  133. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +1 -0
  134. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +1 -0
  135. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +4 -2
  136. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +24 -13
  137. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  138. package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/SKILL.md +5 -5
  139. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  140. package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +1 -1
  141. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  142. package/node_modules/@llblab/pi-telegram/docs/outbound.md +8 -8
  143. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -2
  144. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +17 -15
  145. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +28 -68
  146. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +3 -3
  147. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +1 -1
  148. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +2 -1
  149. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +25 -12
  150. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  151. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +5 -5
  152. package/package.json +3 -3
@@ -2,11 +2,15 @@ import { randomUUID } from "node:crypto";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import { projectArtifactForModel, type ArtifactInvalidationNotice, type ArtifactModelHints } from "./artifact.ts";
4
4
  import type { RecentTransitionWindow } from "./history.ts";
5
- import { applyPatch, isObject, presentationJson, sameJson, type JsonObject, type JsonValue } from "./json.ts";
5
+ import { applyPatch, isObject, presentationJson, pruneEmptyObjects, sameJson, type JsonObject, type JsonValue } from "./json.ts";
6
6
  import type { Snapshot } from "./snapshot.ts";
7
7
  import { formatOwnedPath, type OwnedPath } from "./ownership.ts";
8
+ import { projectModelReferences } from "./model-interface.ts";
9
+ import { captureScopeSelection, DEFAULT_SCOPE_SELECTION, type ScopeSelection } from "./selection.ts";
8
10
  import type { RehydrationPhase } from "./rehydration.ts";
9
- import { projectSemanticPatch, projectStateForModel, type AtomicScopePatches, type SemanticState, type StateScope } from "./state.ts";
11
+ import { projectSemanticPatch, projectSemanticState, projectStateForModel, type AtomicScopePatches, type SemanticState, type StateScope } from "./state.ts";
12
+ import { lazyValueKind, projectLazyNavigation, projectPlaneContext } from "./planes.ts";
13
+ import { evaluateModuleContext, type EvaluatedModuleContext, type ModuleFrame, type SemanticModule } from "./semantic-module.ts";
10
14
 
11
15
  /** Refresh only our section; Pi owns system frames, tools and forced-prompt precedence. */
12
16
  export function projectSystemProtocol(messages: AgentMessage[], protocol: string | undefined): AgentMessage[] {
@@ -31,31 +35,16 @@ export function projectSystemProtocol(messages: AgentMessage[], protocol: string
31
35
  });
32
36
  }
33
37
 
34
- const LAZY_HINT_PATH = "effective.lazy";
35
- const LAZY_HINT_MAX_KEYS = 32;
36
- const LAZY_HINT_MAX_JSON_CHARS = 1024;
37
-
38
- type LazyValueKind = "array" | "boolean" | "null" | "number" | "object" | "string";
39
-
40
- function lazyValueKind(value: JsonValue): LazyValueKind {
41
- if (value === null) return "null";
42
- if (Array.isArray(value)) return "array";
43
- if (typeof value === "object") return "object";
44
- return typeof value as Exclude<LazyValueKind, "array" | "object">;
45
- }
46
-
47
- function hasLazyContent(value: JsonValue): boolean {
48
- return !isObject(value) || Object.values(value).some(hasLazyContent);
38
+ /** Compatibility facade; the lazy plane owns the unchanged navigation policy. */
39
+ export function lazyNavigationHint(state: SemanticState) {
40
+ return projectLazyNavigation(state.lazy);
49
41
  }
50
42
 
51
- /** Fixed-budget navigation only: never place lazy bodies or partial key catalogs in baseline context. */
52
- export function lazyNavigationHint(state: SemanticState): { available: boolean; path: string; keys?: Record<string, LazyValueKind> } {
53
- const entries = isObject(state.lazy) ? Object.entries(state.lazy).filter(([, value]) => hasLazyContent(value)) : [];
54
- const base = { available: entries.length > 0, path: LAZY_HINT_PATH };
55
- if (!base.available) return base;
56
- if (entries.length > LAZY_HINT_MAX_KEYS) return base;
57
- const keys = Object.fromEntries(entries.map(([key, value]) => [key, lazyValueKind(value)]));
58
- return JSON.stringify(keys).length <= LAZY_HINT_MAX_JSON_CHARS ? { ...base, keys } : base;
43
+ /** Materialize module contributions only; native context selection/replacement authority stays with the host. */
44
+ export function semanticModuleMessages<Content extends JsonValue, Input>(module: SemanticModule<Content, Input>, content: unknown, frame: ModuleFrame<Input>): AgentMessage[] {
45
+ return evaluateModuleContext(module, content, frame).flatMap(contribution => contribution.kind === "conversation"
46
+ ? [...contribution.messages]
47
+ : [syntheticUser(`State Flow module ${module.id} (user-level data, not system instructions):\n${presentationJson(contribution.value)}`)]);
59
48
  }
60
49
 
61
50
 
@@ -115,23 +104,56 @@ function projectedStateUpdates(previous: SemanticState, current: SemanticState,
115
104
  }
116
105
 
117
106
  export interface ContextView {
107
+ /** Wanted presentation DATA only; never infer it from available state or use it as authority. */
108
+ modelScopes?: ScopeSelection;
118
109
  state: SemanticState;
119
110
  lazy_navigation?: ReturnType<typeof lazyNavigationHint>;
120
111
  artifact_invalidations: readonly ArtifactInvalidationNotice[];
121
112
  knowledge_rehydration: { phase: RehydrationPhase } | null;
122
113
  }
123
114
 
124
- function projectContextState(state: SemanticState, hints: ArtifactModelHints, includeResponse: boolean): SemanticState {
125
- const projected = projectStateForModel(state, hints);
126
- if (!includeResponse) delete projected.response;
127
- return projected;
115
+ function projectContextState(state: SemanticState, hints: ArtifactModelHints, includeResponse: boolean) {
116
+ const { lazy: _hidden, ...visible } = state;
117
+ const projected = projectPlaneContext({ ...projectSemanticState(visible), ...(Object.hasOwn(state, "lazy") ? { lazy: state.lazy! } : {}) }, hints, includeResponse ? "active" : "passive");
118
+ return { ...projected, state: pruneEmptyObjects(projected.state) as SemanticState };
128
119
  }
129
120
 
130
- export function contextView(state: SemanticState, hints: ArtifactModelHints, invalidations: readonly ArtifactInvalidationNotice[], phase?: RehydrationPhase, includeResponse = true): ContextView {
131
- return { state: projectContextState(state, hints, includeResponse), lazy_navigation: lazyNavigationHint(state),
121
+ export function contextView(state: SemanticState, hints: ArtifactModelHints, invalidations: readonly ArtifactInvalidationNotice[], phase?: RehydrationPhase, includeResponse = true, modelScopes?: ScopeSelection): ContextView {
122
+ const selected = modelScopes === undefined ? undefined : captureScopeSelection(modelScopes);
123
+ return { ...(selected === undefined ? {} : { modelScopes: selected }), ...projectContextState(state, hints, includeResponse),
132
124
  artifact_invalidations: structuredClone(invalidations), knowledge_rehydration: phase === undefined ? null : { phase } };
133
125
  }
134
126
 
127
+ function viewScopes(view?: ContextView): ScopeSelection {
128
+ return view && Object.hasOwn(view, "modelScopes") ? captureScopeSelection(view.modelScopes) : DEFAULT_SCOPE_SELECTION;
129
+ }
130
+
131
+ function modelState(state: SemanticState, scopes: ScopeSelection): SemanticState {
132
+ return scopes.length === 1 ? projectModelReferences(state, scopes) as SemanticState : state;
133
+ }
134
+
135
+ function modelNavigation(navigation: ContextView["lazy_navigation"], scopes: ScopeSelection) {
136
+ return navigation && scopes.length === 1 ? { ...navigation, path: "lazy" } : navigation;
137
+ }
138
+
139
+ function modelUpdates<T extends { effective: ModelStateUpdate[]; lazy_navigation?: ContextView["lazy_navigation"] }>(updates: T, scopes: ScopeSelection) {
140
+ if (scopes.length !== 1) return updates;
141
+ return { ...updates, effective: updates.effective.map(entry => {
142
+ if (!("value" in entry)) return entry;
143
+ // A canonical diff may split a structured ref into its scalar $ref leaf.
144
+ const value = entry.path.at(-1) === "$ref" && typeof entry.value === "string"
145
+ ? (projectModelReferences({ $ref: entry.value }, scopes) as JsonObject).$ref!
146
+ : projectModelReferences(entry.value, scopes);
147
+ return { ...entry, value };
148
+ }), ...(updates.lazy_navigation ? { lazy_navigation: modelNavigation(updates.lazy_navigation, scopes) } : {}) };
149
+ }
150
+
151
+ /** Same owned passive envelope as before, with presentation chosen from captured wanted DATA. */
152
+ export function passiveContextHead(view: ContextView): AgentMessage {
153
+ const scopes = viewScopes(view);
154
+ return syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${presentationJson({ state: modelState(view.state, scopes), lazy_navigation: modelNavigation(view.lazy_navigation, scopes) })}`);
155
+ }
156
+
135
157
  /** Volatile model projection only. Native messages own trajectory; this cache owns no persistence or lifecycle. */
136
158
  export class ContextProjection {
137
159
  private identity = randomUUID();
@@ -150,11 +172,18 @@ export class ContextProjection {
150
172
 
151
173
  /** Called only after successful publication and ancillary acceptance, immediately before returning the native result. */
152
174
  acceptPatch(before: SemanticState, after: SemanticState, patches: AtomicScopePatches, hints: ArtifactModelHints,
153
- cascades: Partial<Record<StateScope, readonly OwnedPath[]>> = {}, includeResponse = true) {
154
- const state = projectContextState(after, hints, includeResponse);
175
+ cascades: Partial<Record<StateScope, readonly OwnedPath[]>> = {}, includeResponse = true, modelScopes?: ScopeSelection) {
176
+ const scopes = modelScopes === undefined ? viewScopes(this.view) : captureScopeSelection(modelScopes);
177
+ // A receipt from another interface cannot be attached to a replacement model head.
178
+ if (this.view && !sameJson(viewScopes(this.view), scopes)) return undefined;
179
+ // External planes have exact tool reads but no automatic core projection. Absence here is not deletion.
180
+ patches = Object.fromEntries(Object.entries(patches).map(([scope, patch]) => [scope,
181
+ Object.fromEntries(Object.entries(patch).filter(([id]) => !id.startsWith("x_"))),
182
+ ]));
183
+ const { state } = projectContextState(after, hints, includeResponse);
155
184
  const navigation = lazyNavigationHint(after);
156
185
  const beforeNavigation = this.view?.lazy_navigation ?? lazyNavigationHint(before);
157
- const updates = projectedStateUpdates(this.view?.state ?? projectContextState(before, hints, includeResponse), state, patches,
186
+ const updates = projectedStateUpdates(this.view?.state ?? projectContextState(before, hints, includeResponse).state, state, patches,
158
187
  beforeNavigation, navigation);
159
188
  // Suppress direct writes only when the accepted effective value matches.
160
189
  // Overlap stays conservative except for explicit top-scope replacements:
@@ -243,13 +272,15 @@ export class ContextProjection {
243
272
  if (this.view) this.view = { ...this.view, state, lazy_navigation: navigation };
244
273
  // Owner paths remain informative even when effective state/navigation is masked
245
274
  // or the model could predict every direct write. Never expose target values.
246
- const cascaded = (["global", "cwd", "session"] as const).flatMap((scope) =>
247
- (cascades[scope] ?? []).map((path) => formatOwnedPath(scope, path)));
275
+ const cascaded = scopes.flatMap(scope => (cascades[scope] ?? []).map(path =>
276
+ scopes.length === 1 ? [path.plane, ...path.keys].join(".") : formatOwnedPath(scope, path)));
248
277
  return updates.effective.length || updates.lazy_navigation || cascaded.length
249
- ? { projection: this.identity, ...updates, ...(cascaded.length ? { cascaded } : {}) } : undefined;
278
+ ? { projection: this.identity, ...modelUpdates(updates, scopes), ...(cascaded.length ? { cascaded } : {}) } : undefined;
250
279
  }
251
280
 
252
281
  project(messages: AgentMessage[], current: ContextView, makeHead: () => AgentMessage, initial?: ContextView): AgentMessage[] {
282
+ const scopes = viewScopes(current);
283
+ if (this.view && !sameJson(viewScopes(this.view), scopes)) this.reset();
253
284
  const identities = messages.map((message) => JSON.stringify([message.role, message.timestamp,
254
285
  "toolCallId" in message ? message.toolCallId : null]));
255
286
  // Native compaction/selection normally resets explicitly; a removed/replaced prefix is also a safe cache boundary.
@@ -263,7 +294,7 @@ export class ContextProjection {
263
294
  const previous = this.view!;
264
295
  const updates = projectedStateUpdates(previous.state, current.state, {}, previous.lazy_navigation, current.lazy_navigation);
265
296
  const notice = {
266
- ...(updates.effective.length || updates.lazy_navigation ? { state_updates: { projection: this.identity, ...updates } } : {}),
297
+ ...(updates.effective.length || updates.lazy_navigation ? { state_updates: { projection: this.identity, ...modelUpdates(updates, scopes) } } : {}),
267
298
  ...(!sameJson(previous.artifact_invalidations, current.artifact_invalidations) ? { artifact_invalidations: current.artifact_invalidations } : {}),
268
299
  ...(!sameJson(previous.knowledge_rehydration, current.knowledge_rehydration) ? { knowledge_rehydration: current.knowledge_rehydration } : {}),
269
300
  };
@@ -308,7 +339,64 @@ function messageText(message: AgentMessage): string {
308
339
  return contentText((message as { content?: unknown }).content);
309
340
  }
310
341
 
311
- export function createPassiveContinuation(state: SemanticState, startedAt = Date.now(), activeRunStartedAt?: number, preserveContext = false): PassiveContinuation {
342
+ /** A unique native user anchor admits completed conversation, never the current run or protocol frames. */
343
+ export function moduleConversationSource(messages: readonly AgentMessage[], anchorTimestamp: number | undefined): { conversation: readonly AgentMessage[]; prompt?: string } {
344
+ if (anchorTimestamp === undefined || !Number.isFinite(anchorTimestamp)) return { conversation: [] };
345
+ const matches = messages.flatMap((message, index) => message.role === "user" && message.timestamp === anchorTimestamp ? [index] : []);
346
+ if (matches.length !== 1) return { conversation: [] };
347
+ const start = matches[0]!;
348
+ return { conversation: messages.slice(0, start).filter(message => ["user", "assistant", "toolResult"].includes(message.role)), prompt: messageText(messages[start]!) };
349
+ }
350
+
351
+ function assertModuleToolUnits(messages: readonly AgentMessage[]): void {
352
+ const pending = new Map<string, string>(), seen = new Set<string>();
353
+ for (const message of messages) {
354
+ if (message.role === "toolResult") {
355
+ if (!pending.has(message.toolCallId) || pending.get(message.toolCallId) !== message.toolName) throw new Error("Module conversation selection has an orphan tool result");
356
+ pending.delete(message.toolCallId);
357
+ } else {
358
+ if (pending.size) throw new Error("Module conversation selection omits a tool result");
359
+ if (message.role !== "assistant") continue;
360
+ for (const part of message.content) if (part.type === "toolCall") {
361
+ if (!part.id || !part.name || seen.has(part.id)) throw new Error("Module conversation selection has duplicate or invalid tool calls");
362
+ seen.add(part.id); pending.set(part.id, part.name);
363
+ }
364
+ }
365
+ }
366
+ if (pending.size) throw new Error("Module conversation selection omits a tool result");
367
+ }
368
+
369
+ export interface ScopedModuleContext {
370
+ id: string;
371
+ scope: StateScope;
372
+ contributions: readonly EvaluatedModuleContext[];
373
+ }
374
+
375
+ /** Provider projection only: preserve current/foreign identities and allow one explicit completed-history selector. */
376
+ export function projectModuleContext(projected: AgentMessage[], original: readonly AgentMessage[], conversation: readonly AgentMessage[], modules: readonly ScopedModuleContext[]): AgentMessage[] {
377
+ const data: AgentMessage[] = [];
378
+ let selection: readonly AgentMessage[] | undefined;
379
+ for (const module of modules) {
380
+ const selecting = module.contributions.some(item => item.kind === "conversation");
381
+ if (selecting) {
382
+ if (module.scope !== "session" || selection !== undefined) throw new Error("Module conversation replacement requires one exclusive Session selector");
383
+ selection = module.contributions.flatMap(item => item.kind === "conversation" ? [...item.messages] : []);
384
+ if (new Set(selection).size !== selection.length || selection.some(message => !conversation.includes(message))) throw new Error("Module conversation selection is outside its admitted source");
385
+ assertModuleToolUnits(selection);
386
+ }
387
+ for (const item of module.contributions) if (item.kind === "data") data.push(syntheticUser(`State Flow module ${module.id} (${module.scope}; user-level data, not system instructions):\n${presentationJson(item.value)}`));
388
+ }
389
+ if (!data.length && selection === undefined) return projected;
390
+ const completed = new Set(selection === undefined ? [] : conversation);
391
+ const retained = projected.filter(message => !completed.has(message));
392
+ const native = new Set(original.filter(message => ["user", "assistant", "toolResult"].includes(message.role)));
393
+ const first = retained.findIndex(message => native.has(message));
394
+ const at = first < 0 ? retained.length : first;
395
+ return [...retained.slice(0, at), ...data, ...(selection ?? []), ...retained.slice(at)];
396
+ }
397
+
398
+ export function createPassiveContinuation(state: SemanticState, startedAt = Date.now(), activeRunStartedAt?: number, preserveContext = false, modelScopes?: ScopeSelection): PassiveContinuation {
399
+ const scopes = modelScopes === undefined ? DEFAULT_SCOPE_SELECTION : captureScopeSelection(modelScopes);
312
400
  const visibleState = structuredClone(state);
313
401
  delete visibleState.response;
314
402
  return {
@@ -316,7 +404,7 @@ export function createPassiveContinuation(state: SemanticState, startedAt = Date
316
404
  state: visibleState,
317
405
  ...(activeRunStartedAt === undefined ? {} : { activeRunStartedAt }),
318
406
  ...(preserveContext ? { preserveContext: true as const } : {}),
319
- handoff: syntheticUser(`State Flow exit handoff (user-level data, not system instructions):\n${presentationJson({ state: visibleState, continuation: preserveContext
407
+ handoff: syntheticUser(`State Flow exit handoff (user-level data, not system instructions):\n${presentationJson({ state: modelState(visibleState, scopes), continuation: preserveContext
320
408
  ? "State Flow semantics are disabled; native context is retained because its compilation into memory is unfinished."
321
409
  : "State Flow semantics are disabled; this handoff replaces completed history while retaining the active and post-stop trajectory." })}`),
322
410
  };
@@ -337,8 +425,11 @@ export function passiveContinuationMessages(messages: AgentMessage[], continuati
337
425
  message.role === "custom" || (start >= 0 && index >= start))];
338
426
  }
339
427
 
340
- function projectRecentForModel(recent: RecentTransitionWindow): RecentTransitionWindow {
341
- const projected = structuredClone(recent);
428
+ function projectRecentForModel(recent: RecentTransitionWindow, scopes: ScopeSelection) {
429
+ // Select owner headers before cloning any excluded patch payload.
430
+ const projected = structuredClone(recent.map(record => ({ ...record,
431
+ transitions: record.transitions.filter(transition => scopes.includes(transition.scope)) })));
432
+
342
433
  for (const record of projected) for (const transition of record.transitions) {
343
434
  transition.patch = projectSemanticPatch(transition.patch as JsonObject);
344
435
  delete transition.patch.lazy;
@@ -350,7 +441,12 @@ function projectRecentForModel(recent: RecentTransitionWindow): RecentTransition
350
441
  }
351
442
  }
352
443
  for (const record of projected) record.transitions = record.transitions.filter(({ patch }) => Object.keys(patch).length > 0);
353
- return projected.filter(({ transitions }) => transitions.length > 0);
444
+ const visible = projected.filter(({ transitions }) => transitions.length > 0);
445
+ if (scopes.length !== 1) return visible;
446
+ return visible.map(record => {
447
+ if (record.transitions.length !== 1) throw new Error("Flat State Flow recent history requires one patch per captured owner");
448
+ return { id: record.id, at: record.at, patch: projectModelReferences(record.transitions[0]!.patch, scopes) };
449
+ });
354
450
  }
355
451
 
356
452
  export function runtimeContextMessage(
@@ -366,11 +462,11 @@ export function runtimeContextMessage(
366
462
 
367
463
  /** Render a view already projected by this domain without cloning the full semantic overlay twice. */
368
464
  export function runtimeContextHead(snapshot: Snapshot, view: ContextView, recentTransitions: RecentTransitionWindow = []): AgentMessage {
369
- const recent = projectRecentForModel(recentTransitions);
465
+ const scopes = viewScopes(view), recent = projectRecentForModel(recentTransitions, scopes);
370
466
  const context = {
371
467
  ...(snapshot.meta.specification === undefined ? {} : { specification: snapshot.meta.specification }),
372
- state: view.state,
373
- ...(view.lazy_navigation === undefined ? {} : { lazy_navigation: view.lazy_navigation }),
468
+ state: modelState(view.state, scopes),
469
+ ...(view.lazy_navigation === undefined ? {} : { lazy_navigation: modelNavigation(view.lazy_navigation, scopes) }),
374
470
  ...(view.knowledge_rehydration === null ? {} : { knowledge_rehydration: view.knowledge_rehydration }),
375
471
  ...(view.artifact_invalidations.length === 0 ? {} : { artifact_invalidations: view.artifact_invalidations.map(({ path, scope, reason }) => ({ path, ...(scope === undefined ? {} : { scope }), reason })) }),
376
472
  ...(recent.length === 0 ? {} : { recent_transitions: recent }),
@@ -1,11 +1,11 @@
1
1
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import { parseScopeStream, resolveSessionAddress, sessionRuntimePaths, temporalScopePaths } from "./durable.ts";
3
+ import { captureOwnedFileBases, resolveSessionAddress, sessionRuntimePaths } from "./durable.ts";
4
4
  import { MAX_HISTORY_LIMIT } from "./history.ts";
5
5
  import { diagnosticText } from "./protocol.ts";
6
- import { parseSessionRuntime } from "./snapshot.ts";
6
+ import { DEFAULT_SCOPE_SELECTION } from "./selection.ts";
7
+ import { prepareSessionPolicyMigration } from "./snapshot.ts";
7
8
  import { withStorageTransaction } from "./storage.ts";
8
- import { validateScopeLineage } from "./temporal.ts";
9
9
 
10
10
  export type ContinuationTransport = "local" | "sdk" | "telegram" | string;
11
11
 
@@ -229,20 +229,17 @@ export async function inspectStateFlowContinuationProvenance(
229
229
  try {
230
230
  if (!lstatSync(root, { throwIfNoEntry: false })) return absent;
231
231
  const result = await withStorageTransaction(root, (tx) => {
232
- const files = new Map(tx.capture(selected.cwd, selected.id, root, sessionKey).files.map((file) => [file.path, file.content]));
233
- const privatePaths = temporalScopePaths(selected.cwd, selected.id, "session", root, sessionKey);
234
- if ([paths.config, paths.runtime, privatePaths.meta, privatePaths.checkpoint, privatePaths.patches].every((path) => files.get(path) === undefined)) return absent;
235
- const runtime = parseSessionRuntime(files.get(paths.config), files.get(paths.runtime), selected.cwd, selected.id);
236
- if (!runtime) throw new Error("incomplete canonical session runtime");
237
- if (runtime.config.mode !== "active") return { stateFlow: { enabled: false, restorable: true }, reason: "State Flow is not active on selected runtime" };
238
- const streams = (["global", "cwd", "session"] as const).map((scope) => {
239
- const owned = temporalScopePaths(selected.cwd, selected.id, scope, root, sessionKey);
240
- return parseScopeStream(files.get(owned.checkpoint), files.get(owned.patches), scope,
241
- scope === "cwd" ? selected.cwd : undefined, files.get(owned.meta));
242
- });
243
- if (streams.some((stream) => stream === undefined)) throw new Error("incomplete canonical temporal cohort");
244
- // Shared streams remain current and independently valid; only the private stream binds to this lineage.
245
- validateScopeLineage(streams[2]!, "session", runtime.meta.lineage, MAX_HISTORY_LIMIT);
232
+ const lifecycleFiles = captureOwnedFileBases([paths.config, paths.runtime], root);
233
+ const files = new Map(lifecycleFiles.map(file => [file.path, file.content]));
234
+ const policy = prepareSessionPolicyMigration(files.get(paths.config), files.get(paths.runtime), selected.cwd, selected.id, DEFAULT_SCOPE_SELECTION);
235
+ if (!policy) {
236
+ // Confirm absence/private presence without reading any shared or private semantic body.
237
+ if (tx.hasSelectedCurrentAuthority(selected.cwd, selected.id, DEFAULT_SCOPE_SELECTION, root, sessionKey)) throw new Error("State Flow lifecycle changed during inspection");
238
+ return absent;
239
+ }
240
+ // Reuse current admission; bind the decoded selection to exact lifecycle bytes before any wider interpretation.
241
+ const current = tx.prepareSelectedCurrent(selected.cwd, selected.id, policy.runtime.config.scopes, MAX_HISTORY_LIMIT, root, sessionKey, { files: lifecycleFiles });
242
+ if (current.lifecycle.runtime.config.mode !== "active") return { stateFlow: { enabled: false, restorable: true }, reason: "State Flow is not active on selected runtime" };
246
243
  return { stateFlow: { enabled: true, restorable: true }, reason: "canonical session lineage is valid beside current shared streams" };
247
244
  }, signal);
248
245
  signal?.throwIfAborted();
@@ -19,8 +19,10 @@ import {
19
19
  type ArtifactProvenanceRegistry,
20
20
  } from "./artifact.ts";
21
21
  import { MAX_HISTORY_LIMIT } from "./history.ts";
22
+ import { prepareSerializedScopeMigration } from "./migration.ts";
22
23
  import { canonicalJson, isJsonValue, isObject, type JsonObject } from "./json.ts";
23
24
  import { validateScopeStream, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
25
+ import { assertRetainedModules, validateRetainedModules } from "./semantic-module.ts";
24
26
  import { selectSemanticFields, type StateScope } from "./state.ts";
25
27
 
26
28
  const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
@@ -66,8 +68,8 @@ export function serializeScopeStream(stream: ScopeStream, scope: StateScope, cwd
66
68
  if (scope === "cwd" && cwdIdentity === undefined) throw new Error("State Flow CWD scope serialization requires its canonical identity");
67
69
  if (scope !== "cwd" && cwdIdentity !== undefined) throw new Error("Only State Flow CWD scope serialization accepts a CWD identity");
68
70
  return {
69
- checkpoint: `${canonicalJson(selectSemanticFields(stream.checkpoint.state))}\n`,
70
- patches: stream.patches.map((record) => `${canonicalJson(selectSemanticFields(record.patch as JsonObject))}\n`).join(""),
71
+ checkpoint: `${canonicalJson(selectSemanticFields(stream.checkpoint.state, stream.modules))}\n`,
72
+ patches: stream.patches.map((record) => `${canonicalJson(selectSemanticFields(record.patch as JsonObject, stream.modules))}\n`).join(""),
71
73
  temporal: {
72
74
  revision: stream.revision,
73
75
  checkpoint: structuredClone(stream.checkpoint.through),
@@ -80,6 +82,10 @@ export type ScopeStreamPresence =
80
82
  | { kind: "absent" }
81
83
  | { kind: "present"; stream: ScopeStream };
82
84
 
85
+ function isSupportedScopeMetadataVersion(version: unknown): boolean {
86
+ return version === 1 || version === 2 || version === 3 || version === 4;
87
+ }
88
+
83
89
  /** Distinguish a wholly absent semantic cohort from partial or malformed surviving authority. */
84
90
  export function classifyScopeStream(
85
91
  checkpointSource: string | undefined,
@@ -93,46 +99,29 @@ export function classifyScopeStream(
93
99
  if (checkpointSource === undefined || patchesSource === undefined) {
94
100
  throw new Error(`State Flow ${scope} scope has an incomplete checkpoint/tail pair`);
95
101
  }
96
- let checkpoint: unknown;
97
- try {
98
- checkpoint = JSON.parse(checkpointSource);
99
- } catch {
100
- throw new Error(`State Flow ${scope} checkpoint contains invalid JSON`);
101
- }
102
- let meta: Record<string, unknown> | undefined;
103
- if (metaSource !== undefined) {
104
- try { meta = JSON.parse(metaSource) as Record<string, unknown>; }
105
- catch { throw new Error(`State Flow ${scope} metadata contains invalid JSON`); }
106
- if (!isObject(meta)) throw new Error(`Invalid State Flow ${scope} metadata`);
107
- }
108
- const patches: unknown[] = [];
109
- for (const [index, line] of patchesSource.split(/\r?\n/).entries()) {
110
- if (line.trim().length === 0) continue;
111
- try {
112
- patches.push(JSON.parse(line));
113
- } catch {
114
- throw new Error(`State Flow ${scope} tail contains invalid JSON at line ${index + 1}`);
102
+ if (metaSource === undefined) {
103
+ try { JSON.parse(checkpointSource); }
104
+ catch { throw new Error(`State Flow ${scope} checkpoint contains invalid JSON`); }
105
+ for (const [index, line] of patchesSource.split(/\r?\n/).entries()) {
106
+ if (line.trim().length === 0) continue;
107
+ try { JSON.parse(line); }
108
+ catch { throw new Error(`State Flow ${scope} tail contains invalid JSON at line ${index + 1}`); }
115
109
  }
116
- }
117
- const temporal = meta?.temporal;
118
- if (!isObject(temporal) || !Object.hasOwn(temporal, "checkpoint") || !Array.isArray(temporal.patches)) {
119
110
  throw new Error(`Unsupported State Flow ${scope} storage format`);
120
111
  }
121
- const owner = meta?.owner;
122
- if (scope === "cwd" && expectedCwd !== undefined
123
- && (!isObject(owner) || Object.keys(owner).join(",") !== "cwd" || owner.cwd !== resolve(expectedCwd))) {
124
- throw new Error(owner === undefined ? "State Flow CWD scope identity is missing" : "State Flow CWD scope identity mismatch");
112
+ const metadata = parseMetadataDocument(metaSource, `State Flow ${scope} metadata`);
113
+ if (!isSupportedScopeMetadataVersion(metadata.version)) throw new Error(`Unsupported State Flow ${scope} metadata version`);
114
+ // In Unicode mode this range matches lone UTF-16 surrogates, not valid pairs or JSON escapes.
115
+ if ([checkpointSource, patchesSource, metaSource].some((source) => /[\uD800-\uDFFF]/u.test(source))) {
116
+ throw new Error(`State Flow ${scope} source is not lossless UTF-8 text`);
125
117
  }
126
- const boundaries = temporal.patches;
127
- if (boundaries.length !== patches.length) throw new Error(`State Flow ${scope} temporal metadata does not match its semantic tail`);
118
+ const decoded = prepareSerializedScopeMigration({
119
+ checkpoint: Buffer.from(checkpointSource), patches: Buffer.from(patchesSource), meta: Buffer.from(metaSource),
120
+ }, scope, scope === "cwd" ? expectedCwd : undefined);
128
121
  const stream = {
129
- // 0.17.4 and earlier did not persist scope revisions. Credit only their still-retained
130
- // semantic tail; discarded ancestry cannot be reconstructed without inventing history.
131
- revision: temporal.revision === undefined ? patches.length : temporal.revision,
132
- checkpoint: { through: temporal.checkpoint, state: isObject(checkpoint) ? selectSemanticFields(checkpoint) : checkpoint },
133
- patches: patches.map((patch, index) => ({
134
- transition: boundaries[index], patch: isObject(patch) ? selectSemanticFields(patch) : patch,
135
- })),
122
+ ...decoded.stream,
123
+ checkpoint: { ...decoded.stream.checkpoint, state: selectSemanticFields(decoded.stream.checkpoint.state, decoded.stream.modules) },
124
+ patches: decoded.stream.patches.map((record) => ({ ...record, patch: selectSemanticFields(record.patch as JsonObject, decoded.stream.modules) })),
136
125
  };
137
126
  validateScopeStream(stream, scope, MAX_HISTORY_LIMIT);
138
127
  return { kind: "present", stream };
@@ -220,15 +209,17 @@ export function serializeScopeMetadata(
220
209
  cwdIdentity?: string, existingSource?: string,
221
210
  ): string {
222
211
  const existing = parseMetadataDocument(existingSource, `State Flow metadata`);
223
- if (scope === "session") {
224
- for (const key of ["identity", "lineage", "step", "specification", "validation", "bootstrap"]) delete existing[key];
212
+ if (Object.keys(existing).length > 0 && !isSupportedScopeMetadataVersion(existing.version)) {
213
+ throw new Error("Unsupported State Flow metadata version");
225
214
  }
215
+ if (existing.version === 4) assertRetainedModules(existing.modules, stream.modules);
226
216
  const sources = serializeScopeStream(stream, scope, cwdIdentity);
227
217
  const value = {
228
218
  ...existing,
229
- version: 2,
219
+ version: stream.modules === undefined ? 3 : 4,
220
+ ...(stream.modules === undefined ? {} : { modules: structuredClone(stream.modules) }),
230
221
  ...(registry === undefined ? {} : { artifacts: serializeArtifactProvenanceRegistry(registry) }),
231
- temporal: sources.temporal,
222
+ temporal: { ...(isObject(existing.temporal) ? existing.temporal : {}), ...sources.temporal },
232
223
  ...(scope === "cwd" ? { owner: { cwd: resolve(cwdIdentity!) } } : {}),
233
224
  };
234
225
  return `${canonicalJson(value)}\n`;
@@ -236,7 +227,7 @@ export function serializeScopeMetadata(
236
227
 
237
228
  /** Compatibility serializer retained for metadata-only callers. */
238
229
  export function serializeScopeProvenance(registry: Readonly<ArtifactProvenanceRegistry>): string {
239
- return `${canonicalJson({ version: 2, artifacts: serializeArtifactProvenanceRegistry(registry) })}\n`;
230
+ return `${canonicalJson({ version: 3, artifacts: serializeArtifactProvenanceRegistry(registry) })}\n`;
240
231
  }
241
232
 
242
233
  function parseMetadataDocument(source: string | undefined, label: string): Record<string, unknown> {
@@ -252,7 +243,8 @@ function parseMetadataDocument(source: string | undefined, label: string): Recor
252
243
  export function parseScopeProvenance(source: string | undefined, path: string): ArtifactProvenanceRegistry {
253
244
  const value = parseMetadataDocument(source, `State Flow provenance file: ${JSON.stringify(path)}`);
254
245
  if (Object.keys(value).length === 0) return {};
255
- if (value.version !== 1 && value.version !== 2) throw new Error(`Invalid State Flow provenance document: ${JSON.stringify(path)}`);
246
+ if (!isSupportedScopeMetadataVersion(value.version)) throw new Error(`Invalid State Flow provenance document: ${JSON.stringify(path)}`);
247
+ if (value.version === 4) validateRetainedModules(value.modules);
256
248
  if (!Object.hasOwn(value, "artifacts")) return {};
257
249
  return parseArtifactProvenanceRegistry(value.artifacts, `State Flow provenance at ${JSON.stringify(path)}`);
258
250
  }
@@ -403,7 +395,8 @@ function readRegularBytes(path: string, repositoryRoot: string): Buffer | undefi
403
395
  }
404
396
 
405
397
  function readRegularFile(path: string, repositoryRoot: string): string | undefined {
406
- return readRegularBytes(path, repositoryRoot)?.toString("utf8");
398
+ const bytes = readRegularBytes(path, repositoryRoot);
399
+ return bytes === undefined ? undefined : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
407
400
  }
408
401
 
409
402
  function byteIdentity(bytes: Uint8Array | undefined): DurableFileBase["identity"] {
@@ -422,6 +415,22 @@ function assertCurrentBytes(path: string, root: string, expected: Uint8Array | u
422
415
  }
423
416
  }
424
417
 
418
+ export interface OwnedFilePresence { path: string; present: boolean }
419
+
420
+ /** Presence-only private-origin/unsupported-layout evidence; never open or decode semantic bodies. */
421
+ export function captureOwnedFilePresence(paths: readonly string[], repositoryRoot: string): OwnedFilePresence[] {
422
+ const root = resolve(repositoryRoot);
423
+ return paths.map((requested) => {
424
+ const path = resolve(requested);
425
+ const retiredLayout = basename(path) === STATE_FILE && isStateFlowOwnedPath(join(dirname(path), CHECKPOINT_FILE), root);
426
+ if (!isStateFlowOwnedPath(path, root) && !retiredLayout) throw new Error(`Cannot inspect a non-State Flow path: ${JSON.stringify(path)}`);
427
+ if (!assertDirectoryChain(root, dirname(path), false)) return { path, present: false };
428
+ const stat = lstatSync(path, { throwIfNoEntry: false });
429
+ if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw new Error(`Durable State Flow path is not a regular file: ${JSON.stringify(path)}`);
430
+ return { path, present: stat !== undefined };
431
+ });
432
+ }
433
+
425
434
  /** Capture exact owned bytes for one compare-and-swap publication cohort. */
426
435
  export function captureOwnedFileBases(paths: readonly string[], repositoryRoot: string): DurableFileBase[] {
427
436
  const root = resolve(repositoryRoot);