@llblab/pi-kit 0.13.0 → 0.14.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 (95) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +7 -7
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +8 -9
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +26 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +1 -3
  7. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +21 -0
  8. package/node_modules/@llblab/pi-state-flow/dist/index.js +20 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +39 -0
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +78 -0
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +110 -0
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +334 -0
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +49 -0
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +67 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +11 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +53 -0
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +23 -0
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +109 -0
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.d.ts +111 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +189 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +21 -0
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +125 -0
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +102 -0
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +507 -0
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.d.ts +8 -0
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.js +27 -0
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +22 -0
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +1263 -0
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +72 -0
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +565 -0
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +22 -0
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +79 -0
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +12 -0
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +109 -0
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +25 -0
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +24 -0
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +36 -0
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +98 -0
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +15 -0
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +42 -0
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +13 -0
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +133 -0
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +7 -0
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +59 -0
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +69 -0
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +335 -0
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +27 -0
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +35 -0
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +8 -0
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +27 -0
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +36 -0
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +38 -0
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +142 -0
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +529 -0
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +21 -0
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +44 -0
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +25 -0
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +131 -0
  59. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +88 -0
  60. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +255 -0
  61. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +55 -0
  62. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +31 -0
  63. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +38 -0
  64. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +79 -0
  65. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +46 -0
  66. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +217 -0
  67. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +98 -0
  68. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +231 -0
  69. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +39 -0
  70. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +203 -0
  71. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +25 -0
  72. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +204 -0
  73. package/node_modules/@llblab/pi-state-flow/dist/package.json +79 -0
  74. package/node_modules/@llblab/pi-state-flow/dist/pi-state-flow/index.js +1 -0
  75. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +138 -0
  76. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +11 -5
  77. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +5 -1
  78. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +3 -3
  79. package/node_modules/@llblab/pi-state-flow/docs/usage.md +6 -4
  80. package/node_modules/@llblab/pi-state-flow/index.ts +1 -0
  81. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +17 -1
  82. package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -1
  83. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +88 -96
  84. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +64 -12
  85. package/node_modules/@llblab/pi-state-flow/lib/git.ts +32 -188
  86. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +84 -48
  87. package/node_modules/@llblab/pi-state-flow/lib/query.ts +40 -0
  88. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +7 -30
  89. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +48 -50
  90. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +41 -97
  91. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +17 -12
  92. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +4 -4
  93. package/node_modules/@llblab/pi-state-flow/package.json +23 -6
  94. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +3 -1
  95. package/package.json +4 -4
@@ -11,6 +11,8 @@ export interface StateFlowConfig {
11
11
  autoStart: boolean;
12
12
  /** Opt-in local capture of rejected patch attempts and unresolved terminal drafts. */
13
13
  logging: boolean;
14
+ /** Show successful patch_state arguments in the interactive tool row. */
15
+ showSuccessfulPatches: boolean;
14
16
  remotePublication?: "off" | "turn-end" | "transition";
15
17
  }
16
18
 
@@ -21,6 +23,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
21
23
  directory: getDurableRepositoryRoot(agentDir),
22
24
  autoStart: false,
23
25
  logging: false,
26
+ showSuccessfulPatches: true,
24
27
  };
25
28
  let value: unknown;
26
29
  try {
@@ -29,12 +32,13 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
29
32
  } catch (error) {
30
33
  throw new Error(`Cannot read State Flow configuration: ${path}`, { cause: error });
31
34
  }
32
- const allowed = new Set(["directory", "autoStart", "logging", "remotePublication"]);
35
+ const allowed = new Set(["directory", "autoStart", "logging", "showSuccessfulPatches", "remotePublication"]);
33
36
  if (!isObject(value) || Object.keys(value).some((key) => !allowed.has(key))) {
34
37
  throw new Error(`State Flow configuration contains unknown settings: ${path}`);
35
38
  }
36
39
  if (Object.hasOwn(value, "autoStart") && typeof value.autoStart !== "boolean") throw new Error(`State Flow autoStart must be a boolean: ${path}`);
37
40
  if (Object.hasOwn(value, "logging") && typeof value.logging !== "boolean") throw new Error(`State Flow logging must be a boolean: ${path}`);
