@llblab/pi-kit 0.10.8 → 0.11.1

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 (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +14 -10
  4. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +9 -2
  5. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +19 -0
  6. package/node_modules/@llblab/pi-state-flow/README.md +52 -261
  7. package/node_modules/@llblab/pi-state-flow/docs/README.md +6 -1
  8. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +47 -26
  9. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +97 -0
  10. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +35 -0
  11. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +47 -0
  12. package/node_modules/@llblab/pi-state-flow/docs/performance.md +459 -0
  13. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +35 -3
  14. package/node_modules/@llblab/pi-state-flow/docs/usage.md +142 -0
  15. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +19 -3
  16. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +74 -0
  17. package/node_modules/@llblab/pi-state-flow/lib/context.ts +12 -12
  18. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -2
  19. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +21 -5
  20. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +20 -5
  21. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +146 -37
  22. package/node_modules/@llblab/pi-state-flow/lib/git.ts +142 -42
  23. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +80 -27
  24. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +157 -20
  25. package/node_modules/@llblab/pi-state-flow/lib/status.ts +7 -12
  26. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +2 -1
  27. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -3
  28. package/node_modules/@llblab/pi-state-flow/package.json +5 -4
  29. package/package.json +2 -2
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstatSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { parseScopeProvenance, parseScopeStream, sessionRuntimePaths, temporalScopePaths, type SessionAddress } from "./durable.ts";
4
+ import { classifyScopeStream, parseScopeProvenance, parseScopeStream, sessionRuntimePaths, temporalScopePaths, type SessionAddress } from "./durable.ts";
5
5
  import { parseArtifactProvenanceRegistry, pruneArtifactProvenance, type ArtifactProvenance, type ArtifactProvenanceRegistry } from "./artifact.ts";
6
6
  import { adoptFileStateToGit, initializeGitRepository, isLocalGitRepository, captureTemporalGitBase, loadLegacyStatesAtRevision, loadTemporalRevision, migrateHashedCwdAtHead, migrateHashedLayoutAtHead, migrateLegacyStorageToGit, publishTemporalStateToGit, type TemporalGitBase } from "./git.ts";
7
7
  import { captureTemporalFileBase, detectGitCapability, initializeFileStore, loadTemporalFileRevision, migrateLegacyStorageToFiles, publishTemporalStateToFiles, type TemporalFileBase } from "./storage.ts";
@@ -16,6 +16,12 @@ const SCOPES = ["global", "cwd", "session"] as const;
16
16
  const SHARED_SCOPES = ["global", "cwd"] as const;
17
17
  export type RuntimePublication = ReturnType<typeof publishTemporalStateToGit> & { revision?: string };
18
18
 
19
+ interface SessionCopy {
20
+ stream: ScopeStream;
21
+ provenance: ArtifactProvenanceRegistry;
22
+ backend: "git" | "files";
23
+ }
24
+
19
25
  function emptyProvenance(): Record<StateScope, ArtifactProvenanceRegistry> {
20
26
  return { global: {}, cwd: {}, session: {} };
21
27
  }
@@ -33,10 +39,27 @@ function targetScopeConflict(scopes: readonly StateScope[]): Error {
33
39
  return new Error(`State Flow cannot publish the ${labels.join(" and ")} patches because the live ${labels.join(" and ")} states advanced after this transition's selected basis. Refresh or reconcile the target scopes before retrying.`);
34
40
  }
35
41
 
42
+ /** Disappearance invalidates a selected write target even though untouched scopes can adopt empty reality. */
43
+ function removedTargetScopeConflict(scopes: readonly StateScope[]): Error {
44
+ const labels = scopes.map(scopeLabel);
45
+ if (labels.length === 1) {
46
+ return new Error(`State Flow cannot publish the ${labels[0]} patch because the live ${labels[0]} scope was removed after this transition's selected basis. Refresh or reconcile the target scope before retrying.`);
47
+ }
48
+ return new Error(`State Flow cannot publish the ${labels.join(" and ")} patches because the live ${labels.join(" and ")} scopes were removed after this transition's selected basis. Refresh or reconcile the target scopes before retrying.`);
49
+ }
50
+
51
+ function freshEmptyScopeStream(scope: StateScope, origin: string): ScopeStream {
52
+ return createTemporalState({ global: emptyState(), cwd: emptyState(), session: emptyState() }, origin).scopes[scope];
53
+ }
54
+
55
+ export class MissingSessionRuntimeError extends Error {
56
+ constructor() { super("Linked State Flow revision has no session runtime"); }
57
+ }
58
+
36
59
  /** Immutable target validation is independent of acquiring the live publication basis. */
37
60
  export function inspectRuntimeRevision(cwd: string, sessionId: string, root: string, revision: string, sessionKey = sessionId) {
38
61
  const loaded = loadTemporalRevision(cwd, sessionId, root, revision, sessionKey);
39
- if (!loaded.runtime) throw new Error("Linked State Flow revision has no session runtime");
62
+ if (!loaded.runtime) throw new MissingSessionRuntimeError();
40
63
  const resolved = resolveSessionRuntime(loaded.runtime.document, loaded.runtime.revision);
41
64
  if (!loaded.scopes.global || !loaded.scopes.cwd || !loaded.scopes.session) throw new Error("Incomplete temporal scope cohort");
42
65
  const view = { lineage: resolved.lineage, scopes: { global: loaded.scopes.global, cwd: loaded.scopes.cwd, session: loaded.scopes.session } };
@@ -71,6 +94,8 @@ export class TemporalRuntime {
71
94
  private savedRuntime: string | undefined;
72
95
  private backend: "git" | "files" | undefined;
73
96
  private provenanceByScope: Record<StateScope, ArtifactProvenanceRegistry> = emptyProvenance();
97
+ /** Shared scopes whose wholly absent live basis was accepted after one stale-target refusal. */
98
+ private readonly absentSharedScopes = new Set<StateScope>();
74
99
  readonly cwd: string;
75
100
  private readonly session: SessionAddress;
76
101
  readonly root: string;
@@ -109,6 +134,7 @@ export class TemporalRuntime {
109
134
  this.backend = "git";
110
135
  this.semanticRevision = result.revision;
111
136
  this.savedRuntime = savedRuntime;
137
+ this.absentSharedScopes.clear();
112
138
  return result;
113
139
  }
114
140
 
@@ -138,8 +164,66 @@ export class TemporalRuntime {
138
164
  return result;
139
165
  }
140
166
 
141
- restore(revision: string, legacySnapshot?: Snapshot): Snapshot {
167
+ /** Validate selection without installing state; only a matching immutable Git inspection is reusable. */
168
+ prepareRestore(revision: string, legacySnapshot?: Snapshot): { snapshot: Snapshot; restore: () => Snapshot } {
142
169
  const inspected = inspectSnapshotRevision(this.cwd, this.sessionId, this.root, revision, legacySnapshot, this.sessionKey);
170
+ const selected = inspected.snapshot.meta.durableBase ?? revision;
171
+ let consumed = false;
172
+ return {
173
+ snapshot: structuredClone(inspected.snapshot),
174
+ restore: () => {
175
+ if (consumed) throw new Error("Prepared State Flow restore was already consumed");
176
+ consumed = true;
177
+ // File cohorts can expire; legacy migration and owner redirection keep their fresh-read path.
178
+ return inspected.temporal && selected === revision
179
+ ? this.restoreInspected(revision, inspected)
180
+ : this.restore(selected, inspected.snapshot);
181
+ },
182
+ };
183
+ }
184
+
185
+ /** Copy only a proven source session stream; shared streams come from the child's fresh live basis. */
186
+ prepareFork(source: SessionAddress, revision: string): { snapshot: Snapshot; fork: () => { snapshot: Snapshot; publication: RuntimePublication } } {
187
+ const parent = Object.freeze({ ...source });
188
+ if (parent.id === this.sessionId || parent.key === this.sessionKey) throw new Error("State Flow fork requires a distinct session identity and key");
189
+ const inspect = () => inspectSnapshotRevision(this.cwd, parent.id, this.root, revision, undefined, parent.key);
190
+ const inspected = inspect();
191
+ const snapshot: Snapshot = {
192
+ config: structuredClone(inspected.snapshot.config),
193
+ meta: {
194
+ step: 0,
195
+ ...(inspected.snapshot.meta.bootstrap === undefined ? {} : { bootstrap: inspected.snapshot.meta.bootstrap }),
196
+ ...(inspected.snapshot.meta.remotePublication === undefined ? {} : { remotePublication: structuredClone(inspected.snapshot.meta.remotePublication) }),
197
+ },
198
+ };
199
+ let consumed = false;
200
+ return {
201
+ snapshot: { ...structuredClone(snapshot), meta: { ...structuredClone(snapshot.meta), durableBase: revision } },
202
+ fork: () => {
203
+ if (consumed) throw new Error("Prepared State Flow fork was already consumed");
204
+ consumed = true;
205
+ if (this.view) throw new Error("State Flow fork target already has session storage");
206
+ // Unlike immutable Git input, a file-only source must still match its complete cohort.
207
+ const current = inspected.file ? inspect() : inspected;
208
+ const selected = current.temporal ?? current.file;
209
+ if (!selected) throw new Error("State Flow fork requires a temporal session stream");
210
+ const publication = this.initializeOrigin(snapshot, { allowCreateCwd: false, copy: {
211
+ stream: selected.view.scopes.session,
212
+ provenance: selected.provenance.session,
213
+ backend: current.file ? "files" : "git",
214
+ } });
215
+ const ownedRevision = publication?.revision ?? publication?.commit;
216
+ if (!publication || !ownedRevision) throw new Error("State Flow fork requires existing shared scope storage");
217
+ return { snapshot: { ...structuredClone(snapshot), meta: { ...structuredClone(snapshot.meta), durableBase: ownedRevision } }, publication };
218
+ },
219
+ };
220
+ }
221
+
222
+ restore(revision: string, legacySnapshot?: Snapshot): Snapshot {
223
+ return this.restoreInspected(revision, inspectSnapshotRevision(this.cwd, this.sessionId, this.root, revision, legacySnapshot, this.sessionKey));
224
+ }
225
+
226
+ private restoreInspected(revision: string, inspected: ReturnType<typeof inspectSnapshotRevision>): Snapshot {
143
227
  if (inspected.file) {
144
228
  const savedRuntime = hashJson(createSessionRuntime(inspected.snapshot, this.cwd, this.sessionId, inspected.file.view.lineage, "files", inspected.file.provenance.session));
145
229
  this.view = inspected.file.view;
@@ -148,6 +232,7 @@ export class TemporalRuntime {
148
232
  this.provenanceByScope = structuredClone(inspected.file.provenance);
149
233
  this.semanticRevision = revision;
150
234
  this.savedRuntime = savedRuntime;
235
+ this.absentSharedScopes.clear();
151
236
  return inspected.snapshot;
152
237
  }
153
238
  if (!inspected.temporal) {
@@ -201,20 +286,31 @@ export class TemporalRuntime {
201
286
  this.provenanceByScope = structuredClone(loaded.provenance);
202
287
  this.semanticRevision = semanticRevision;
203
288
  this.savedRuntime = savedRuntime;
289
+ this.absentSharedScopes.clear();
204
290
  return resolved.snapshot;
205
291
  }
206
292
 
207
293
  initialize(snapshot: Snapshot, allowCreateCwd: boolean, expectedShared?: Pick<ScopedStates, "global" | "cwd">, newSessionOrigin = false): RuntimePublication | undefined {
294
+ return this.initializeOrigin(snapshot, { allowCreateCwd, expectedShared, newSessionOrigin });
295
+ }
296
+
297
+ private initializeOrigin(snapshot: Snapshot, options: {
298
+ allowCreateCwd: boolean;
299
+ expectedShared?: Pick<ScopedStates, "global" | "cwd">;
300
+ newSessionOrigin?: boolean;
301
+ copy?: SessionCopy;
302
+ }): RuntimePublication | undefined {
303
+ const { allowCreateCwd, expectedShared, newSessionOrigin = false, copy } = options;
208
304
  const hasCwd = hasCwdMaterialization(this.cwd, this.root);
209
305
  if (!allowCreateCwd && !hasCwd) return undefined;
210
- const backend = this.backend ?? (detectGitCapability() === "git" && lstatSync(join(this.root, ".git"), { throwIfNoEntry: false }) ? "git" : "files");
211
- if (backend === "git") {
306
+ const backend = copy?.backend ?? this.backend ?? (detectGitCapability() === "git" && lstatSync(join(this.root, ".git"), { throwIfNoEntry: false }) ? "git" : "files");
307
+ if (!copy && backend === "git") {
212
308
  if (!hasCwd) migrateHashedCwdAtHead(this.cwd, this.root);
213
309
  if (hasLegacyStateSources(this.cwd, this.sessionId, this.root, this.sessionKey)) {
214
310
  migrateLegacyStorageToGit(this.cwd, this.sessionId, this.root, this.sessionKey);
215
311
  }
216
312
  }
217
- else {
313
+ else if (!copy) {
218
314
  if (allowCreateCwd) initializeFileStore(this.root);
219
315
  if (hasLegacyStateSources(this.cwd, this.sessionId, this.root, this.sessionKey)) {
220
316
  migrateLegacyStorageToFiles(this.cwd, this.sessionId, this.root, this.sessionKey);
@@ -222,6 +318,15 @@ export class TemporalRuntime {
222
318
  }
223
319
  const base: TemporalGitBase = backend === "git" ? captureTemporalGitBase(this.cwd, this.sessionId, this.root, this.sessionKey) : captureTemporalFileBase(this.cwd, this.sessionId, this.root, this.sessionKey);
224
320
  const files = new Map(base.files.map((file) => [file.path, file.content]));
321
+ if (copy) {
322
+ const session = temporalScopePaths(this.cwd, this.sessionId, "session", this.root, this.sessionKey);
323
+ const owned = [session.checkpoint, session.patches, session.meta, join(session.directory, "config.json"), join(session.directory, "state.json")];
324
+ const occupied = owned.some((path) => files.get(path) !== undefined);
325
+ const historical = !occupied && backend === "git" && base.head ? loadTemporalRevision(this.cwd, this.sessionId, this.root, base.head, this.sessionKey) : undefined;
326
+ if (occupied || historical?.runtime || historical?.scopes.session) {
327
+ throw new Error("State Flow fork target already has session storage");
328
+ }
329
+ }
225
330
  if (SCOPES.some((scope) => {
226
331
  const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
227
332
  return files.get(join(paths.directory, "state.json")) !== undefined;
@@ -231,11 +336,13 @@ export class TemporalRuntime {
231
336
  return [scope, parseScopeStream(files.get(paths.checkpoint), files.get(paths.patches), scope, scope === "cwd" ? this.cwd : undefined)];
232
337
  })) as Record<StateScope, TemporalState["scopes"][StateScope] | undefined>;
233
338
  if (!streams.cwd && !allowCreateCwd) return undefined;
339
+ if (copy && !streams.global) throw new Error("State Flow fork requires existing shared scope storage");
234
340
  const paths = sessionRuntimePaths(this.cwd, this.sessionId, this.root, this.sessionKey);
235
341
  const existingRuntime = parseSessionRuntime(files.get(paths.config), files.get(paths.meta), this.cwd, this.sessionId);
236
342
  if (existingRuntime && !snapshot.legacySession && !newSessionOrigin) throw new Error("Existing session runtime requires a branch revision pointer");
237
343
  // Explicit start before any branch runtime is a new origin, never inheritance of a later session layer.
238
344
  if (snapshot.legacySession || newSessionOrigin) streams.session = undefined;
345
+ if (copy) streams.session = copy.stream;
239
346
  const fresh = createTemporalState({ global: emptyState(), cwd: emptyState(), session: snapshot.legacySession?.state ?? emptyState() }, randomUUID());
240
347
  const candidate = new TemporalRuntime(this.cwd, this.session, this.root);
241
348
  candidate.backend = backend;
@@ -246,18 +353,32 @@ export class TemporalRuntime {
246
353
  candidate.provenanceByScope = {
247
354
  global: parseScopeProvenance(files.get(globalMeta), globalMeta),
248
355
  cwd: parseScopeProvenance(files.get(cwdMeta), cwdMeta),
249
- session: streams.session === undefined ? {} : parseArtifactProvenanceRegistry(existingRuntime?.meta.artifacts, "State Flow session artifact provenance"),
356
+ session: copy ? structuredClone(copy.provenance) : streams.session === undefined ? {} : parseArtifactProvenanceRegistry(existingRuntime?.meta.artifacts, "State Flow session artifact provenance"),
250
357
  };
251
358
  if (expectedShared && (["global", "cwd"] as const).some((scope) => !sameJson(candidate.read(0, scope), expectedShared[scope]))) {
252
359
  throw new Error("Legacy branch shared scopes diverged from the selected revision; migration cannot overwrite them");
253
360
  }
254
- const publication = candidate.publish(snapshot, true);
361
+ const publication = copy ? candidate.publishForkOrigin(snapshot) : candidate.publish(snapshot, true);
255
362
  this.view = candidate.view;
256
363
  this.base = candidate.base;
257
364
  this.backend = backend;
258
365
  this.provenanceByScope = structuredClone(candidate.provenanceByScope);
259
366
  this.semanticRevision = candidate.semanticRevision;
260
367
  this.savedRuntime = candidate.savedRuntime;
368
+ this.absentSharedScopes.clear();
369
+ return publication;
370
+ }
371
+
372
+ /** Initial copy owns only the new session files, without pruning or rewriting shared provenance. */
373
+ private publishForkOrigin(snapshot: Snapshot): RuntimePublication {
374
+ const runtime = createSessionRuntime(snapshot, this.cwd, this.sessionId, this.view!.lineage, this.backend === "files" ? "files" : "unconfirmed", this.provenanceByScope.session);
375
+ const result = this.backend === "files"
376
+ ? publishTemporalStateToFiles(this.cwd, this.sessionId, this.view!, ["session"], this.base!, this.root, runtime, this.sessionKey, this.provenanceByScope)
377
+ : publishTemporalStateToGit(this.cwd, this.sessionId, this.view!, ["session"], this.base!, this.root, runtime, this.sessionKey, (snapshot.meta.remotePublication?.mode ?? "transition") === "transition", this.provenanceByScope);
378
+ const publication: RuntimePublication = result;
379
+ this.base = publication.base;
380
+ this.semanticRevision = publication.revision ?? publication.commit;
381
+ this.savedRuntime = hashJson(runtime);
261
382
  return publication;
262
383
  }
263
384
 
@@ -295,10 +416,23 @@ export class TemporalRuntime {
295
416
  const provenance = structuredClone(this.provenanceByScope);
296
417
  const adopted = new Map<StateScope, ScopeStream>();
297
418
  const targets: StateScope[] = [];
419
+ const removedTargets: StateScope[] = [];
420
+ const absentScopes: StateScope[] = [];
421
+ const head = "head" in captured ? captured.head : undefined;
422
+ const reconciliation = `${head ?? "files"}:reconcile:${randomUUID()}`;
298
423
  for (const scope of SHARED_SCOPES) {
299
424
  const paths = temporalScopePaths(this.cwd, this.sessionId, scope, this.root, this.sessionKey);
300
- const stream = parseScopeStream(liveFiles.get(paths.checkpoint)?.content, liveFiles.get(paths.patches)?.content, scope, scope === "cwd" ? this.cwd : undefined);
301
- if (stream === undefined) throw new Error(`Live State Flow ${scope} scope storage is incomplete`);
425
+ const presence = classifyScopeStream(liveFiles.get(paths.checkpoint)?.content, liveFiles.get(paths.patches)?.content, scope, scope === "cwd" ? this.cwd : undefined);
426
+ if (presence.kind === "absent") {
427
+ absentScopes.push(scope);
428
+ provenance[scope] = {};
429
+ if (changedScopes.has(scope) && !this.absentSharedScopes.has(scope)) removedTargets.push(scope);
430
+ if (!this.absentSharedScopes.has(scope)) {
431
+ adopted.set(scope, freshEmptyScopeStream(scope, `${reconciliation}:${scope}:absent`));
432
+ }
433
+ continue;
434
+ }
435
+ const stream = presence.stream;
302
436
  const liveProvenance = parseScopeProvenance(liveFiles.get(paths.meta)?.content, paths.meta);
303
437
  const streamDrifted = !sameJson(stream, this.view!.scopes[scope]);
304
438
  const provenanceDrifted = !sameJson(liveProvenance, this.provenanceByScope[scope]);
@@ -310,19 +444,20 @@ export class TemporalRuntime {
310
444
  provenance[scope] = liveProvenance;
311
445
  if (streamDrifted) adopted.set(scope, stream);
312
446
  }
313
- if (targets.length > 0) throw targetScopeConflict(targets);
314
- if (adopted.size === 0) return { view: this.view!, base: captured, provenance };
315
- const streams = {
447
+ const reconciledView = () => adopted.size === 0 ? this.view! : adoptTemporalStreams({
316
448
  global: adopted.get("global") ?? structuredClone(this.view!.scopes.global),
317
449
  cwd: adopted.get("cwd") ?? structuredClone(this.view!.scopes.cwd),
318
450
  session: structuredClone(this.view!.scopes.session),
319
- };
320
- const head = "head" in captured ? captured.head : undefined;
321
- return {
322
- view: adoptTemporalStreams(streams, `${head ?? "unborn"}:reconcile:${randomUUID()}`),
323
- base: captured,
324
- provenance,
325
- };
451
+ }, reconciliation);
452
+ if (removedTargets.length > 0) {
453
+ this.view = reconciledView();
454
+ this.base = captured;
455
+ this.provenanceByScope = provenance;
456
+ for (const scope of absentScopes) this.absentSharedScopes.add(scope);
457
+ throw removedTargetScopeConflict(removedTargets);
458
+ }
459
+ if (targets.length > 0) throw targetScopeConflict(targets);
460
+ return { view: reconciledView(), base: captured, provenance };
326
461
  }
327
462
 
328
463
  publish(
@@ -372,6 +507,7 @@ export class TemporalRuntime {
372
507
  this.provenanceByScope = nextProvenance;
373
508
  this.savedRuntime = fingerprint;
374
509
  this.semanticRevision = result.revision;
510
+ if (semantic) this.absentSharedScopes.clear();
375
511
  return { base: result.base, revision: result.revision };
376
512
  }
377
513
  if (!scopedWrite) {
@@ -395,6 +531,7 @@ export class TemporalRuntime {
395
531
  this.provenanceByScope = nextProvenance;
396
532
  this.savedRuntime = fingerprint;
397
533
  if (scopedWrite && result.commit) this.semanticRevision = result.commit;
534
+ if (semantic) this.absentSharedScopes.clear();
398
535
  return result;
399
536
  }
400
537
  }
@@ -57,16 +57,11 @@ export function detailedStatus(snapshot: Snapshot, diagnostics: StatusDiagnostic
57
57
  diagnostics.recent,
58
58
  );
59
59
  const available = diagnostics.temporal !== undefined && diagnostics.durableStateError === undefined;
60
- const materialized = !available ? undefined : {
61
- global: diagnostics.scopeStates.global,
62
- cwd: diagnostics.scopeStates.cwd,
63
- session: diagnostics.scopeStates.session,
64
- effective: overlayStates(
65
- diagnostics.scopeStates.global,
66
- diagnostics.scopeStates.cwd,
67
- diagnostics.scopeStates.session,
68
- ),
69
- };
60
+ const materialized = !available ? undefined : overlayStates(
61
+ diagnostics.scopeStates.global,
62
+ diagnostics.scopeStates.cwd,
63
+ diagnostics.scopeStates.session,
64
+ );
70
65
  const stateJson = materialized === undefined ? undefined : JSON.stringify(materialized, null, 2);
71
66
  const freshnessError = diagnostics.artifactFreshnessError ?? (available ? undefined : "temporal global artifact registry is unavailable");
72
67
  const stale = freshnessError === undefined
@@ -124,7 +119,7 @@ export function detailedStatus(snapshot: Snapshot, diagnostics: StatusDiagnostic
124
119
  `Publication: ${publication}`,
125
120
  ...staleLines,
126
121
  ...(stateJson === undefined
127
- ? ["Materialized states: unavailable (global/CWD/session/effective)"]
128
- : [`Materialized states (${Buffer.byteLength(stateJson, "utf8")} bytes; global/CWD/session Git-backed, effective overlay):`, "", stateJson]),
122
+ ? ["Effective memory: unavailable"]
123
+ : [`Effective memory (${Buffer.byteLength(stateJson, "utf8")} JSON bytes; globalCWD session overlay):`, "", stateJson]),
129
124
  ].join("\n");
130
125
  }
@@ -90,7 +90,8 @@ export function planTemporalPublication(
90
90
  changedScopes.push(scope);
91
91
  }
92
92
  const provenanceUpdates: OwnedFileUpdate[] = [];
93
- if (provenance !== undefined) {
93
+ // Shared provenance belongs to its semantic scopes, not the session config being saved.
94
+ if (!runtimeOnly && provenance !== undefined) {
94
95
  for (const scope of ["global", "cwd"] as const) {
95
96
  const paths = temporalScopePaths(cwd, sessionId, scope, root, sessionKey);
96
97
  const registry = provenance[scope];
@@ -3,6 +3,7 @@ import {
3
3
  ORDINARY_ARTIFACT_COMPILER,
4
4
  validateArtifactMetadata,
5
5
  validateArtifactRegistry,
6
+ validateModelArtifactPatch,
6
7
  type ArtifactCompilerOutput,
7
8
  type ArtifactProvenance,
8
9
  } from "./artifact.ts";
@@ -77,9 +78,6 @@ function compileReadSkills(
77
78
  if (!isObject(output)) {
78
79
  throw new Error(`Every successfully read Skill must have a CWD artifact compiler output at artifacts[exactReadPath]; missing: ${read.path}`);
79
80
  }
80
- if (Object.hasOwn(output, "hash") || Object.hasOwn(output, "compiler")) {
81
- throw new Error(`Skill artifact compiler output at ${read.path} cannot set runtime-owned hash or compiler fields`);
82
- }
83
81
  if (typeof output.description !== "string" || output.description.trim().length === 0) {
84
82
  throw new Error(`Skill artifact compiler output at ${read.path} must have a non-empty description`);
85
83
  }
@@ -133,6 +131,7 @@ function validateScopePatch(scope: unknown, patch: unknown): asserts patch is Sc
133
131
  throw new Error(`Scoped State Flow patch field ${key} must be a JSON object`);
134
132
  }
135
133
  }
134
+ if (isObject(patch.artifacts)) validateModelArtifactPatch(patch.artifacts);
136
135
  }
137
136
 
138
137
  function completePatch(patch: ScopePatch, response: string): StatePatch {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.9.6",
3
+ "version": "0.10.1",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -30,6 +30,7 @@
30
30
  "scripts": {
31
31
  "check": "node --experimental-strip-types -e \"await import('./index.ts')\"",
32
32
  "test": "node --experimental-strip-types --test tests/*.test.ts",
33
+ "benchmark": "node --experimental-strip-types benchmarks/benchmark.ts",
33
34
  "typecheck": "tsc --noEmit",
34
35
  "validate": "npm run typecheck && npm test && npm run check",
35
36
  "prepack": "npm run validate"
@@ -50,9 +51,9 @@
50
51
  "node": ">=22.19.0"
51
52
  },
52
53
  "peerDependencies": {
53
- "@earendil-works/pi-agent-core": "^0.84.4",
54
- "@earendil-works/pi-ai": "^0.84.4",
55
- "@earendil-works/pi-coding-agent": "^0.84.4"
54
+ "@earendil-works/pi-agent-core": "^0.84.4 || ^0.85.1",
55
+ "@earendil-works/pi-ai": "^0.84.4 || ^0.85.1",
56
+ "@earendil-works/pi-coding-agent": "^0.84.4 || ^0.85.1"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/node": "latest",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.10.8",
3
+ "version": "0.11.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -44,7 +44,7 @@
44
44
  "@llblab/pi-clean-room": "0.1.1",
45
45
  "@llblab/pi-codex-usage": "0.9.4",
46
46
  "@llblab/pi-grow-loop": "0.8.1",
47
- "@llblab/pi-state-flow": "0.9.6",
47
+ "@llblab/pi-state-flow": "0.10.1",
48
48
  "@llblab/pi-telegram": "0.45.8",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },