@llblab/pi-kit 0.8.1 → 0.9.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.
- package/CHANGELOG.md +9 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -6
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +9 -2
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +12 -0
- package/node_modules/@llblab/pi-state-flow/README.md +11 -9
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -5
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +2 -2
- package/node_modules/@llblab/pi-state-flow/index.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +159 -67
- package/node_modules/@llblab/pi-state-flow/lib/logging.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +7 -0
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +4 -4
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +15 -6
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +4 -4
- package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +8 -0
- package/node_modules/@llblab/pi-telegram/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +4 -4
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
- package/node_modules/@llblab/pi-telegram/lib/config.ts +6 -4
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +2 -1
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +20 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@llblab/pi-kit` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.9.1 - 2026-09-11
|
|
6
|
+
|
|
7
|
+
- `Telegram Draft Cadence`: Advances the exact Telegram pin to `0.45.4`, so each preview segment holds its first frame for one full two-second interval from its first visible text; the opening draft is an accumulated passage instead of a single streamed word, later frames keep the trailing cadence, and rollover after tool work reopens the window while sealing or final publication still cancels pending timers. The package set, resource inventory, and explicit load order remain unchanged.
|
|
8
|
+
- `State Flow Preserved Answers`: Advances the exact State Flow pin to `0.8.1`, preserving the first terminal draft as the runtime-owned response even without `final:true` and bounding same-run fallback turns to eliciting the final patch without replacing the visible answer. The package set, resource inventory, and explicit load order remain unchanged.
|
|
9
|
+
|
|
10
|
+
## 0.9.0 - 2026-09-11
|
|
11
|
+
|
|
12
|
+
- `State Flow Atomic Cohorts`: Advances the exact State Flow pin to `0.8.0`, replacing the single-scope PATCH/UNCHANGED protocol with one `patch_state({global?, cwd?, session?, final?})` primitive whose supplied scopes commit as one atomic transition, turning `final:true` into an ephemeral per-iteration terminal-eligibility latch with bounded three-attempt steering, and retaining exact rejected-call arguments in opt-in diagnostics while preserving the package set, resource inventory, and explicit load order.
|
|
13
|
+
|
|
5
14
|
## 0.8.1 - 2026-09-11
|
|
6
15
|
|
|
7
16
|
- `Telegram Schema Hotfix`: Advances the exact Telegram pin to `0.45.2`, whose bounded-depth `telegram_bind` argument schema is accepted by OpenAI and Gemini tool APIs again, restoring working agent requests while the bridge is connected. The package set, resource inventory, and explicit load order remain unchanged.
|
package/README.md
CHANGED
|
@@ -14,8 +14,8 @@ Package links lead to the owning repositories for usage, documentation, issues,
|
|
|
14
14
|
| [`@llblab/pi-clean-room`](https://github.com/llblab/pi-clean-room) | `0.1.1` | Isolated nested Pi TUI with explicitly selected extensions |
|
|
15
15
|
| [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.9.4` | Compact Codex/Spark subscription-limit status |
|
|
16
16
|
| [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.7.5` | Visible continuation scheduling and bounded worker Skills |
|
|
17
|
-
| [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.
|
|
18
|
-
| [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.
|
|
17
|
+
| [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.8.1` | Atomic scoped state cohorts with final-eligibility resolution |
|
|
18
|
+
| [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.4` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
|
|
19
19
|
| [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
|
|
20
20
|
|
|
21
21
|
Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
|
|
@@ -23,16 +23,16 @@
|
|
|
23
23
|
- At instance initialization resolve the repository, CWD key, and session key; load each scope's anchored checkpoint and tail, materialize at the selected temporal boundary, then overlay `global → cwd → session`. Install cached view and publication basis atomically only after successful restoration/initialization; unavailable publication is an error, not a semantic no-op. Preserve the selected revision across transient restore failure so an explicit start can retry it. A genuinely new session gets an empty session layer and inherits only global/CWD values; it must never reuse another same-CWD session layer.
|
|
24
24
|
- Bind every loaded scope and active Pi branch/checkpoint to the corresponding State Flow Git revision. Resume and tree restoration must recover branch-correct runtime config and semantic layers from that revision without blindly importing Git `HEAD`; reading an older revision must use object-level Git reads and never reset or check out the shared repository worktree.
|
|
25
25
|
- Every accepted semantic change, including session-only and response-only changes, atomically updates affected checkpoint/tail pairs and temporal metadata and creates one immediate local Git commit when Git is available; each commit stages the complete non-ignored worktree delta (`git add -A` semantics, including manual deletions) before overlaying exact prepared State Flow outputs, respects `.gitignore`, keeps State Flow-owned active files under compare-and-swap protection, and synchronizes the caller-visible index to the committed tree. `remotePublication` is branch runtime policy: `turn-end` queues only the newest accepted target for asynchronous non-interactive push, `off` remains local-only, and `transition` preserves synchronous legacy behavior. Queue state is operational metadata beneath the Git common directory, not semantic history; use exact targets, destination identity, symlink-safe atomic CAS, descendant-only coalescing, cross-process worker leases, restart recovery, and truthful failure diagnostics. Remote failure never rolls back accepted state or regenerates an answer. A repository with no remote is intentionally local-only; only Git executable `ENOENT` authorizes file-only mode.
|
|
26
|
-
- Register `patch_state` as the sole model-authored semantic mutation protocol. It accepts
|
|
27
|
-
- Treat
|
|
28
|
-
- Reconcile `response` only from the actually accepted ordinary assistant answer at `turn_end`; it remains runtime-owned. State Flow has no terminal HTML-comment mutation protocol and does not parse generic service comments.
|
|
26
|
+
- Register `patch_state` as the sole model-authored semantic mutation protocol. It accepts optional fixed `global`, `cwd`, and `session` semantic patches plus optional `final:true`; require at least one scope or `final:true`, reject unknown fields, `final:false`, empty supplied scopes, material no-ops, and the retired `{scope, patch}` / `unchanged` grammar. Validate every supplied scope against one causal basis and publish it all-or-nothing with one identity, temporal boundary, and durable cohort. Never accept model-authored `response`.
|
|
27
|
+
- Treat every semantic `patch_state` as a strict inference barrier. Give it sequential execution mode, inspect Pi's synchronized current assistant message during tool preflight, require exactly one `patch_state` call in that response, and block every sibling tool call before execution. Every enabled iteration begins terminal-ineligible; only a successful call containing `final:true` latches eligibility for the next accepted `turn_end`, without stopping later reasoning, tools, or patches. If a terminal draft ends before eligibility, preserve it as the runtime-owned response at that `turn_end` and start at most two same-run fallback turns whose only purpose is the `final:true` patch; an eligible draft whose final validation fails after a later acquisition follows the same path. Fallback turns never enter `response`, and their only instruction is to apply `patch_state` with any durable changes and `final:true`, or `{final:true}` alone. A successful fallback closes resolution with the preserved answer intact; two failed fallbacks close the iteration with the preserved answer and current state plus one bounded warning and a finalization diagnostic. Failed patch calls do not consume fallback turns. A subsequent legal patch remains possible, and only an accepted ordinary answer is reconciled.
|
|
28
|
+
- Reconcile `response` only from the actually accepted ordinary assistant answer at `turn_end`; it remains runtime-owned. State Flow has no terminal HTML-comment mutation protocol and does not parse generic service comments. The first terminal draft that cannot be reconciled is preserved as the iteration response rather than discarded; fallback turns during pending resolution never reach `response`. Other extensions retain ownership of their own comments and output handling.
|
|
29
29
|
- Treat terminal state as a decision-relevant handoff, not narration: retain source-addressed reusable operational knowledge in `artifacts`; compile stable requirements, confirmed decisions, rejected approaches, and interface commitments into `contract`; retain observations, validation, failures, current domain state, unresolved work, interaction consequences, and exact continuation in `working`. Preserve relevant completed prerequisites and verified outcomes while removing obsolete progress narration; reconcile only information affected by the run and relevant existing commitments, not every scope or repository surface.
|
|
30
30
|
- Preserve active constraints, unresolved questions, consequential negative results, and the next discriminating check before compression. Distinguish observations, user requirements, assistant decisions, and hypotheses; do not promote assistant conclusions to user requirements. Retain useful source locators and validity conditions for consequential facts without mandatory per-value metadata. Keep rejection reasons and reconsideration conditions. Reconcile contradictions through evidence or user clarification instead of silently overwriting established constraints or observations; retain unresolved conflicts and decision-relevant hypotheses as uncertain. These are protocol obligations, not deterministic semantic validation gates.
|
|
31
31
|
- Treat `working` as last observations, not a live workspace. Revalidate volatile facts before consequential actions; after interruption or branch navigation inspect relevant external effects before repeating operations. Failed state commits and restored memory do not undo tool effects. Missing evidence proves neither success nor absence of effects: retain uncertainty and the next check. Keep revalidation targeted, without action ledgers or runtime freshness/rollback guarantees.
|
|
32
|
-
- Treat each successful `SKILL.md` read as CWD artifact acquisition using the finalized tool-execution arguments after mutable interception: require a non-empty compiler output in the next
|
|
32
|
+
- Treat each successful `SKILL.md` read as CWD artifact acquisition using the finalized tool-execution arguments after mutable interception: require a non-empty compiler output in the next `patch_state` call under `cwd.artifacts[exactReadPath]` with `description`, `kind: "skill"`, and a flexible non-empty `compilation` object; hash the executed source bytes and record runtime-owned `sourceHash` plus `skill-artifact-v1` `compilerRevision` in the CWD provenance registry; reject missing, unhashable, malformed, or forged freshness data; replace the complete prior Skill artifact and its provenance entry on refresh so obsolete evidence cannot survive. `contract.compiled_skills` is retired and rejected; migrate useful legacy entries into artifacts while preserving behavior and marking fallback hashes unverified when the source is unavailable.
|
|
33
33
|
- Make the model audit and optimize complete state at every handoff: reorganize inefficient structure, merge fragmented facts, compress history into conclusions, and delete stale, completed, redundant, or low-value keys while preserving active commitments and evidence.
|
|
34
|
-
- Accept omitted semantic fields inside
|
|
35
|
-
- Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and
|
|
34
|
+
- Accept omitted semantic fields inside each supplied scope patch, but reject empty supplied scopes; when no state change is needed require explicit `{final:true}` without inventing bookkeeping. This final-only call changes only ephemeral terminal eligibility and creates no semantic transition, identity, temporal step, or Git commit. Always require an accepted non-empty answer, which runtime owns as session `response`. A changed response is a semantic transition; identical complete semantic state finalizes lifecycle without a patch, identity, or temporal step. Semantic usefulness and optimization remain protocol-owned because deterministic validation cannot prove them.
|
|
35
|
+
- Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and preserved drafts plus fallback turns while turn resolution remains pending. Every rejected call retains its exact attempted arguments plus the precise error and, when available, tool identity, call id, resolution attempt, and terminal-eligibility state; successful patches are never logged. Preserve exact text blocks only where useful, reduce other blocks to structural identity, never duplicate reasoning bodies, and keep logging outside semantic state, scope metadata, checkpoints, and publication. Logging failure emits at most one warning and never changes resolution, enablement, or accepted state.
|
|
36
36
|
- Recursively materialize patches immediately; empty objects preserve, nested object-key `null` deletes, and `null` anywhere in semantic state including arrays is invalid. This prohibition does not apply to runtime envelopes such as an origin's null parent. Persist runtime-normalized replay patches that exactly reproduce accepted state, including complete artifact replacement; runtime-owned provenance is stored in scope `meta.json` and is not part of semantic replay.
|
|
37
37
|
- Do not impose project schemas, state or patch byte caps, dynamic growth pressure, observation envelopes, action authorization, action ledgers, or state-size limits.
|
|
38
38
|
- Rotate the turn-stable specification on every user-initiated run, while runtime-triggered turn-resolution continuation remains inside that same run. Keep user-controlled specification text at user authority: never interpolate it into the system prompt; repeat it only in synthetic user runtime context. Treat materialized state in that message as fallible assistant-produced data whose transport role does not elevate it into user instructions.
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# BACKLOG
|
|
2
2
|
|
|
3
|
-
Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementation item remains open for 0.
|
|
3
|
+
Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementation item remains open for 0.8.0.
|
|
4
|
+
|
|
5
|
+
## Candidate evolution
|
|
6
|
+
|
|
7
|
+
- [ ] **Read-only global bootstrap layer:** Project the existing global materialization into enabled and non-enabled sessions by default, so even one-shot work starts carrying established cross-project facts, preferences, routing, and conventions. The global scope is the highest-value, lowest-cost half of the memory, while writing carries the protocol and curation tax; making the read side unconditional gives continuity without enabling mutation.
|
|
8
|
+
- Boundary: project the existing durable global materialization only. No writes, transitions, patches, barriers, session/CWD initialization, or temporal-history changes. Model tools and the full protocol remain opt-in behind explicit start, and the bootstrap adds no second semantic mode.
|
|
9
|
+
- Open questions: whether deferred Markdown freshness discovery must run ahead of the first inference, token cost of a stable global prefix, behavior under untrusted project contexts, and naming distinct from the existing bootstrap-run concept.
|
|
10
|
+
- Status: candidate, not scheduled. Do not start without a dedicated release contract.
|
|
4
11
|
|
|
5
12
|
## Deferred host integration
|
|
6
13
|
|
|
7
|
-
- [ ] **Native default session continuation:** Integrate the existing read-only recommendation, exact-selection, and rehydration contracts before Pi creates `SessionManager`, while preserving explicit new/resume and native-picker precedence, truthful notices, and cross-process session ownership. Pi 0.84.4 exposes no suitable pre-session resolver hook, so this requires upstream support or an SDK/launcher integration. Reverify the host API before taking the item; it is not a
|
|
14
|
+
- [ ] **Native default session continuation:** Integrate the existing read-only recommendation, exact-selection, and rehydration contracts before Pi creates `SessionManager`, while preserving explicit new/resume and native-picker precedence, truthful notices, and cross-process session ownership. Pi 0.84.4 exposes no suitable pre-session resolver hook, so this requires upstream support or an SDK/launcher integration. Reverify the host API before taking the item; it is not a release dependency.
|
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
> Each release keeps at most 8 outcome records of at most 512 characters.
|
|
4
4
|
|
|
5
|
+
## 0.8.1: Preserved answers and bounded fallback resolution
|
|
6
|
+
|
|
7
|
+
- `Resolution`: A terminal draft is never discarded. A draft ending before eligibility — or an eligible draft whose final validation fails for a later acquisition — is preserved as the runtime-owned response at `turn_end`, then at most two same-run fallback turns exist only to obtain the `final:true` patch. Fallback turns never enter `response`; success commits their patches with the preserved answer intact, and exhaustion closes the iteration with the preserved answer, current state, one bounded warning, and a finalization diagnostic.
|
|
8
|
+
|
|
9
|
+
## 0.8.0: Atomic cohorts and final eligibility
|
|
10
|
+
|
|
11
|
+
- `Protocol`: Replaced the single-scope PATCH and UNCHANGED forms with one canonical `patch_state({global?, cwd?, session?, final?})` call. Supplied scopes validate against one causal basis and publish as one atomic transition with a single identity, temporal boundary, durable cohort, and inference barrier; unknown fields, `final:false`, empty scopes, and material no-ops fail closed.
|
|
12
|
+
- `Eligibility`: `final:true` is now an ephemeral per-iteration latch rather than a resolution form. It permits a later `turn_end` without stopping reasoning, tools, or further patches; eligibility survives later activity and repeated final calls, resets only with the next enabled iteration, and `{final:true}` alone creates no semantic transition, identity, step, or Git commit.
|
|
13
|
+
- `Resolution`: An ineligible terminal draft is discarded and steered for at most three attempted completions. The first two attempts emit hidden same-run instructions; the third reports one bounded error while preserving committed state, enablement, and runtime-owned response. Failed and host-rejected patch calls consume no attempts, and only the accepted post-eligibility answer becomes `response`.
|
|
14
|
+
- `Diagnostics`: Rejected `patch_state` calls now retain their exact attempted arguments alongside the precise error, tool identity, call id, resolution-attempt count, and terminal-eligibility state; publication conflicts use the same record and successful patches are never logged.
|
|
15
|
+
- `Compatibility`: Persisted checkpoints, tails, provenance registries, scope layout, and Git/file durability are unchanged and need no migration; the removed model-facing grammar is not accepted as a compatibility alias.
|
|
16
|
+
|
|
5
17
|
## 0.7.0: Explicit resolution and leaner runtime
|
|
6
18
|
|
|
7
19
|
- `Protocol`: Made `patch_state` the sole model-authored semantic mutation path. Every enabled turn now requires a successful resolution call, each exactly PATCH `{scope, patch}` or UNCHANGED `{"unchanged":true}`; unresolved prose is discarded and steered within the same run, failed calls remain unresolved, and only the later accepted ordinary answer becomes runtime-owned `response`.
|
|
@@ -45,7 +45,7 @@ Optional `~/.pi/agent/state-flow.json` (or `state-flow.json` beneath `PI_CODING_
|
|
|
45
45
|
|
|
46
46
|
- `directory`: State storage location; omitted defaults to `state-flow/` beneath Pi's agent directory. Absolute paths, `~`/`~/` and relative paths are supported; relative paths resolve from the configuration directory, not the project CWD.
|
|
47
47
|
- `autoStart`: Set to `true` to enable State Flow automatically for genuinely new sessions, including fresh CWDs. Omitted defaults to `false`: manual activation, even when previous CWD materialization exists.
|
|
48
|
-
- `logging`: Set to `true` to append JSONL diagnostics for rejected `patch_state` calls and unresolved terminal drafts to `tmp/state-flow/logs.jsonl` beneath Pi's agent directory; omitted or `false` writes nothing. Exact text blocks are retained only when useful, while reasoning bodies are excluded. The file stays local unless you move it, deleting or rotating it is operator-owned, and logging never enters semantic state, metadata, checkpoints, or the state repository. If a custom state repository contains the diagnostic path, capture fails closed rather than committing the log. A write failure changes nothing except one local warning.
|
|
48
|
+
- `logging`: Set to `true` to append JSONL diagnostics for rejected `patch_state` calls and unresolved terminal drafts to `tmp/state-flow/logs.jsonl` beneath Pi's agent directory; omitted or `false` writes nothing. Rejected calls retain their exact attempted arguments, the precise error, and, when available, the tool identity, call id, resolution attempt, and terminal-eligibility state; successful patches are never logged. Exact text blocks are retained only when useful, while reasoning bodies are excluded. The file stays local unless you move it, deleting or rotating it is operator-owned, and logging never enters semantic state, metadata, checkpoints, or the state repository. If a custom state repository contains the diagnostic path, capture fails closed rather than committing the log. A write failure changes nothing except one local warning.
|
|
49
49
|
- `remotePublication`: For new runtimes, `turn-end` (default) queues one newest target after local acceptance and pushes asynchronously; `off` keeps commits local; `transition` preserves legacy synchronous push behavior. A resumed branch keeps its persisted policy.
|
|
50
50
|
|
|
51
51
|
Configuration is read at extension load; edit it and use `/reload` (or restart Pi) before opening a new session. Existing branches retain their stored enablement on resume/tree navigation. `/state-flow-stop` does not rewrite this file or disable automatic mode for later new sessions. Invalid JSON, unknown keys or invalid values fail extension loading rather than silently choosing another directory. A missing file uses defaults and is not generated automatically.
|
|
@@ -173,7 +173,7 @@ Session `config.json` owns runtime behavior such as `enabled`. Session `meta.jso
|
|
|
173
173
|
|
|
174
174
|
Git-backed Pi checkpoint data is exactly `{"revision":"<full commit hash>"}`; file-only checkpoints use `{"revision":"file:<64 lowercase hex>"}` for an exact current-cohort reference. `{"disabled":true}` denotes an ordinary disabled branch without durable runtime. Configuration, counters, specification, lineage, and semantic state are not copied into these entries. Older config/meta and state-bearing checkpoints remain read-only compatibility input. Invalid immutable pointer targets fall back through the selected branch; transient publication-lock failure instead retains the selected revision for retry. Explicit start on a pre-runtime branch establishes an empty session origin, even if a later branch already created same-session files. Shared streams remain unchanged and the later session state remains recoverable from its Git revision; it is not imported into the new origin. Legacy checkpoint syntax and immutable targets undergo the same fallback checks, while an unanchored legacy semantic payload cannot be silently reduced to an ordinary-disabled marker. Stop on an unproven branch fails visibly instead of turning failed recovery into permission to replace existing runtime; select a valid checkpoint before retrying.
|
|
175
175
|
|
|
176
|
-
Every materially effective semantic transition, including response-only and session-only changes, immediately publishes affected checkpoint/tail pairs and necessary lineage metadata. In Git mode each accepted cohort gets its own consistent local commit; `turn-end` queues the newest target for asynchronous replication, `off` stays local, and compatibility `transition` attempts the push synchronously. Intermediate patches are not batched until turn end. With no remote configured, Git persistence is intentionally local-only, not a pending publication error. With Git absent, persistence remains file-only and does not report fake commits or pending pushes. One
|
|
176
|
+
Every materially effective semantic transition, including response-only and session-only changes, immediately publishes affected checkpoint/tail pairs and necessary lineage metadata. In Git mode each accepted cohort gets its own consistent local commit; `turn-end` queues the newest target for asynchronous replication, `off` stays local, and compatibility `transition` attempts the push synchronously. Intermediate patches are not batched until turn end. With no remote configured, Git persistence is intentionally local-only, not a pending publication error. With Git absent, persistence remains file-only and does not report fake commits or pending pushes. One model-authored call may update global, CWD, and session together; every supplied scope shares one validation basis, transition identity, temporal boundary, and durable commit. Config-only changes may persist runtime state but never invent semantic transitions.
|
|
177
177
|
|
|
178
178
|
Writes use regular non-symlink owned files, same-directory atomic rename, isolated Git indexes, and compare-and-swap against the reconciled base. Cooperating State Flow Git publishers hold a common-Git-directory publication lock through capture, commit, and rollback. Raw bytes own file identity and recovery; non-UTF-8 explanatory journals are not reconstructed from decoded strings. Per-file publication checks catch changed bases. Both semantic and migration publishers retain exact prepared-output receipts: Git commits those bytes, not later worktree contents, and rollback requires the receipt to preserve detected external changes rather than replacing them. Low-level file helpers require caller exclusion; this is not kernel-atomic multi-file CAS against writers ignoring the protocol. An existing publication lock fails before writes and is not silently stolen; reconcile its active or interrupted owner before retrying. Scope keys mirror Pi's CWD/session directory names and retain separately verified canonical identity provenance inside owned state. Conflicts fail rather than silently selecting another scope or auto-merging semantic state. Every Git commit stages the complete non-ignored worktree delta — tracked edits, new files, and manual deletions, respecting `.gitignore` — then overlays the exact prepared State Flow outputs and synchronizes the caller-visible index to the committed tree, so no artificial staged or unstaged entries remain; unrelated user worktree changes may therefore be committed while ignored files stay untouched. With distinct roots, the Knowledge repository's files, index, HEAD, locks, and remote are not used for state publication; keep custom stores separate from sources to retain this isolation.
|
|
179
179
|
|
|
@@ -198,7 +198,7 @@ An existing current `state.json` becomes the initial checkpoint state, anchored
|
|
|
198
198
|
Use `patch_state` only for established future-relevant state whose delayed persistence risks meaningful loss, never scratchpad, narration, routine progress, or speculative churn:
|
|
199
199
|
|
|
200
200
|
```json
|
|
201
|
-
{"
|
|
201
|
+
{"session":{"working":{"verified":"result"}},"final":true}
|
|
202
202
|
```
|
|
203
203
|
|
|
204
204
|
```text
|
|
@@ -211,15 +211,17 @@ LLM(state[0] = Sn)
|
|
|
211
211
|
|
|
212
212
|
The barrier executes alone. State Flow opts into sequential preflight, inspects Pi's synchronized assistant response, requires exactly one `patch_state` call, and blocks every sibling tool before execution. The next inference sees one rebuilt current-state projection; it must not reconstruct the update itself. Tool calls/results remain current-run causality, not duplicate full-state messages.
|
|
213
213
|
|
|
214
|
-
`patch_state` is the only model-authored semantic mutation mechanism.
|
|
214
|
+
`patch_state` is the only model-authored semantic mutation mechanism. Each invocation may supply any combination of fixed `global`, `cwd`, and `session` patches plus optional `final:true`. Supplied scopes are validated and published atomically; at least one scope or `final:true` is required. The retired `{scope, patch}` and `unchanged` forms are rejected.
|
|
215
215
|
|
|
216
216
|
```text
|
|
217
|
-
patch_state(
|
|
217
|
+
patch_state({global?, cwd?, session?}) → atomic accepted state → further work
|
|
218
|
+
patch_state({...?, final:true}) → atomic accepted state + terminal eligibility → later turn_end
|
|
219
|
+
patch_state({final:true}) → terminal eligibility only, with no semantic transition
|
|
218
220
|
```
|
|
219
221
|
|
|
220
|
-
|
|
222
|
+
Every enabled iteration begins terminal-ineligible. `final:true` latches eligibility for the next accepted `turn_end`; it does not terminate the turn, stop tools or reasoning, freeze state, or prevent later `patch_state` calls. A terminal draft that ends before eligibility, or an eligible draft whose final validation fails after a later acquisition, is preserved: it is reconciled as the runtime-owned `response` at that `turn_end` instead of being discarded, and State Flow starts at most two same-run fallback turns whose only purpose is to obtain the `final:true` patch. Fallback turns are never the response. A successful `final:true` during a fallback commits its patches and closes resolution with the preserved answer intact; when both fallbacks pass without it, the iteration closes with the preserved answer and current state, plus one bounded warning and a finalization diagnostic. Failed patch calls do not consume fallback turns. Only an accepted answer reaches `response`; aborted, length-limited, and provider-error endings never do.
|
|
221
223
|
|
|
222
|
-
A
|
|
224
|
+
A semantic patch remains an inference barrier. The complete scope cohort is validated, durably accepted, and rematerialized before further reasoning. Empty supplied scopes and materially no-op scope cohorts are rejected; omit unchanged scopes and use `{final:true}` alone when no semantic update is needed. `response` is never accepted from the model. The final answer is ordinary assistant output, not a state transport. State Flow has no terminal `state_flow` HTML-comment protocol and does not parse generic service comments. Historical `state_flow` text is ordinary text. Other extensions retain ownership of their own comments such as `telegram_button`. Never put secrets in state.
|
|
223
225
|
|
|
224
226
|
## Artifact compilation and acquisition
|
|
225
227
|
|
|
@@ -259,7 +261,7 @@ Correlation follows finalized mutable `tool_call` arguments with execution-start
|
|
|
259
261
|
|
|
260
262
|
### Optional memory curation
|
|
261
263
|
|
|
262
|
-
The package includes the progressively disclosed `state-flow-memory` Skill for explicit audits, contradiction cleanup, scope narrowing, stale continuation review, and ownership migration. It is not part of routine retention and never runs as background maintenance. The Skill preserves useful uncertainty, confirmed decisions, bounded results, interaction consequences, and completed prerequisites without fixing provisional methods. Its compilation-first sequence writes and separately verifies a destination before source deletion, then inspects the effective overlay;
|
|
264
|
+
The package includes the progressively disclosed `state-flow-memory` Skill for explicit audits, contradiction cleanup, scope narrowing, stale continuation review, and ownership migration. It is not part of routine retention and never runs as background maintenance. The Skill preserves useful uncertainty, confirmed decisions, bounded results, interaction consequences, and completed prerequisites without fixing provisional methods. Its compilation-first sequence writes and separately verifies a destination before source deletion, then inspects the effective overlay; it deliberately preserves that verification boundary even though one `patch_state` call can atomically update multiple scopes. External promotion cannot delete the only accepted copy without destination identity, content, and revision evidence. State Flow remains the memory owner and global memory remains available throughout.
|
|
263
265
|
|
|
264
266
|
External handoffs may use the generic global `working.memory_promotions` map. Each entry requires `status` (`pending`, `accepted`, `failed`, or `unknown`) and a non-empty `owner`; accepted entries additionally require an exact destination `pointer` and `revision`. Other fields remain forward-compatible and destination-specific. This bookkeeping does not replace the retained candidate: failures and uncertainty keep the accepted State Flow body plus exact continuation, while proven acceptance may remove the duplicate body and retain the compact pointer record. `/state-flow-status` reports owner mode, fallback activation, memory-bearing scopes, promotion counts, and bounded pointer/error details; malformed or incompletely evidenced accepted records appear as invalid rather than accepted.
|
|
265
267
|
|
|
@@ -292,7 +294,7 @@ npm install
|
|
|
292
294
|
npm run validate
|
|
293
295
|
```
|
|
294
296
|
|
|
295
|
-
Validation runs TypeScript checks, automated tests, and an import smoke check. Temporal algebra tests compare all available offsets with independently retained test snapshots through dense/sparse repeated folding, shared transitions, deletion overlays, no-ops, and forks. Temporary-repository tests also cover explicit migration, ten committed sparse transitions with folding and all hot offsets, cold Git scope recovery, and historical symlink rejection. Storage-level tests also prove atomic runtime/semantic publication, config-only stop without semantic history, runtime identity and lineage validation, self-reference resolution across unrelated commits, and exact accepted-commit retry. Real Pi tests also exercise configured automatic activation, deferred source discovery, strict draft → resolution → accepted-answer continuation, temporal barriers and next-inference state, lazy runtime offset-one reads, unchanged shared-stream inheritance, old-branch stop/resume, and bounded passive stop/restart projection. The complete twenty-case temporal acceptance audit is documented in the property map; the canonical backlog identifies any subsequent open work.
|
|
297
|
+
Validation runs TypeScript checks, automated tests, and an import smoke check. Temporal algebra tests compare all available offsets with independently retained test snapshots through dense/sparse repeated folding, shared transitions, deletion overlays, no-ops, and forks. Temporary-repository tests also cover explicit migration, ten committed sparse transitions with folding and all hot offsets, cold Git scope recovery, and historical symlink rejection. Storage-level tests also prove atomic runtime/semantic publication, config-only stop without semantic history, runtime identity and lineage validation, self-reference resolution across unrelated commits, and exact accepted-commit retry. Real Pi tests also exercise configured automatic activation, deferred source discovery, strict draft → preserved answer and fallback resolution → accepted-answer continuation, temporal barriers and next-inference state, lazy runtime offset-one reads, unchanged shared-stream inheritance, old-branch stop/resume, and bounded passive stop/restart projection. The complete twenty-case temporal acceptance audit is documented in the property map; the canonical backlog identifies any subsequent open work.
|
|
296
298
|
|
|
297
299
|
- [Canonical open work](BACKLOG.md)
|
|
298
300
|
- [Delivery history](CHANGELOG.md)
|
|
@@ -63,21 +63,21 @@ The checkpoint is an older anchored materialization. The tail contains at most s
|
|
|
63
63
|
|
|
64
64
|
`state[n]`, `state.global[n]`, `state.cwd[n]` and `state.session[n]` resolve the same nth previous causal boundary. They are not independent per-scope patch counters. Pre-origin history is unavailable rather than empty.
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
A final-only `patch_state({"final":true})` call changes only ephemeral terminal eligibility and creates no identity, commit, or history step. A changed accepted response is runtime-owned semantic state and advances history.
|
|
67
67
|
|
|
68
68
|
## Pi lifecycle
|
|
69
69
|
|
|
70
|
-
`patch_state` is the sole mutation tool. It validates and publishes one
|
|
70
|
+
`patch_state` is the sole mutation tool. It validates any supplied global/CWD/session patches against one causal basis and publishes them as one atomic transition, then acts as an inference barrier. Pi executes no sibling tools from the same assistant response; the next inference sees rematerialized `state[0]`.
|
|
71
71
|
|
|
72
72
|
`read_state` reads one cached effective or scoped projection at offsets zero through seven. It never publishes or advances history.
|
|
73
73
|
|
|
74
|
-
Every enabled assistant
|
|
74
|
+
Every enabled assistant iteration starts terminal-ineligible. Only a successful `patch_state` call containing `final:true` latches eligibility for the next accepted `turn_end`; the call may atomically include global, CWD, and session patches. Eligibility does not stop later reasoning, tools, or patches. If terminal prose arrives before eligibility, State Flow preserves that draft and reconciles it into runtime-owned `response` at `turn_end`, then starts at most two same-run fallback turns whose only purpose is the `final:true` patch. The same path covers an eligible draft whose final validation fails after a later acquisition. Fallback turns never become the response: a successful `final:true` commits its patches and closes resolution with the preserved answer intact, while two failed fallbacks close the iteration with the preserved answer and current state plus one bounded warning and a finalization diagnostic. Failed patch calls do not consume the budget. A following legal patch remains possible, and only an accepted ordinary answer is reconciled into runtime-owned `response` at `turn_end`. State Flow no longer parses `state_flow` or generic HTML comments; historical comments are ordinary text and other extensions retain their own comment handling.
|
|
75
75
|
|
|
76
76
|
## Lifecycle planes
|
|
77
77
|
|
|
78
78
|
```text
|
|
79
79
|
SEMANTIC STATE artifacts + contract + working
|
|
80
|
-
TURN
|
|
80
|
+
TURN ELIGIBILITY false → patch_state(..., final:true) → latched true
|
|
81
81
|
CONTEXT PROJECTION active State Flow projection | passive post-stop handoff
|
|
82
82
|
```
|
|
83
83
|
|
|
@@ -199,7 +199,7 @@ Unknown keys fail loading. State Flow memory ownership and global availability a
|
|
|
199
199
|
|
|
200
200
|
`/state-flow-status` reports branch mode, runtime revision, temporal head/history depth, scope keys, patch tails, artifact freshness, memory-bearing scopes, external-promotion summaries, remote policy/queue state, and pending publication. Unavailable materialization is reported as unavailable, never fabricated as empty. Artifact source bodies are not read for status.
|
|
201
201
|
|
|
202
|
-
Opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` calls and
|
|
202
|
+
Opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` calls and preserved drafts plus fallback turns while turn resolution is pending. A rejected call retains its exact attempted arguments, the precise error, and, when available, tool identity, call id, resolution attempt, and terminal-eligibility state; accepted patches are never logged. Records preserve useful exact text blocks and reduce other blocks to structural identity without duplicating reasoning. They are never semantic state, scope `meta.json`, Pi checkpoints, or repository input. Write failure changes no resolution, enablement, or accepted state and reports at most one bounded local warning.
|
|
203
203
|
|
|
204
204
|
## Validation boundaries
|
|
205
205
|
|
|
@@ -17,8 +17,8 @@ The numbering below follows the twenty required tests in the temporal design cor
|
|
|
17
17
|
11. **Barrier shifts current to offset 1:** `tests/integration.test.ts` — “real Pi patch_state barriers rematerialize every scope before the next inference” observes the predecessor immediately after a barrier.
|
|
18
18
|
12. **Next inference sees new current state:** The same real-Pi test inspects actual model-input projections after session, CWD, and global barriers. “real Pi reads prior scoped state lazily after a barrier and rejects offset eight without a transition” adds model-tool access to the predecessor.
|
|
19
19
|
13. **No automatic old full-state duplication:** `tests/context.test.ts` — “projects only the latest seven compact accepted transitions” rejects full-state records in transition context. The real-Pi barrier test requires exactly one current runtime projection per inference. Explicitly requested history remains ordinary tool-result trajectory, not eager snapshot injection.
|
|
20
|
-
14. **Accepted response changes are transitions:** `tests/
|
|
21
|
-
15. **
|
|
20
|
+
14. **Accepted response changes are transitions:** `tests/extension.test.ts` — “the preserved answer stands after two fallback turns without final:true” and `tests/skills.test.ts` — “a late Skill obligation that outlives the fallback budget keeps the preserved answer” verify that a primary draft without a reconciled `final:true` is preserved as the runtime-owned response, fallback turns never enter `response`, and an exhausted fallback budget keeps the preserved answer with its current state. `tests/integration.test.ts` — “real Pi keeps the preserved answer after the fallback budget” repeats the closure through the native Pi turn loop.
|
|
21
|
+
15. **Final-only eligibility creates no fake history:** `tests/extension.test.ts` — “a primary draft without eligibility is preserved as the response and final-only fallback resolution keeps it” checks that `{final:true}` latches turn eligibility without changing semantic lineage, tails, step, or Git. A later changed accepted response remains a runtime-owned transition.
|
|
22
22
|
16. **Offset eight is outside hot history:** `tests/temporal.test.ts` — “hot range and unproven pre-migration history are explicit read boundaries”; the real-Pi history-reader test also exercises native tool rejection without a transition.
|
|
23
23
|
17. **Cold Git history remains recoverable:** `tests/git.test.ts` — “temporal Git writer preserves all hot states through sparse folding and cold revision reads” loads older committed states without moving the worktree.
|
|
24
24
|
18. **Tree/resume select the correct lineage:** `tests/integration.test.ts` — “real Pi preserves branch-local state through compaction, tree navigation, stop, and restart” and “real Pi old tree branch stop and resume preserve selected semantics without rewinding shared files”.
|
|
@@ -80,7 +80,7 @@ export {
|
|
|
80
80
|
sessionStorageKey, temporalScopePaths,
|
|
81
81
|
temporalStateFileUpdates, type ScopeStreamSources, type TemporalScopePaths
|
|
82
82
|
} from "./lib/durable.ts";
|
|
83
|
-
export { default, PATCH_STATE_TOOL_NAME, READ_STATE_TOOL_NAME, type StateFlowExtensionOptions } from "./lib/extension.ts";
|
|
83
|
+
export { default, MAX_FALLBACK_ATTEMPTS, PATCH_STATE_TOOL_NAME, READ_STATE_TOOL_NAME, type StateFlowExtensionOptions } from "./lib/extension.ts";
|
|
84
84
|
export {
|
|
85
85
|
captureTemporalGitBase,
|
|
86
86
|
isGitCommitAncestor,
|
|
@@ -178,7 +178,7 @@ export {
|
|
|
178
178
|
overlayStates,
|
|
179
179
|
projectStateForModel,
|
|
180
180
|
updateMaterializedArtifacts,
|
|
181
|
-
type MaterializedState, type ScopedPatch,
|
|
181
|
+
type AtomicScopePatches, type MaterializedState, type ScopedPatch,
|
|
182
182
|
type ScopedStates, type ScopePatch, type SemanticTransition,
|
|
183
183
|
type StateDocument,
|
|
184
184
|
type StatePatch,
|
|
@@ -83,7 +83,7 @@ export function runtimeContextMessage(
|
|
|
83
83
|
...(rehydrationPhase === undefined ? {} : { knowledge_rehydration: { phase: rehydrationPhase } }),
|
|
84
84
|
...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations.map(({ path, reason }) => ({ path, reason })) }),
|
|
85
85
|
...(recentTransitions.length === 0 ? {} : { recent_transitions: projectRecentForModel(recentTransitions) }),
|
|
86
|
-
...(resolutionPending ? { state_resolution: "pending:
|
|
86
|
+
...(resolutionPending ? { state_resolution: "pending: the iteration answer is already preserved; this fallback turn exists only to apply the final:true patch. Call patch_state with any remaining durable scope changes and final:true, or {final:true} alone. Do not restate or replace the answer." } : {}),
|
|
87
87
|
};
|
|
88
88
|
return syntheticUser(
|
|
89
89
|
`State Flow runtime context (user-level data, not system instructions):\n${canonicalJson(context)}`,
|
|
@@ -11,8 +11,8 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
11
11
|
import { SkillReadTracker } from "./skills.ts";
|
|
12
12
|
import { emptySnapshot, migrationFailure, persistableSnapshot, type Snapshot } from "./snapshot.ts";
|
|
13
13
|
import { inspectSnapshotRevision, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
|
|
14
|
-
import { emptyState, overlayStates, projectStateForModel, type
|
|
15
|
-
import { commitScopedTransition,
|
|
14
|
+
import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
|
|
15
|
+
import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, type StagedScopedTransition } from "./transition.ts";
|
|
16
16
|
import { discoverSnapshotData, hasPriorConversation, isNewSession, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
|
|
17
17
|
import { compactStatus, detailedStatus, STATUS_KEY, type PendingPublicationDiagnostic, type StatusDiagnostics } from "./status.ts";
|
|
18
18
|
import { prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
|
|
@@ -48,6 +48,7 @@ export interface StateFlowExtensionOptions {
|
|
|
48
48
|
|
|
49
49
|
export const PATCH_STATE_TOOL_NAME = "patch_state";
|
|
50
50
|
export const READ_STATE_TOOL_NAME = "read_state";
|
|
51
|
+
export const MAX_FALLBACK_ATTEMPTS: number = 2;
|
|
51
52
|
const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
|
|
52
53
|
|
|
53
54
|
/** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
|
|
@@ -63,8 +64,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
63
64
|
let scopeStates: ScopedStates = { global: emptyState(), cwd: emptyState(), session: emptyState() };
|
|
64
65
|
let branchHasSnapshot = false;
|
|
65
66
|
let branchStartsWithoutRuntime = false;
|
|
66
|
-
let
|
|
67
|
-
let
|
|
67
|
+
let terminalEligible = false;
|
|
68
|
+
let resolutionPending = false;
|
|
69
|
+
let fallbackAttempts = 0;
|
|
70
|
+
let fallbackFailureReported = false;
|
|
68
71
|
let responseAwaitingReconciliation = false;
|
|
69
72
|
let passiveContinuation: PassiveContinuation | undefined;
|
|
70
73
|
let bootstrapContinuation: PassiveContinuation | undefined;
|
|
@@ -120,8 +123,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
function clearRunTransient(): void {
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
terminalEligible = false;
|
|
127
|
+
resolutionPending = false;
|
|
128
|
+
fallbackAttempts = 0;
|
|
129
|
+
fallbackFailureReported = false;
|
|
125
130
|
responseAwaitingReconciliation = false;
|
|
126
131
|
runAnchorTimestamp = undefined;
|
|
127
132
|
skillReads.clear();
|
|
@@ -170,9 +175,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
170
175
|
artifactInvalidations = structuredClone(plan.requiresCompilation);
|
|
171
176
|
if (plan.removed.length > 0) {
|
|
172
177
|
const removals = Object.fromEntries(plan.removed.map((path) => [path, null]));
|
|
173
|
-
const stage =
|
|
178
|
+
const stage = stageAtomicScopePatches(
|
|
174
179
|
scopeStates,
|
|
175
|
-
{
|
|
180
|
+
{ global: { artifacts: removals } },
|
|
176
181
|
[],
|
|
177
182
|
runtime!.causalBasis(),
|
|
178
183
|
);
|
|
@@ -268,10 +273,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
268
273
|
}, runtime!.causalBasis(), { finalizeRun });
|
|
269
274
|
if (!committed) return false;
|
|
270
275
|
installScopeStates();
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
276
|
+
// A preserved primary response commits mid-run; pending acquisition obligations must
|
|
277
|
+
// still block final eligibility until the fallback turns resolve or expire.
|
|
278
|
+
if (!(finalizeRun && resolutionPending)) {
|
|
279
|
+
artifactInvalidations = artifactInvalidations.filter(({ path }) => !acquiredArtifactPaths.has(path));
|
|
280
|
+
artifactReads.setCandidates(artifactInvalidations);
|
|
281
|
+
skillReads.clear();
|
|
282
|
+
artifactReads.clear();
|
|
283
|
+
}
|
|
275
284
|
persist();
|
|
276
285
|
return true;
|
|
277
286
|
}
|
|
@@ -537,7 +546,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
537
546
|
updateUi(ctx);
|
|
538
547
|
}
|
|
539
548
|
|
|
540
|
-
|
|
549
|
+
interface DiagnosticExtras {
|
|
550
|
+
content?: unknown;
|
|
551
|
+
input?: unknown;
|
|
552
|
+
tool?: string;
|
|
553
|
+
toolCallId?: string;
|
|
554
|
+
resolutionAttempt?: number;
|
|
555
|
+
terminalEligible?: boolean;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, extras: DiagnosticExtras = {}): void {
|
|
541
559
|
if (!config.logging) return;
|
|
542
560
|
try {
|
|
543
561
|
const path = stateFlowLogPath(agentDir);
|
|
@@ -551,7 +569,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
551
569
|
cwd: resolve(ctx.cwd),
|
|
552
570
|
category,
|
|
553
571
|
error,
|
|
554
|
-
...(content === undefined ? {} : { content: projectDiagnosticContent(content) }),
|
|
572
|
+
...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
|
|
573
|
+
...(extras.input === undefined ? {} : { input: extras.input }),
|
|
574
|
+
...(extras.tool === undefined ? {} : { tool: extras.tool }),
|
|
575
|
+
...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
|
|
576
|
+
...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
|
|
577
|
+
...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
|
|
555
578
|
});
|
|
556
579
|
} catch (failure) {
|
|
557
580
|
if (loggingWarningReported) return;
|
|
@@ -560,15 +583,77 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
560
583
|
}
|
|
561
584
|
}
|
|
562
585
|
|
|
563
|
-
function
|
|
564
|
-
|
|
586
|
+
function continueFallbackResolution(lastWarning: boolean): void {
|
|
587
|
+
resolutionPending = true;
|
|
565
588
|
pi.sendMessage({
|
|
566
589
|
customType: VALIDATION_MESSAGE_TYPE,
|
|
567
|
-
content:
|
|
590
|
+
content: lastWarning
|
|
591
|
+
? "This is the last State Flow fallback turn. The iteration's answer is preserved and will not change. Apply the final:true patch now: call patch_state with any remaining durable scope changes and final:true, or with {\"final\":true} when nothing remains. Do not restate the answer."
|
|
592
|
+
: "The iteration's answer is preserved as the final response; later turns cannot replace it. Apply the final:true patch: call patch_state with any durable scope changes from this iteration (including required artifact or Skill compilation) and final:true, or with {\"final\":true} when nothing remains to persist. Do not restate the answer.",
|
|
568
593
|
display: false,
|
|
569
594
|
}, { deliverAs: "steer", triggerTurn: true });
|
|
570
595
|
}
|
|
571
596
|
|
|
597
|
+
/** Preserve the primary answer as this iteration's response and steer bounded fallback turns whose only purpose is the final:true patch. */
|
|
598
|
+
function beginFallbackResolution(ctx: ExtensionContext, message: { content?: unknown }, reason: string): void {
|
|
599
|
+
responseAwaitingReconciliation = true;
|
|
600
|
+
fallbackAttempts = 0;
|
|
601
|
+
fallbackFailureReported = false;
|
|
602
|
+
recordDiagnostic(`Preserved the terminal draft as the iteration response; fallback resolution started: ${reason}`, "terminal-pending", ctx, {
|
|
603
|
+
content: message.content,
|
|
604
|
+
resolutionAttempt: 0,
|
|
605
|
+
terminalEligible,
|
|
606
|
+
});
|
|
607
|
+
continueFallbackResolution(MAX_FALLBACK_ATTEMPTS === 1);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Fallback turns never become the response; they exist only to supply the final:true patch. */
|
|
611
|
+
function resolveFallbackTurn(ctx: ExtensionContext, message: { content?: unknown }): any {
|
|
612
|
+
let resolved = terminalEligible;
|
|
613
|
+
if (resolved) {
|
|
614
|
+
try {
|
|
615
|
+
validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
|
|
616
|
+
} catch (error) {
|
|
617
|
+
resolved = false;
|
|
618
|
+
recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, {
|
|
619
|
+
content: message.content,
|
|
620
|
+
resolutionAttempt: fallbackAttempts,
|
|
621
|
+
terminalEligible,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (resolved) {
|
|
626
|
+
resolutionPending = false;
|
|
627
|
+
recordDiagnostic(`Fallback resolution obtained final:true after ${fallbackAttempts} fallback turn${fallbackAttempts === 1 ? "" : "s"}; the preserved answer stands`, "finalization", ctx, {
|
|
628
|
+
content: message.content,
|
|
629
|
+
resolutionAttempt: fallbackAttempts,
|
|
630
|
+
terminalEligible,
|
|
631
|
+
});
|
|
632
|
+
return { message: { ...message, role: "assistant" as const, content: [] } };
|
|
633
|
+
}
|
|
634
|
+
fallbackAttempts = Math.min(MAX_FALLBACK_ATTEMPTS, fallbackAttempts + 1);
|
|
635
|
+
if (fallbackAttempts < MAX_FALLBACK_ATTEMPTS) {
|
|
636
|
+
recordDiagnostic(`Fallback turn ended without final:true (${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS})`, "terminal-pending", ctx, {
|
|
637
|
+
content: message.content,
|
|
638
|
+
resolutionAttempt: fallbackAttempts,
|
|
639
|
+
terminalEligible,
|
|
640
|
+
});
|
|
641
|
+
continueFallbackResolution(fallbackAttempts + 1 >= MAX_FALLBACK_ATTEMPTS);
|
|
642
|
+
return { message: { ...message, role: "assistant" as const, content: [] } };
|
|
643
|
+
}
|
|
644
|
+
resolutionPending = false;
|
|
645
|
+
recordDiagnostic(`Fallback resolution exhausted without final:true (${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS}); the preserved response and current state remain`, "finalization", ctx, {
|
|
646
|
+
content: message.content,
|
|
647
|
+
resolutionAttempt: fallbackAttempts,
|
|
648
|
+
terminalEligible,
|
|
649
|
+
});
|
|
650
|
+
if (!fallbackFailureReported) {
|
|
651
|
+
fallbackFailureReported = true;
|
|
652
|
+
ctx.ui.notify(`State Flow kept the preserved answer; no final:true patch arrived after ${MAX_FALLBACK_ATTEMPTS} fallback turns, so the iteration closed with its current state.`, "warning");
|
|
653
|
+
}
|
|
654
|
+
return { message: { ...message, role: "assistant" as const, content: [] } };
|
|
655
|
+
}
|
|
656
|
+
|
|
572
657
|
pi.registerTool({
|
|
573
658
|
name: READ_STATE_TOOL_NAME,
|
|
574
659
|
label: "Read State",
|
|
@@ -599,52 +684,65 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
599
684
|
pi.registerTool({
|
|
600
685
|
name: PATCH_STATE_TOOL_NAME,
|
|
601
686
|
label: "Patch State",
|
|
602
|
-
description: "The sole State Flow semantic mutation protocol.
|
|
603
|
-
promptSnippet: "
|
|
687
|
+
description: "The sole State Flow semantic mutation protocol. Supply any combination of global, cwd, and session patches; all supplied scopes commit atomically. Set final:true when the current iteration may finish at a later turn_end. final:true does not stop reasoning, tools, or later patch_state calls. Use {final:true} when no semantic update is needed. This call must be the only State Flow barrier in its assistant response.",
|
|
688
|
+
promptSnippet: "Atomically patch global/cwd/session; final:true permits a later turn_end",
|
|
604
689
|
promptGuidelines: [
|
|
605
|
-
"Use patch_state for
|
|
606
|
-
"Call patch_state alone in an assistant response;
|
|
690
|
+
"Use patch_state for durable semantic changes. Before a final answer, make the iteration terminal-eligible with final:true, optionally alongside atomic global/cwd/session patches.",
|
|
691
|
+
"Call patch_state alone in an assistant response; after its acknowledgement, further reasoning, tools, and later patch_state calls remain allowed.",
|
|
607
692
|
],
|
|
608
693
|
executionMode: "sequential",
|
|
609
|
-
// Type.Union/Type.Literal schemas are not portable across Pi's Google-compatible tool adapters.
|
|
610
|
-
// Field descriptions expose the discriminated forms while runtime validation preserves exclusivity.
|
|
611
694
|
parameters: Type.Object({
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
695
|
+
global: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional global semantic patch" })),
|
|
696
|
+
cwd: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional project semantic patch" })),
|
|
697
|
+
session: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional session semantic patch" })),
|
|
698
|
+
final: Type.Optional(Type.Boolean({ description: "Set exactly true to permit this iteration to finish at a later turn_end" })),
|
|
615
699
|
}, { additionalProperties: false }),
|
|
616
|
-
async execute(
|
|
700
|
+
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
617
701
|
try {
|
|
618
702
|
if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
|
|
619
703
|
if (signal?.aborted) throw new Error("State Flow patch was aborted before materialization");
|
|
620
|
-
if (!isObject(params)) throw new Error("patch_state requires an object
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
if (
|
|
624
|
-
validateUnchangedResolution(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
|
|
625
|
-
stateResolutionSatisfied = true;
|
|
626
|
-
terminalDraftIntercepted = false;
|
|
627
|
-
return { content: [{ type: "text", text: "\nState resolution acknowledged unchanged." }], details: { unchanged: true } };
|
|
704
|
+
if (!isObject(params)) throw new Error("patch_state requires an object");
|
|
705
|
+
const allowed = new Set(["global", "cwd", "session", "final"]);
|
|
706
|
+
for (const key of Object.keys(params)) {
|
|
707
|
+
if (!allowed.has(key)) throw new Error(`patch_state does not accept field ${key}`);
|
|
628
708
|
}
|
|
629
|
-
if (
|
|
630
|
-
|
|
631
|
-
|
|
709
|
+
if (Object.hasOwn(params, "final") && params.final !== true) throw new Error("patch_state final must be exactly true when supplied");
|
|
710
|
+
const patches: AtomicScopePatches = {};
|
|
711
|
+
for (const scope of ["global", "cwd", "session"] as const) {
|
|
712
|
+
if (!Object.hasOwn(params, scope)) continue;
|
|
713
|
+
const patch = params[scope];
|
|
714
|
+
if (!isObject(patch)) throw new Error(`patch_state ${scope} must be a semantic patch object`);
|
|
715
|
+
if (Object.keys(patch).length === 0) throw new Error(`patch_state ${scope} cannot be empty; omit it when unchanged`);
|
|
716
|
+
patches[scope] = patch;
|
|
632
717
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
718
|
+
const scopes = Object.keys(patches) as StateScope[];
|
|
719
|
+
if (scopes.length === 0) {
|
|
720
|
+
if (params.final !== true) throw new Error('patch_state requires at least one scope patch or {"final":true}');
|
|
721
|
+
validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
|
|
722
|
+
terminalEligible = true;
|
|
723
|
+
return { content: [{ type: "text", text: "\nState iteration is terminal-eligible." }], details: { final: true } };
|
|
724
|
+
}
|
|
725
|
+
const stage = stageAtomicScopePatches(scopeStates, patches, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
|
|
636
726
|
const semanticChange = (["global", "cwd", "session"] as const).some((scope) => !sameJson(scopeStates[scope], stage.nextStates[scope]));
|
|
637
727
|
const provenanceChange = Object.values(stage.provenanceUpdates).some((updates) => Object.keys(updates).length > 0);
|
|
638
|
-
if (!semanticChange && !provenanceChange) throw new Error('patch_state
|
|
728
|
+
if (!semanticChange && !provenanceChange) throw new Error('patch_state scope patches must materially update state or required provenance; omit them and use {"final":true} when unchanged');
|
|
639
729
|
commitStage(stage, ctx, false);
|
|
640
|
-
|
|
641
|
-
|
|
730
|
+
if (params.final === true) {
|
|
731
|
+
terminalEligible = true;
|
|
732
|
+
}
|
|
642
733
|
updateUi(ctx);
|
|
643
734
|
const publication = pendingPublication === undefined ? "" : "; durable publication pending";
|
|
644
|
-
return { content: [{ type: "text", text: `\nState materialized at ${
|
|
735
|
+
return { content: [{ type: "text", text: `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}${publication}.` }], details: { scopes, final: params.final === true, step: snapshot.meta.step } };
|
|
645
736
|
} catch (error) {
|
|
646
|
-
|
|
647
|
-
|
|
737
|
+
let attempted: unknown;
|
|
738
|
+
try { attempted = structuredClone(params); } catch { attempted = undefined; }
|
|
739
|
+
recordDiagnostic(error instanceof Error ? error.message : String(error), /concurrently|advanced/.test(String(error)) ? "publication-conflict" : "invalid-patch", ctx, {
|
|
740
|
+
input: attempted,
|
|
741
|
+
tool: PATCH_STATE_TOOL_NAME,
|
|
742
|
+
toolCallId,
|
|
743
|
+
resolutionAttempt: fallbackAttempts,
|
|
744
|
+
terminalEligible,
|
|
745
|
+
});
|
|
648
746
|
throw separatedFailure(error);
|
|
649
747
|
}
|
|
650
748
|
},
|
|
@@ -774,8 +872,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
774
872
|
skillReads.clear();
|
|
775
873
|
artifactReads.clear();
|
|
776
874
|
if (artifactRefreshPending) refreshArtifactInvalidations(ctx);
|
|
777
|
-
|
|
778
|
-
|
|
875
|
+
terminalEligible = false;
|
|
876
|
+
resolutionPending = false;
|
|
877
|
+
fallbackAttempts = 0;
|
|
878
|
+
fallbackFailureReported = false;
|
|
779
879
|
responseAwaitingReconciliation = false;
|
|
780
880
|
const rotatesRun = snapshot.meta.specification !== undefined;
|
|
781
881
|
if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
|
|
@@ -805,7 +905,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
805
905
|
? passiveContinuationMessages(event.messages as AgentMessage[], bootstrapContinuation)
|
|
806
906
|
: event.messages as AgentMessage[];
|
|
807
907
|
const messages = withoutPrivateValidation(sourceMessages);
|
|
808
|
-
return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase,
|
|
908
|
+
return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, resolutionPending), ...messages] };
|
|
809
909
|
}
|
|
810
910
|
const trajectory = currentRunTrajectory(
|
|
811
911
|
event.messages as AgentMessage[],
|
|
@@ -815,7 +915,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
815
915
|
runAnchorTimestamp = trajectory.anchorTimestamp;
|
|
816
916
|
return {
|
|
817
917
|
messages: [
|
|
818
|
-
runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase,
|
|
918
|
+
runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, resolutionPending),
|
|
819
919
|
...trajectory.messages,
|
|
820
920
|
],
|
|
821
921
|
};
|
|
@@ -853,7 +953,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
853
953
|
|
|
854
954
|
pi.on("tool_execution_end", (event) => {
|
|
855
955
|
if (!snapshot.config.enabled) return;
|
|
856
|
-
if (event.toolName === PATCH_STATE_TOOL_NAME && event.isError) stateResolutionSatisfied = false;
|
|
857
956
|
skillReads.recordEnd(event.toolCallId, event.toolName, event.isError);
|
|
858
957
|
artifactReads.recordEnd(event.toolCallId, event.toolName, event.isError);
|
|
859
958
|
});
|
|
@@ -867,25 +966,19 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
867
966
|
}
|
|
868
967
|
if (message.stopReason === "length" || message.stopReason === "error") {
|
|
869
968
|
responseAwaitingReconciliation = false;
|
|
870
|
-
recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, message.content);
|
|
969
|
+
recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, { content: message.content, terminalEligible });
|
|
871
970
|
return;
|
|
872
971
|
}
|
|
873
|
-
if (
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
continueForResolution();
|
|
878
|
-
return { message: { ...message, role: "assistant" as const, content: [] } };
|
|
972
|
+
if (resolutionPending) return resolveFallbackTurn(ctx, message);
|
|
973
|
+
if (!terminalEligible) {
|
|
974
|
+
beginFallbackResolution(ctx, message, "the terminal draft ended without State Flow eligibility");
|
|
975
|
+
return;
|
|
879
976
|
}
|
|
880
977
|
try {
|
|
881
|
-
|
|
978
|
+
validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
|
|
882
979
|
} catch (error) {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
terminalDraftIntercepted = true;
|
|
886
|
-
recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, message.content);
|
|
887
|
-
continueForResolution();
|
|
888
|
-
return { message: { ...message, role: "assistant" as const, content: [] } };
|
|
980
|
+
beginFallbackResolution(ctx, message, error instanceof Error ? error.message : String(error));
|
|
981
|
+
return;
|
|
889
982
|
}
|
|
890
983
|
responseAwaitingReconciliation = true;
|
|
891
984
|
});
|
|
@@ -908,7 +1001,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
908
1001
|
ctx.ui.notify(`State Flow could not reconcile the final response: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
909
1002
|
} finally {
|
|
910
1003
|
responseAwaitingReconciliation = false;
|
|
911
|
-
terminalDraftIntercepted = false;
|
|
912
1004
|
}
|
|
913
1005
|
updateUi(ctx);
|
|
914
1006
|
});
|
|
@@ -18,6 +18,12 @@ export interface StateFlowDiagnosticRecord {
|
|
|
18
18
|
category: StateFlowDiagnosticCategory;
|
|
19
19
|
error: string;
|
|
20
20
|
content?: StateFlowDiagnosticBlock[];
|
|
21
|
+
/** Rejected tool arguments, captured for reproducible diagnosis. Never reasoning bodies. */
|
|
22
|
+
input?: unknown;
|
|
23
|
+
tool?: string;
|
|
24
|
+
toolCallId?: string;
|
|
25
|
+
resolutionAttempt?: number;
|
|
26
|
+
terminalEligible?: boolean;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/** Preserve exact text blocks and block boundaries; reasoning bodies are never duplicated. */
|
|
@@ -40,6 +40,13 @@ export interface ScopedPatch {
|
|
|
40
40
|
patch: ScopePatch;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Canonical model-authored scope cohort before runtime final-eligibility handling. */
|
|
44
|
+
export interface AtomicScopePatches {
|
|
45
|
+
global?: ScopePatch;
|
|
46
|
+
cwd?: ScopePatch;
|
|
47
|
+
session?: ScopePatch;
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
export interface SemanticTransition {
|
|
44
51
|
transitions: ScopedPatch[];
|
|
45
52
|
}
|
|
@@ -21,15 +21,15 @@ response: previous complete answer, owned by runtime.
|
|
|
21
21
|
|
|
22
22
|
Use read_state only for a concrete historical or scope-specific gap. It reads one cached effective/global/cwd/session projection at offset 0..7 without mutation; all scopes use the same nth prior accepted semantic boundary.
|
|
23
23
|
|
|
24
|
-
Use patch_state as the sole model-authored semantic mutation mechanism.
|
|
24
|
+
Use patch_state as the sole model-authored semantic mutation mechanism. Supply any combination of global, cwd, and session patches; all supplied scopes are validated and durably accepted as one atomic transition before further reasoning. Call patch_state alone in its assistant response; after its acknowledgement choose the next action from accepted state.
|
|
25
25
|
|
|
26
|
-
Every enabled
|
|
26
|
+
Every enabled iteration starts terminal-ineligible. Set final:true in a successful patch_state call when the iteration may finish at a later turn_end. final:true does not stop reasoning, tools, or later patch_state calls, and repeated final:true calls are allowed. Use {"final":true} when no semantic update is needed. If you end a terminal turn without eligibility, runtime preserves that answer as the iteration response and starts bounded fallback turns whose only purpose is the final:true patch: call patch_state with any durable scope changes and final:true, or {"final":true} alone, and never restate or replace the answer. After two fallback turns without final:true the iteration closes with its preserved answer and current state. A final-only call creates no semantic transition. Never write response through patch_state; runtime records what was actually delivered at turn_end.
|
|
27
27
|
|
|
28
|
-
SCOPES: session is branch/run continuation, cwd is project state and Skills, global is cross-project state.
|
|
28
|
+
SCOPES: session is branch/run continuation, cwd is project state and Skills, global is cross-project state. Deleting an override affects only its scope and may reveal a parent value.
|
|
29
29
|
|
|
30
30
|
${baselineMemoryProtocol()}
|
|
31
31
|
|
|
32
|
-
PATCH:
|
|
32
|
+
PATCH: Fields are optional global, cwd, session semantic patches and optional final:true. At least one scope or final:true is required. Supplied scopes commit atomically; empty or materially no-op scopes must be omitted. Patches use only object-valued artifacts, contract, and working; omitted fields preserve. Never patch runtime config/meta/response. Recursive merge; arrays/primitives replace; nested null deletes. Materialized null is forbidden.
|
|
33
33
|
|
|
34
34
|
HANDOFF: Preserve active commitments, unresolved questions, consequential results, and exact continuation. Distinguish user requirements, confirmed decisions, observations, assistant conclusions, and hypotheses. Remove stale narration and never invent memory changes.
|
|
35
35
|
|
|
@@ -12,6 +12,7 @@ import { applyPatch, containsNull, hashJson, isObject, validatePatch } from "./j
|
|
|
12
12
|
import { hasCompiledSkillArtifact, SKILL_ARTIFACT_COMPILER, type SuccessfulSkillRead } from "./skills.ts";
|
|
13
13
|
import type { Snapshot } from "./snapshot.ts";
|
|
14
14
|
import type {
|
|
15
|
+
AtomicScopePatches,
|
|
15
16
|
MaterializedState,
|
|
16
17
|
ScopePatch,
|
|
17
18
|
ScopedPatch,
|
|
@@ -194,8 +195,8 @@ function stageScopedSemanticTransition(
|
|
|
194
195
|
};
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
/** Validate that
|
|
198
|
-
export function
|
|
198
|
+
/** Validate that final eligibility has no pending acquisition/compilation obligation. */
|
|
199
|
+
export function validateFinalEligibility(
|
|
199
200
|
currentStates: ScopedStates,
|
|
200
201
|
successfulSkillReads: Iterable<SuccessfulSkillRead>,
|
|
201
202
|
causalBasis: string,
|
|
@@ -210,17 +211,25 @@ export function validateUnchangedResolution(
|
|
|
210
211
|
);
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
/** Stage one
|
|
214
|
-
export function
|
|
214
|
+
/** Stage one canonical atomic scope cohort without changing the finalized response. */
|
|
215
|
+
export function stageAtomicScopePatches(
|
|
215
216
|
currentStates: ScopedStates,
|
|
216
|
-
|
|
217
|
+
patches: AtomicScopePatches,
|
|
217
218
|
successfulSkillReads: Iterable<SuccessfulSkillRead>,
|
|
218
219
|
causalBasis: string,
|
|
219
220
|
successfulArtifactReads: Iterable<SuccessfulArtifactRead> = [],
|
|
220
221
|
): StagedScopedTransition {
|
|
222
|
+
if (!isObject(patches)) throw new Error("Atomic State Flow scope patches must be an object");
|
|
223
|
+
for (const key of Object.keys(patches)) {
|
|
224
|
+
if (!SCOPES.has(key as StateScope)) throw new Error(`Unknown atomic State Flow scope: ${key}`);
|
|
225
|
+
}
|
|
226
|
+
const transitions: ScopedPatch[] = [];
|
|
227
|
+
for (const scope of ["global", "cwd", "session"] as const) {
|
|
228
|
+
if (Object.hasOwn(patches, scope)) transitions.push({ scope, patch: patches[scope] as ScopePatch });
|
|
229
|
+
}
|
|
221
230
|
return stageScopedSemanticTransition(
|
|
222
231
|
currentStates,
|
|
223
|
-
{ transitions
|
|
232
|
+
{ transitions },
|
|
224
233
|
successfulSkillReads,
|
|
225
234
|
causalBasis,
|
|
226
235
|
successfulArtifactReads,
|
|
@@ -88,18 +88,18 @@ Adjust only identified defects. This is a semantic review, not a request for ext
|
|
|
88
88
|
|
|
89
89
|
## Apply one reconciliation cohort
|
|
90
90
|
|
|
91
|
-
Use `patch_state` only for material changes to `artifacts`, `contract`, or `working`.
|
|
91
|
+
Use `patch_state` only for material changes to `artifacts`, `contract`, or `working`. One call may supply `global`, `cwd`, and `session` patches as one atomic cohort; each call must be alone in its assistant response, and subsequent actions must use the rematerialized state. Set `final:true` only when the iteration is eligible to finish at a later `turn_end`. Do not patch runtime-owned `response`, config, or metadata, or bypass validation by editing backing files.
|
|
92
92
|
|
|
93
93
|
Schedule acquisition and migration barriers in this order:
|
|
94
94
|
|
|
95
95
|
1. After reading this Skill, compile it into its exact-path CWD artifact before acquiring a stale global Markdown source or attempting an unrelated state write.
|
|
96
|
-
2. Read only the smallest required state projections. If a justified stale Markdown read creates a global compilation obligation,
|
|
96
|
+
2. Read only the smallest required state projections. If a justified stale Markdown read creates a global compilation obligation, include every pending compilation scope in the next atomic patch before unrelated work.
|
|
97
97
|
3. Write the migration destination with `patch_state`, verify it with a separate `read_state`, then delete or narrow the source and verify both its scope and the effective overlay. Do all readback before the terminal answer.
|
|
98
|
-
4. Complete one terminal reconciliation without repeating accepted compilations or inventing memory changes. Simultaneously pending CWD and global acquisitions
|
|
98
|
+
4. Complete one terminal reconciliation without repeating accepted compilations or inventing memory changes. Simultaneously pending CWD and global acquisitions must be compiled together in one atomic `patch_state` call; set `final:true` in that call only when the iteration is otherwise ready to finish.
|
|
99
99
|
|
|
100
100
|
Scope-local deletion may reveal a lower-scope value. Deleting an override is not necessarily removal from effective state.
|
|
101
101
|
|
|
102
|
-
For movement between State Flow scopes, resolve destination conflicts before writing; do not overwrite stronger or unrelated knowledge. Write and verify the destination before deleting the source.
|
|
102
|
+
For movement between State Flow scopes, resolve destination conflicts before writing; do not overwrite stronger or unrelated knowledge. Write and verify the destination before deleting the source. Do not combine destination creation and source deletion merely because multi-scope publication is atomic: preserve a temporary duplicate until readback proves the destination. Do not claim migration is complete until source cleanup and the effective result are verified.
|
|
103
103
|
|
|
104
104
|
On rejection, interruption, or conflicting state, inspect what was actually accepted before continuing. Never assume the entire cohort succeeded or failed. Keep recovery bounded; report a blocker rather than repeatedly regenerating patches.
|
|
105
105
|
|
|
@@ -99,7 +99,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
|
|
|
99
99
|
- `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for active preview flushes and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
|
|
100
100
|
- Native `sendChatAction(typing)` is the automatic activity signal for unsettled agent and compaction work while Telegram transport is authorized. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
|
|
101
101
|
- Public activity handlers and connected companion delivery are asynchronous, target-bound, generation-fenced surfaces. Connected companion projection has no independent opt-out: disconnect or authority loss is its boundary. Token deltas, hidden reasoning, unknown sources, and stale authority never enter public projection.
|
|
102
|
-
- Thread display defaults to the profile-scoped Letters strategy, with Directories as the other automatic
|
|
102
|
+
- Thread display defaults to the profile-scoped Letters strategy, with Names and Directories as the other automatic choices; Names projects the generated dictionary name for the slot. A durable manual Thread display name retained on its Workspace binding overrides any automatic projection until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable prompt-button labels use `emoji + space + text`; emoji-free text is only a reasoned no-semantic-marker fallback. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
|
|
103
103
|
|
|
104
104
|
## 5. Domain Ownership Index
|
|
105
105
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
_This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
|
|
4
4
|
|
|
5
5
|
- [ ] `Channel multimedia posts` (`0.45.1`, live-acceptance-gated): `telegram_message` channel delivery accepts one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video, uploads it through the multipart transport as `sendPhoto`/`sendVideo` with `text` as the HTML caption, validates kind and size (photo ≤ 10 MiB, video ≤ 50 MiB) plus ≤ 1024 visible caption characters before issuance, and rejects unsupported types and albums instead of downgrading them to links. The channel-post journal binds kind/file name/byte size/SHA-256 and caption, so duplicate requests and lost acknowledgements never re-upload; media-post edits replace the caption through `editMessageCaption`, and Markdown spoilers render as `<tg-spoiler>`. Live image publication passed on `@llb_log`. Regressions cover confirmed publication, duplicate requests, lost ACK, pre-issuance rejection, caption edits, and reconnect replacement. Remaining: operator-authorized disposable-channel acceptance of rejected upload, duplicate request, and caption edit.
|
|
6
|
-
- [ ] `Manual Thread naming` (`gated-but-preparable`, release priority): Local bot-owned `/name Name` and bare `/name` flows avoid model dispatch. One expiring exact-target input dialog immediately accepts the next valid name, always offers cancel, and offers **Reset to automatic** only while a manual override exists; duplicate/stale callbacks cannot repeat mutation. Durable manual override supersedes
|
|
6
|
+
- [ ] `Manual Thread naming` (`gated-but-preparable`, release priority): Local bot-owned `/name Name` and bare `/name` flows avoid model dispatch. One expiring exact-target input dialog immediately accepts the next valid name, always offers cancel, and offers **Reset to automatic** only while a manual override exists; duplicate/stale callbacks cannot repeat mutation. Durable manual override supersedes every automatic display mode, reset is leader/follower generation- and target-fenced, and Letters remains the default without rewriting recovery identity. Local review findings are remediated, including Bot-API-wait target-replacement regressions for leader/follower rename and reset. Remaining: disposable live acceptance for command-menu ordering, dialog, invalid input, duplicate callbacks, leader/follower rename and reset.
|
|
7
7
|
- [ ] `OMP schema acceptance` ([#267](https://github.com/llblab/pi-telegram/issues/267), `human-/environment-gated`): Local emitted-schema, Pi process, and llama.cpp source checks now prove explicit recursive JSON values, root `$defs`, only supported local `#/...` references, and no bare boolean schema. Confirm one connected `telegram_bind` request through the reporter's OMP + llama-server build before closing interoperability acceptance; do not treat this progressive external check as a Pi release blocker.
|
|
8
8
|
- [ ] [`Workspace operator gates`](./docs/multi-instance-bus.md#approved-next-contract-directory-names-and-reclaimable-slots): Complete the remaining external evidence for display modes, unique slots, and durable recovery. Local recovery and snapshot-equality reviews are closed; do not repeat them without changed relevant inputs. No live deletion, commit, publication, restart, or automatic retirement activation belongs to local implementation authority.
|
|
9
9
|
- [ ] `Bus performance escalation` (`research`, deferred): The [isolated registry/store baselines](./docs/architecture.md#persistence-io-baseline) do not justify secondary indexes, shared mutable views, or IPC multiplexing at 26 slots. Resume full IPC/authenticated registration/routing and allocation-cost measurement only after attributable latency, event-loop blocking, or growing work establishes a concrete claim. Existing local counts are not throughput or end-to-end evidence. Preserve owner/generation fences, journal authority, and unknown-ACK behavior; do not repeat unchanged synthetic measurements merely to sustain the loop.
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.45.4: Draft Cadence Hotfix
|
|
8
|
+
|
|
9
|
+
- `Draft Cadence`: Each preview segment now holds its first frame for one full two-second interval from its first visible text, so the opening draft is an accumulated passage instead of a single streamed word. Later frames keep the trailing cadence, message/turn rollover preserves the remaining interval and reopens the window, and sealing or final publication still cancels the pending timer; first frames no longer ship immediately.
|
|
10
|
+
|
|
11
|
+
## 0.45.3: Thread Display Names Hotfix
|
|
12
|
+
|
|
13
|
+
- `Thread Display Names`: Settings again offers the dictionary naming mode as the second chooser between Letters and Directories. Names shows each Workspace's generated slot-letter palette word, such as `Anchor` for slot `A`; switching renames live tabs and fresh tabs start under the active projection. `profiles.<name>.threadDisplayMode` persists all three values, absent or invalid ones resolve to Letters, and Names works with legacy followers that predate `thread-display-mode-v1`.
|
|
14
|
+
|
|
7
15
|
## 0.45.2: Provider-Compatible Bind Schema Hotfix
|
|
8
16
|
|
|
9
17
|
- `Provider-Compatible Bind Argument`: Serializes the `telegram_bind` `argument` schema as an inline builder-made JSON-value union bounded to four container levels, with no `$ref`/`$defs` recursion or raw TypeBox marker leakage; OpenAI no longer rejects every request with "Recursive JSON schemas are not currently supported" (#273) and Gemini no longer rejects the unknown `~optional` field (#269) while the tool is registered.
|
|
@@ -240,7 +240,7 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
|
|
|
240
240
|
- Unknown threads are preserved and offered explicit reroute/restore choices.
|
|
241
241
|
- Telegram never launches hidden Pi processes.
|
|
242
242
|
|
|
243
|
-
In Threaded Mode, open Settings → **🧵 Thread display** to choose **Letters** (default) or **Directories** for this bot profile. Fresh tabs are created with the active mode's title instead of being visibly renamed afterward. Telegram tab titles, Pi terminal status, live Thread choosers/notices, prompt attribution, and named `telegram_message` targeting use the same acknowledged display name; target IDs and live registrations still own routing. Directory mode adds persistent global-letter suffixes when a Workspace has multiple instances, such as `extensions_a` and `extensions_c`. `/name` sets a manual Thread display name; **Reset to automatic** restores the selected automatic projection.
|
|
243
|
+
In Threaded Mode, open Settings → **🧵 Thread display** to choose **Letters** (default), **Names**, or **Directories** for this bot profile. Fresh tabs are created with the active mode's title instead of being visibly renamed afterward. Telegram tab titles, Pi terminal status, live Thread choosers/notices, prompt attribution, and named `telegram_message` targeting use the same acknowledged display name; target IDs and live registrations still own routing. Names shows the generated dictionary name chosen for the slot, such as `Anchor` for slot `A`. Directory mode adds persistent global-letter suffixes when a Workspace has multiple instances, such as `extensions_a` and `extensions_c`. `/name` sets a manual Thread display name; **Reset to automatic** restores the selected automatic projection. Switching preserves Thread IDs, slots, generated recovery identity, and queue ownership. Partial application reports an error and can be retried without recreating Threads.
|
|
244
244
|
|
|
245
245
|
| Mode | Best for | Runtime shape |
|
|
246
246
|
| --- | --- | --- |
|
|
@@ -371,7 +371,7 @@ Profile reality follows three explicit storage classes. `telegram.json` shared s
|
|
|
371
371
|
|
|
372
372
|
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Followers heartbeat every `1s`; the leader uses a `15s` stale grace and a `1s` prune loop so transient IPC stalls do not create false routing gaps while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, `workspaceBindings` stores profile-scoped normalized exact-`cwd` target/name/slot reuse hints, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
|
|
373
373
|
|
|
374
|
-
Fresh follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then may that process allocate a profile-scoped normalized exact-`cwd` Workspace identity and cause the leader to create a Thread. A later process reopening that remembered Workspace automatically sends capability-gated restore-only admission under a live leader. The leader may reclaim, visibility-probe, or stale-replace the remembered target, but an absent binding returns quietly without creating a Thread. `/telegram-connect [profile] as=Name` supplies a unique capitalized Latin-word identity only to fresh Workspace provisioning; an existing Workspace keeps its persisted name. Telegram `/name Name` stores the owning Thread's durable `manualThreadName` and immediately applies it over
|
|
374
|
+
Fresh follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then may that process allocate a profile-scoped normalized exact-`cwd` Workspace identity and cause the leader to create a Thread. A later process reopening that remembered Workspace automatically sends capability-gated restore-only admission under a live leader. The leader may reclaim, visibility-probe, or stale-replace the remembered target, but an absent binding returns quietly without creating a Thread. `/telegram-connect [profile] as=Name` supplies a unique capitalized Latin-word identity only to fresh Workspace provisioning; an existing Workspace keeps its persisted name. Telegram `/name Name` stores the owning Thread's durable `manualThreadName` and immediately applies it over the active automatic display projection; bare `/name` opens five-minute exact-target input whose next valid text is consumed before agent dispatch. Name input, cancel, and reset are consume-once; stale scope, target, message ID, expiry, and duplicate callbacks cannot mutate. Reset clears only the override and restores the current automatic projection. Leader command routing reuses its already-held profile admission for the rename body rather than recursively entering the non-reentrant Workspace gate; standalone leader renames acquire their own admission. Followers send an authenticated exact-generation `workspace-thread-rename-v1` request, and the leader owns any Bot API mutation plus durable binding persistence. Concurrent processes from one directory receive deterministic Workspace suffixes, while leader/follower roles remain transient projections over that durable identity. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection. A confirmed runtime transition from Threaded to Singleton stops threaded transport and suspends the process-local leader target before classic polling can accept new work; durable Workspace slot, generated name, manual display name, and binding evidence remain retained. Re-enabling Threaded Mode restores or replaces that logical binding before publishing one new live target. Already-admitted turns keep their captured destination and are never silently retargeted or duplicated.
|
|
375
375
|
|
|
376
376
|
### Unbound Thread Detection
|
|
377
377
|
|
|
@@ -145,11 +145,11 @@ A registered instance exposes:
|
|
|
145
145
|
}
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
`instanceId` is liveness identity. `owner` is explicit current binding identity (`leader`, `manual-follower`, or `pending-topic`). Internal compatibility keys may be derived, but `state.json` should not hide ownership direction inside legacy string keys. `threadName` is the stable named-mode and restoration identity; `displayTitle` is the separately acknowledged user-facing projection. Fresh threads receive a compact palette name from the assigned slot, while Letters and Directories project other titles without replacing
|
|
148
|
+
`instanceId` is liveness identity. `owner` is explicit current binding identity (`leader`, `manual-follower`, or `pending-topic`). Internal compatibility keys may be derived, but `state.json` should not hide ownership direction inside legacy string keys. `threadName` is the stable named-mode and restoration identity; `displayTitle` is the separately acknowledged user-facing projection. Fresh threads receive a compact palette name from the assigned slot; Names projects that saved name, while Letters and Directories project other titles without replacing it.
|
|
149
149
|
|
|
150
150
|
## Approved Next Contract: Directory Names And Reclaimable Slots
|
|
151
151
|
|
|
152
|
-
Status: approved design with a locally tested pure selection policy in `lib/workspace-slots.ts` and profile-isolated display preference persistence/default resolution in `lib/config.ts`. Workspace claims now reserve global letters before provisioning and preserve legacy binding keys. An exact claim assigns the first free letter to a missing-slot binding or the selected member of a duplicate-slot set, but persistence waits for successful target recovery; unresolved duplicates block unrelated fresh allocation. Sticky suffix metadata and acknowledged `displayTitle` persist in Workspace bindings. `lib/thread-display.ts` provides the three-mode projection plus serialized title reconciliation wired into leader startup and follower registration. Heartbeat ACKs carry acknowledged display titles to followers and the current-thread/TUI projection uses them without changing restoration identity. Settings now exposes Letters
|
|
152
|
+
Status: approved design with a locally tested pure selection policy in `lib/workspace-slots.ts` and profile-isolated display preference persistence/default resolution in `lib/config.ts`. Workspace claims now reserve global letters before provisioning and preserve legacy binding keys. An exact claim assigns the first free letter to a missing-slot binding or the selected member of a duplicate-slot set, but persistence waits for successful target recovery; unresolved duplicates block unrelated fresh allocation. Sticky suffix metadata and acknowledged `displayTitle` persist in Workspace bindings. `lib/thread-display.ts` provides the three-mode projection plus serialized title reconciliation wired into leader startup and follower registration. Heartbeat ACKs carry acknowledged display titles to followers and the current-thread/TUI projection uses them without changing restoration identity. Settings now exposes Letters (default), Names, and Directories; follower changes use the capability-gated leader-owned setting path. Live bot chooser/notice labels and cross-instance agent-target resolution use acknowledged titles without granting routing authority. Confirmed owner cleanup now persists the first proven `inactiveSinceMs` transition atomically with target invalidation; successful active provisioning clears it. Pressure selection, intents, mocked execution, and recovery are implemented. A 2/2 same-model independent post-fix quorum cleared the admission-composition blocker at 0.96 confidence per reviewer, but production deletion remains disconnected by this release scope; `BACKLOG.md` owns operator smoke and release readiness.
|
|
153
153
|
|
|
154
154
|
The pure policy distinguishes a free letter, a proposed pressure-reclamation victim, and protected/invalid capacity. Its caller must supply a validated profile-wide snapshot, reservations, proven inactivity start, and explicit protection classification; duplicate legacy letters block selection. The policy performs no filesystem or Telegram operations and does not establish liveness or deletion authority. It proposes a victim only when every profile-wide letter is occupied or reserved; elapsed time alone never triggers retirement.
|
|
155
155
|
|
|
@@ -167,7 +167,7 @@ Production retirement requires a durable profile-scoped reader/writer ledger own
|
|
|
167
167
|
- Retirement intent preparation/adoption/execution owns its exact gate-and-ledger protocol but remains absent from production composition. Status projection and polling/routing bot-mode writes change only diagnostic or capability metadata; they neither create nor remove Thread/Workspace authority and serialize through the store's local persistence queue.
|
|
168
168
|
- Production callers of reservation, provision/cleanup intent, target-record, Workspace-binding, and display mutators are contained by the owners above. The generic store remains policy-free for isolated tests and domain composition; calling a primitive directly is not production retirement authority.
|
|
169
169
|
- Workspace identity remains the selected bot profile plus normalized exact full `cwd`; directory basenames are presentation, never routing keys. Each concurrent binding receives one profile-wide unique lowercase slot from `a` through `z`, persisted on the wire/store as its uppercase equivalent, independent of directory and leader/follower role. This replaces the two competing displayed allocation identities; immutable legacy `instanceSlot` and `bindingKey` remain recovery keys, not another displayed pool.
|
|
170
|
-
- Automatic display mode is a bot-profile setting shared by Telegram Thread titles and Pi TUI status. The selector offers `letters` then `directories`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; directories use the directory basename. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides
|
|
170
|
+
- Automatic display mode is a bot-profile setting shared by Telegram Thread titles and Pi TUI status. The selector offers `letters`, `names`, then `directories`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; Names shows the generated dictionary name for the slot, such as `Anchor` for `A`; directories use the directory basename. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides any automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Store the automatic preference at `profiles.<name>.threadDisplayMode`, not as a process-local choice or a setting shared by unrelated bots.
|
|
171
171
|
- In directory mode, a singleton may hide its suffix; once another retained binding for that Workspace exists, all its labels expose their globally assigned suffixes (for example `extensions_a`, `skills_b`, `extensions_c`). Persist the decision to show suffixes so later closure does not make names oscillate. Equal basenames from different paths require a deterministic parent-path qualifier. Preserve the existing `threadName` as generated/recovery identity while automatic modes are selected. New manual names live only in `manualThreadName`; do not guess manual provenance from a legacy name or palette membership. `showSlotSuffix: true` is sticky binding metadata; sibling creation and legacy multi-binding loads expose it, and later upserts that omit it cannot reset it. Telegram `/name Name` changes the manual override and displayed title only for its exact originating target. Leader and follower requests carry that target through final generation/binding checks, so replacement cannot redirect a stale dialog mutation. Reset uses the same negotiated `workspace-thread-rename-v1` capability and exact follower generation; the leader computes the current automatic projection, edits the exact target, clears only `manualThreadName`, and persists before acknowledging. Follower metadata refresh preserves an acknowledged display title only while target and registration generation stay unchanged. Named-profile setup preserves the latest saved automatic preference even if another instance changes it while the token form is open.
|
|
172
172
|
- Fresh provisioning projects the candidate together with retained bindings and sends the active mode's title in `createForumTopic`. The exact targeted provision retains creation-title evidence until the Workspace commit publishes the binding and consumes that evidence together. Recovery preserves it even when a starting record already exists; an untargeted or unknown creation never authorizes a title commit. Proven deletion removes exact-target pending creation evidence, including when no current record was committed. Older contradictory pending/deleted snapshots settle that evidence durably before replacement; closed targets and pending cleanup block recovery until reconciliation, rather than becoming active again. The same exact-target check protects follower reconnect/carried-target shortcuts and the final Workspace commit, before creation evidence can be consumed. A matching carried pending target resumes through the provisioner that owns its reserved slot and acknowledged title instead of allocating that slot again. The shared provision-commit helper first commits the claim, then applies the acknowledged title with exact-binding comparison, preserving generic stale-title rejection on target replacement. Switching display mode changes projection only: preserve Thread ID, binding identity, slot, queue ownership, and routing. `displayTitle` records a successful Telegram edit independently of `threadName`, survives same-target registration updates, and is cleared on target replacement. The title reconciler captures profile, mode, leader epoch, and exact live-binding authority before each edit, rechecks after ACK and persistence, and skips dormant bindings. Failed persistence retains acknowledged dirty metadata for a later persist without repeating that API edit; a late or unknown ACK never commits a title to a replacement binding. Keep the stable palette/manual name separate from the current display title so switching back does not generate a different name. The leader owns Telegram title edits and acknowledged follower/TUI convergence, with generation/profile fencing and truthful partial-failure recovery. Successful registration ACKs optionally carry the acknowledged `displayTitle` with the exact target and registration generation, making it available before the initial status refresh. Heartbeats carry later title changes; stale generations cannot update display state. Connected notices use acknowledged titles while runtime `threadName` remains the stable restoration identity. Live bot chooser/notice labels, prompt attribution, and cross-instance agent-target name selection use the same acknowledged projection, but candidate liveness and the captured numeric `{chatId, threadId}` remain authoritative. Ambiguous projected names fail closed. Older peers can ignore the optional field, and no separate polling connection or follower snapshot-read loop is needed; do not expose a setting control that merely stores a preference without updating its promised surfaces.
|
|
173
173
|
- Reopening a retained inactive binding restores its slot and name without taking leadership. Explicit connection from the same directory may allocate a second binding. Startup restore remains restore-only: it must not evict another Workspace or allocate a fresh binding merely because all remembered bindings are owned. Explicit connection already skips a live peer's migrated binding instead of attempting to adopt it; that admission rule is independent of the display redesign.
|
|
@@ -236,7 +236,7 @@ Run this only with an operator-approved disposable bot/profile and disposable Th
|
|
|
236
236
|
1. Start one Pi in directory A, enable private-chat Threaded Mode, and run `/telegram-connect`. Confirm leader ownership, one Thread created directly with the selected display-mode title, the same title in the connected notice and initial Pi status, and no generated-name flash or second polling owner.
|
|
237
237
|
2. Start a Pi in directory B and connect it. Confirm follower registration rather than takeover, a distinct globally ordered slot/name, the selected title in creation/notice/initial status without waiting for a heartbeat, exact prompt/reply routing, and no traffic in the leader Thread.
|
|
238
238
|
3. Explicitly connect a second Pi from directory A. Confirm it receives a separate binding/slot without copying the first target. Restart each follower independently and confirm restore-only startup reuses its remembered exact-directory binding without allocating a new Thread.
|
|
239
|
-
4. Rename the leader and a follower from their respective Telegram Threads with `/name Name`. Confirm each manual override converges in Telegram, Pi status, choosers, notices, and agent-target labels under
|
|
239
|
+
4. Rename the leader and a follower from their respective Telegram Threads with `/name Name`. Confirm each manual override converges in Telegram, Pi status, choosers, notices, and agent-target labels under all three display modes. Reset each override to the current automatic projection; confirm target IDs never change and same-basename directory suffixes remain sticky after a sibling disconnects.
|
|
240
240
|
5. From leader and follower Threads, exercise ordinary prompts, callback buttons, one file, and one voice response. Confirm each result remains reply-anchored to the originating numeric Thread and no upload, notice, or final is duplicated.
|
|
241
241
|
6. From `All`, create an unbound disposable Thread. Test forward and Replace/restore separately. Confirm accepted content reaches only the selected live instance, Restore carries the selected identity onto the source target, and only the confirmed old/chooser targets are deleted.
|
|
242
242
|
7. Replace a follower session, then stop the leader and allow follower promotion. Confirm profile, target, slot, saved name, acknowledged title, accepted queue work, and routing survive without a new Thread or competing poller.
|
|
@@ -16,7 +16,7 @@ Projected blocks use `assistant.rendering` independently of voice policy. Rich m
|
|
|
16
16
|
|
|
17
17
|
Assistant-message completion seals its preview state: queued follow-up drafts and late updates are suppressed. Native final delivery still waits for the already-issued draft request before sending the permanent answer, so an older draft is not deliberately allowed to overtake the final. Intermediate publication seals and drains its captured preview before sending permanent text. Preview rollover itself sends no permanent message: it carries the preceding delivery boundary and draft identity into the next state without holding the Pi message-start hook. The next draft waits for that publication to settle, including failure or cancellation. Active-turn final delivery captures its preview operations before entering the background queue: it drains only the originating draft, leaves a successor's preview untouched, and cannot wait for a successor publication queued behind itself. Delivery authority is rechecked after the captured flush; if the original preview has been replaced, ordinary final sending remains the queue's responsibility. This does not promise instant delivery or eliminate Telegram/client latency.
|
|
18
18
|
|
|
19
|
-
Assistant previews use a two-second
|
|
19
|
+
Assistant previews use a two-second trailing throttle per preview controller, with at most one request in flight. Each preview segment holds its first frame for one full interval from its first visible text, so the opening frame is an accumulated passage instead of the first streamed word even when the previous cadence boundary has already passed (fresh turn, slow first token, or rollover after tool work); changes inside the window replace pending text, and one trailing timer sends the latest safe snapshot without moving its deadline on every delta. Message/turn rollover preserves the remaining interval and reopens the initial window for the next segment. Sealing, clearing, or replacing preview state cancels its timer; final publication drains only an already-issued request and never waits for the throttle deadline.
|
|
20
20
|
|
|
21
21
|
The Bot API client does not replay draft snapshots through API retry backoff. After a retryable draft HTTP failure (`429` or `5xx`), it defers new draft requests for that bot/chat/thread using the existing `Retry-After` or default backoff delay; it stores a deadline, not a body or a background retry. A deferred update does not advance the preview's last-delivered text. Fresh updates can send after the deadline, while a sealed preview cannot resume. Different bots and thread targets have independent cooldowns; credential rotation for the same bot does not bypass its deadline. Permanent replies retain their existing retry policy. Already-issued requests and Telegram/client rendering can still delay visible completion.
|
|
22
22
|
|
|
@@ -46,7 +46,7 @@ Stable commands inside Pi:
|
|
|
46
46
|
|
|
47
47
|
### Telegram commands
|
|
48
48
|
|
|
49
|
-
- `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title
|
|
49
|
+
- `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title over the active automatic projection. Bare `/name` immediately opens expiring exact-target input; the next valid name is consumed before agent dispatch. Cancel is always available; **Reset to automatic** appears only when a manual name exists. Entering a bare uppercase slot letter such as `A` is also treated as an explicit reset to the current automatic display projection rather than as a manual name.
|
|
50
50
|
|
|
51
51
|
Stable commands inside the paired Telegram DM:
|
|
52
52
|
|
|
@@ -120,7 +120,7 @@ Bot/session identity always persists under `profiles.<name>`. The ordinary setup
|
|
|
120
120
|
|
|
121
121
|
The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
|
|
122
122
|
|
|
123
|
-
Threaded Mode Settings exposes **Thread display** as Letters (default) or Directories. `profiles.<name>.threadDisplayMode` is profile-scoped; absent
|
|
123
|
+
Threaded Mode Settings exposes **Thread display** as Letters (default), Names, or Directories. `profiles.<name>.threadDisplayMode` is profile-scoped; absent and invalid values resolve to `letters`, and Names projects the generated dictionary name for the slot. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1` and its exact registration generation. Letters and Directories require compatible connected followers and recheck compatibility before live publication; Names remains usable with legacy peers because it is their generated-name behavior. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
|
|
124
124
|
|
|
125
125
|
Hidden/default semantics are represented by absence:
|
|
126
126
|
|
|
@@ -232,7 +232,7 @@ Rules:
|
|
|
232
232
|
- Explain what the setting does and what the options mean only as much as needed.
|
|
233
233
|
- Order setting value descriptions exactly like the chooser: rows top-to-bottom and values in a shared row left-to-right. Keep `(default)` on the actual default wherever it falls; default status never changes order.
|
|
234
234
|
- Keep descriptions short and clear.
|
|
235
|
-
- Automatic Thread display uses the same setting card: current value in `<code>`, then descriptions ordered `letters`, `directories`, with `(default)` only on `letters`. Its vertical chooser marks only the current option. A manual `/name Name` sets the current Thread display name and supersedes
|
|
235
|
+
- Automatic Thread display uses the same setting card: current value in `<code>`, then descriptions ordered `letters`, `names`, `directories`, with `(default)` only on `letters`. Its vertical chooser marks only the current option. A manual `/name Name` sets the current Thread display name and supersedes any automatic projection until reset; switching automatic mode preserves the slot and override.
|
|
236
236
|
|
|
237
237
|
Examples:
|
|
238
238
|
|
|
@@ -141,9 +141,11 @@ export type TelegramThreadDisplayMode = "letters" | "names" | "directories";
|
|
|
141
141
|
export function resolveTelegramThreadDisplayMode(
|
|
142
142
|
config: Pick<TelegramConfig, "threadDisplayMode">,
|
|
143
143
|
): TelegramThreadDisplayMode {
|
|
144
|
-
return config.threadDisplayMode === "
|
|
145
|
-
? "
|
|
146
|
-
: "
|
|
144
|
+
return config.threadDisplayMode === "names"
|
|
145
|
+
? "names"
|
|
146
|
+
: config.threadDisplayMode === "directories"
|
|
147
|
+
? "directories"
|
|
148
|
+
: "letters";
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
export async function setTelegramThreadDisplayMode(
|
|
@@ -151,7 +153,7 @@ export async function setTelegramThreadDisplayMode(
|
|
|
151
153
|
mode: TelegramThreadDisplayMode,
|
|
152
154
|
isCurrent: () => boolean,
|
|
153
155
|
): Promise<void> {
|
|
154
|
-
if (!["letters", "directories"].includes(mode)) {
|
|
156
|
+
if (!["letters", "names", "directories"].includes(mode)) {
|
|
155
157
|
throw new Error("Invalid Telegram Thread display mode.");
|
|
156
158
|
}
|
|
157
159
|
const profile = store.getActiveProfileName();
|
|
@@ -182,6 +182,7 @@ export function buildThreadDisplaySettingsText(mode: TelegramThreadDisplayMode):
|
|
|
182
182
|
"Choose how this bot profile labels Telegram tabs and Pi terminal status. Each slot is unique across this bot profile.",
|
|
183
183
|
"",
|
|
184
184
|
"<code>-</code> <code>letters</code> (default): show the unique slot, such as <b><i>A</i></b> or <b><i>B</i></b>.",
|
|
185
|
+
"<code>-</code> <code>names</code>: show the generated dictionary name for the slot, such as <b><i>Anchor</i></b> or <b><i>Briar</i></b>.",
|
|
185
186
|
"<code>-</code> <code>directories</code>: show the directory, such as <b><i>extensions</i></b>; shared Workspaces keep slot suffixes, such as <b><i>extensions_a</i></b> and <b><i>extensions_c</i></b>.",
|
|
186
187
|
"A manual <code>/name Name</code> overrides this Thread display name until reset.",
|
|
187
188
|
].join("\n");
|
|
@@ -414,7 +415,7 @@ export async function openTelegramSettingsMenu<
|
|
|
414
415
|
export function buildThreadDisplaySettingsReplyMarkup(mode: TelegramThreadDisplayMode): TelegramSettingsMenuReplyMarkup {
|
|
415
416
|
return { inline_keyboard: [
|
|
416
417
|
[{ text: "⬆️ Back", callback_data: "settings:list" }],
|
|
417
|
-
...(["letters", "directories"] as const).map((value) => [{
|
|
418
|
+
...(["letters", "names", "directories"] as const).map((value) => [{
|
|
418
419
|
text: `${mode === value ? "🟢 " : ""}${value}`,
|
|
419
420
|
callback_data: `settings:set:thread-display:${value}`,
|
|
420
421
|
}]),
|
|
@@ -15,6 +15,9 @@ import { shouldSuppressPreviewForVoice } from "./voice.ts";
|
|
|
15
15
|
|
|
16
16
|
const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
|
|
17
17
|
const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
|
|
18
|
+
// Native draft cadence: at most one frame per interval, and a fresh preview
|
|
19
|
+
// segment holds its first frame for one full interval so the opening frame is
|
|
20
|
+
// an accumulated passage rather than a single streamed word.
|
|
18
21
|
const TELEGRAM_DRAFT_INTERVAL_MS = 2_000;
|
|
19
22
|
|
|
20
23
|
export type TelegramDraftSupport = "unknown" | "supported";
|
|
@@ -90,6 +93,7 @@ export interface TelegramAssistantMessagePreviewUpdateDeps<TMessage> {
|
|
|
90
93
|
createPreviewState: () => TelegramPreviewRuntimeState;
|
|
91
94
|
canSend?: () => boolean;
|
|
92
95
|
getMessageText: (message: TMessage) => string;
|
|
96
|
+
minDraftIntervalMs?: number;
|
|
93
97
|
schedulePreviewFlush: (
|
|
94
98
|
chatId: number,
|
|
95
99
|
options?: { target?: TelegramTarget },
|
|
@@ -305,6 +309,7 @@ export function createTelegramAssistantPreviewRuntime<
|
|
|
305
309
|
createPreviewState: controller.createState,
|
|
306
310
|
canSend: deps.canSend,
|
|
307
311
|
getMessageText: deps.getMessageText,
|
|
312
|
+
minDraftIntervalMs: TELEGRAM_DRAFT_INTERVAL_MS,
|
|
308
313
|
schedulePreviewFlush: controller.scheduleFlush,
|
|
309
314
|
}),
|
|
310
315
|
};
|
|
@@ -466,9 +471,24 @@ export async function handleTelegramAssistantMessagePreviewUpdate<TMessage>(
|
|
|
466
471
|
deps.setState(state);
|
|
467
472
|
}
|
|
468
473
|
if (state.sealed) return;
|
|
474
|
+
const hadVisibleText = state.pendingText.length > 0;
|
|
469
475
|
state.pendingText = stripTelegramCommentMarkupForPreview(
|
|
470
476
|
deps.getMessageText(message),
|
|
471
477
|
);
|
|
478
|
+
// The first visible text of a preview segment opens an initial accumulation
|
|
479
|
+
// window, so the segment's first frame cannot ship as a single word even
|
|
480
|
+
// when the previous cadence boundary has already passed (fresh turn, slow
|
|
481
|
+
// first token, or message rollover after tool work). Later deltas keep the
|
|
482
|
+
// trailing deadline instead of sliding it on every update.
|
|
483
|
+
const interval = deps.minDraftIntervalMs ?? 0;
|
|
484
|
+
if (
|
|
485
|
+
interval > 0 &&
|
|
486
|
+
!hadVisibleText &&
|
|
487
|
+
!state.lastSentText &&
|
|
488
|
+
state.pendingText.length > 0
|
|
489
|
+
) {
|
|
490
|
+
state.nextDraftAt = Math.max(state.nextDraftAt ?? 0, Date.now() + interval);
|
|
491
|
+
}
|
|
472
492
|
deps.schedulePreviewFlush(turn.chatId, { target: turn.target });
|
|
473
493
|
}
|
|
474
494
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -44,8 +44,8 @@
|
|
|
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.7.5",
|
|
47
|
-
"@llblab/pi-state-flow": "0.
|
|
48
|
-
"@llblab/pi-telegram": "0.45.
|
|
47
|
+
"@llblab/pi-state-flow": "0.8.1",
|
|
48
|
+
"@llblab/pi-telegram": "0.45.4",
|
|
49
49
|
"@llblab/skills": "1.15.0"
|
|
50
50
|
},
|
|
51
51
|
"bundledDependencies": [
|