41
+ if (Object.hasOwn(value, "showSuccessfulPatches") && typeof value.showSuccessfulPatches !== "boolean") throw new Error(`State Flow showSuccessfulPatches must be a boolean: ${path}`);
38
42
  if (Object.hasOwn(value, "remotePublication") && value.remotePublication !== "off" && value.remotePublication !== "turn-end" && value.remotePublication !== "transition") {
39
43
  throw new Error(`State Flow remotePublication must be off, turn-end, or transition: ${path}`);
40
44
  }
@@ -48,6 +52,7 @@ export function loadStateFlowConfig(agentDir = getAgentDir()): StateFlowConfig {
48
52
  directory: expanded === undefined ? defaults.directory : resolve(dirname(path), expanded),
49
53
  autoStart: value.autoStart === true,
50
54
  logging: value.logging === true,
55
+ showSuccessfulPatches: value.showSuccessfulPatches !== false,
51
56
  ...(value.remotePublication === undefined ? {} : { remotePublication: value.remotePublication as "off" | "turn-end" | "transition" }),
52
57
  };
53
58
  }
@@ -18,22 +18,26 @@ import {
18
18
  serializeArtifactProvenanceRegistry,
19
19
  type ArtifactProvenanceRegistry,
20
20
  } from "./artifact.ts";
21
- import { canonicalJson, containsNull, isJsonValue, isObject } from "./json.ts";
21
+ import { canonicalJson, isJsonValue, isObject } from "./json.ts";
22
22
  import { validateScopeStream, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
23
- import { isMaterializedState, type MaterializedState, type StateScope } from "./state.ts";
23
+ import type { StateScope } from "./state.ts";
24
24
 
25
- const LEGACY_MAX_SCOPE_SLUG_LENGTH = 80;
26
- const LEGACY_SCOPE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,80}-[a-f0-9]{64}$/;
27
25
  const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
28
26
  const STATE_FILE = "state.json";
29
27
  const CHECKPOINT_FILE = "checkpoint.json";
30
28
  const PATCHES_FILE = "patches.jsonl";
31
29
  const META_FILE = "meta.json";
32
30
 
33
- /** Canonical replay sources; current state is deliberately not serialized beside the tail. */
31
+ /** Canonical semantic sources plus runtime-owned temporal metadata. */
34
32
  export interface ScopeStreamSources {
35
33
  checkpoint: string;
36
34
  patches: string;
35
+ temporal: ScopeTemporalMetadata;
36
+ }
37
+
38
+ export interface ScopeTemporalMetadata {
39
+ checkpoint: ScopeStream["checkpoint"]["through"];
40
+ patches: ScopeStream["patches"][number]["transition"][];
37
41
  }
38
42
 
