@llblab/pi-kit 0.27.4 → 0.27.5

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 (28) hide show
  1. package/BACKLOG.md +2 -1
  2. package/CHANGELOG.md +4 -0
  3. package/README.md +3 -3
  4. package/node_modules/@llblab/pi-state-flow/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +4 -3
  6. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +7 -0
  7. package/node_modules/@llblab/pi-state-flow/README.md +1 -0
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +6 -1
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +2 -2
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +9 -1
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +52 -11
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +3 -3
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +3 -1
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +7 -3
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +3 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +15 -5
  17. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  18. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +1 -1
  19. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +4 -2
  20. package/node_modules/@llblab/pi-state-flow/lib/context.ts +6 -1
  21. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +3 -2
  22. package/node_modules/@llblab/pi-state-flow/lib/json.ts +61 -11
  23. package/node_modules/@llblab/pi-state-flow/lib/query.ts +3 -3
  24. package/node_modules/@llblab/pi-state-flow/lib/state.ts +8 -3
  25. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +17 -5
  26. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  27. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +1 -1
  28. package/package.json +2 -2
package/BACKLOG.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # Backlog
2
2
 
3
- The 0.27.4 composition is recorded in [CHANGELOG.md](./CHANGELOG.md). Package pins, resource order and bundled runtime ownership remain authoritative in `package.json`.
3
+ The 0.27.5 composition is recorded in [CHANGELOG.md](./CHANGELOG.md). Package pins, resource order and bundled runtime ownership remain authoritative in `package.json`.
4
4
 
5
5
  ## Carried checks
6
6
 
7
+ - **Installed 0.27.5 cleanup smoke (operator-owned):** After separately authorized installation/reload, confirm State Flow missing-deletion hints, recursive empty-object cleanup, inherited fallback and preserved array slots in disposable storage. Packed validation does not certify installed clients.
7
8
  - **Installed 0.27.4 inspection smoke (operator-owned):** After separately authorized installation/reload, inspect a large nested State Flow field in disposable storage. Confirm readable JSON layout, separate truncation notices and intact genuine string escapes. Packed validation does not certify installed Telegram clients.
8
9
  - **Installed 0.27.3 single patch display smoke (operator-owned):** After separately authorized installation/reload, confirm one argument block followed by changed/no-op acknowledgements, compact configuration and visible rejected arguments/errors in disposable State Flow storage.
9
10
  - **Installed 0.27.2 cascade receipt smoke (operator-owned):** After separately authorized installation/reload, close an intent owning a nested lazy key in disposable State Flow storage and confirm the receipt lists its owner path under `cascaded`, without the body. Packed validation does not certify installed clients.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.27.5: State Flow Cleanup and Deletion Hints
6
+
7
+ - `Cleanup and Deletion Hints`: Advances the exact State Flow pin to `0.25.4`. Accepted scopes recursively drop empty object fields without shifting array slots; cleanup may reveal inherited values. Missing authored deletions report their target and first unavailable component without blocking useful writes or creating revisions. Exact history and untouched scopes stay unchanged. Other pins, resources and load order are unchanged.
8
+
5
9
  ## 0.27.4: Readable State Flow Inspection
6
10
 
7
11
  - `Readable Inspection`: Advances the exact State Flow pin to published `0.25.3`. Large Telegram inspection fields now show pretty-printed JSON prefixes directly, with separate truncation notices instead of escaped `preview` strings. Genuine JSON string escapes and storage semantics are preserved. Other pins, resources and load order are unchanged.
