@llblab/pi-kit 0.24.0 → 0.25.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 (112) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +6 -5
  4. package/node_modules/@llblab/pi-claude-usage/AGENTS.md +20 -0
  5. package/node_modules/@llblab/pi-claude-usage/BACKLOG.md +3 -0
  6. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +13 -0
  7. package/node_modules/@llblab/pi-claude-usage/LICENSE +22 -0
  8. package/node_modules/@llblab/pi-claude-usage/README.md +110 -0
  9. package/node_modules/@llblab/pi-claude-usage/banner.jpg +0 -0
  10. package/node_modules/@llblab/pi-claude-usage/index.ts +1159 -0
  11. package/node_modules/@llblab/pi-claude-usage/package.json +60 -0
  12. package/node_modules/@llblab/pi-state-flow/AGENTS.md +42 -56
  13. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +16 -3
  14. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -0
  15. package/node_modules/@llblab/pi-state-flow/README.md +15 -12
  16. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
  17. package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +7 -3
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +16 -7
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +9 -9
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +5 -4
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +2 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -1
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -4
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.d.ts +3 -3
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.js +5 -5
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +3 -5
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +275 -199
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +11 -4
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +6 -7
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +4 -1
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +1 -0
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +4 -5
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +13 -13
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +7 -6
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +9 -9
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +3 -2
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +17 -12
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +4 -1
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +2 -1
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +17 -8
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +49 -20
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +22 -3
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +30 -10
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +5 -3
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +19 -28
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +17 -15
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +52 -52
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +8 -4
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +34 -18
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +5 -5
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +13 -19
  53. package/node_modules/@llblab/pi-state-flow/dist/package.json +3 -3
  54. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +2 -2
  55. package/node_modules/@llblab/pi-state-flow/docs/README.md +2 -1
  56. package/node_modules/@llblab/pi-state-flow/docs/agent-contract-relocation.md +72 -0
  57. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +36 -32
  58. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +12 -4
  59. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +5 -5
  60. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +6 -6
  61. package/node_modules/@llblab/pi-state-flow/docs/performance.md +1 -1
  62. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +13 -12
  63. package/node_modules/@llblab/pi-state-flow/docs/usage.md +32 -29
  64. package/node_modules/@llblab/pi-state-flow/index.ts +3 -2
  65. package/node_modules/@llblab/pi-state-flow/lib/config.ts +20 -10
  66. package/node_modules/@llblab/pi-state-flow/lib/context.ts +15 -14
  67. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +1 -1
  68. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -6
  69. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +6 -6
  70. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +274 -197
  71. package/node_modules/@llblab/pi-state-flow/lib/history.ts +16 -11
  72. package/node_modules/@llblab/pi-state-flow/lib/logging.ts +5 -1
  73. package/node_modules/@llblab/pi-state-flow/lib/query.ts +16 -16
  74. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +11 -11
  75. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +19 -13
  76. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -3
  77. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +55 -22
  78. package/node_modules/@llblab/pi-state-flow/lib/state.ts +46 -13
  79. package/node_modules/@llblab/pi-state-flow/lib/status.ts +23 -32
  80. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +66 -65
  81. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +39 -19
  82. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +19 -27
  83. package/node_modules/@llblab/pi-state-flow/package.json +3 -3
  84. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +2 -2
  85. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  86. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  87. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +2 -0
  88. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +55 -2
  89. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -0
  90. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +144 -1
  91. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +9 -0
  92. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +19 -0
  93. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +13 -0
  94. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +29 -6
  95. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +16 -0
  96. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +5 -1
  97. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +6 -2
  98. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +7 -0
  99. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +13 -5
  100. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  101. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
  102. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  103. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +79 -1
  104. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +197 -0
  105. package/node_modules/@llblab/pi-telegram/lib/bus.ts +33 -0
  106. package/node_modules/@llblab/pi-telegram/lib/commands.ts +38 -6
  107. package/node_modules/@llblab/pi-telegram/lib/extension.ts +15 -0
  108. package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
  109. package/node_modules/@llblab/pi-telegram/lib/polling.ts +10 -2
  110. package/node_modules/@llblab/pi-telegram/lib/threads.ts +19 -5
  111. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  112. package/package.json +7 -3
@@ -1,13 +1,21 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { isJsonValue, isObject, sameJson, validatePatch, type JsonObject } from "./json.ts";
3
- import type { ScopePatch, ScopedStates, StateScope } from "./state.ts";
2
+ import { isJsonValue, isObject, sameJson, validatePatch, type JsonObject, type JsonValue } from "./json.ts";
3
+ import type { ScopedSemanticStates, StateScope } from "./state.ts";
4
4
 
5
5
  export const DEFAULT_HISTORY_LIMIT = 7;
6
6
  export const MAX_HISTORY_LIMIT = 100;
7
7
 
8
8
  export interface RecentScopePatch {
9
9
  scope: StateScope;
10
- patch: ScopePatch & { response?: string };
10
+ patch: {
11
+ [key: string]: JsonValue | undefined;
12
+ intents?: JsonObject | null;
13
+ contract?: JsonObject | null;
14
+ working?: JsonObject | null;
15
+ artifacts?: JsonObject | null;
16
+ lazy?: JsonObject | null;
17
+ response?: string | null;
18
+ };
11
19
  }
12
20
 
13
21
  /** Exact accepted replay cohort; temporal runtime owns its causal boundary. */
@@ -51,13 +59,9 @@ function validateScopedPatch(value: unknown): asserts value is RecentScopePatch
51
59
  validatePatch(value.patch);