39
43
  export interface SessionAddress {
@@ -41,17 +45,18 @@ export interface SessionAddress {
41
45
  readonly key: string;
42
46
  }
43
47
 
44
- /** CWD storage requires its canonical owner; legacy ownerless sources are read-only. */
48
+ /** Semantic files contain no runtime envelope; temporal boundaries and CWD ownership live in meta.json. */
45
49
  export function serializeScopeStream(stream: ScopeStream, scope: StateScope, cwdIdentity?: string): ScopeStreamSources {
46
50
  validateScopeStream(stream, scope);
47
51
  if (scope === "cwd" && cwdIdentity === undefined) throw new Error("State Flow CWD scope serialization requires its canonical identity");
48
52
  if (scope !== "cwd" && cwdIdentity !== undefined) throw new Error("Only State Flow CWD scope serialization accepts a CWD identity");
49
- const checkpoint = scope === "cwd"
50
- ? { ...stream.checkpoint, owner: { cwd: resolve(cwdIdentity!) } }
51
- : stream.checkpoint;
52
53
  return {
53
- checkpoint: `${canonicalJson(checkpoint)}\n`,
54
- patches: stream.patches.map((record) => `${canonicalJson(record)}\n`).join(""),
54
+ checkpoint: `${canonicalJson(stream.checkpoint.state)}\n`,
55
+ patches: stream.patches.map((record) => `${canonicalJson(record.patch)}\n`).join(""),
56
+ temporal: {
57
+ checkpoint: structuredClone(stream.checkpoint.through),
58
+ patches: stream.patches.map((record) => structuredClone(record.transition)),
59
+ },
55
60
  };
56
61
  }
57
62
 
@@ -65,6 +70,7 @@ export function classifyScopeStream(
65
70
  patchesSource: string | undefined,
66
71
  scope: StateScope,
67
72
  expectedCwd?: string,
73
+ metaSource?: string,
68
74
  ): ScopeStreamPresence {
69
75
  if (checkpointSource === undefined && patchesSource === undefined) return { kind: "absent" };
70
76
  if (checkpointSource === undefined || patchesSource === undefined) {
@@ -76,16 +82,11 @@ export function classifyScopeStream(
76
82
  } catch {
77
83
  throw new Error(`State Flow ${scope} checkpoint contains invalid JSON`);
78
84
  }
79
- if (checkpoint !== null && typeof checkpoint === "object" && !Array.isArray(checkpoint) && Object.hasOwn(checkpoint, "owner")) {
80
- const { owner, ...semantic } = checkpoint as Record<string, unknown>;
81
- if (scope !== "cwd" || owner === null || typeof owner !== "object" || Array.isArray(owner)
82
- || Object.keys(owner).join(",") !== "cwd" || typeof (owner as { cwd?: unknown }).cwd !== "string") {
83
- throw new Error("Invalid State Flow CWD scope identity");
84
- }
85
- if (expectedCwd !== undefined && (owner as { cwd: string }).cwd !== resolve(expectedCwd)) throw new Error("State Flow CWD scope identity mismatch");
86
- checkpoint = semantic;
87
- } else if (scope === "cwd" && expectedCwd !== undefined) {
88
- throw new Error("State Flow CWD scope identity is missing");
85
+ let meta: Record<string, unknown> | undefined;
86
+ if (metaSource !== undefined) {
87
+ try { meta = JSON.parse(metaSource) as Record<string, unknown>; }
88
+ catch { throw new Error(`State Flow ${scope} metadata contains invalid JSON`); }
89
+ if (!isObject(meta)) throw new Error(`Invalid State Flow ${scope} metadata`);
89
90
  }
90
91
  const patches: unknown[] = [];
91
92
  for (const [index, line] of patchesSource.split(/\r?\n/).entries()) {
@@ -96,7 +97,35 @@ export function classifyScopeStream(
96
97
  throw new Error(`State Flow ${scope} tail contains invalid JSON at line ${index + 1}`);
97
98
  }
98
99
  }
99
- const stream = { checkpoint, patches };
100
+ const temporal = meta?.temporal;
101
+ if (isObject(temporal) && Object.hasOwn(temporal, "checkpoint") && Array.isArray(temporal.patches)) {
102
+ const owner = meta?.owner;
103
+ if (scope === "cwd" && expectedCwd !== undefined
104
+ && (!isObject(owner) || Object.keys(owner).join(",") !== "cwd" || owner.cwd !== resolve(expectedCwd))) {
105
+ throw new Error(owner === undefined ? "State Flow CWD scope identity is missing" : "State Flow CWD scope identity mismatch");
106
+ }
107
+ const boundaries = temporal.patches;
108
+ if (boundaries.length !== patches.length) throw new Error(`State Flow ${scope} temporal metadata does not match its semantic tail`);
109
+ const stream = {
110
+ checkpoint: { through: temporal.checkpoint, state: checkpoint },
111
+ patches: patches.map((patch, index) => ({ transition: boundaries[index], patch })),
112
+ };
113
+ validateScopeStream(stream, scope);
114
+ return { kind: "present", stream };
115
+ }
116
+ // Complete predecessor envelopes are the only no-meta form that may be unwrapped.
117
+ let legacyCheckpoint = checkpoint;
118
+ if (isObject(checkpoint) && Object.hasOwn(checkpoint, "owner")) {
119
+ const { owner, ...semantic } = checkpoint;
120
+ if (scope !== "cwd" || !isObject(owner) || Object.keys(owner).join(",") !== "cwd" || typeof owner.cwd !== "string") {
121
+ throw new Error("Invalid State Flow CWD scope identity");
122
+ }
123
+ if (expectedCwd !== undefined && owner.cwd !== resolve(expectedCwd)) throw new Error("State Flow CWD scope identity mismatch");
124
+ legacyCheckpoint = semantic;
125
+ } else if (scope === "cwd" && expectedCwd !== undefined) {
126
+ throw new Error("State Flow CWD scope identity is missing");
127
+ }
128
+ const stream = { checkpoint: legacyCheckpoint, patches };
100
129
  validateScopeStream(stream, scope);
101
130
  return { kind: "present", stream };
102
131
  }
@@ -107,8 +136,9 @@ export function parseScopeStream(
107
136
  patchesSource: string | undefined,
108
137
  scope: StateScope,
109
138
  expectedCwd?: string,
139
+ metaSource?: string,
110
140
  ): ScopeStream | undefined {
111
- const presence = classifyScopeStream(checkpointSource, patchesSource, scope, expectedCwd);
141
+ const presence = classifyScopeStream(checkpointSource, patchesSource, scope, expectedCwd, metaSource);
112
142
  return presence.kind === "present" ? presence.stream : undefined;
113
143
  }
114
144
 
@@ -134,13 +164,13 @@ export function sessionRuntimePaths(cwd: string, sessionId: string, repositoryRo
134
164
  return { config: join(directory, "config.json"), meta: join(directory, META_FILE) };
135
165
  }
136
166
 
137
- /** A temporal reader never treats a legacy current snapshot as an anchored checkpoint. */
167
+ /** Unsupported state.json presence never becomes an anchored checkpoint. */
138
168
  export function loadScopeStream(cwd: string, sessionId: string, scope: StateScope, repositoryRoot: string, sessionKey = sessionId): ScopeStream | undefined {
139
169
  const paths = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
140
170
  if (readRegularBytes(join(paths.directory, STATE_FILE), repositoryRoot) !== undefined) {
141
171
  throw new Error(`Legacy State Flow storage requires explicit migration: ${paths.directory}`);
142
172
  }
143
- return parseScopeStream(readRegularFile(paths.checkpoint, repositoryRoot), readRegularFile(paths.patches, repositoryRoot), scope, scope === "cwd" ? cwd : undefined);
173
+ return parseScopeStream(readRegularFile(paths.checkpoint, repositoryRoot), readRegularFile(paths.patches, repositoryRoot), scope, scope === "cwd" ? cwd : undefined, readRegularFile(paths.meta, repositoryRoot));
144
174
  }
145
175
 
146
176
  /** Include legacy names in the CAS basis solely to prevent format races during cutover. */
@@ -182,24 +212,43 @@ export interface DurablePaths {
182
212
  globalPatches: string;
183
213
  }
184
214
 
185
- /** One scope-level provenance document; versioned for lenient forward evolution. */
215
+ /** Merge authoritative owned leaves while preserving forward-compatible metadata siblings. */
216
+ export function serializeScopeMetadata(
217
+ registry: Readonly<ArtifactProvenanceRegistry> | undefined, stream: ScopeStream, scope: StateScope,
218
+ cwdIdentity?: string, existingSource?: string,
219
+ ): string {
220
+ const existing = parseMetadataDocument(existingSource, `State Flow metadata`);
221
+ const sources = serializeScopeStream(stream, scope, cwdIdentity);
222
+ const value = {
223
+ ...existing,
224
+ version: 1,
225
+ ...(registry === undefined ? {} : { artifacts: serializeArtifactProvenanceRegistry(registry) }),
226
+ temporal: sources.temporal,
227
+ ...(scope === "cwd" ? { owner: { cwd: resolve(cwdIdentity!) } } : {}),
228
+ };
229
+ return `${canonicalJson(value)}\n`;
230
+ }
231
+
232
+ /** Compatibility serializer retained for metadata-only callers. */
186
233
  export function serializeScopeProvenance(registry: Readonly<ArtifactProvenanceRegistry>): string {
187
234
  return `${canonicalJson({ version: 1, artifacts: serializeArtifactProvenanceRegistry(registry) })}\n`;
188
235
  }
189
236
 
190
- /** Missing provenance is unavailable evidence, never corrupt state. */
191
- export function parseScopeProvenance(source: string | undefined, path: string): ArtifactProvenanceRegistry {
237
+ function parseMetadataDocument(source: string | undefined, label: string): Record<string, unknown> {
192
238
  if (source === undefined) return {};
193
239
  let value: unknown;
194
- try {
195
- value = JSON.parse(source);
196
- } catch {
197
- throw new Error(`State Flow provenance file contains invalid JSON: ${path}`);
198
- }
199
- if (!isObject(value) || value.version !== 1 || !Object.hasOwn(value, "artifacts")
200
- || Object.keys(value).some((key) => key !== "version" && key !== "artifacts")) {
201
- throw new Error(`Invalid State Flow provenance document: ${path}`);
202
- }
240
+ try { value = JSON.parse(source); }
241
+ catch { throw new Error(`${label} contains invalid JSON`); }
242
+ if (!isObject(value) || !isJsonValue(value)) throw new Error(`Invalid ${label}`);
243
+ return value;
244
+ }
245
+
246
+ /** Missing provenance is unavailable evidence, never corrupt state. Unknown metadata is preserved by writers. */
247
+ export function parseScopeProvenance(source: string | undefined, path: string): ArtifactProvenanceRegistry {
248
+ const value = parseMetadataDocument(source, `State Flow provenance file: ${path}`);
249
+ if (Object.keys(value).length === 0) return {};
250
+ if (value.version !== 1) throw new Error(`Invalid State Flow provenance document: ${path}`);
251
+ if (!Object.hasOwn(value, "artifacts")) return {};
203
252
  return parseArtifactProvenanceRegistry(value.artifacts, `State Flow provenance at ${path}`);
204
253
  }
205
254
 
@@ -232,12 +281,6 @@ export function durablePaths(repositoryRoot = getDurableRepositoryRoot()): Durab
232
281
  };
233
282
  }
234
283
 
235
- function legacyReadableScopeKey(identity: string, readable: string): string {
236
- if (identity.length === 0) throw new Error("State Flow scope identity must be non-empty");
237
- const slug = readable.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, LEGACY_MAX_SCOPE_SLUG_LENGTH) || "scope";
238
- return `${slug}-${createHash("sha256").update(identity).digest("hex")}`;
239
- }
240
-
241
284
  /** Match Pi's native project-session directory convention exactly. */
242
285
  export function cwdScopeKey(cwd: string): string {
243
286
  const canonical = resolve(cwd);
@@ -266,16 +309,6 @@ export function resolveSessionAddress(sessionFile: string | undefined, sessionId
266
309
  return Object.freeze({ id: sessionId, key: sessionStorageKey(sessionFile, sessionId, timestamp) });
267
310
  }
268
311
 
269
- /** Read-only migration input for the untagged hashed-layout draft. */
270
- export function legacyCwdScopeKey(cwd: string): string {
271
- const canonical = resolve(cwd);
272
- return legacyReadableScopeKey(canonical, `--${canonical.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`);
273
- }
274
-
275
- export function legacySessionScopeKey(sessionId: string): string {
276
- return legacyReadableScopeKey(sessionScopeKey(sessionId), sessionId);
277
- }
278
-
279
312
  export function cwdScopePaths(cwd: string, repositoryRoot = getDurableRepositoryRoot()): ScopePaths {
280
313
  const directory = join(resolve(repositoryRoot), cwdScopeKey(cwd));
281
314
  return { directory, state: join(directory, STATE_FILE), patches: join(directory, PATCHES_FILE) };
@@ -299,28 +332,6 @@ export function cwdPatchesPath(cwd: string, repositoryRoot = getDurableRepositor
299
332
  return cwdScopePaths(cwd, repositoryRoot).patches;
300
333
  }
301
334
 
302
- /** Historical paths from the pre-0.4 hashed-layout draft; never selected for new writes. */
303
- export function legacyTemporalScopePaths(cwd: string, sessionId: string, scope: StateScope, repositoryRoot: string): TemporalScopePaths {
304
- const root = resolve(repositoryRoot);
305
- const cwdDirectory = join(root, legacyCwdScopeKey(cwd));
306
- const directory = scope === "global" ? root : scope === "cwd" ? cwdDirectory : join(cwdDirectory, legacySessionScopeKey(sessionId));
307
- return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE), meta: join(directory, META_FILE) };
308
- }
309
-
310
- export function legacySessionRuntimePaths(cwd: string, sessionId: string, repositoryRoot: string): { config: string; meta: string } {
311
- const directory = legacyTemporalScopePaths(cwd, sessionId, "session", repositoryRoot).directory;
312
- return { config: join(directory, "config.json"), meta: join(directory, "meta.json") };
313
- }
314
-
315
- export function captureLegacyTemporalFileBases(cwd: string, sessionId: string, repositoryRoot: string): DurableFileBase[] {
316
- const paths = (["global", "cwd", "session"] as const).flatMap((scope) => {
317
- const pair = legacyTemporalScopePaths(cwd, sessionId, scope, repositoryRoot);
318
- const runtime = scope === "session" ? legacySessionRuntimePaths(cwd, sessionId, repositoryRoot) : undefined;
319
- return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [] : [runtime.config, runtime.meta])];
320
- });
321
- return captureOwnedFileBases(paths, repositoryRoot);
322
- }
323
-
324
335
  export function sessionStatePath(cwd: string, sessionId: string, repositoryRoot = getDurableRepositoryRoot(), sessionKey = sessionId): string {
325
336
  return sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).state;
