@llblab/pi-kit 0.15.0 → 0.16.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 (106) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +9 -9
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +0 -13
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +15 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +7 -6
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +5 -2
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +17 -16
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +8 -0
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +24 -0
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +4 -3
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +8 -3
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.d.ts +2 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.js +4 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +5 -0
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +93 -35
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +20 -23
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +8 -4
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +29 -3
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +34 -18
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +14 -16
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +27 -1
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +182 -10
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +2 -0
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +34 -5
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +5 -5
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +20 -24
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +6 -3
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +5 -3
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +1 -1
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +2 -0
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +55 -20
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +8 -6
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +10 -3
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +7 -3
  37. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  38. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +11 -3
  39. package/node_modules/@llblab/pi-state-flow/docs/README.md +1 -0
  40. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -3
  41. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +3 -2
  42. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +504 -0
  43. package/node_modules/@llblab/pi-state-flow/docs/usage.md +11 -8
  44. package/node_modules/@llblab/pi-state-flow/lib/config.ts +18 -15
  45. package/node_modules/@llblab/pi-state-flow/lib/context.ts +24 -0
  46. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -3
  47. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -4
  48. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +5 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +86 -34
  50. package/node_modules/@llblab/pi-state-flow/lib/git.ts +23 -22
  51. package/node_modules/@llblab/pi-state-flow/lib/history.ts +8 -4
  52. package/node_modules/@llblab/pi-state-flow/lib/json.ts +31 -3
  53. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +33 -16
  54. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +14 -16
  55. package/node_modules/@llblab/pi-state-flow/lib/query.ts +173 -9
  56. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +32 -4
  57. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +22 -25
  58. package/node_modules/@llblab/pi-state-flow/lib/state.ts +11 -6
  59. package/node_modules/@llblab/pi-state-flow/lib/status.ts +1 -1
  60. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +46 -18
  61. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +19 -6
  62. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +7 -3
  63. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  64. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +11 -3
  65. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  66. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  67. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +7 -0
  68. package/node_modules/@llblab/pi-telegram/README.md +1 -0
  69. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -1
  70. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -1
  71. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +134 -2
  72. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +297 -16
  73. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +2 -0
  74. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +11 -0
  75. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +68 -8
  76. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +4 -3
  77. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +17 -13
  78. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -1
  79. package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +1 -0
  80. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
  81. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +55 -5
  82. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +23 -0
  83. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +20 -0
  84. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +18 -0
  85. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +152 -6
  86. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +1 -0
  87. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +5 -3
  88. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  89. package/node_modules/@llblab/pi-telegram/docs/architecture.md +3 -3
  90. package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
  91. package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -3
  92. package/node_modules/@llblab/pi-telegram/docs/sections.md +2 -2
  93. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +2 -1
  94. package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
  95. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +3 -0
  96. package/node_modules/@llblab/pi-telegram/lib/commands.ts +466 -21
  97. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +15 -0
  98. package/node_modules/@llblab/pi-telegram/lib/extension.ts +77 -9
  99. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +25 -10
  100. package/node_modules/@llblab/pi-telegram/lib/pi.ts +3 -0
  101. package/node_modules/@llblab/pi-telegram/lib/routing.ts +84 -17
  102. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +30 -0
  103. package/node_modules/@llblab/pi-telegram/lib/threads.ts +199 -6
  104. package/node_modules/@llblab/pi-telegram/lib/updates.ts +5 -2
  105. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  106. package/package.json +3 -3
@@ -7,6 +7,29 @@ import type { RehydrationPhase } from "./rehydration.ts";
7
7
  import { projectStateForModel, type MaterializedState } from "./state.ts";
8
8
 
9
9
  export const VALIDATION_MESSAGE_TYPE = "state-flow-validation";
10
+ const LAZY_HINT_PATH = "effective.lazy";
11
+ const LAZY_HINT_MAX_KEYS = 32;
12
+ const LAZY_HINT_MAX_JSON_CHARS = 1024;
13
+
14
+ type LazyValueKind = "array" | "boolean" | "null" | "number" | "object" | "string";
15
+
16
+ function lazyValueKind(value: Exclude<MaterializedState["lazy"], undefined>): LazyValueKind {
17
+ if (value === null) return "null";
18
+ if (Array.isArray(value)) return "array";
19
+ if (typeof value === "object") return "object";
20
+ return typeof value as Exclude<LazyValueKind, "array" | "object">;
21
+ }
22
+
23
+ /** Fixed-budget navigation only: never place lazy bodies or partial key catalogs in baseline context. */
24
+ export function lazyNavigationHint(state: MaterializedState): { available: boolean; path: string; keys?: Record<string, LazyValueKind> } {
25
+ const base = { available: Object.hasOwn(state, "lazy"), path: LAZY_HINT_PATH };
26
+ if (!base.available || typeof state.lazy !== "object" || state.lazy === null || Array.isArray(state.lazy)) return base;
27
+ const entries = Object.entries(state.lazy);
28
+ if (entries.length > LAZY_HINT_MAX_KEYS) return base;
29
+ const keys = Object.fromEntries(entries.map(([key, value]) => [key, lazyValueKind(value)]));
30
+ return JSON.stringify(keys).length <= LAZY_HINT_MAX_JSON_CHARS ? { ...base, keys } : base;
31
+ }
32
+
10
33
 