52
60
  for (const [key, field] of Object.entries(value.patch)) {
53
61
  const valid = key === "response"
54
- ? value.scope === "session" && typeof field === "string"
55
- : key === "lazy"
56
- ? field !== null
57
- : PATCH_KEYS.has(key) && isObject(field);
58
- if (!PATCH_KEYS.has(key) || !valid) {
59
- throw new Error("Recent State Flow patches may contain hot object planes, ordinary-JSON lazy state, and a session response string");
60
- }
62
+ ? value.scope === "session" && (field === null || typeof field === "string")
63
+ : !PATCH_KEYS.has(key) || field === null || isObject(field);
64
+ if (!valid) throw new Error("Recent State Flow patches require object planes or deletion, and a session response string or deletion");
61
65
  }
62
66
  }
63
67
 
@@ -74,10 +78,11 @@ export function validateRecentTransition(value: unknown): asserts value is Recen
74
78
  }
75
79
  }
76
80
 
77
- export function createAcceptedTransition(currentStates: ScopedStates, nextStates: ScopedStates, id?: string): AcceptedTransition | undefined {
81
+ export function createAcceptedTransition(currentStates: ScopedSemanticStates, nextStates: ScopedSemanticStates, id?: string): AcceptedTransition | undefined {
78
82
  const transitions: RecentScopePatch[] = [];
79
83
  for (const scope of SCOPES) {
80
84
  const patch = replayPatch(currentStates[scope], nextStates[scope]);
85
+ if ((currentStates[scope].response ?? "") === (nextStates[scope].response ?? "")) delete patch.response;
81
86
  if (Object.keys(patch).length > 0) transitions.push({ scope, patch });
82
87
  }
83
88
  if (transitions.length === 0) return undefined;
@@ -3,7 +3,7 @@ import { appendFileSync, closeSync, constants, fstatSync, lstatSync, mkdirSync,
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { isObject } from "./json.ts";
5
5
 
6
- export type StateFlowDiagnosticCategory = "invalid-patch" | "publication-conflict" | "finalization";
6
+ export type StateFlowDiagnosticCategory = "invalid-patch" | "publication-conflict" | "finalization" | "barrier-block";
7
7
 
8
8
  /** Minimal structural block; only ordinary text keeps its exact content. */
9
9
  export interface StateFlowDiagnosticBlock {
@@ -22,6 +22,8 @@ export interface StateFlowDiagnosticRecord {
22
22
  input?: unknown;
23
23
  tool?: string;
24
24
  toolCallId?: string;
25
+ /** Names only, in native assistant-batch order; never sibling arguments. */
26
+ batchToolNames?: string[];
25
27
  }
26
28
 
27
29
  /** Preserve exact text blocks and block boundaries; reasoning bodies are never duplicated. */
@@ -62,6 +64,7 @@ export interface DiagnosticExtras {
62
64
  input?: unknown;
63
65
  tool?: string;
64
66
  toolCallId?: string;
67
+ batchToolNames?: readonly string[];
65
68
  }
66
69
 
67
70
  /** Own diagnostic path safety, projection, persistence, and one-shot failure reporting. */
@@ -105,6 +108,7 @@ export class StateFlowDiagnosticWriter {
105
108
  ...(extras.input === undefined ? {} : { input: extras.input }),
106
109
  ...(extras.tool === undefined ? {} : { tool: extras.tool }),
107
110
  ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
111
+ ...(extras.batchToolNames === undefined ? {} : { batchToolNames: [...extras.batchToolNames] }),
108
112
  });
109
113
  return true;
110
114
  } catch (failure) {
@@ -1,15 +1,15 @@
1
- import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT } from "./history.ts";
2
- import { isObject, sameJson, type JsonValue } from "./json.ts";
3
- import { projectStateForModel, type ModelState, type ScopePatch, type StateScope } from "./state.ts";
4
- import { readTemporalState, type TemporalState, type TransitionBoundary } from "./temporal.ts";
1
+ import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT, type RecentScopePatch } from "./history.ts";
2
+ import { isObject, sameJson, type JsonObject, type JsonValue } from "./json.ts";
3
+ import { emptyState, projectSemanticPatch, projectStateForModel, type SemanticState, type StateScope } from "./state.ts";
4
+ import { readTemporalView, type TemporalState, type TransitionBoundary } from "./temporal.ts";
5
5
 
6
6
  export type StateReadQuery =
7
7
  | { kind: "state"; path: string; offset: number; scope?: StateScope }
8
8
  | { kind: "patch"; path: string; offset: number; scope: StateScope };
9
9
 
10
10
  export type StateReadResult =
11
- | { path: string; boundary: TransitionBoundary; state: ModelState }
12
- | { path: string; boundary: TransitionBoundary; patch: ScopePatch & { response?: string } };
11
+ | { path: string; boundary: TransitionBoundary; state: SemanticState }
12
+ | { path: string; boundary: TransitionBoundary; patch: RecentScopePatch["patch"] };
13
13
 
14
14
  export type StateReadProjection = "value" | "keys" | "patch";
15
15
  type StateReadMeta = { type: "object"; size: number } | { type: "array"; length: number } | { type: "string"; length: number } | { type: "number" | "boolean" };
@@ -61,11 +61,11 @@ export function readStatePath(view: TemporalState, path: string, historyLimit =
61
61
  if (query.kind === "state") {
62
62
  const boundary = view.lineage[view.lineage.length - 1 - query.offset];
63
63
  if (!boundary) throw new Error("Requested history predates the proven temporal origin");
64
- return { path, boundary: structuredClone(boundary), state: projectStateForModel(readTemporalState(view, query.offset, query.scope, historyLimit)) };
64
+ return { path, boundary: structuredClone(boundary), state: projectStateForModel(readTemporalView(view, query.offset, query.scope, historyLimit)) };
65
65
  }
66
66
  const record = view.scopes[query.scope].patches.at(-1 - query.offset);
67
67
  if (!record) throw new Error(`Requested ${query.scope} patch predates retained hot history`);
68
- return { path, boundary: structuredClone(record.transition), patch: structuredClone(record.patch) };
68
+ return { path, boundary: structuredClone(record.transition), patch: projectSemanticPatch(record.patch as JsonObject) };
69
69
  }
70
70
 
71
71
  function parseValuePath(path: string): { root: string; selectors: ValueSelector[] } {
@@ -170,7 +170,7 @@ export function findStateReferenceSources(view: TemporalState, path: string, his
170
170
  }
171
171
  };
172
172
  for (const scope of ["global", "cwd", "session"] as const) {
173
- const state = readTemporalState(view, 0, scope, historyLimit);
173
+ const state = readTemporalView(view, 0, scope, historyLimit);
174
174
  for (const plane of ["artifacts", "contract", "working", "intents", "lazy"] as const) {
175
175
  const value = state[plane];
176
176
  if (value !== undefined) visit(value as JsonValue, scope, `${scope}.${plane}`);
@@ -213,12 +213,12 @@ function patchAtPath(view: TemporalState, root: string, selectors: readonly Valu
213
213
  if (!boundary) throw new Error("Requested history predates the proven temporal origin");
214
214
  let patch: JsonValue = {};
215
215
  if (rootQuery.scope) {
216
- patch = structuredClone(view.scopes[rootQuery.scope].patches.find((record) => record.transition.id === boundary.id)?.patch ?? {}) as JsonValue;
216
+ patch = projectSemanticPatch((view.scopes[rootQuery.scope].patches.find((record) => record.transition.id === boundary.id)?.patch ?? {}) as JsonObject);
217
217
  } else {
218
218
  const before = view.lineage.at(-2 - rootQuery.offset);
219
219
  if (before) {
220
- const current = readTemporalState(view, rootQuery.offset, undefined, historyLimit);
221
- const previous = readTemporalState(view, rootQuery.offset + 1, undefined, historyLimit);
220
+ const current = readTemporalView(view, rootQuery.offset, undefined, historyLimit);
221
+ const previous = readTemporalView(view, rootQuery.offset + 1, undefined, historyLimit);
222
222
  patch = diffObjects(previous, current);
223
223
  }
224
224
  }
@@ -274,10 +274,10 @@ export function readProjectedState(view: TemporalState, paths: readonly string[]
274
274
  const query = parseStateReadPath(root, historyLimit);
275
275
  if (query.kind !== "state") throw new Error("Value and keys projections require a state path");
276
276
  const readsLazy = selectors[0]?.kind === "key" && selectors[0].key === "lazy";
277
- const state = readsLazy
278
- ? readTemporalState(view, query.offset, query.scope, historyLimit)
279
- : projectStateForModel(readTemporalState(view, query.offset, query.scope, historyLimit));
280
- if (readsLazy && !Object.hasOwn(state, "lazy")) state.lazy = {};
277
+ const raw = readTemporalView(view, query.offset, query.scope, historyLimit);
278
+ const state = readsLazy ? raw : projectStateForModel(raw);
279
+ const field = selectors.length === 1 && selectors[0]?.kind === "key" ? selectors[0].key : undefined;
280
+ if (projection === "value" && field !== undefined && Object.hasOwn(emptyState(), field) && !Object.hasOwn(state, field)) return { value: null };
281
281
  return projectValue(selectValue(state, selectors, path), projection);
282
282
  } catch (error) {
283
283
  const message = error instanceof Error ? error.message : String(error);
@@ -1,34 +1,34 @@
1
- import { parseRetainedPiCheckpoint, migrationFailure, type RetainedBoundaryCheckpoint, type RetainedPiCheckpoint, type Snapshot } from "./snapshot.ts";
1
+ import { parseRetainedPiCheckpoint, migrationFailure, type InactiveMode, type RetainedBoundaryCheckpoint, type RetainedPiCheckpoint, type Snapshot } from "./snapshot.ts";
2
2
 
3
3
  export type RetainedCheckpointSelection =
4
4
  | { kind: "boundary"; checkpoint: RetainedBoundaryCheckpoint; skipped: string[] }
5
- | { kind: "disabled"; skipped: string[] }
5
+ | { kind: "pre-runtime"; mode: InactiveMode; skipped: string[] }
6
6
  | { kind: "unavailable"; snapshot: Snapshot; skipped: string[] };
7
7
 
8
- /** Select the newest supported retained-boundary checkpoint or disabled marker; unsupported pointers fail closed. */
9
- export function selectRetainedCheckpoint(candidates: readonly unknown[]): RetainedCheckpointSelection {
8
+ /** Select the newest supported retained-boundary checkpoint or pre-runtime mode; unsupported pointers fail closed. */
9
+ export function selectRetainedCheckpoint(candidates: readonly unknown[], inactiveMode: InactiveMode = "passive"): RetainedCheckpointSelection {
10
10
  const skipped: string[] = [];
11
11
  for (const candidate of candidates) {
12
12
  let retained: RetainedPiCheckpoint;
13
13
  try {
14
14
  if (typeof candidate === "object" && candidate !== null && Object.hasOwn(candidate, "revision")) {
15
- return { kind: "unavailable", snapshot: migrationFailure({}, "Snapshot restoration failed: revision-pointer checkpoints are unsupported"), skipped };
15
+ return { kind: "unavailable", snapshot: migrationFailure({}, "Snapshot restoration failed: revision-pointer checkpoints are unsupported", inactiveMode), skipped };
16
16
  }
17
- retained = parseRetainedPiCheckpoint(candidate);
17
+ retained = parseRetainedPiCheckpoint(candidate, inactiveMode);
18
18
  } catch (error) {
19
19
  skipped.push(`Snapshot restoration failed: ${error instanceof Error ? error.message : String(error)}`);
20
20
  continue;
21
21
  }
22
- return "disabled" in retained ? { kind: "disabled", skipped } : { kind: "boundary", checkpoint: retained, skipped };
22
+ return "boundary" in retained ? { kind: "boundary", checkpoint: retained, skipped } : { kind: "pre-runtime", mode: retained.mode, skipped };
23
23
  }
24
24
  return {
25
25
  kind: "unavailable",
26
- snapshot: migrationFailure({}, skipped[0] ?? "Snapshot restoration failed: no supported checkpoint"),
26
+ snapshot: migrationFailure({}, skipped[0] ?? "Snapshot restoration failed: no supported checkpoint", inactiveMode),
27
27
  skipped,
28
28
  };
29
29
  }
30
30
 
31
- /** Withdraw a caller's join without cancelling independently owned recovery or Stop persistence. */
31
+ /** Withdraw a caller's join without cancelling independently owned recovery or mode persistence. */
32
32
  export function waitForRecovery<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
33
33
  return new Promise<T>((resolve, reject) => {
34
34
  const aborted = () => reject(signal.reason);
@@ -45,6 +45,6 @@ export function waitForRecovery<T>(operation: Promise<T>, signal: AbortSignal):
45
45
  }
46
46
 
47
47
  /** A selected boundary that cannot be resolved stays unavailable; callers never fall through to older evidence. */
48
- export function selectedBoundaryFailure(cause: string): Snapshot {
49
- return migrationFailure({}, `Snapshot restoration failed: ${cause}`);
48
+ export function selectedBoundaryFailure(cause: string, mode: InactiveMode = "passive"): Snapshot {
49
+ return migrationFailure({}, `Snapshot restoration failed: ${cause}`, mode);
50
50
  }
@@ -6,16 +6,16 @@ import { parseArtifactProvenanceRegistry, pruneArtifactProvenance, type Artifact
6
6
  import { assertTemporalFileBase, captureTemporalFileBase, initializeFileStore, publishTemporalStateToFiles, withStorageTransaction, type StorageTransaction, type TemporalFileBase } from "./storage.ts";
7
7
  import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT, type AcceptedTransition, type RecentTransitionWindow } from "./history.ts";
8
8
  import { hashJson, sameJson } from "./json.ts";
9
- import { HistoryBoundaryExpiredError, RevisionUnavailableError, createSessionRuntime, parseSessionRuntime, retainedBoundaryCheckpoint, type RetainedBoundaryCheckpoint, type RetainedPiCheckpoint, type Snapshot } from "./snapshot.ts";
10
- import { emptyState, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
11
- import { adoptTemporalStreams, advanceTemporalState, constrainTemporalState, createTemporalState, readTemporalState, selectScopeStreamAtBoundary, validateScopeLineage, type ScopeStream, type TemporalState } from "./temporal.ts";
9
+ import { HistoryBoundaryExpiredError, RevisionUnavailableError, createSessionRuntime, parseSessionRuntime, preRuntimeCheckpoint, retainedBoundaryCheckpoint, type RetainedBoundaryCheckpoint, type RetainedPiCheckpoint, type Snapshot } from "./snapshot.ts";
10
+ import { type MaterializedState, type SemanticState, type ScopedSemanticStates, type ScopedStates, type StateScope } from "./state.ts";
11
+ import { adoptTemporalStreams, advanceTemporalState, constrainTemporalState, createTemporalState, readTemporalScopes, readTemporalState, readTemporalView, selectScopeStreamAtBoundary, validateScopeLineage, type ScopeStream, type TemporalState } from "./temporal.ts";
12
12
 
13
13
  const SCOPES = ["global", "cwd", "session"] as const;
14
14
  const SHARED_SCOPES = ["global", "cwd"] as const;
15
15
  export type RuntimePublication = ReturnType<typeof publishTemporalStateToFiles>;
16
16
 
17
17
  export interface RuntimePatchTransaction {
18
- readonly states: ScopedStates;
18
+ readonly states: ScopedSemanticStates;
19
19
  readonly causalBasis: string;
20
20
  readonly provenance: Record<StateScope, ArtifactProvenanceRegistry>;
21
21
  publish(snapshot: Snapshot, accepted?: AcceptedTransition, provenance?: Partial<Record<StateScope, Record<string, ArtifactProvenance>>>): RuntimePublication;
@@ -68,7 +68,7 @@ function removedTargetScopeConflict(scopes: readonly StateScope[]): Error {
68
68
  }
69
69
 
70
70
  function freshEmptyScopeStream(scope: StateScope, origin: string, historyLimit: number): ScopeStream {
71
- return createTemporalState({ global: emptyState(), cwd: emptyState(), session: emptyState() }, origin, historyLimit).scopes[scope];
71
+ return createTemporalState({ global: {}, cwd: {}, session: {} }, origin, historyLimit).scopes[scope];
72
72
  }
73
73
 
74
74
  /** Cached branch-selected temporal state and publication basis; excludes Pi event policy. */
@@ -117,7 +117,7 @@ export class TemporalRuntime {
117
117
  })) as Record<(typeof SHARED_SCOPES)[number], ScopeStream | undefined>;
118
118
  if (!shared.global && !shared.cwd) return false;
119
119
  if (!shared.global) throw new Error("Incomplete passive State Flow shared storage: CWD state exists without global state");
120
- const fresh = createTemporalState({ global: emptyState(), cwd: emptyState(), session: emptyState() }, randomUUID(), this.historyLimit);
120
+ const fresh = createTemporalState({ global: {}, cwd: {}, session: {} }, randomUUID(), this.historyLimit);
121
121
  // Global memory is valid before this CWD has ever materialized its own scope.
122
122
  const view = adoptTemporalStreams({ global: shared.global, cwd: shared.cwd ?? fresh.scopes.cwd, session: fresh.scopes.session }, `passive:files:${randomUUID()}`, this.historyLimit);
123
123
  const provenance = {
@@ -151,6 +151,11 @@ export class TemporalRuntime {
151
151
  return readTemporalState(this.view, offset, scope, this.historyLimit);
152
152
  }
153
153
 
154
+ readView(offset = 0, scope?: StateScope): SemanticState {
155
+ if (!this.view) throw new Error("State Flow temporal runtime is unavailable");
156
+ return readTemporalView(this.view, offset, scope, this.historyLimit);
157
+ }
158
+
154
159
  states(): ScopedStates {
155
160
  return { global: this.read(0, "global"), cwd: this.read(0, "cwd"), session: this.read(0, "session") };
156
161
  }
@@ -162,7 +167,7 @@ export class TemporalRuntime {
162
167
 
163
168
  /** Encode Pi lifecycle state against the current retained semantic boundary. */
164
169
  retainedCheckpoint(snapshot: Snapshot): RetainedPiCheckpoint {
165
- if (!this.view) return { disabled: true };
170
+ if (!this.view) return preRuntimeCheckpoint(snapshot.config.mode);
166
171
  return retainedBoundaryCheckpoint(snapshot, this.causalBasis());
167
172
  }
168
173
 
@@ -211,7 +216,7 @@ export class TemporalRuntime {
211
216
  session: selectedSession,
212
217
  }, `restore:${checkpoint.boundary}:${randomUUID()}`, this.historyLimit);
213
218
  const snapshot: Snapshot = {
214
- config: { enabled: checkpoint.enabled },
219
+ config: { mode: checkpoint.mode },
215
220
  meta: {
216
221
  step: checkpoint.step,
217
222
  ...(checkpoint.bootstrap === true ? { bootstrap: true } : {}),
@@ -226,7 +231,7 @@ export class TemporalRuntime {
226
231
  // Live evidence cannot prove an earlier artifact version, even after a change-away-and-back.
227
232
  for (const record of scopes.session.patches) {
228
233
  if (record.transition.position <= boundary.position) continue;
229
- for (const path of Object.keys(record.patch.artifacts ?? {})) delete retained[path];
234
+ for (const path of Object.keys(record.patch.artifacts === null ? retained : record.patch.artifacts ?? {})) delete retained[path];
230
235
  }
231
236
  }
232
237
  return [scope, retained];
@@ -294,8 +299,9 @@ export class TemporalRuntime {
294
299
  const meta = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey).meta;
295
300
  return [scope, parseScopeProvenance(files.get(meta), meta)];
296
301
  })) as Record<StateScope, ArtifactProvenanceRegistry>;
302
+ // Read-only recovery grants no active policy; callers select the inactive mode.
297
303
  const snapshot: Snapshot = {
298
- config: { enabled: false },
304
+ config: { mode: "passive" },
299
305
  meta: { step: document.meta.step, ...(document.meta.bootstrap === true ? { bootstrap: true } : {}) },
300
306
  };
301
307
  this.view = view;
@@ -380,7 +386,7 @@ export class TemporalRuntime {
380
386
  // A pre-runtime branch may establish an empty origin, never import a later session layer.
381
387
  if (newSessionOrigin) streams.session = undefined;
382
388
  if (copy) streams.session = copy.stream;
383
- const fresh = createTemporalState({ global: emptyState(), cwd: emptyState(), session: emptyState() }, randomUUID(), this.historyLimit);
389
+ const fresh = createTemporalState({ global: {}, cwd: {}, session: {} }, randomUUID(), this.historyLimit);
384
390
  const candidate = new TemporalRuntime(this.cwd, this.session, this.root, undefined, this.historyLimit);
385
391
  candidate.base = base;
386
392
  candidate.view = adoptTemporalStreams({ global: streams.global ?? fresh.scopes.global, cwd: streams.cwd ?? fresh.scopes.cwd, session: streams.session ?? fresh.scopes.session }, `files:${randomUUID()}`, this.historyLimit);
@@ -534,7 +540,7 @@ export class TemporalRuntime {
534
540
  const stream = parseScopeStream(files.get(session.checkpoint), files.get(session.patches), "session", undefined, files.get(session.meta));
535
541
  if (!document || !stream) throw new RevisionUnavailableError("Current State Flow session memory is incomplete");
536
542
  validateScopeLineage(stream, "session", document.meta.lineage, MAX_HISTORY_LIMIT);
537
- throw new RevisionUnavailableError("Existing State Flow session memory is not selected; select an accepted boundary or use /state-flow-start");
543
+ throw new RevisionUnavailableError("Existing State Flow session memory is not selected; select an accepted boundary or use /state-flow-active");
538
544
  }
539
545
  const origin = `patch:${randomUUID()}`;
540
546
  const streams = Object.fromEntries(SCOPES.map((scope) => {
@@ -555,7 +561,7 @@ export class TemporalRuntime {
555
561
  /** Stage and accept synchronously inside an awaited lock; expose neither selection nor raw storage operations. */
556
562
  async withPatchTransaction<T>(action: (transaction: RuntimePatchTransaction) => T, signal?: AbortSignal): Promise<T> {
557
563
  return this.withPublicationTransaction((candidate, publish) => action({
558
- states: candidate.states(),
564
+ states: readTemporalScopes(candidate.view!, 0, this.historyLimit),
559
565
  causalBasis: candidate.causalBasis(),
560
566
  provenance: structuredClone(candidate.provenanceByScope),
561
567
  publish,
@@ -1,4 +1,4 @@
1
- import { parseRetainedPiCheckpoint } from "./snapshot.ts";
1
+ import { parseRetainedPiCheckpoint, type InactiveMode } from "./snapshot.ts";
2
2
 
3
3
  export const SNAPSHOT_ENTRY_TYPE = "state-flow-snapshot";
4
4
 
@@ -18,8 +18,10 @@ export interface PassiveStopBoundary {
18
18
  at: number;
19
19
  from?: number;
20
20
  preserveContext?: true;
21
- /** A same-owner failed Stop remains a write fence until a later accepted checkpoint. */
21
+ /** A same-owner failed mode change remains a write fence until a later accepted checkpoint. */
22
22
  persistenceError?: string;
23
+ /** The inactive mode selected by that failed change; legacy markers omit it. */
24
+ mode?: InactiveMode;
23
25
  }
24
26
 
25
27
  export interface SnapshotDiscovery {
@@ -114,7 +116,7 @@ export function findPassiveStopBoundary(branch: readonly BranchEntry[], sessionI
114
116
  continue;
115
117
  }
116
118
  if (entry.customType !== entryType) continue;
117
- const { at, from, reset, owner, preserveContext, persistenceError } = (entry.data as { at?: unknown; from?: unknown; reset?: unknown; owner?: unknown; preserveContext?: unknown; persistenceError?: unknown } | undefined) ?? {};
119
+ const { at, from, reset, owner, preserveContext, persistenceError, mode } = (entry.data as { at?: unknown; from?: unknown; reset?: unknown; owner?: unknown; preserveContext?: unknown; persistenceError?: unknown; mode?: unknown } | undefined) ?? {};
118
120
  if (reset === true && owner === sessionId) return undefined;
119
121
  if (owner !== undefined && owner !== sessionId) continue;
120
122
  if (typeof at === "number" && Number.isSafeInteger(at) && at >= 0) return {
@@ -122,6 +124,7 @@ export function findPassiveStopBoundary(branch: readonly BranchEntry[], sessionI
122
124
  ...(typeof from === "number" && Number.isSafeInteger(from) && from >= 0 ? { from } : {}),
123
125
  ...(preserveContext === true ? { preserveContext: true } : {}),
124
126
  ...(!checkpointSeen && owner === sessionId && typeof persistenceError === "string" && persistenceError.trim().length > 0 ? { persistenceError } : {}),
127
+ ...(mode === "passive" || mode === "off" ? { mode } : {}),
125
128
  };
126
129
  } catch {
127
130
  // A hostile unrelated branch entry cannot manufacture or suppress a valid marker.
@@ -13,8 +13,25 @@ export class RevisionUnavailableError extends Error {}
13
13
  /** Expired history cannot be restored, but explicit activation may use validated current memory. */
14
14
  export class HistoryBoundaryExpiredError extends RevisionUnavailableError {}
15
15
 
16
+ export type StateFlowMode = "active" | "passive" | "off";
17
+ export type InactiveMode = Exclude<StateFlowMode, "active">;
18
+
19
+ export function isStateFlowMode(value: unknown): value is StateFlowMode {
20
+ return value === "active" || value === "passive" || value === "off";
21
+ }
22
+
23
+ /** The session's selected mode is the only serialized behavior switch. */
16
24
  export interface SnapshotConfig {
17
- enabled: boolean;
25
+ mode: StateFlowMode;
26
+ }
27
+
28
+ /**
29
+ * Read-only compatibility for session config and native checkpoints written before `mode`:
30
+ * `enabled:true` stays active; `enabled:false` stays the caller's inactive policy.
31
+ */
32
+ function decodeLegacyMode(value: JsonObject, inactiveMode: InactiveMode): StateFlowMode | undefined {
33
+ if (Object.hasOwn(value, "mode")) return Object.hasOwn(value, "enabled") || !isStateFlowMode(value.mode) ? undefined : value.mode;
34
+ return typeof value.enabled === "boolean" ? value.enabled ? "active" : inactiveMode : undefined;
18
35
  }
19
36
 
20
37
  interface LegacyValidationFeedback {
@@ -69,7 +86,7 @@ export function validateSessionRuntime(value: unknown, cwd: string, sessionId: s
69
86
  }
70
87
  const runtimeFields = Object.fromEntries(Object.entries(fields).filter(([key]) => known.has(key)));
71
88
  const normalized: Snapshot = {
72
- config: { enabled: value.config.enabled === true },
89
+ config: { mode: isStateFlowMode(value.config.mode) ? value.config.mode : "off" },
73
90
  meta: restoredMeta(runtimeFields),
74
91
  };
75
92
  if (runtimeFields.bootstrap === false) normalized.meta.bootstrap = false;
@@ -108,6 +125,7 @@ export function serializeSessionRuntime(
108
125
  return { config: `${canonicalJson(runtime.config)}\n`, runtime: `${canonicalJson(runtimeMeta)}\n` };
109
126
  }
110
127
 
128
+ /** Legacy `enabled:false` decodes as non-active; native checkpoints, not this file, select branch policy. */
111
129
  export function parseSessionRuntime(
112
130
  config: string | undefined, runtimeSource: string | undefined, cwd: string, sessionId: string,
113
131
  ): SessionRuntime | undefined {
@@ -115,13 +133,17 @@ export function parseSessionRuntime(
115
133
  if (config === undefined || runtimeSource === undefined) throw new Error("Incomplete State Flow config/runtime pair");
116
134
  let runtime: unknown;
117
135
  try {
118
- runtime = { config: JSON.parse(config), meta: JSON.parse(runtimeSource) };
119
- } catch {
120
- throw new Error("State Flow session runtime contains invalid JSON");
136
+ const settings: unknown = JSON.parse(config);
137
+ const mode = isObject(settings) && Object.keys(settings).length === 1 ? decodeLegacyMode(settings, "passive") : undefined;
138
+ if (mode === undefined) throw new Error("Invalid State Flow runtime configuration or counters");
139
+ runtime = { config: { mode }, meta: JSON.parse(runtimeSource) };
140
+ } catch (error) {
141
+ if (error instanceof SyntaxError) throw new Error("State Flow session runtime contains invalid JSON");
142
+ throw error;
121
143
  }
122
144
  validateSessionRuntime(runtime, cwd, sessionId);
123
145
  const { temporal: _legacyTemporal, ...runtimeMeta } = runtime.meta;
124
- return { config: { enabled: runtime.config.enabled }, meta: runtimeMeta };
146
+ return { config: { mode: runtime.config.mode }, meta: runtimeMeta };
125
147
  }
126
148
 
127
149
  function restoredStep(value: unknown): number {
@@ -158,22 +180,24 @@ function restoredMeta(value: unknown, legacy: JsonObject = {}): SnapshotMeta {
158
180
  };
159
181
  }
160
182
 
161
- function envelope(enabled: boolean, meta: SnapshotMeta): Snapshot {
162
- return { config: { enabled }, meta };
183
+ function envelope(mode: StateFlowMode, meta: SnapshotMeta): Snapshot {
184
+ return { config: { mode }, meta };
163
185
  }
164
186
 
165
- export function emptySnapshot(enabled = false): Snapshot {
166
- return envelope(enabled, { step: 0 });
187
+ export function emptySnapshot(mode: StateFlowMode = "passive"): Snapshot {
188
+ return envelope(mode, { step: 0 });
167
189
  }
168
190
 
169
191
  export type RetainedBoundaryCheckpoint = {
170
192
  boundary: string;
171
- enabled: boolean;
193
+ mode: StateFlowMode;
172
194
  step: number;
173
195
  bootstrap?: true;
174
196
  specification?: string;
175
197
  };
176
- export type RetainedPiCheckpoint = RetainedBoundaryCheckpoint | { disabled: true };
198
+ /** A proven pre-runtime branch retains only its explicit inactive choice, never semantic storage. */
199
+ export type PreRuntimeCheckpoint = { mode: InactiveMode };
200
+ export type RetainedPiCheckpoint = RetainedBoundaryCheckpoint | PreRuntimeCheckpoint;
177
201
  export type FileRevision = `file:${string}`;
178
202
 
179
203
  export function isFileRevision(value: unknown): value is FileRevision {
@@ -185,7 +209,7 @@ export function retainedBoundaryCheckpoint(snapshot: Snapshot, boundary: string)
185
209
  if (typeof boundary !== "string" || boundary.trim().length === 0) throw new Error("Checkpoint requires a retained temporal boundary identity");
186
210
  const checkpoint: RetainedBoundaryCheckpoint = {
187
211
  boundary,
188
- enabled: snapshot.config.enabled,
212
+ mode: snapshot.config.mode,
189
213
  step: snapshot.meta.step,
190
214
  ...(snapshot.meta.bootstrap === true ? { bootstrap: true as const } : {}),
191
215
  ...(snapshot.meta.specification === undefined ? {} : { specification: snapshot.meta.specification }),
@@ -193,14 +217,23 @@ export function retainedBoundaryCheckpoint(snapshot: Snapshot, boundary: string)
193
217
  return parseRetainedPiCheckpoint(checkpoint) as RetainedBoundaryCheckpoint;
194
218
  }
195
219
 
196
- /** Decode the 0.17 retained-window checkpoint contract. */
197
- export function parseRetainedPiCheckpoint(value: unknown): RetainedPiCheckpoint {
220
+ /** Encode an explicit inactive choice on a branch that has no accepted runtime. */
221
+ export function preRuntimeCheckpoint(mode: StateFlowMode): PreRuntimeCheckpoint {
222
+ if (mode === "active") throw new Error("An active State Flow branch requires a retained semantic boundary");
223
+ return { mode };
224
+ }
225
+
226
+ /** Decode the retained-window checkpoint contract; legacy `enabled`/`{disabled:true}` markers map through `inactiveMode`. */
227
+ export function parseRetainedPiCheckpoint(value: unknown, inactiveMode: InactiveMode = "passive"): RetainedPiCheckpoint {
198
228
  if (!isObject(value)) throw new Error("Invalid State Flow retained-boundary checkpoint");
199
- if (Object.keys(value).length === 1 && value.disabled === true) return { disabled: true };
200
- const allowed = new Set(["boundary", "enabled", "step", "bootstrap", "specification"]);
201
- if (Object.keys(value).some((key) => !allowed.has(key))
229
+ const keys = Object.keys(value);
230
+ if (keys.length === 1 && value.disabled === true) return { mode: inactiveMode };
231
+ if (keys.length === 1 && (value.mode === "passive" || value.mode === "off")) return { mode: value.mode };
232
+ const allowed = new Set(["boundary", "mode", "enabled", "step", "bootstrap", "specification"]);
233
+ const mode = decodeLegacyMode(value, inactiveMode);
234
+ if (keys.some((key) => !allowed.has(key))
202
235
  || typeof value.boundary !== "string" || value.boundary.trim().length === 0
203
- || typeof value.enabled !== "boolean"
236
+ || mode === undefined
204
237
  || !Number.isSafeInteger(value.step) || (value.step as number) < 0 || (value.step as number) > MAX_RESTORED_STEP
205
238
  || (value.bootstrap !== undefined && value.bootstrap !== true)
206
239
  || (value.specification !== undefined && typeof value.specification !== "string")) {
@@ -208,19 +241,19 @@ export function parseRetainedPiCheckpoint(value: unknown): RetainedPiCheckpoint
208
241
  }
209
242
  return {
210
243
  boundary: value.boundary,
211
- enabled: value.enabled,
244
+ mode,
212
245
  step: value.step as number,
213
246
  ...(value.bootstrap === true ? { bootstrap: true } : {}),
214
247
  ...(typeof value.specification === "string" ? { specification: value.specification } : {}),
215
248
  };
216
249
  }
217
250
 
218
- export function migrationFailure(data: JsonObject, error: string): Snapshot {
251
+ export function migrationFailure(data: JsonObject, error: string, mode: InactiveMode = "passive"): Snapshot {
219
252
  const meta = restoredMeta(data.meta, data);
220
253
  meta.validation = {
221
254
  attempt: 0,
222
255
  error,
223
256
  instruction: "Start a fresh State Flow episode; null is reserved for patch deletion.",
224
257
  };
225
- return envelope(false, meta);
258
+ return envelope(mode, meta);
226
259
  }
@@ -8,7 +8,7 @@ import {
8
8
  } from "./artifact.ts";
9
9
  import { applyPatch, isObject, type JsonObject } from "./json.ts";
10
10
 
11
- /** The canonical semantic state shape shared by global, CWD, and session scopes. */
11
+ /** Runtime defaults for documented semantic planes; stored objects may omit them or retain other fields. */
12
12
  export type MaterializedState = JsonObject & {
13
13
  intents: JsonObject;
14
14
  contract: JsonObject;
@@ -18,6 +18,17 @@ export type MaterializedState = JsonObject & {
18
18
  lazy: JsonObject;
19
19
  };
20
20
 
21
+ /** Sparse semantic state: documented planes may be absent. Disk codecs select only known fields. */
22
+ export type SemanticState = JsonObject & Partial<{
23
+ intents: JsonObject;
24
+ contract: JsonObject;
25
+ working: JsonObject;
26
+ artifacts: ArtifactRegistry;
27
+ response: string;
28
+ lazy: JsonObject;
29
+ }>;
30
+ export type ScopedSemanticStates = Record<StateScope, SemanticState>;
31
+
21
32
  /** Compatibility name for callers that still treat materialized state as a document. */
22
33
  export type StateDocument = MaterializedState;
23
34
 
@@ -79,8 +90,12 @@ export function isMaterializedState(value: unknown): value is MaterializedState
79
90
  && isObject(value.working)
80
91
  && isObject(value.intents)
81
92
  && typeof value.response === "string"
82
- && isObject(value.lazy)
83
- && Object.keys(value).every((key) => key === "artifacts" || key === "contract" || key === "working" || key === "intents" || key === "response" || key === "lazy");
93
+ && isObject(value.lazy);
94
+ }
95
+
96
+ /** Missing planes are valid storage, not missing authority. Present values retain their type checks. */
97
+ export function isSemanticState(value: unknown): value is SemanticState {
98
+ return isObject(value) && isMaterializedState({ ...emptyState(), ...value });
84
99
  }
85
100
 
86
101
  export const isStateDocument = isMaterializedState;
@@ -97,12 +112,13 @@ export function updateMaterializedArtifacts(
97
112
  }
98
113
 
99
114
  /** Overlay lower-to-higher scopes without mutating any scope document. */
100
- export function overlayStates(...scopes: readonly MaterializedState[]): MaterializedState {
115
+ export function overlayStates(...scopes: readonly JsonObject[]): MaterializedState {
101
116
  return scopes.reduce<MaterializedState>((effective, scope) => {
102
117
  return applyPatch(effective, scope) as MaterializedState;
103
118
  }, emptyState());
104
119
  }
105
120
 
121
+ /** Default-bearing SDK compatibility view; model transport uses sparse SemanticState instead. */
106
122
  export type ModelState = JsonObject & {
107
123
  intents: JsonObject;
108
124
  contract: JsonObject;
@@ -111,14 +127,31 @@ export type ModelState = JsonObject & {
111
127
  response: string;
112
128
  };
113
129
 
130
+ /** Select only owned top-level fields, preserving nested data and replay deletion markers. */
131
+ export function selectSemanticFields(value: JsonObject): JsonObject {
132
+ return structuredClone(Object.fromEntries(Object.keys(emptyState())
133
+ .filter((key) => Object.hasOwn(value, key))
134
+ .map((key) => [key, value[key]])));
135
+ }
136
+
137
+ /** Read only documented, present planes; empty responses carry no semantic value. */
138
+ export function projectSemanticState(state: JsonObject): SemanticState {
139
+ const projected = selectSemanticFields(state);
140
+ if (projected.response === "") delete projected.response;
141
+ return projected;
142
+ }
143
+
144
+ /** Preserve deletion meaning in visible history without exposing ignored fields or empty responses. */
145
+ export function projectSemanticPatch(patch: JsonObject): JsonObject {
146
+ const projected = selectSemanticFields(patch);
147
+ if (projected.response === "") projected.response = null;
148
+ return projected;
149
+ }
150
+
114
151
  /** Model-visible projection: lazy bodies and runtime artifact bookkeeping stay out of ordinary context. */
115
- export function projectStateForModel(state: MaterializedState, artifactHints: ArtifactModelHints = {}): ModelState {
116
- const cloned = structuredClone(state);
117
- return {
118
- intents: cloned.intents,
119
- contract: cloned.contract,
120
- working: cloned.working,
121
- artifacts: projectArtifactsForModel(cloned.artifacts, artifactHints),
122
- response: cloned.response,
123
- };
152
+ export function projectStateForModel(state: SemanticState, artifactHints: ArtifactModelHints = {}): SemanticState {
153
+ const { lazy: _hidden, ...visible } = state;
154
+ const projected = projectSemanticState(visible);
155
+ if (projected.artifacts) projected.artifacts = projectArtifactsForModel(projected.artifacts, artifactHints);
156
+ return projected;
124
157
  }