326
337
  }
@@ -337,7 +348,7 @@ export function isStateFlowOwnedPath(candidate: string, repositoryRoot = getDura
337
348
  if (absolute === global.globalState || absolute === global.globalPatches
338
349
  || absolute === join(root, CHECKPOINT_FILE) || absolute === join(root, META_FILE)) return true;
339
350
  const segments = relative(root, absolute).split(sep);
340
- const cwdKey = (value: string) => (value.startsWith("--") && value.endsWith("--")) || LEGACY_SCOPE_KEY_PATTERN.test(value);
351
+ const cwdKey = (value: string) => value.startsWith("--") && value.endsWith("--");
341
352
  const sessionKey = (value: string) => {
342
353
  try { return sessionScopeKey(value) === value; } catch { return false; }
343
354
  };
@@ -346,7 +357,7 @@ export function isStateFlowOwnedPath(candidate: string, repositoryRoot = getDura
346
357
  }
347
358
  if (segments.length === 3) {
348
359
  return cwdKey(segments[0]!)
349
- && (sessionKey(segments[1]!) || LEGACY_SCOPE_KEY_PATTERN.test(segments[1]!))
360
+ && sessionKey(segments[1]!)
350
361
  && (segments[2] === STATE_FILE || segments[2] === CHECKPOINT_FILE || segments[2] === PATCHES_FILE
351
362
  || segments[2] === "config.json" || segments[2] === META_FILE);
352
363
  }
@@ -446,25 +457,6 @@ export function captureOwnedFileBases(paths: readonly string[], repositoryRoot:
446
457
  });