11
34
  /** Bounded context retained after semantic State Flow is stopped in this physical session. */
12
35
  export interface PassiveContinuation {
@@ -85,6 +108,7 @@ export function runtimeContextMessage(
85
108
  const context = {
86
109
  specification: snapshot.meta.specification,
87
110
  state: projectStateForModel(state),
111
+ lazy_navigation: lazyNavigationHint(state),
88
112
  ...(rehydrationPhase === undefined ? {} : { knowledge_rehydration: { phase: rehydrationPhase } }),
89
113
  ...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations.map(({ path, reason }) => ({ path, reason })) }),
90
114
  ...(recentTransitions.length === 0 ? {} : { recent_transitions: projectRecentForModel(recentTransitions) }),
@@ -223,11 +223,12 @@ export function inspectStateFlowContinuationProvenance(
223
223
  const paths = sessionRuntimePaths(header.cwd, header.id, repositoryRoot, sessionKey);
224
224
  try {
225
225
  const config = readRegular(paths.config);
226
+ const runtimeSource = readRegular(paths.runtime);
226
227
  const meta = readRegular(paths.meta);
227
- if (config === undefined && meta === undefined) {
228
+ if (config === undefined && runtimeSource === undefined && meta === undefined) {
228
229
  return { stateFlow: { enabled: false, restorable: true }, reason: "no State Flow session runtime" };
229
230
  }
230
- const runtime = parseSessionRuntime(config, meta, header.cwd, header.id);
231
+ const runtime = parseSessionRuntime(config, runtimeSource, header.cwd, header.id, meta);
231
232
  if (!runtime) return { stateFlow: { enabled: false, restorable: true }, reason: "no State Flow session runtime" };
232
233
  if (!runtime.config.enabled) return { stateFlow: { enabled: false, restorable: true }, reason: "State Flow stopped on selected runtime" };
233
234
  if (runtime.meta.publication === "files") {
@@ -238,7 +239,7 @@ export function inspectStateFlowContinuationProvenance(
238
239
  validateTemporalState({ lineage: runtime.meta.lineage, scopes: { global, cwd, session } });
239
240
  return { stateFlow: { enabled: true, restorable: true }, reason: "exact file-only runtime cohort is restorable" };
240
241
  }
241
- const runtimePath = relative(resolve(repositoryRoot), paths.meta);
242
+ const runtimePath = relative(resolve(repositoryRoot), runtimeSource === undefined ? paths.meta : paths.runtime);
242
243
  if (runtimePath.startsWith("..")) throw new Error("runtime provenance escapes repository");
243
244
  const owner = execFileSync("git", ["-C", repositoryRoot, "log", "-1", "--format=%H", "--", runtimePath], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
244
245
  if (!owner) throw new Error("runtime owner commit is unavailable");
@@ -27,6 +27,7 @@ const STATE_FILE = "state.json";
27
27
  const CHECKPOINT_FILE = "checkpoint.json";
28
28
  const PATCHES_FILE = "patches.jsonl";
29
29
  const META_FILE = "meta.json";
30
+ const RUNTIME_FILE = "runtime.json";
30
31
 
31
32
  /** Canonical semantic sources plus runtime-owned temporal metadata. */
32
33
  export interface ScopeStreamSources {
@@ -159,9 +160,9 @@ export function temporalScopePaths(cwd: string, sessionId: string, scope: StateS
159
160
  return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE), meta: join(directory, META_FILE) };
160
161
  }
161
162
 
162
- export function sessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string, sessionKey = sessionId): { config: string; meta: string } {
163
+ export function sessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string, sessionKey = sessionId): { config: string; runtime: string; meta: string } {
163
164
  const directory = sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory;
164
- return { config: join(directory, "config.json"), meta: join(directory, META_FILE) };
165
+ return { config: join(directory, "config.json"), runtime: join(directory, RUNTIME_FILE), meta: join(directory, META_FILE) };
165
166
  }
166
167
 
167
168
  /** Unsupported state.json presence never becomes an anchored checkpoint. */
@@ -178,7 +179,7 @@ export function captureTemporalFileBases(cwd: string, sessionId: string, reposit
178
179
  const paths = (["global", "cwd", "session"] as const).flatMap((scope) => {
179
180
  const pair = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
180
181
  const runtime = scope === "session" ? sessionRuntimePaths(cwd, sessionId, repositoryRoot, sessionKey) : undefined;
181
- return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [pair.meta] : [runtime.config, runtime.meta])];
182
+ return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [pair.meta] : [runtime.config, runtime.runtime, runtime.meta])];
182
183
  });
183
184
  return captureOwnedFileBases(paths, repositoryRoot);
184
185
  }