package/README.md CHANGED
@@ -17,7 +17,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
17
17
  | [`@llblab/pi-clean-room`](https://github.com/llblab/pi-clean-room) | `0.3.0` | Isolated nested Pi TUI with named npm extensions and compatible model selection |
18
18
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.12.1` | Shared Codex quota/Business credit status and persistent priority Fast toggle, mirrored in Telegram |
19
19
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
20
- | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.25.3` | Scoped context/memory compiler with intent-owned memory, cascade receipts, memory-inert Off, single patch display and readable Telegram inspection |
20
+ | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.25.4` | Scoped context/memory compiler with intent-owned memory, recursive empty-object cleanup, missing-deletion hints and readable Telegram inspection |
21
21
  | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.51.6` | Telegram companion with connection resume, Workspace slot recovery, follower Threads, filterable Skills, files, voice, and controls |
22
22
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
23
23
 
@@ -25,7 +25,7 @@ Versions are exact by design. An upstream release does not change an installed k
25
25
 
26
26
  ## Install
27
27
 
28
- Requires **Pi 1.0.0+** and **Node.js 22.19.0+**. State Flow requires its canonical checkpoint/tail storage format and does not convert unsupported stores in place. Preserve existing stores and consult the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.25.3/docs/usage.md#moving-a-store-and-supported-formats) before changing installations.
28
+ Requires **Pi 1.0.0+** and **Node.js 22.19.0+**. State Flow requires its canonical checkpoint/tail storage format and does not convert unsupported stores in place. Preserve existing stores and consult the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.25.4/docs/usage.md#moving-a-store-and-supported-formats) before changing installations.
29
29
 
30
30
  From npm:
31
31
 
@@ -45,7 +45,7 @@ Prefer the kit instead of separately loading the same packages. If you already u
45
45
 
46
46
  ## Development
47
47
 
48
- The `0.27.4` composition includes published State Flow `0.25.3` with readable Telegram inspection; all other pins, resources and load order remain unchanged. `npm run validate` checks exact installed pins, declared resources, dependency audit and bundled inventory. It does not certify installed-client rendering; carried checks remain in [Backlog](./BACKLOG.md).
48
+ The `0.27.5` composition includes State Flow `0.25.4` with recursive empty-object cleanup and missing-deletion hints; all other pins, resources and load order remain unchanged. `npm run validate` checks exact installed pins, declared resources, dependency audit and bundled inventory. It does not certify installed-client rendering; carried checks remain in [Backlog](./BACKLOG.md).
49
49
 
50
50
  ```bash
51
51
  npm install
@@ -8,7 +8,7 @@ The [relocation ledger](docs/agent-contract-relocation.md) maps every pre-compac
8
8
  - Keep `index.ts` minimal, independent domains in `lib/` with same-named tests, architecture checks in `tests/invariants.test.ts`, and Pi lifecycle composition in `lib/extension.ts`; delegate low-level mechanics to their owners. See [composition](docs/architecture.md#composition).
9
9
  - Keep Off the genuinely new-session default, with Passive and Active opt-in; Off removes State Flow model tools/context without deleting memory. Modes belong to the current session; global mode is only a new-session default. Passive declares both tools even without memory, but contributes protocol/projected state only with a validated view. See [mode behavior](docs/usage.md#active-passive-and-configured-off) and [configuration](docs/usage.md#configuration).
10
10
  - Preserve Pi's native tool loop, trace and foreign context; the context domain alone projects the raw scope overlay. Freeze/rebase the head only at specified boundaries, retain current-run trajectory and stable-position tail notices, and never give projection IDs or guessed user anchors publication/compaction authority. See [Pi lifecycle](docs/architecture.md#pi-lifecycle) and [projection evidence](docs/performance.md#context-projection-and-trajectory-selection).
11
- - Store only present known `intents`, `contract`, `working`, `artifacts`, `response`, `lazy` fields; preserve nested data and retained causal identities. Missing planes and empty response do not become fabricated stored defaults; malformed evidence fails closed. Keep lazy bodies out of automatic projection and Session as the sole author of new responses. See [semantic state](docs/architecture.md#semantic-state) and [temporal model](docs/architecture.md#temporal-model).
11
+ - Store only present known `intents`, `contract`, `working`, `artifacts`, `response`, `lazy` fields; preserve nonempty nested data and retained causal identities. Normalize empty object fields/planes only in supplied scopes and response-owned Session, before ownership and after cascades/compilation; preserve array slots and exact current/historical reads. Model projection may omit legacy empty branches. Missing planes and empty response do not become fabricated stored defaults; malformed evidence fails closed. Keep lazy bodies out of automatic projection and Session as the sole author of new responses. See [semantic state](docs/architecture.md#semantic-state) and [temporal model](docs/architecture.md#temporal-model).
12
12
  - Respect global → CWD → session ownership, scope-local deletion and inherited fallbacks; scope does not confer instruction authority. Registered Skill ownership follows Pi source provenance, not path shape. See [semantic state](docs/architecture.md#semantic-state) and [artifact routing](docs/architecture.md#artifact-routing).
13
13
  - Artifacts name exact registered source paths; observe only regular non-symlink files, never discover directories or read unrelated bodies. Preserve hidden per-scope provenance, exact-owner compilation, stable fingerprint checks and separate Skill hashes; unavailable sources are not proof of deletion. See [artifact routing](docs/architecture.md#artifact-routing).
14
14
  - Preserve session config/runtime, per-scope metadata, lineage and provenance outside model-patchable state; decode legacy mode evidence read-only, never mix representations or normalize storage eagerly. See [storage and identity](docs/architecture.md#storage-and-identity) and [mode compatibility](docs/compatibility.md#mode-configuration-compatibility).
@@ -1,16 +1,17 @@
1
1
  # Backlog
2
2
 
3
- The **0.25.3: Readable Telegram Inspection** hotfix is prepared; outcomes belong in [CHANGELOG.md](CHANGELOG.md). This backlog retains installed-client gates and deferred decisions. Cascade semantics, canonical storage, stored patch records and lifecycle behaviour remain unchanged.
3
+ The **0.25.4: Empty-Object Cleanup and Deletion Hints** hotfix outcomes belong in [CHANGELOG.md](CHANGELOG.md). This backlog retains publication, installed-client gates and deferred decisions.
4
4
 
5
5
  ## Out of scope
6
6
 
7
7
  - Changing what a cascade deletes, including writes into an owned target made by the closing patch. That stays deleted by design and already appears in the receipt.
8
- - Nested `lazy_navigation`, warnings, validation or rejection of any kind.
8
+ - Nested `lazy_navigation` and intent-ownership warnings, validation or rejection. Authored missing-deletion hints do not change cascade behavior.
9
9
  - Any reduction of lifecycle state; the tagged-union question stays deferred.
10
10
 
11
11
  ## Carried gates
12
12
 
13
- - **0.25.3 publication.** Validate and publish through the exact-tag workflow; verify GitHub Release and npm commit, then synchronize and release Pi Kit.
13
+ - **0.25.4 publication and kit synchronization.** Verify the exact-tag workflow, GitHub Release and npm commit, then bundle and publish Pi Kit 0.27.5.
14
+ - **Installed 0.25.4 cleanup smoke (operator-owned).** After separately authorized installation/reload, use disposable storage to confirm missing-deletion hints, recursive empty-object cleanup, inherited fallback and preserved array slots. Local validation and publication do not certify installed clients.
14
15
  - **Installed 0.25.3 inspection smoke (operator-owned).** After separately authorized installation/reload, inspect a large nested field: real layout newlines/quotes, separate omitted-character notice and intact genuine JSON string escapes. Use disposable storage; local adapter tests do not certify installed Telegram clients.
15
16
  - **Installed 0.25.2 smoke (operator-owned).** Disposable store: confirm one patch argument block followed by changed/no-op acknowledgements, compact rows with `showSuccessfulPatches: false`, and visible rejected arguments/errors.
16
17
  - **Installed 0.25.1 smoke (operator-owned).** Disposable store: close an intent that owns a nested lazy key and confirm the receipt lists it under `cascaded`. May be combined with the open 0.25.0 smoke.
@@ -2,6 +2,13 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.25.4: Empty-Object Cleanup and Deletion Hints
8
+
9
+ - `Safe missing deletions`: Deletion-only patches through absent ancestors succeed without creating data or blocking other writes. Missing authored targets report their owner-scoped JSON Pointer and first unavailable component; hints remain outside state/history and create no revisions. Mixed new objects process `null` as deletion markers. Stored null and array-index deletion remain rejected; ordinary repeats and intent cascades stay quiet.
10
+ - `Recursive empty-object cleanup`: Accepted supplied scopes and response-owned Session omit empty object fields/planes, before ownership and after cascades/compilation. Explicit `{}` carries no retained value; roots, array slots and empty arrays survive. Model context hides legacy empty branches; untouched scopes and exact historical reads/replay remain unchanged. Cleanup can reveal inherited values and is recorded once as ordinary deletions.
11
+
5
12
  ## 0.25.3: Readable Telegram Inspection
6
13
 
7
14
  - `Readable inspection`: Telegram memory inspection shows large fields as direct pretty-printed JSON prefixes instead of JSON-encoded `preview` strings with escaped layout and quotes. A separate notice reports omitted characters. Transport-aware budgeting preserves the Rich message ceiling and Unicode boundaries; genuine JSON string escapes, stored state and exact `read_state` results remain unchanged.
@@ -145,6 +145,7 @@ Registered Pi Skills may be compiled into source-addressed artifacts when durabl
145
145
  - Overlapping assignments follow successful acceptance order. Correct repeats succeed without new semantic revisions.
146
146
  - Session remains private.
147
147
  - Object patches merge recursively, and `null` deletes an object key instead of being stored.
148
+ - Accepted supplied scopes recursively drop empty object fields and planes, including explicit `{}`; empty ancestors disappear. Array slots and empty arrays stay intact. Cleanup is scope-local and may reveal inherited values. Model context hides legacy empty branches, but reading never rewrites history. See [patch semantics](docs/architecture.md#model-tools).
148
149
 
149
150
  ```json
150
151
  {
@@ -43,9 +43,12 @@ function lazyValueKind(value) {
43
43
  return "object";
44
44
  return typeof value;
45
45
  }
46
+ function hasLazyContent(value) {
47
+ return !isObject(value) || Object.values(value).some(hasLazyContent);
48
+ }
46
49
  /** Fixed-budget navigation only: never place lazy bodies or partial key catalogs in baseline context. */
47
50
  export function lazyNavigationHint(state) {
48
- const entries = isObject(state.lazy) ? Object.entries(state.lazy) : [];
51
+ const entries = isObject(state.lazy) ? Object.entries(state.lazy).filter(([, value]) => hasLazyContent(value)) : [];
49
52
  const base = { available: entries.length > 0, path: LAZY_HINT_PATH };
50
53
  if (!base.available)
51
54
  return base;
@@ -196,6 +199,8 @@ export class ContextProjection {
196
199
  }
197
200
  const prefix = (a, b) => a.length <= b.length && a.every((part, index) => part === b[index]);
198
201
  updates.effective = updates.effective.filter((entry) => {
202
+ if ("deleted" in entry && this.view && known(entry.path) === undefined)
203
+ return false;
199
204
  const matches = ({ path }) => path.length === entry.path.length && prefix(path, entry.path);
200
205
  const authored = leaves.findLast(matches) ?? objects.findLast(matches);
201
206
  if (!authored)
@@ -808,9 +808,9 @@ export default function stateFlowExtension(pi, options = {}) {
808
808
  ? `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}.`
809
809
  : "\nState already current.";
810
810
  return { content: [
811
- { type: "text", text: acknowledgement },
811
+ { type: "text", text: acknowledgement + stage.missingDeletions.map(({ path, unavailablePath }) => `\n\nHint: deletion skipped at ${path}; target absent. First unavailable component: ${unavailablePath}. Check the path if you expected an existing value.`).join("") },
812
812
  ...(updates ? [{ type: "text", text: `\n${presentationJson({ state_updates: updates })}` }] : []),
813
- ], details: { scopes, step: snapshot.meta.step, changed } };
813
+ ], details: { scopes, step: snapshot.meta.step, changed, missingDeletions: stage.missingDeletions } };
814
814
  }, signal);
815
815
  }
816
816
  catch (error) {
@@ -2,8 +2,16 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObj
2
2
  export interface JsonObject {
3
3
  [key: string]: JsonValue;
4
4
  }
5
+ export interface MissingDeletion {
6
+ /** JSON Pointer paths relative to the patched object. */
7
+ path: string;
8
+ unavailablePath: string;
9
+ }
10
+ /** Remove empty object fields without removing array slots or mutating the input. */
11
+ export declare function pruneEmptyObjects(value: JsonObject): JsonObject;
12
+ export declare function pruneEmptyObjects(value: JsonValue): JsonValue;
5
13
  /** Detach at the mutable public boundary; share untouched paths only inside the owned draft. */
6
- export declare function applyPatch(state: JsonObject, patch: JsonObject): JsonObject;
14
+ export declare function applyPatch(state: JsonObject, patch: JsonObject, missingDeletions?: MissingDeletion[]): JsonObject;
7
15
  export declare function isObject(value: JsonValue | unknown): value is JsonObject;
8
16
  export declare function validatePatch(value: unknown): asserts value is JsonObject;
9
17
  export declare function canonicalJson(value: JsonValue | unknown): string;
@@ -4,17 +4,17 @@ function isIndexedArrayPatch(value) {
4
4
  const keys = Object.keys(value);
5
5
  return keys.length > 0 && keys.every((key) => ARRAY_INDEX_SELECTOR.test(key));
6
6
  }
7
- function applyOwnedValue(current, value, owned) {
7
+ function applyOwnedValue(current, value, owned, missingDeletions, path, unavailablePath) {
8
8
  // Preserve inherited-object merge semantics without borrowing prototype objects.
9
9
  if (!owned && current !== null && typeof current === "object")
10
10
  current = structuredClone(current);
11
11
  return Array.isArray(current) && isObject(value) && isIndexedArrayPatch(value)
12
- ? applyOwnedArrayPatch(current, value)
13
- : isObject(current) && isObject(value)
14
- ? applyOwnedPatch(current, value)
12
+ ? applyOwnedArrayPatch(current, value, missingDeletions, path, unavailablePath)
13
+ : isObject(value)
14
+ ? applyOwnedPatch(isObject(current) ? current : {}, value, missingDeletions, path, unavailablePath ?? (!isObject(current) ? path : undefined))
15
15
  : structuredClone(value);
16
16
  }
17
- function applyOwnedArrayPatch(state, patch) {
17
+ function applyOwnedArrayPatch(state, patch, missingDeletions, path, unavailablePath) {
18
18
  let next = state;
19
19
  for (const [selector, value] of Object.entries(patch)) {
20
20
  const index = Number(ARRAY_INDEX_SELECTOR.exec(selector)[1]);
@@ -25,7 +25,7 @@ function applyOwnedArrayPatch(state, patch) {
25
25
  throw new Error(`State patch array index ${selector} cannot be deleted; replace the whole array instead`);
26
26
  const owns = Object.hasOwn(next, index);
27
27
  const current = next[index];
28
- const materialized = applyOwnedValue(current, value, owns);
28
+ const materialized = applyOwnedValue(current, value, owns, missingDeletions, `${path}/${index}`, unavailablePath);
29
29
  if (owns && Object.is(current, materialized))
30
30
  continue;
31
31
  if (next === state)
@@ -34,20 +34,27 @@ function applyOwnedArrayPatch(state, patch) {
34
34
  }
35
35
  return next;
36
36
  }
37
- function applyOwnedPatch(state, patch) {
37
+ function applyOwnedPatch(state, patch, missingDeletions, path = "", unavailablePath) {
38
38
  let next = state;
39
39
  for (const [key, value] of Object.entries(patch)) {
40
40
  const owns = Object.hasOwn(next, key);
41
+ const target = missingDeletions ? `${path}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}` : "";
41
42
  if (value === null) {
42
- if (!owns)
43
+ if (!owns) {
44
+ missingDeletions?.push({ path: target, unavailablePath: unavailablePath ?? target });
43
45
  continue;
46
+ }
44
47
  if (next === state)
45
48
  next = { ...state };
46
49
  delete next[key];
47
50
  continue;
48
51
  }
49
52
  const current = next[key];
50
- const materialized = applyOwnedValue(current, value, owns);
53
+ const materialized = applyOwnedValue(current, value, owns, missingDeletions, target, unavailablePath ?? (!owns ? target : undefined));
54
+ // Deletion-only patches must not fabricate absent ancestor objects; explicit {} still writes.
55
+ if (!owns && current === undefined && isObject(value) && Object.keys(value).length > 0
56
+ && isObject(materialized) && Object.keys(materialized).length === 0)
57
+ continue;
51
58
  if (owns && Object.is(current, materialized))
52
59
  continue;
53
60
  if (next === state)
@@ -56,9 +63,43 @@ function applyOwnedPatch(state, patch) {
56
63
  }
57
64
  return next;
58
65
  }
66
+ export function pruneEmptyObjects(value) {
67
+ if (Array.isArray(value)) {
68
+ let next = value;
69
+ for (let index = 0; index < value.length; index++) {
70
+ const pruned = pruneEmptyObjects(value[index]);
71
+ if (pruned === value[index])
72
+ continue;
73
+ if (next === value)
74
+ next = value.slice();
75
+ next[index] = pruned;
76
+ }
77
+ return next;
78
+ }
79
+ if (!isObject(value))
80
+ return value;
81
+ let next = value;
82
+ for (const [key, child] of Object.entries(value)) {
83
+ const pruned = pruneEmptyObjects(child);
84
+ const empty = isObject(pruned) && Object.keys(pruned).length === 0;
85
+ if (!empty && pruned === child)
86
+ continue;
87
+ if (next === value)
88
+ next = { ...value };
89
+ if (empty)
90
+ delete next[key];
91
+ else
92
+ Object.defineProperty(next, key, { value: pruned, enumerable: true, configurable: true, writable: true });
93
+ }
94
+ return next;
95
+ }
59
96
  /** Detach at the mutable public boundary; share untouched paths only inside the owned draft. */
60
- export function applyPatch(state, patch) {
61
- return applyOwnedPatch(structuredClone(state), patch);
97
+ export function applyPatch(state, patch, missingDeletions) {
98
+ const collected = missingDeletions ? [] : undefined;
99
+ const next = applyOwnedPatch(structuredClone(state), patch, collected);
100
+ if (collected)
101
+ missingDeletions.push(...collected);
102
+ return next;
62
103
  }
63
104
  export function isObject(value) {
64
105
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT } from "./history.js";
2
2
  import { isObject, sameJson } from "./json.js";
3
- import { emptyState, projectSemanticPatch, projectStateForModel } from "./state.js";
3
+ import { emptyState, projectSemanticPatch, projectStateForRead } from "./state.js";
4
4
  import { readTemporalView } from "./temporal.js";
5
5
  const MAX_REFERENCE_SOURCES = 3;
6
6
  const MAX_REFERENCE_SCAN_NODES = 10_000;
@@ -32,7 +32,7 @@ export function readStatePath(view, path, historyLimit = DEFAULT_HISTORY_LIMIT)
32
32
  const boundary = view.lineage[view.lineage.length - 1 - query.offset];
33
33
  if (!boundary)
34
34
  throw new Error("Requested history predates the proven temporal origin");
35
- return { path, boundary: structuredClone(boundary), state: projectStateForModel(readTemporalView(view, query.offset, query.scope, historyLimit)) };
35
+ return { path, boundary: structuredClone(boundary), state: projectStateForRead(readTemporalView(view, query.offset, query.scope, historyLimit)) };
36
36
  }
37
37
  const record = view.scopes[query.scope].patches.at(-1 - query.offset);
38
38
  if (!record)
@@ -271,7 +271,7 @@ export function readProjectedState(view, paths, projection = "value", historyLim
271
271
  throw new Error("Value and keys projections require a state path");
272
272
  const readsLazy = selectors[0]?.kind === "key" && selectors[0].key === "lazy";
273
273
  const raw = readTemporalView(view, query.offset, query.scope, historyLimit);
274
- const state = readsLazy ? raw : projectStateForModel(raw);
274
+ const state = readsLazy ? raw : projectStateForRead(raw);
275
275
  const field = selectors.length === 1 && selectors[0]?.kind === "key" ? selectors[0].key : undefined;
276
276
  if (projection === "value" && field !== undefined && Object.hasOwn(emptyState(), field) && !Object.hasOwn(state, field))
277
277
  return { value: null };
@@ -83,5 +83,7 @@ export declare function selectSemanticFields(value: JsonObject): JsonObject;
83
83
  export declare function projectSemanticState(state: JsonObject): SemanticState;
84
84
  /** Preserve deletion meaning in visible history without exposing ignored fields or empty responses. */
85
85
  export declare function projectSemanticPatch(patch: JsonObject): JsonObject;
86
- /** Model-visible projection: lazy bodies and runtime artifact bookkeeping stay out of ordinary context. */
86
+ /** Explicit hot reads filter hidden bodies/bookkeeping without normalizing legacy semantic data. */
87
+ export declare function projectStateForRead(state: SemanticState, artifactHints?: ArtifactModelHints): SemanticState;
88
+ /** Ordinary model context omits empty object fields; exact reads and replay remain observational. */
87
89
  export declare function projectStateForModel(state: SemanticState, artifactHints?: ArtifactModelHints): SemanticState;
@@ -1,5 +1,5 @@
1
1
  import { isArtifactRegistry, projectArtifactsForModel, updateArtifactRegistry, } from "./artifact.js";
2
- import { applyPatch, isObject } from "./json.js";
2
+ import { applyPatch, isObject, pruneEmptyObjects } from "./json.js";
3
3
  export function emptyState() {
4
4
  return { intents: {}, contract: {}, working: {}, artifacts: {}, response: "", lazy: {} };
5
5
  }
@@ -50,11 +50,15 @@ export function projectSemanticPatch(patch) {
50
50
  projected.response = null;
51
51
  return projected;
52
52
  }
53
- /** Model-visible projection: lazy bodies and runtime artifact bookkeeping stay out of ordinary context. */
54
- export function projectStateForModel(state, artifactHints = {}) {
53
+ /** Explicit hot reads filter hidden bodies/bookkeeping without normalizing legacy semantic data. */
54
+ export function projectStateForRead(state, artifactHints = {}) {
55
55
  const { lazy: _hidden, ...visible } = state;
56
56
  const projected = projectSemanticState(visible);
57
57
  if (projected.artifacts)
58
58
  projected.artifacts = projectArtifactsForModel(projected.artifacts, artifactHints);
59
59
  return projected;
60
60
  }
61
+ /** Ordinary model context omits empty object fields; exact reads and replay remain observational. */
62
+ export function projectStateForModel(state, artifactHints = {}) {
63
+ return pruneEmptyObjects(projectStateForRead(state, artifactHints));
64
+ }
@@ -1,12 +1,15 @@
1
1
  import { type ArtifactProvenance } from "./artifact.ts";
2
2
  import type { SuccessfulArtifactRead } from "./acquisition.ts";
3
3
  import { type AcceptedTransition } from "./history.ts";
4
+ import { type MissingDeletion } from "./json.ts";
4
5
  import { type OwnedPath } from "./ownership.ts";
5
6
  import { type SuccessfulSkillRead } from "./skills.ts";
6
7
  import type { Snapshot } from "./snapshot.ts";
7
8
  import type { AtomicScopePatches, ScopedSemanticStates, StateScope, TerminalTransition } from "./state.ts";
8
9
  export interface StagedScopedTransition {
9
10
  nextStates: ScopedSemanticStates;
11
+ /** Authored no-op deletions; presentation evidence only, never replay input. */
12
+ missingDeletions: MissingDeletion[];
10
13
  /** Scope-local targets removed by the staged intent cascade, not replay input. */
11
14
  cascades: Record<StateScope, OwnedPath[]>;
12
15
  stateHashes: Record<StateScope, string>;
@@ -1,6 +1,6 @@
1
1
  import { compileArtifact, ORDINARY_ARTIFACT_COMPILER, validateArtifactMetadata, validateArtifactRegistry, validateModelArtifactPatch, } from "./artifact.js";
2
2
  import { createAcceptedTransition } from "./history.js";
3
- import { applyPatch, containsNull, hashJson, isObject, validatePatch } from "./json.js";
3
+ import { applyPatch, containsNull, hashJson, isObject, pruneEmptyObjects, validatePatch } from "./json.js";
4
4
  import { cascadeDeletionPatch, computeIntentCascade } from "./ownership.js";
5
5
  import { hasCompiledSkillArtifact, SKILL_ARTIFACT_COMPILER } from "./skills.js";
6
6
  import { emptyState } from "./state.js";
@@ -137,27 +137,37 @@ function stageScopedSemanticTransition(currentStates, transition, successfulSkil
137
137
  const nextStates = { ...currentStates };
138
138
  const provenanceUpdates = { global: {}, cwd: {}, session: {} };
139
139
  const cascades = { global: [], cwd: [], session: [] };
140
+ const missingDeletions = [];
140
141
  for (const scope of SCOPES) {
141
142
  const authored = patches.get(scope) ?? {};
142
143
  const patch = { ...authored, ...(scope === "session" && acceptedResponse !== undefined ? { response: acceptedResponse } : {}) };
143
- let materialized = applyPatch({ ...emptyState(), ...currentStates[scope] }, patch);
144
+ const scopedMissing = [];
145
+ const normalize = patches.has(scope) || scope === "session" && acceptedResponse !== undefined;
146
+ const applied = applyPatch(currentStates[scope], patch, scopedMissing);
147
+ const patched = normalize ? pruneEmptyObjects(applied) : applied;
148
+ missingDeletions.push(...scopedMissing.map(({ path, unavailablePath }) => ({
149
+ path: `/${scope}${path}`, unavailablePath: `/${scope}${unavailablePath}`,
150
+ })));
151
+ let materialized = { ...emptyState(), ...patched };
144
152
  // Authored operations first, then the same-scope intent ownership cascade.
145
153
  const cascade = cascades[scope] = computeIntentCascade(scope, currentStates[scope], materialized);
146
154
  if (cascade.length > 0)
147
155
  materialized = applyPatch(materialized, cascadeDeletionPatch(cascade));
148
156
  compileReadArtifacts(materialized, { artifacts: authored.artifacts ?? {} }, artifactReads.filter((read) => (read.scope ?? "global") === scope), provenanceUpdates[scope]);
149
157
  compileReadSkills(scope, materialized, { artifacts: authored.artifacts ?? {} }, skillReads.filter((read) => read.scope === scope), provenanceUpdates[scope]);
150
- validateMaterializedTransition(materialized, scope);
151
158
  const nextState = materialized;
152
159
  for (const key of Object.keys(emptyState())) {
153
- if (!Object.hasOwn(currentStates[scope], key) && !Object.hasOwn(patch, key)
160
+ if (!Object.hasOwn(patched, key)
154
161
  && !(key === "artifacts" && Object.keys(provenanceUpdates[scope]).length > 0))
155
162
  delete nextState[key];
156
163
  }
157
- nextStates[scope] = nextState;
164
+ const normalized = normalize ? pruneEmptyObjects(nextState) : nextState;
165
+ validateMaterializedTransition({ ...emptyState(), ...normalized }, scope);
166
+ nextStates[scope] = normalized;
158
167
  }
159
168
  return {
160
169
  nextStates,
170
+ missingDeletions,
161
171
  cascades,
162
172
  provenanceUpdates,
163
173
  stateHashes: {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.25.3",
3
+ "version": "0.25.4",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
@@ -56,7 +56,7 @@ Call `patch_state` alone per assistant response; await acceptance before depende
56
56
 
57
57
  The runtime waits cancelably for publication ownership, then applies authored Global/CWD operations to current canonical values. Untouched fields survive; overlapping targets follow successful acceptance order. Correct repeats succeed as `State already current.` without another semantic revision. Do not repeat external actions during a memory wait, or rebuild an entire scope from an older snapshot. Session ownership/history fences remain private, not a universal merge.
58
58
 
59
- When present, semantic planes `intents`, `contract`, `working`, `artifacts`, and `lazy` are objects; nested lazy values may contain ordinary JSON without stored nulls. Stored checkpoints and patches may omit any documented plane. Current and historical views assemble only known fields present in the selected scopes. Absent fields and empty responses are omitted from views. Checkpoint/tail readers ignore unknown top-level fields, and writers emit only known fields. Nested data within known planes remains intact. Explicit value reads of an absent documented top-level field return `null`. Authored `patch_state` keeps its documented field grammar. Objects merge, arrays/scalars replace, omitted fields persist. Nested `null` removes an owned object key; inherited content may reappear. Canonical `"[N]"` keys patch array elements; indexed deletion is forbidden.
59
+ When present, semantic planes `intents`, `contract`, `working`, `artifacts`, and `lazy` are objects; nested lazy values may contain ordinary JSON without stored nulls. Stored checkpoints and patches may omit any documented plane. Current and historical views assemble only known fields present in the selected scopes. Absent fields and empty responses are omitted from views. Checkpoint/tail readers ignore unknown top-level fields, and writers emit only known fields. Nested data within known planes remains intact. Explicit value reads of an absent documented top-level field return `null`. Authored `patch_state` keeps its documented field grammar. Objects merge, arrays/scalars replace, and omitted nonempty fields persist. Accepted supplied scopes recursively omit empty object fields and planes, including explicit `{}`; the scope root remains `{}`. This also cleans legacy empty branches in that scope, which can advance its revision once. Array slots and empty arrays remain; empty object fields inside elements disappear without shifting indices. Model projection hides legacy empty branches, but exact current/historical reads and untouched scopes are not normalized. Nested `null` removes an owned object key; empty ancestors disappear and inherited content may reappear. Canonical `"[N]"` keys patch existing array elements; indexed deletion and out-of-bounds indices are forbidden. Replace the whole array to add/remove elements. Missing authored object-key deletions succeed with a nonfatal hint showing owner-scoped JSON Pointers for the target and first unavailable component. Hints are not state, do not advance revisions and do not prove a typo; check the address only when deletion was expected to change something. Never guess or automatically correct the target.
60
60
 
61
61
  Work from intents. A structured `{"$ref"}` anywhere inside an intent owns ("delete with me") an existing object key under `working` or `lazy` in the same scope; a textual `$path` mention only uses it. Opening a task:
62
62
 
@@ -66,7 +66,7 @@ Later scopes win. A scope-local `null` deletion removes only that scope's key an
66
66
  - `readTemporalState` and the embedding callback keep default-bearing compatibility views for internal registry consumers. Those defaults never become stored overrides or model context.
67
67
  - Passive reads and Start do not fill checkpoint fields or create revisions for normalization; missing shared pairs initialize as empty objects.
68
68
 
69
- **Staging and replay.** Authored staging uses raw scope presence and derives runtime-normalized replay from the writes actually accepted, including complete artifact replacement; replay must reproduce accepted state exactly. Existing empty/no-op tail records keep their proven boundaries, while new no-op writes still create none.
69
+ **Staging and replay.** Authored staging uses raw scope presence and derives runtime-normalized replay from the writes actually accepted, including complete artifact replacement; replay must reproduce accepted state exactly. Each supplied scope (and Session on response reconciliation) recursively omits empty object fields, including explicit `{}` and empty planes, before intent ownership and after cascades/compilation. The scope root remains `{}`; array slots and empty arrays remain, while object fields inside array elements are pruned. Untouched scopes are not normalized. Existing empty branches in a supplied scope become explicit accepted deletions and may advance its revision once; clean repeats create none. Model projection hides legacy empty object fields without changing stored state, exact current/historical reads or replay.
70
70
 
71
71
  **Validation.** Present documented fields keep usable object/string types, valid artifact entries and the no-stored-null rule; ignored fields may contain arbitrary JSON. Malformed JSON and invalid storage/history authority remain errors.
72
72
 
@@ -701,7 +701,9 @@ The package exposes read-only host contracts that:
701
701
  **`patch_state` grammar.** It accepts one or more fixed `global`, `cwd` and `session` semantic patches.
702
702
 
703
703
  - Supplied scopes contain object-valued `artifacts`, `contract`, `working` and `intents`, plus object-valued `lazy` whose nested values are ordinary JSON.
704
- - Omitted fields keep their values, recursive object merge updates them, arrays/primitives replace, and nested object-key `null` deletes.
704
+ - Recursive object merge updates fields, arrays/primitives replace, and nested object-key `null` deletes, including inside newly created objects. Deletion-only patches through absent ancestors create no empty parents. Accepted supplied scopes recursively omit empty object fields and empty planes: deleting the last child removes its empty ancestors, and explicit `{}` is not a retained value. Nonempty omitted fields persist; an object patch that turns a scalar/array into an empty object removes that field. Cleanup is scope-local and can reveal inherited values; it stops at the scope root and never removes array slots.
705
+ - Against an existing array, a nonempty object containing only canonical `"[N]"` keys recursively patches those existing elements (zero-based). Out-of-bounds indices and indexed `null` deletions are rejected atomically; adding/removing elements requires replacing the whole array. Against objects, those keys remain literal object keys.
706
+ - Missing authored object-key deletions succeed with nonfatal hints in the acknowledgement, for both unchanged and mixed patches. Each hint gives the owner-scoped target and first unavailable component as escaped JSON Pointers; a non-object ancestor is also unavailable. These hints are presentation-only: no state fields, revisions, history records, automatic path correction or cascade warnings.
705
707
  - Rejected: materialized null within documented semantic planes, empty supplied scopes, unknown top-level fields, model-authored `response`, and finalization or `{scope, patch}` / `unchanged` grammars. The semantic null rule does not apply to runtime envelopes such as an origin's null parent.
706
708
  - Correct repeated results succeed as `State already current.` without another semantic revision or history record; avoid gratuitous acknowledgment patches.
707
709
  - Global/CWD overlap follows successful acceptance order, and unmentioned current fields survive. Session ownership is not a shared-memory merge.
@@ -44,9 +44,13 @@ function lazyValueKind(value: JsonValue): LazyValueKind {
44
44
  return typeof value as Exclude<LazyValueKind, "array" | "object">;
45
45
  }
46
46
 
47
+ function hasLazyContent(value: JsonValue): boolean {
48
+ return !isObject(value) || Object.values(value).some(hasLazyContent);
49
+ }
50
+
47
51
  /** Fixed-budget navigation only: never place lazy bodies or partial key catalogs in baseline context. */
48
52
  export function lazyNavigationHint(state: SemanticState): { available: boolean; path: string; keys?: Record<string, LazyValueKind> } {
49
- const entries = isObject(state.lazy) ? Object.entries(state.lazy) : [];
53
+ const entries = isObject(state.lazy) ? Object.entries(state.lazy).filter(([, value]) => hasLazyContent(value)) : [];
50
54
  const base = { available: entries.length > 0, path: LAZY_HINT_PATH };
51
55
  if (!base.available) return base;
52
56
  if (entries.length > LAZY_HINT_MAX_KEYS) return base;
@@ -184,6 +188,7 @@ export class ContextProjection {
184
188
  const prefix = (a: readonly (string | number)[], b: readonly (string | number)[]) =>
185
189
  a.length <= b.length && a.every((part, index) => part === b[index]);
186
190
  updates.effective = updates.effective.filter((entry) => {
191
+ if ("deleted" in entry && this.view && known(entry.path) === undefined) return false;
187
192
  const matches = ({ path }: typeof leaves[number]) => path.length === entry.path.length && prefix(path, entry.path);
188
193
  const authored = leaves.findLast(matches) ?? objects.findLast(matches);
189
194
  if (!authored) return true;
@@ -815,9 +815,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
815
815
  ? `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}.`
816
816
  : "\nState already current.";
817
817
  return { content: [
818
- { type: "text" as const, text: acknowledgement },
818
+ { type: "text" as const, text: acknowledgement + stage.missingDeletions.map(({ path, unavailablePath }) =>
819
+ `\n\nHint: deletion skipped at ${path}; target absent. First unavailable component: ${unavailablePath}. Check the path if you expected an existing value.`).join("") },
819
820
  ...(updates ? [{ type: "text" as const, text: `\n${presentationJson({ state_updates: updates })}` }] : []),
820
- ], details: { scopes, step: snapshot.meta.step, changed } };
821
+ ], details: { scopes, step: snapshot.meta.step, changed, missingDeletions: stage.missingDeletions } };
821
822
  }, signal);
822
823
  } catch (error) {
823
824
  let attempted: unknown;
@@ -3,6 +3,12 @@ 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
+ export interface MissingDeletion {
7
+ /** JSON Pointer paths relative to the patched object. */
8
+ path: string;
9
+ unavailablePath: string;
10
+ }
11
+
6
12
  const ARRAY_INDEX_SELECTOR = /^\[(0|[1-9]\d*)\]$/;
7
13
 
8
14
  function isIndexedArrayPatch(value: JsonObject): boolean {
@@ -10,17 +16,22 @@ function isIndexedArrayPatch(value: JsonObject): boolean {
10
16
  return keys.length > 0 && keys.every((key) => ARRAY_INDEX_SELECTOR.test(key));
11
17
  }
12
18
 
13
- function applyOwnedValue(current: JsonValue | undefined, value: JsonValue, owned: boolean): JsonValue {
19
+ function applyOwnedValue(
20
+ current: JsonValue | undefined, value: JsonValue, owned: boolean,
21
+ missingDeletions: MissingDeletion[] | undefined, path: string, unavailablePath?: string,
22
+ ): JsonValue {
14
23
  // Preserve inherited-object merge semantics without borrowing prototype objects.
15
24
  if (!owned && current !== null && typeof current === "object") current = structuredClone(current);
16
25
  return Array.isArray(current) && isObject(value) && isIndexedArrayPatch(value)
17
- ? applyOwnedArrayPatch(current, value)
18
- : isObject(current) && isObject(value)
19
- ? applyOwnedPatch(current, value)
26
+ ? applyOwnedArrayPatch(current, value, missingDeletions, path, unavailablePath)
27
+ : isObject(value)
28
+ ? applyOwnedPatch(isObject(current) ? current : {}, value, missingDeletions, path, unavailablePath ?? (!isObject(current) ? path : undefined))
20
29
  : structuredClone(value);
21
30
  }
22
31
 
23
- function applyOwnedArrayPatch(state: JsonValue[], patch: JsonObject): JsonValue[] {
32
+ function applyOwnedArrayPatch(
33
+ state: JsonValue[], patch: JsonObject, missingDeletions: MissingDeletion[] | undefined, path: string, unavailablePath?: string,
34
+ ): JsonValue[] {
24
35
  let next = state;
25
36
  for (const [selector, value] of Object.entries(patch)) {
26
37
  const index = Number(ARRAY_INDEX_SELECTOR.exec(selector)![1]);
@@ -30,7 +41,7 @@ function applyOwnedArrayPatch(state: JsonValue[], patch: JsonObject): JsonValue[
30
41
  if (value === null) throw new Error(`State patch array index ${selector} cannot be deleted; replace the whole array instead`);
31
42
  const owns = Object.hasOwn(next, index);
32
43
  const current = next[index];
33
- const materialized = applyOwnedValue(current, value, owns);
44
+ const materialized = applyOwnedValue(current, value, owns, missingDeletions, `${path}/${index}`, unavailablePath);
34
45
  if (owns && Object.is(current, materialized)) continue;
35
46
  if (next === state) next = state.slice();
36
47
  next[index] = materialized;
@@ -38,18 +49,27 @@ function applyOwnedArrayPatch(state: JsonValue[], patch: JsonObject): JsonValue[
38
49
  return next;
39
50
  }
40
51
 
41
- function applyOwnedPatch(state: JsonObject, patch: JsonObject): JsonObject {
52
+ function applyOwnedPatch(
53
+ state: JsonObject, patch: JsonObject, missingDeletions?: MissingDeletion[], path = "", unavailablePath?: string,
54
+ ): JsonObject {
42
55
  let next = state;
43
56
  for (const [key, value] of Object.entries(patch)) {
44
57
  const owns = Object.hasOwn(next, key);
58
+ const target = missingDeletions ? `${path}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}` : "";
45
59
  if (value === null) {
46
- if (!owns) continue;
60
+ if (!owns) {
61
+ missingDeletions?.push({ path: target, unavailablePath: unavailablePath ?? target });
62
+ continue;
63
+ }
47
64
  if (next === state) next = { ...state };
48
65
  delete next[key];
49
66
  continue;
50
67
  }
51
68
  const current = next[key];
52
- const materialized = applyOwnedValue(current, value, owns);
69
+ const materialized = applyOwnedValue(current, value, owns, missingDeletions, target, unavailablePath ?? (!owns ? target : undefined));
70
+ // Deletion-only patches must not fabricate absent ancestor objects; explicit {} still writes.
71
+ if (!owns && current === undefined && isObject(value) && Object.keys(value).length > 0
72
+ && isObject(materialized) && Object.keys(materialized).length === 0) continue;
53
73
  if (owns && Object.is(current, materialized)) continue;
54
74
  if (next === state) next = { ...state };
55
75
  Object.defineProperty(next, key, { value: materialized, enumerable: true, configurable: true, writable: true });
@@ -57,9 +77,39 @@ function applyOwnedPatch(state: JsonObject, patch: JsonObject): JsonObject {
57
77
  return next;
58
78
  }
59
79
 
80
+ /** Remove empty object fields without removing array slots or mutating the input. */
81
+ export function pruneEmptyObjects(value: JsonObject): JsonObject;
82
+ export function pruneEmptyObjects(value: JsonValue): JsonValue;
83
+ export function pruneEmptyObjects(value: JsonValue): JsonValue {
84
+ if (Array.isArray(value)) {
85
+ let next = value;
86
+ for (let index = 0; index < value.length; index++) {
87
+ const pruned = pruneEmptyObjects(value[index]!);
88
+ if (pruned === value[index]) continue;
89
+ if (next === value) next = value.slice();
90
+ next[index] = pruned;
91
+ }
92
+ return next;
93
+ }
94
+ if (!isObject(value)) return value;
95
+ let next = value as JsonObject;
96
+ for (const [key, child] of Object.entries(value)) {
97
+ const pruned = pruneEmptyObjects(child);
98
+ const empty = isObject(pruned) && Object.keys(pruned).length === 0;
99
+ if (!empty && pruned === child) continue;
100
+ if (next === value) next = { ...value };
101
+ if (empty) delete next[key];
102
+ else Object.defineProperty(next, key, { value: pruned, enumerable: true, configurable: true, writable: true });
103
+ }
104
+ return next;
105
+ }
106
+
60
107
  /** Detach at the mutable public boundary; share untouched paths only inside the owned draft. */
61
- export function applyPatch(state: JsonObject, patch: JsonObject): JsonObject {
62
- return applyOwnedPatch(structuredClone(state), patch);
108
+ export function applyPatch(state: JsonObject, patch: JsonObject, missingDeletions?: MissingDeletion[]): JsonObject {
109
+ const collected = missingDeletions ? [] as MissingDeletion[] : undefined;
110
+ const next = applyOwnedPatch(structuredClone(state), patch, collected);
111
+ if (collected) missingDeletions!.push(...collected);
112
+ return next;
63
113
  }
64
114
 
65
115
  export function isObject(value: JsonValue | unknown): value is JsonObject {
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT, type RecentScopePatch } from "./history.ts";
2
2
  import { isObject, sameJson, type JsonObject, type JsonValue } from "./json.ts";
3
- import { emptyState, projectSemanticPatch, projectStateForModel, type SemanticState, type StateScope } from "./state.ts";
3
+ import { emptyState, projectSemanticPatch, projectStateForRead, type SemanticState, type StateScope } from "./state.ts";
4
4
  import { readTemporalView, type TemporalState, type TransitionBoundary } from "./temporal.ts";
5
5
 
6
6
  export type StateReadQuery =
@@ -61,7 +61,7 @@ export function readStatePath(view: TemporalState, path: string, historyLimit =
61
61
  if (query.kind === "state") {
62
62
  const boundary = view.lineage[view.lineage.length - 1 - query.offset];
63
63
  if (!boundary) throw new Error("Requested history predates the proven temporal origin");
64
- return { path, boundary: structuredClone(boundary), state: projectStateForModel(readTemporalView(view, query.offset, query.scope, historyLimit)) };
64
+ return { path, boundary: structuredClone(boundary), state: projectStateForRead(readTemporalView(view, query.offset, query.scope, historyLimit)) };
65
65
  }
66
66
  const record = view.scopes[query.scope].patches.at(-1 - query.offset);
67
67
  if (!record) throw new Error(`Requested ${query.scope} patch predates retained hot history`);
@@ -275,7 +275,7 @@ export function readProjectedState(view: TemporalState, paths: readonly string[]
275
275
  if (query.kind !== "state") throw new Error("Value and keys projections require a state path");
276
276
  const readsLazy = selectors[0]?.kind === "key" && selectors[0].key === "lazy";
277
277
  const raw = readTemporalView(view, query.offset, query.scope, historyLimit);
278
- const state = readsLazy ? raw : projectStateForModel(raw);
278
+ const state = readsLazy ? raw : projectStateForRead(raw);
279
279
  const field = selectors.length === 1 && selectors[0]?.kind === "key" ? selectors[0].key : undefined;
280
280
  if (projection === "value" && field !== undefined && Object.hasOwn(emptyState(), field) && !Object.hasOwn(state, field)) return { value: null };
281
281
  return projectValue(selectValue(state, selectors, path), projection);
@@ -6,7 +6,7 @@ import {
6
6
  type ArtifactModelHints,
7
7
  type ArtifactRegistry,
8
8
  } from "./artifact.ts";
9
- import { applyPatch, isObject, type JsonObject } from "./json.ts";
9
+ import { applyPatch, isObject, pruneEmptyObjects, type JsonObject } from "./json.ts";
10
10
 
11
11
  /** Runtime defaults for documented semantic planes; stored objects may omit them or retain other fields. */
12
12
  export type MaterializedState = JsonObject & {
@@ -148,10 +148,15 @@ export function projectSemanticPatch(patch: JsonObject): JsonObject {
148
148
  return projected;
149
149
  }
150
150
 
151
- /** Model-visible projection: lazy bodies and runtime artifact bookkeeping stay out of ordinary context. */
152
- export function projectStateForModel(state: SemanticState, artifactHints: ArtifactModelHints = {}): SemanticState {
151
+ /** Explicit hot reads filter hidden bodies/bookkeeping without normalizing legacy semantic data. */
152
+ export function projectStateForRead(state: SemanticState, artifactHints: ArtifactModelHints = {}): SemanticState {
153
153
  const { lazy: _hidden, ...visible } = state;
154
154
  const projected = projectSemanticState(visible);
155
155
  if (projected.artifacts) projected.artifacts = projectArtifactsForModel(projected.artifacts, artifactHints);
156
156
  return projected;
157
157
  }
158
+
159
+ /** Ordinary model context omits empty object fields; exact reads and replay remain observational. */
160
+ export function projectStateForModel(state: SemanticState, artifactHints: ArtifactModelHints = {}): SemanticState {
161
+ return pruneEmptyObjects(projectStateForRead(state, artifactHints));
162
+ }
@@ -9,7 +9,7 @@ import {
9
9
  } from "./artifact.ts";
10
10
  import type { SuccessfulArtifactRead } from "./acquisition.ts";
11
11
  import { createAcceptedTransition, type AcceptedTransition } from "./history.ts";
12
- import { applyPatch, containsNull, hashJson, isObject, validatePatch, type JsonObject } from "./json.ts";
12
+ import { applyPatch, containsNull, hashJson, isObject, pruneEmptyObjects, validatePatch, type JsonObject, type MissingDeletion } from "./json.ts";
13
13
  import { cascadeDeletionPatch, computeIntentCascade, type OwnedPath } from "./ownership.ts";
14
14
  import { hasCompiledSkillArtifact, SKILL_ARTIFACT_COMPILER, type SuccessfulSkillRead } from "./skills.ts";
15
15
  import type { Snapshot } from "./snapshot.ts";
@@ -29,6 +29,8 @@ import type {
29
29
 
30
30
  export interface StagedScopedTransition {
31
31
  nextStates: ScopedSemanticStates;
32
+ /** Authored no-op deletions; presentation evidence only, never replay input. */
33
+ missingDeletions: MissingDeletion[];
32
34
  /** Scope-local targets removed by the staged intent cascade, not replay input. */
33
35
  cascades: Record<StateScope, OwnedPath[]>;
34
36
  stateHashes: Record<StateScope, string>;
@@ -189,10 +191,18 @@ function stageScopedSemanticTransition(
189
191
  const nextStates = { ...currentStates };
190
192
  const provenanceUpdates: Record<StateScope, Record<string, ArtifactProvenance>> = { global: {}, cwd: {}, session: {} };
191
193
  const cascades: Record<StateScope, OwnedPath[]> = { global: [], cwd: [], session: [] };
194
+ const missingDeletions: MissingDeletion[] = [];
192
195
  for (const scope of SCOPES) {
193
196
  const authored = patches.get(scope) ?? {};
194
197
  const patch = { ...authored, ...(scope === "session" && acceptedResponse !== undefined ? { response: acceptedResponse } : {}) };
195
- let materialized = applyPatch({ ...emptyState(), ...currentStates[scope] }, patch) as StateDocument;
198
+ const scopedMissing: MissingDeletion[] = [];
199
+ const normalize = patches.has(scope) || scope === "session" && acceptedResponse !== undefined;
200
+ const applied = applyPatch(currentStates[scope], patch, scopedMissing);
201
+ const patched = normalize ? pruneEmptyObjects(applied) : applied;
202
+ missingDeletions.push(...scopedMissing.map(({ path, unavailablePath }) => ({
203
+ path: `/${scope}${path}`, unavailablePath: `/${scope}${unavailablePath}`,
204
+ })));
205
+ let materialized = { ...emptyState(), ...patched } as StateDocument;
196
206
  // Authored operations first, then the same-scope intent ownership cascade.
197
207
  const cascade = cascades[scope] = computeIntentCascade(scope, currentStates[scope], materialized);
198
208
  if (cascade.length > 0) materialized = applyPatch(materialized, cascadeDeletionPatch(cascade)) as StateDocument;
@@ -203,16 +213,18 @@ function stageScopedSemanticTransition(
203
213
  provenanceUpdates[scope],
204
214
  );
205
215
  compileReadSkills(scope, materialized, { artifacts: authored.artifacts ?? {} }, skillReads.filter((read) => read.scope === scope), provenanceUpdates[scope]);
206
- validateMaterializedTransition(materialized, scope);
207
216
  const nextState: SemanticState = materialized;
208
217
  for (const key of Object.keys(emptyState())) {
209
- if (!Object.hasOwn(currentStates[scope], key) && !Object.hasOwn(patch, key)
218
+ if (!Object.hasOwn(patched, key)
210
219
  && !(key === "artifacts" && Object.keys(provenanceUpdates[scope]).length > 0)) delete nextState[key];
211
220
  }
212
- nextStates[scope] = nextState;
221
+ const normalized = normalize ? pruneEmptyObjects(nextState) : nextState;
222
+ validateMaterializedTransition({ ...emptyState(), ...normalized }, scope);
223
+ nextStates[scope] = normalized;
213
224
  }
214
225
  return {
215
226
  nextStates,
227
+ missingDeletions,
216
228
  cascades,
217
229
  provenanceUpdates,
218
230
  stateHashes: {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.25.3",
3
+ "version": "0.25.4",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
@@ -56,7 +56,7 @@ Call `patch_state` alone per assistant response; await acceptance before depende
56
56
 
57
57
  The runtime waits cancelably for publication ownership, then applies authored Global/CWD operations to current canonical values. Untouched fields survive; overlapping targets follow successful acceptance order. Correct repeats succeed as `State already current.` without another semantic revision. Do not repeat external actions during a memory wait, or rebuild an entire scope from an older snapshot. Session ownership/history fences remain private, not a universal merge.
58
58
 
59
- When present, semantic planes `intents`, `contract`, `working`, `artifacts`, and `lazy` are objects; nested lazy values may contain ordinary JSON without stored nulls. Stored checkpoints and patches may omit any documented plane. Current and historical views assemble only known fields present in the selected scopes. Absent fields and empty responses are omitted from views. Checkpoint/tail readers ignore unknown top-level fields, and writers emit only known fields. Nested data within known planes remains intact. Explicit value reads of an absent documented top-level field return `null`. Authored `patch_state` keeps its documented field grammar. Objects merge, arrays/scalars replace, omitted fields persist. Nested `null` removes an owned object key; inherited content may reappear. Canonical `"[N]"` keys patch array elements; indexed deletion is forbidden.
59
+ When present, semantic planes `intents`, `contract`, `working`, `artifacts`, and `lazy` are objects; nested lazy values may contain ordinary JSON without stored nulls. Stored checkpoints and patches may omit any documented plane. Current and historical views assemble only known fields present in the selected scopes. Absent fields and empty responses are omitted from views. Checkpoint/tail readers ignore unknown top-level fields, and writers emit only known fields. Nested data within known planes remains intact. Explicit value reads of an absent documented top-level field return `null`. Authored `patch_state` keeps its documented field grammar. Objects merge, arrays/scalars replace, and omitted nonempty fields persist. Accepted supplied scopes recursively omit empty object fields and planes, including explicit `{}`; the scope root remains `{}`. This also cleans legacy empty branches in that scope, which can advance its revision once. Array slots and empty arrays remain; empty object fields inside elements disappear without shifting indices. Model projection hides legacy empty branches, but exact current/historical reads and untouched scopes are not normalized. Nested `null` removes an owned object key; empty ancestors disappear and inherited content may reappear. Canonical `"[N]"` keys patch existing array elements; indexed deletion and out-of-bounds indices are forbidden. Replace the whole array to add/remove elements. Missing authored object-key deletions succeed with a nonfatal hint showing owner-scoped JSON Pointers for the target and first unavailable component. Hints are not state, do not advance revisions and do not prove a typo; check the address only when deletion was expected to change something. Never guess or automatically correct the target.
60
60
 
61
61
  Work from intents. A structured `{"$ref"}` anywhere inside an intent owns ("delete with me") an existing object key under `working` or `lazy` in the same scope; a textual `$path` mention only uses it. Opening a task:
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.27.4",
3
+ "version": "0.27.5",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -46,7 +46,7 @@
46
46
  "@llblab/pi-clean-room": "0.3.0",
47
47
  "@llblab/pi-codex-usage": "0.12.1",
48
48
  "@llblab/pi-grow-loop": "0.9.0",
49
- "@llblab/pi-state-flow": "0.25.3",
49
+ "@llblab/pi-state-flow": "0.25.4",
50
50
  "@llblab/pi-telegram": "0.51.6",
51
51
  "@llblab/skills": "1.15.0"
52
52
  },