447
458
  }
448
459
 
449
- function validateState(value: unknown, path: string): asserts value is MaterializedState {
450
- if (!isMaterializedState(value) || !isJsonValue(value) || containsNull(value)) {
451
- throw new Error(`Durable State Flow file has invalid materialized state: ${path}`);
452
- }
453
- }
454
-
455
- /** One-way legacy current-state interpretation; explanatory journals are not recovery input. */
456
- export function parseStateSource(source: string | undefined, path: string): MaterializedState | undefined {
457
- if (source === undefined) return undefined;
458
- let value: unknown;
459
- try {
460
- value = JSON.parse(source);
461
- } catch {
462
- throw new Error(`Durable State Flow file contains invalid JSON: ${path}`);
463
- }
464
- validateState(value, path);
465
- return structuredClone(value);
466
- }
467
-
468
460
  interface PreparedFile {
469
461
  path: string;
470
462
  repositoryRoot: string;
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { StringEnum, Type } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { Text } from "@earendil-works/pi-tui";
6
7
  import { assistantToolCallCount, finalizedAssistantResponse, stateFlowProtocol } from "./protocol.ts";
7
8
  import { createPassiveContinuation, currentRunTrajectory, passiveContinuationMessages, runtimeContextMessage, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
8
9
  import { ArtifactReadTracker } from "./acquisition.ts";
@@ -26,6 +27,7 @@ import { acquirePublicationWorkerLease, loadPublicationQueue, publicationQueuePa
26
27
  import { runPublicationWorker } from "./publication.ts";
27
28
  import { getKnowledgeRoot, GlobalMarkdownDiscovery } from "./discovery.ts";
28
29
  import { isObject, sameJson } from "./json.ts";
30
+ import { readStatePath } from "./query.ts";
29
31
  import {
30
32
  cwdScopeKey,
31
33
  resolveSessionAddress,
@@ -40,7 +42,7 @@ import {
40
42
  planArtifactInvalidation,
41
43
  type ArtifactInvalidationRequest,
42
44
  } from "./artifact.ts";
43
- import { planStateFlowCompaction, shouldRequestStateFlowCompaction, stateFlowCompactionResult, type StateFlowCompactionPlan } from "./compaction.ts";
45
+ import { hasCompactionSizedTranscript, planStateFlowCompaction, shouldRequestStateFlowCompaction, stateFlowCompactionResult, type StateFlowCompactionPlan } from "./compaction.ts";
44
46
 
45
47
  export interface StateFlowExtensionOptions {
46
48
  agentDir?: string;
@@ -55,6 +57,34 @@ export const READ_STATE_TOOL_NAME = "read_state";
55
57
  export const MAX_FALLBACK_ATTEMPTS: number = 2;
56
58
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
57
59
  const PUBLICATION_SHUTDOWN_WAIT_MS = 2_000;
60
+ const PATCH_DISPLAY_SECTION_KEYS = new Set([
61
+ "global",
62
+ "cwd",
63
+ "session",
64
+ "artifacts",
65
+ "contract",
66
+ "working",
67
+ "response",
68
+ "final",
69
+ ]);
70
+
71
+ /** Keep successful patch JSON valid while separating adjacent scopes and memory sections visually. */
72
+ export function formatPatchStateArguments(args: unknown): string {
73
+ const seenAtIndent = new Set<number>();
74
+ return JSON.stringify(args, null, 2).split("\n").flatMap((line) => {
75
+ const indent = line.length - line.trimStart().length;
76
+ const match = /^(\s+)"([^"]+)":/.exec(line);
77
+ if (match === null || !PATCH_DISPLAY_SECTION_KEYS.has(match[2])) {
78
+ for (const seenIndent of seenAtIndent) {
79
+ if (seenIndent > indent) seenAtIndent.delete(seenIndent);
80
+ }
81
+ return [line];
82
+ }
83
+ const separator = seenAtIndent.has(indent) ? [""] : [];
84
+ seenAtIndent.add(indent);
85
+ return [...separator, line];
86
+ }).join("\n");
87
+ }
58
88
 
59
89
  /** Normalize a bounded compatibility superset without advertising aliases in the model-facing contract. */
60
90
  export function normalizePatchStateArguments(args: unknown): any {
@@ -70,10 +100,15 @@ export function normalizePatchStateArguments(args: unknown): any {
70
100
  return { ...args, final };
71
101
  }
72
102
 
103
+ /** Keep tool output visually separated from its heading with exactly one leading newline. */
104
+ function separatedOutput(text: string): string {
105
+ return `\n${text.replace(/^\n+/, "")}`;
106
+ }
107
+
73
108
  /** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
74
109
  function separatedFailure(error: unknown): Error {
75
110
  const message = error instanceof Error ? error.message : String(error);
76
- return new Error(`\n${message}`, error instanceof Error ? { cause: error } : undefined);
111
+ return new Error(separatedOutput(message), error instanceof Error ? { cause: error } : undefined);
77
112
  }
78
113
 
79
114
  export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowExtensionOptions = {}): void {
@@ -560,9 +595,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
560
595
  const branch = ctx.sessionManager.getBranch();
561
596
  const discovery = discoverSnapshotData(branch);
562
597
  let restoreSelected: (() => Snapshot) | undefined;
563
- const recovery = recoverSnapshot(discovery.candidates, (revision, legacy) => {
598
+ const recovery = recoverSnapshot(discovery.candidates, (revision) => {
564
599
  try {
565
- const prepared = prepareBranchRestore(ctx, revision, legacy);
600
+ const prepared = prepareBranchRestore(ctx, revision);
566
601
  restoreSelected = prepared.restore;
567
602
  return prepared.snapshot;
568
603
  } catch (error) {
@@ -586,7 +621,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
586
621
  } else if (branchHasSnapshot && snapshot.config.enabled) {
587
622
  const publication = runtime.initialize(snapshot, true);
588
623
  recordPolicyPublication(publication, ctx);
589
- delete snapshot.legacySession;
590
624
  }
591
625
  installScopeStates();
592
626
  } else if (config.autoStart && isNewSession(sessionStartReason, branch)) {
@@ -762,23 +796,33 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
762
796
  pi.registerTool({
763
797
  name: READ_STATE_TOOL_NAME,
764
798
  label: "Read State",
765
- description: "Read one effective or scoped State Flow materialization at offset 0–7 in the active causal lineage. Read-only and lazy; unavailable pre-origin history is an error. Use only for a concrete historical or scope-specific gap, not routine rereading of current context.",
766
- promptSnippet: "Read one cached effective/global/CWD/session state at temporal offset 0–7",
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",
767
801
  parameters: Type.Object({
768
- offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 7, description: "Accepted transitions before current state; defaults to 0" })),
769
- scope: Type.Optional(StringEnum(["effective", "global", "cwd", "session"] as const, { description: "Projection at that same boundary; defaults to effective" })),
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" })),
770
805
  }, { additionalProperties: false }),
771
806
  async execute(_toolCallId, params, signal) {
772
807
  try {
808
+ type ReadDetails = { path?: string; offset?: number; scope?: string; transitionId: string };
773
809
  if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
774
810
  if (signal?.aborted) throw new Error("State Flow read was aborted");
775
811
  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);
815
+ return {
816
+ content: [{ type: "text", text: `\n${JSON.stringify(result)}` }],
817
+ details: { path: params.path, transitionId: result.boundary.id } as ReadDetails,
818
+ };
819
+ }
776
820
  const { offset = 0, scope = "effective" } = params;
777
821
  const state = runtime.read(offset, scope === "effective" ? undefined : scope);
778
822
  const boundary = runtime.view.lineage.at(-1 - offset)!;
779
823
  return {
780
824
  content: [{ type: "text", text: `\n${JSON.stringify({ offset, scope, boundary, state: projectStateForModel(state) })}` }],
781
- details: { offset, scope, transitionId: boundary.id },
825
+ details: { offset, scope, transitionId: boundary.id } as ReadDetails,
782
826
  };
783
827
  } catch (error) {
784
828
  throw separatedFailure(error);
@@ -804,6 +848,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
804
848
  final: Type.Optional(Type.Boolean({ description: "Set exactly true to permit this iteration to finish at a later turn_end" })),
805
849
  }, { additionalProperties: false }),
806
850
  prepareArguments: normalizePatchStateArguments,
851
+ renderResult(result, { isPartial }, theme, context) {
852
+ const text = result.content.find((block) => block.type === "text")?.text ?? "";
853
+ if (context.isError) return new Text(separatedOutput(text), 0, 0);
854
+ if (isPartial || !config.showSuccessfulPatches) return new Text(text, 0, 0);
855
+ return new Text(separatedOutput(theme.fg("dim", formatPatchStateArguments(context.args))), 0, 0);
856
+ },
807
857
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
808
858
  try {
809
859
  if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
@@ -874,6 +924,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
874
924
  activeContext = ctx;
875
925
  runtime ??= createRuntime(ctx);
876
926
  if (branchStartsWithoutRuntime) runtime.prepare();
927
+ runtime.migrateLegacyStorage();
877
928
  const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
878
929
  && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
879
930
  if (!runtime.view && snapshot.meta.durableBase) {
@@ -896,7 +947,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
896
947
  : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
897
948
  recordPolicyPublication(publication, ctx);
898
949
  installScopeStates();
899
- delete snapshot.legacySession;
900
950
  clearRunTransient();
901
951
  passiveContinuation = undefined;
902
952
  bootstrapContinuation = snapshot.meta.bootstrap
@@ -1195,7 +1245,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
1195
1245
  || compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !snapshot.meta.durableBase
1196
1246
  || !shouldRequestStateFlowCompaction(ctx.getContextUsage())) return;
1197
1247
  completedRunAccepted = false;
1198
- const plan = planStateFlowCompaction(ctx.sessionManager.buildContextEntries(), snapshot.meta.durableBase, snapshot.meta.step);
1248
+ const entries = ctx.sessionManager.buildContextEntries();
1249
+ if (!hasCompactionSizedTranscript(entries)) return;
1250
+ const plan = planStateFlowCompaction(entries, snapshot.meta.durableBase, snapshot.meta.step);
1199
1251
  if (!plan) return;
1200
1252
  compactionPlan = plan;
1201
1253
  compactionInFlight = true;