@@ -218,6 +219,9 @@ export function serializeScopeMetadata(
218
219
  cwdIdentity?: string, existingSource?: string,
219
220
  ): string {
220
221
  const existing = parseMetadataDocument(existingSource, `State Flow metadata`);
222
+ if (scope === "session") {
223
+ for (const key of ["identity", "lineage", "step", "specification", "validation", "bootstrap", "remotePublication", "revision", "temporalRevision", "publication"]) delete existing[key];
224
+ }
221
225
  const sources = serializeScopeStream(stream, scope, cwdIdentity);
222
226
  const value = {
223
227
  ...existing,
@@ -359,7 +363,7 @@ export function isStateFlowOwnedPath(candidate: string, repositoryRoot = getDura
359
363
  return cwdKey(segments[0]!)
360
364
  && sessionKey(segments[1]!)
361
365
  && (segments[2] === STATE_FILE || segments[2] === CHECKPOINT_FILE || segments[2] === PATCHES_FILE
362
- || segments[2] === "config.json" || segments[2] === META_FILE);
366
+ || segments[2] === "config.json" || segments[2] === RUNTIME_FILE || segments[2] === META_FILE);
363
367
  }
364
368
  return false;
365
369
  }
@@ -27,3 +27,8 @@ export function prepareRun(snapshot: Snapshot, prompt: string): boolean {
27
27
  snapshot.meta.validation = undefined;
28
28
  return true;
29
29
  }
30
+
31
+ /** Retain the full prompt only while its run remains recoverable and unfinished. */
32
+ export function completeRun(snapshot: Snapshot): void {
33
+ delete snapshot.meta.specification;
34
+ }
@@ -5,7 +5,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
7
  import { assistantToolCallCount, finalizedAssistantResponse, stateFlowProtocol } from "./protocol.ts";
8
- import { createPassiveContinuation, currentRunTrajectory, passiveContinuationMessages, runtimeContextMessage, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
8
+ import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, passiveContinuationMessages, runtimeContextMessage, syntheticUser, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
9
9
  import { ArtifactReadTracker } from "./acquisition.ts";
10
10
  import { loadStateFlowConfig } from "./config.ts";
11
11
  import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
@@ -18,7 +18,7 @@ import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatche
18
18
  import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, type StagedScopedTransition } from "./transition.ts";
19
19
  import { discoverSnapshotData, hasPriorConversation, isNewSession, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
20
20
  import { compactStatus, detailedStatus, STATUS_KEY, type PendingPublicationDiagnostic, type StatusDiagnostics } from "./status.ts";
21
- import { prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
21
+ import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
22
22
  import { recoverSnapshot } from "./recovery.ts";
23
23
  import type { RehydrationPhase } from "./rehydration.ts";
24
24
  import { resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument } from "./publication.ts";
@@ -26,8 +26,8 @@ import { coalescePublicationTarget, createPublicationQueue, type PublicationQueu
26
26
  import { acquirePublicationWorkerLease, loadPublicationQueue, publicationQueuePath, removePublicationQueue, savePublicationQueue } from "./publication.ts";
27
27
  import { runPublicationWorker } from "./publication.ts";
28
28
  import { getKnowledgeRoot, GlobalMarkdownDiscovery } from "./discovery.ts";
29
- import { isObject, sameJson } from "./json.ts";
30
- import { readStatePath } from "./query.ts";
29
+ import { canonicalJson, isObject, sameJson } from "./json.ts";
30
+ import { readProjectedState, readStatePath } from "./query.ts";
31
31
  import {
32
32
  cwdScopeKey,
33
33
  resolveSessionAddress,
@@ -50,6 +50,8 @@ export interface StateFlowExtensionOptions {
50
50
  knowledgeRoot?: string;
51
51
  onRuntime?: (accessor: { read(offset?: number, scope?: StateScope): MaterializedState }) => void;
52
52
  telegram?: { load?: StateFlowTelegramLoader };
53
+ /** Test/SDK capability override; repository config remains the Pi default. */
54
+ passive?: { bootstrap?: boolean; tools?: boolean };
53
55
  }
54
56
 
55
57
  export const PATCH_STATE_TOOL_NAME = "patch_state";
@@ -113,7 +115,12 @@ function separatedFailure(error: unknown): Error {
113
115
 
114
116
  export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowExtensionOptions = {}): void {
115
117
  const agentDir = options.agentDir ?? getAgentDir();
116
- const config = loadStateFlowConfig(agentDir);
118
+ const loadedConfig = loadStateFlowConfig(agentDir, options.repositoryRoot);
119
+ const config = {
120
+ ...loadedConfig,
121
+ passiveBootstrap: options.passive?.bootstrap ?? loadedConfig.passiveBootstrap,
122
+ passiveTools: options.passive?.tools ?? loadedConfig.passiveTools,
123
+ };
117
124
  let snapshot: Snapshot = emptySnapshot();
118
125
  let scopeStates: ScopedStates = { global: emptyState(), cwd: emptyState(), session: emptyState() };
119
126
  let branchHasSnapshot = false;
@@ -268,11 +275,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
268
275
  scopeStates = runtime?.view ? runtime.states() : { global: emptyState(), cwd: emptyState(), session: emptyState() };
269
276
  }
270
277
 
278
+ function passiveToolsAvailable(): boolean {
279
+ return snapshot.config.enabled || config.passiveTools;
280
+ }
281
+
271
282
  function syncStateFlowTools(): void {
272
283
  const active = pi.getActiveTools();
273
284
  const owned = [PATCH_STATE_TOOL_NAME, READ_STATE_TOOL_NAME];
274
- if (owned.every((name) => active.includes(name) === snapshot.config.enabled)) return;
275
- pi.setActiveTools(snapshot.config.enabled
285
+ const available = passiveToolsAvailable();
286
+ if (owned.every((name) => active.includes(name) === available)) return;
287
+ pi.setActiveTools(available
276
288
  ? [...new Set([...active, ...owned])]
277
289
  : active.filter((name) => !owned.includes(name)));
278
290
  }
@@ -680,6 +692,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
680
692
  else if (snapshot.meta.bootstrap) bootstrapContinuation = continuation;
681
693
  }
682
694
  }
695
+ if (!snapshot.config.enabled && (config.passiveBootstrap || config.passiveTools) && !runtime?.view) {
696
+ try {
697
+ runtime?.loadPassive();
698
+ installScopeStates();
699
+ } catch (error) {
700
+ ctx.ui.notify(`State Flow passive memory is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
701
+ }
702
+ }
683
703
  if (snapshot.config.enabled) deferArtifactRefresh();
684
704
  syncStateFlowTools();
685
705
  updateUi(ctx);
@@ -796,34 +816,38 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
796
816
  pi.registerTool({
797
817
  name: READ_STATE_TOOL_NAME,
798
818
  label: "Read State",
799
- description: "Read State Flow through a unified path such as state, state[1], state.cwd[2], or state.global.patches[0]. The legacy offset/scope form remains accepted. Read-only and lazy; unavailable hot history is an error.",
800
- promptSnippet: "Read cached state or accepted scope patches with a unified path",
819
+ description: "Read exact State Flow values or historical semantic patches with one path or an ordered path list. Unscoped semantic paths read the effective overlay; effective makes that overlay explicit, while global, cwd, and session select ownership. Array selectors support zero-based indices and half-open [start..end] ranges. Projection value returns semantic data; keys returns minimal structure; patch intersects the selected path at its boundary.",
820
+ promptSnippet: "Read exact state values, structures, or historical semantic patches",
801
821
  parameters: Type.Object({
802
- path: Type.Optional(Type.String({ description: "Unified query path; current aliases use index 0" })),
803
- offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 7, description: "Legacy accepted-transition offset; defaults to 0" })),
804
- scope: Type.Optional(StringEnum(["effective", "global", "cwd", "session"] as const, { description: "Legacy projection at the same boundary; defaults to effective" })),
822
+ path: Type.Optional(Type.String({ description: "One semantic path; unscoped paths use effective, explicit roots may use effective/global/cwd/session and historical [N], and arrays support half-open [start..end] ranges" })),
823
+ paths: Type.Optional(Type.Array(Type.String(), { minItems: 1, description: "Ordered query paths evaluated as one all-or-error read" })),
824
+ projection: Type.Optional(StringEnum(["value", "keys", "patch"] as const, { description: "Value snapshot by default, structural keys with minimal meta, or the selected historical semantic patch" })),
805
825
  }, { additionalProperties: false }),
806
826
  async execute(_toolCallId, params, signal) {
807
827
  try {
808
- type ReadDetails = { path?: string; offset?: number; scope?: string; transitionId: string };
809
- if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
828
+ type ReadDetails = { path?: string; paths?: string[]; projection?: string; transitionId?: string };
829
+ if (!passiveToolsAvailable()) throw new Error("State Flow tools are disabled by configuration");
810
830
  if (signal?.aborted) throw new Error("State Flow read was aborted");
811
831
  if (!runtime?.view) throw new Error("State Flow temporal runtime is unavailable");
812
- if (params.path !== undefined) {
813
- if (params.offset !== undefined || params.scope !== undefined) throw new Error("read_state path cannot be combined with legacy offset or scope");
814
- const result = readStatePath(runtime.view, params.path);
832
+ if (params.path === undefined && params.paths === undefined) throw new Error("read_state requires path or paths");
833
+ if (params.path !== undefined && params.paths !== undefined) throw new Error("read_state accepts path or paths, not both");
834
+ {
835
+ const paths = params.paths ?? [params.path!];
836
+ if (paths.length === 1 && /^(?:global|cwd|session)\.patches(?:\[\d+\])?$/.test(paths[0]!)) {
837
+ if (params.projection !== undefined && params.projection !== "value") throw new Error("Scope patch paths support only the value projection");
838
+ const result = readStatePath(runtime.view, paths[0]!);
839
+ if (!("patch" in result)) throw new Error("Expected a scope patch path");
840
+ return {
841
+ content: [{ type: "text", text: `\n${JSON.stringify({ patch: result.patch })}` }],
842
+ details: { path: paths[0], transitionId: result.boundary.id } as ReadDetails,
843
+ };
844
+ }
845
+ const result = readProjectedState(runtime.view, paths, params.projection);
815
846
  return {
816
847
  content: [{ type: "text", text: `\n${JSON.stringify(result)}` }],
817
- details: { path: params.path, transitionId: result.boundary.id } as ReadDetails,
848
+ details: { ...(params.path === undefined ? { paths } : { path: params.path }), projection: params.projection ?? "value" } as ReadDetails,
818
849
  };
819
850
  }
820
- const { offset = 0, scope = "effective" } = params;
821
- const state = runtime.read(offset, scope === "effective" ? undefined : scope);
822
- const boundary = runtime.view.lineage.at(-1 - offset)!;
823
- return {
824
- content: [{ type: "text", text: `\n${JSON.stringify({ offset, scope, boundary, state: projectStateForModel(state) })}` }],
825
- details: { offset, scope, transitionId: boundary.id } as ReadDetails,
826
- };
827
851
  } catch (error) {
828
852
  throw separatedFailure(error);
829
853
  }
@@ -856,7 +880,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
856
880
  },
857
881
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
858
882
  try {
859
- if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
883
+ if (!passiveToolsAvailable()) throw new Error("State Flow tools are disabled by configuration");
860
884
  if (signal?.aborted) throw new Error("State Flow patch was aborted before materialization");
861
885
  if (!isObject(params)) throw new Error("patch_state requires an object");
862
886
  const allowed = new Set(["global", "cwd", "session", "final"]);
@@ -878,16 +902,27 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
878
902
  return { content: [{ type: "text", text: "\nState unchanged; iteration remains non-terminal." }], details: { final: false } };
879
903
  }
880
904
  if (params.final !== true) throw new Error('patch_state requires at least one scope patch or {"final":true}');
905
+ if (!snapshot.config.enabled) return { content: [{ type: "text", text: "\nState unchanged; passive turns have no terminal barrier." }], details: { final: false } };
881
906
  validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
882
907
  terminalEligible = true;
883
908
  return { content: [{ type: "text", text: "\nState iteration is terminal-eligible." }], details: { final: true } };
884
909
  }
885
- const stage = stageAtomicScopePatches(scopeStates, patches, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
910
+ if (!runtime?.view) {
911
+ runtime ??= createRuntime(ctx);
912
+ runtime.prepare();
913
+ const publication = runtime.initialize(snapshot, true, undefined, true);
914
+ recordPolicyPublication(publication, ctx);
915
+ installScopeStates();
916
+ branchHasSnapshot = true;
917
+ }
918
+ runtime.migrateLegacyStorage();
919
+ installScopeStates();
920
+ const stage = stageAtomicScopePatches(scopeStates, patches, skillReads.successful.values(), runtime.causalBasis(), artifactReads.successful.values());
886
921
  const semanticChange = (["global", "cwd", "session"] as const).some((scope) => !sameJson(scopeStates[scope], stage.nextStates[scope]));
887
922
  const provenanceChange = Object.values(stage.provenanceUpdates).some((updates) => Object.keys(updates).length > 0);
888
923
  if (!semanticChange && !provenanceChange) throw new Error('patch_state scope patches must materially update state or required provenance; omit them and use {"final":true} when unchanged');
889
924
  commitStage(stage, ctx, false);
890
- if (params.final === true) {
925
+ if (params.final === true && snapshot.config.enabled) {
891
926
  terminalEligible = true;
892
927
  }
893
928
  updateUi(ctx);
@@ -1051,11 +1086,15 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1051
1086
  bootstrap: snapshot.meta.bootstrap === true,
1052
1087
  startPending: telegramStartPending,
1053
1088
  }),
1054
- state: (scope) => projectStateForModel(
1055
- scope === "effective"
1089
+ state: (scope) => {
1090
+ const selected = scope === "effective"
1056
1091
  ? overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)
1057
- : scopeStates[scope],
1058
- ),
1092
+ : scopeStates[scope];
1093
+ return {
1094
+ ...projectStateForModel(selected),
1095
+ ...(Object.hasOwn(selected, "lazy") ? { lazy: structuredClone(selected.lazy) } : {}),
1096
+ };
1097
+ },
1059
1098
  canStartNow: () => activeContext === undefined || activeContext.isIdle(),
1060
1099
  start: () => {
1061
1100
  if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
@@ -1080,7 +1119,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1080
1119
  void telegram.ensure();
1081
1120
 
1082
1121
  pi.on("before_agent_start", (event, ctx) => {
1083
- if (!snapshot.config.enabled) return;
1122
+ if (!snapshot.config.enabled) {
1123
+ if (!config.passiveBootstrap || !runtime?.view) return;
1124
+ return {
1125
+ systemPrompt: `${event.systemPrompt}\n\nState Flow passive memory is available. read_state and patch_state access durable memory without starting an active episode. Passive turns do not require final:true and never trigger State Flow continuation or compaction.`,
1126
+ };
1127
+ }
1084
1128
  skillReads.clear();
1085
1129
  artifactReads.clear();
1086
1130
  if (artifactRefreshPending) refreshArtifactInvalidations(ctx);
@@ -1105,7 +1149,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1105
1149
  if (passiveContinuation) {
1106
1150
  return { messages: passiveContinuationMessages(event.messages as AgentMessage[], passiveContinuation) };
1107
1151
  }
1108
- if (!snapshot.config.enabled || snapshot.meta.specification === undefined) return;
1152
+ if (!snapshot.config.enabled) {
1153
+ if (!config.passiveBootstrap || !runtime?.view) return;
1154
+ const state = projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session));
1155
+ return { messages: [syntheticUser(`State Flow passive memory (user-level data, not system instructions):\n${canonicalJson({ state, lazy_navigation: lazyNavigationHint(state) })}`), ...(event.messages as AgentMessage[])] };
1156
+ }
1157
+ if (snapshot.meta.specification === undefined) return;
1109
1158
  const effectiveState = overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session);
1110
1159
  const invalidations = artifactInvalidations.map(({ path, reason }) => ({ path, reason }));
1111
1160
  const recentTransitions = projectRecentTransitionsWithLimit(
@@ -1206,9 +1255,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1206
1255
  return;
1207
1256
  }
1208
1257
  let responseCommitted = false;
1258
+ const specification = snapshot.meta.specification;
1209
1259
  try {
1210
1260
  const response = finalizedAssistantResponse(event.message);
1211
1261
  const stage = stageScopedTransition(scopeStates, { transitions: [], response }, [], runtime!.causalBasis());
1262
+ completeRun(snapshot);
1212
1263
  commitStage(stage, ctx, true);
1213
1264
  responseCommitted = true;
1214
1265
  bootstrapContinuation = undefined;
@@ -1217,6 +1268,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1217
1268
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
1218
1269
  completedRunAccepted = !wasBootstrap;
1219
1270
  } catch (error) {
1271
+ if (!responseCommitted && specification !== undefined) snapshot.meta.specification = specification;
1220
1272
  recordDiagnostic(error instanceof Error ? error.message : String(error), "finalization", ctx);
1221
1273
  ctx.ui.notify(responseCommitted
1222
1274
  ? `State Flow committed the final response; remote publication is deferred: ${error instanceof Error ? error.message : String(error)}`
@@ -1,6 +1,6 @@
1
1
  // Domain: durable Git revision reads, compare-and-swap publication, and exact-commit push retry.
2
2
  import { createHash } from "node:crypto";
3
- import { closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { closeSync, lstatSync, mkdirSync, mkdtempSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, relative, resolve, sep } from "node:path";
6
6
  import { spawn, spawnSync } from "node:child_process";
@@ -25,7 +25,7 @@ import { planLegacyStorageMigration } from "./migration.ts";
25
25
  import { canonicalJson, sameJson } from "./json.ts";
26
26
  import { validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
27
27
  import { createSessionRuntime, parseSessionRuntime, serializeSessionRuntime, type SessionRuntime, type Snapshot } from "./snapshot.ts";
28
- import { assertTemporalFileBase, loadTemporalFileRevision, planTemporalPublication, temporalFileReceipts, withStoragePublicationLock } from "./storage.ts";
28
+ import { acquirePublicationLock, assertTemporalFileBase, loadTemporalFileRevision, planTemporalPublication, temporalFileReceipts, withStoragePublicationLock } from "./storage.ts";
29
29
  import type { MaterializedState, StateScope } from "./state.ts";
30
30
 
31
31
  const GIT_TIMEOUT_MS = 15_000;
@@ -108,12 +108,9 @@ function withPublicationLock<T>(repositoryRoot: string, action: (root: string) =
108
108
  const root = assertRepositoryRoot(lockedRoot);
109
109
  const common = resolve(root, git(root, ["rev-parse", "--git-common-dir"]).stdout.trim());
110
110
  const path = resolve(common, "state-flow-publication.lock");
111
- let descriptor: number;
112
- try {
113
- descriptor = openSync(path, "wx", 0o600);
114
- } catch (error) {
115
- throw new Error(`State Flow publication lock is unavailable at ${path}; reconcile the active or interrupted publisher before retrying`, { cause: error });
116
- }
111
+ const descriptor = acquirePublicationLock(path, (cause) => new Error(
112
+ `State Flow publication lock is unavailable at ${path}; reconcile the active or interrupted publisher before retrying`, { cause },
113
+ ));
117
114
  try {
118
115
  writeFileSync(descriptor, `${process.pid}\n`);
119
116
  return action(root);
@@ -229,15 +226,15 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
229
226
  const canonicalRuntime = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
230
227
  const readFile = revisionFileReader(root, revision, [
231
228
  ...scopePaths.flatMap(({ paths }) => [paths.checkpoint, paths.patches, paths.meta]),
232
- canonicalRuntime.config, canonicalRuntime.meta,
229
+ canonicalRuntime.config, canonicalRuntime.runtime,
233
230
  ]);
234
231
  const files: DurableFileBase[] = [];
235
232
  const scopes = {} as Record<StateScope, ScopeStream | undefined>;
236
233
  const provenance: Record<StateScope, ArtifactProvenanceRegistry> = { global: {}, cwd: {}, session: {} };
237
234
  for (const { scope, paths } of scopePaths) {
238
235
  const selected = revisionScopeFiles(readFile, paths);
239
- files.push(selected.checkpoint, selected.patches, ...(scope === "session" ? [] : [selected.meta]));
240
- if (scope !== "session") provenance[scope] = parseScopeProvenance(selected.meta.content, selected.meta.path);
236
+ files.push(selected.checkpoint, selected.patches, selected.meta);
237
+ provenance[scope] = parseScopeProvenance(selected.meta.content, selected.meta.path);
241
238
  try {
242
239
  scopes[scope] = parseScopeStream(selected.checkpoint.content, selected.patches.content, scope,
243
240
  scope === "cwd" ? cwd : undefined, selected.meta.content);
@@ -246,15 +243,18 @@ export function loadTemporalRevision(cwd: string, sessionId: string, repositoryR
246
243
  }
247
244
  }
248
245
  const readRuntime = (paths: ReturnType<typeof sessionRuntimePaths>) => ({
249
- paths, config: readFile(paths.config), meta: readFile(paths.meta),
246
+ paths, config: readFile(paths.config), runtime: readFile(paths.runtime), meta: readFile(paths.meta),
250
247
  });
251
- const { paths: runtimePaths, config, meta } = readRuntime(canonicalRuntime);
252
- files.push(config, meta);
253
- const document = parseSessionRuntime(config.content, meta.content, cwd, sessionId);
248
+ const { paths: runtimePaths, config, runtime, meta } = readRuntime(canonicalRuntime);
249
+ files.push(config, runtime);
250
+ const document = parseSessionRuntime(config.content, runtime.content, cwd, sessionId, meta.content);
254
251
  if (document === undefined) return { base: { head: revision, files }, scopes, provenance };
255
- provenance.session = parseArtifactProvenanceRegistry(document.meta.artifacts, "State Flow session artifact provenance");
252
+ if (Object.keys(provenance.session).length === 0 && document.meta.artifacts !== undefined) {
253
+ provenance.session = parseArtifactProvenanceRegistry(document.meta.artifacts, "State Flow session artifact provenance");
254
+ }
255
+ const runtimeOwnerPath = runtime.content === undefined ? runtimePaths.meta : runtimePaths.runtime;
256
256
  const owner = git(root, ["log", "-1", "--format=%H", revision, "--",
257
- relativeOwnedPath(runtimePaths.config, root), relativeOwnedPath(runtimePaths.meta, root),
257
+ relativeOwnedPath(runtimePaths.config, root), relativeOwnedPath(runtimeOwnerPath, root),
258
258
  ]).stdout.trim();
259
259
  assertReadableRevision(root, owner);
260
260
  let temporalRevision = document.meta.temporalRevision === undefined || document.meta.temporalRevision === "self"
@@ -410,8 +410,7 @@ export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRo
410
410
  if (snapshot.meta.step !== selected.runtime.meta.step) throw new Error("Git adoption must preserve the semantic step");
411
411
  const runtime = createSessionRuntime(snapshot, cwd, sessionId, selected.view.lineage, "unconfirmed", selected.provenance.session);
412
412
  runtime.meta.temporalRevision = "self";
413
- const sources = serializeSessionRuntime(runtime, cwd, sessionId, selected.view.scopes.session,
414
- selected.base.files.find(({ path }) => path === sessionRuntimePaths(cwd, sessionId, repositoryRoot, sessionKey).meta)?.content);
413
+ const sources = serializeSessionRuntime(runtime, cwd, sessionId);
415
414
  initializeGitRepository(repositoryRoot);
416
415
  return withPublicationLock(repositoryRoot, (root) => {
417
416
  const current = captureTemporalBaseUnderLock(cwd, sessionId, root, sessionKey);
@@ -420,13 +419,14 @@ export function adoptFileStateToGit(cwd: string, sessionId: string, repositoryRo
420
419
  const provenancePaths = new Set([
421
420
  temporalScopePaths(cwd, sessionId, "global", root, sessionKey).meta,
422
421
  temporalScopePaths(cwd, sessionId, "cwd", root, sessionKey).meta,
422
+ temporalScopePaths(cwd, sessionId, "session", root, sessionKey).meta,
423
423
  ]);
424
424
  // Preserve exact valid scope bytes; only runtime provenance changes representation.
425
425
  const updates = current.files.filter(({ path, identity }) => path.endsWith("checkpoint.json")
426
426
  || path.endsWith("patches.jsonl")
427
427
  || (provenancePaths.has(path) && identity !== "missing"))
428
428
  .map(({ path, content }) => ({ path, content: content! }));
429
- updates.push({ path: paths.config, content: sources.config }, { path: paths.meta, content: sources.meta });
429
+ updates.push({ path: paths.config, content: sources.config }, { path: paths.runtime, content: sources.runtime });
430
430
  const existing = current.head && updates.every(({ path, content }) => revisionFile(root, current.head!, path).content === content)
431
431
  ? loadTemporalRevision(cwd, sessionId, root, current.head, sessionKey) : undefined;
432
432
  if (existing && (!existing.runtime || !sameJson({ lineage: existing.runtime.document.meta.lineage, scopes: existing.scopes }, selected.view))) {
@@ -450,7 +450,7 @@ function includeUncommittedCohort(cwd: string, sessionId: string, root: string,
450
450
  return { scope, pair: desired([paths.checkpoint, paths.patches]) };
451
451
  });
452
452
  const runtimePaths = runtime ? sessionRuntimePaths(cwd, sessionId, root, sessionKey) : undefined;
453
- const runtimePair = runtimePaths ? desired([runtimePaths.config, runtimePaths.meta]) : [];
453
+ const runtimePair = runtimePaths ? desired([runtimePaths.config, runtimePaths.runtime]) : [];
454
454
  const readFile = current.head === undefined ? undefined : revisionFileReader(root, current.head,
455
455
  [...pairs.flatMap(({ pair }) => pair), ...runtimePair].map(({ path }) => path));
456
456
  const absentFromHead = (files: OwnedFileUpdate[]) => files.some(({ path, content }) => readFile === undefined || readFile(path).content !== content);
@@ -483,7 +483,8 @@ export function publishTemporalStateToGit(
483
483
  if (runtime?.meta.publication === "files") throw new Error("Git publication requires explicit Git provenance");
484
484
  const runtimePaths = sessionRuntimePaths(cwd, sessionId, root, sessionKey);
485
485
  const previousRuntime = parseSessionRuntime(current.files.find(({ path }) => path === runtimePaths.config)?.content,
486
- current.files.find(({ path }) => path === runtimePaths.meta)?.content, cwd, sessionId);
486
+ current.files.find(({ path }) => path === runtimePaths.runtime)?.content, cwd, sessionId,
487
+ current.files.find(({ path }) => path === runtimePaths.meta)?.content);
487
488
  if (previousRuntime?.meta.publication === "files") throw new Error("File-only storage requires explicit full-cohort Git adoption");
488
489
  const runtimeOnly = runtime?.meta.temporalRevision !== undefined && runtime.meta.temporalRevision !== "self";
489
490
  if (runtimeOnly) {
@@ -22,7 +22,7 @@ export interface RecentTransition extends AcceptedTransition {
22
22
 
23
23
  export type RecentTransitionWindow = RecentTransition[];
24
24
  const SCOPES = new Set<StateScope>(["global", "cwd", "session"]);
25
- const PATCH_KEYS = new Set(["artifacts", "contract", "working", "response"]);
25
+ const PATCH_KEYS = new Set(["artifacts", "contract", "working", "response", "lazy"]);
26
26
 
27
27
  /** Normalize accepted replacements into recursive-merge replay, including removals. */
28
28
  function replayPatch(before: JsonObject, after: JsonObject): JsonObject {
@@ -49,9 +49,13 @@ function validateScopedPatch(value: unknown): asserts value is RecentScopePatch
49
49
  }
50
50
  validatePatch(value.patch);
51
51
  for (const [key, field] of Object.entries(value.patch)) {
52
- if (!PATCH_KEYS.has(key) || (key === "response"
53
- ? value.scope !== "session" || typeof field !== "string" : !isObject(field))) {
54
- throw new Error("Recent State Flow patches may contain object-valued artifacts, contract, and working plus a session response string");
52
+ const valid = key === "response"
53
+ ? value.scope === "session" && typeof field === "string"
54
+ : key === "lazy"
55
+ ? field !== null
56
+ : PATCH_KEYS.has(key) && isObject(field);
57
+ if (!PATCH_KEYS.has(key) || !valid) {
58
+ throw new Error("Recent State Flow patches may contain hot object planes, ordinary-JSON lazy state, and a session response string");
55
59
  }
56
60
  }
57
61
  }
@@ -3,6 +3,32 @@ import { createHash } from "node:crypto";
3
3
  export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;
4
4
  export interface JsonObject { [key: string]: JsonValue }
5
5
 
6
+ const ARRAY_INDEX_SELECTOR = /^\[(0|[1-9]\d*)\]$/;
7
+
8
+ function isIndexedArrayPatch(value: JsonObject): boolean {
9
+ const keys = Object.keys(value);
10
+ return keys.length > 0 && keys.every((key) => ARRAY_INDEX_SELECTOR.test(key));
11
+ }
12
+
13
+ function applyArrayPatch(state: JsonValue[], patch: JsonObject): JsonValue[] {
14
+ const next = structuredClone(state);
15
+ for (const [selector, value] of Object.entries(patch)) {
16
+ const match = ARRAY_INDEX_SELECTOR.exec(selector)!;
17
+ const index = Number(match[1]);
18
+ if (!Number.isSafeInteger(index) || index >= next.length) {
19
+ throw new Error(`State patch array index ${selector} is out of bounds for length ${next.length}`);
20
+ }
21
+ if (value === null) throw new Error(`State patch array index ${selector} cannot be deleted; replace the whole array instead`);
22
+ const current = next[index]!;
23
+ next[index] = Array.isArray(current) && isObject(value) && isIndexedArrayPatch(value)
24
+ ? applyArrayPatch(current, value)
25
+ : isObject(current) && isObject(value)
26
+ ? applyPatch(current, value)
27
+ : structuredClone(value);
28
+ }
29
+ return next;
30
+ }
31
+
6
32
  export function applyPatch(state: JsonObject, patch: JsonObject): JsonObject {
7
33
  const next: JsonObject = structuredClone(state);
8
34
  for (const [key, value] of Object.entries(patch)) {
@@ -11,9 +37,11 @@ export function applyPatch(state: JsonObject, patch: JsonObject): JsonObject {
11
37
  continue;
12
38
  }
13
39
  const current = next[key];
14
- const materialized = isObject(current) && isObject(value)
15
- ? applyPatch(current, value)
16
- : structuredClone(value);
40
+ const materialized = Array.isArray(current) && isObject(value) && isIndexedArrayPatch(value)
41
+ ? applyArrayPatch(current, value)
42
+ : isObject(current) && isObject(value)
43
+ ? applyPatch(current, value)
44
+ : structuredClone(value);
17
45
  Object.defineProperty(next, key, {
18
46
  value: materialized,
19
47
  enumerable: true,