@llblab/pi-kit 0.10.8 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +13 -10
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +15 -1
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +13 -0
- package/node_modules/@llblab/pi-state-flow/README.md +52 -261
- package/node_modules/@llblab/pi-state-flow/docs/README.md +5 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +47 -26
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +97 -0
- package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +47 -0
- package/node_modules/@llblab/pi-state-flow/docs/performance.md +459 -0
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +35 -3
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +134 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +19 -3
- package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +74 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +12 -12
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -2
- package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +21 -5
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +146 -37
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +142 -42
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +80 -27
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +109 -7
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +7 -12
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +2 -1
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -3
- package/node_modules/@llblab/pi-state-flow/package.json +5 -4
- package/package.json +2 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Usage and recovery
|
|
2
|
+
|
|
3
|
+
For the concept and installation, start with the [README](../README.md). This guide covers operating State Flow; the [architecture](architecture.md) owns its internal contracts.
|
|
4
|
+
|
|
5
|
+
## Session behavior
|
|
6
|
+
|
|
7
|
+
`/state-flow-start` initializes any missing storage and enables the current Pi branch. No remote is required. Starting mid-conversation retains Pi's active context for one complete bootstrap run, during which the agent must compile future-relevant information into state.
|
|
8
|
+
|
|
9
|
+
- **New session:** Ordinary Pi unless `autoStart` is enabled. An enabled new session has its own empty session layer and inherits global/CWD state, never another session's private continuation.
|
|
10
|
+
- **Resume:** Restores the selected session's stored enablement, state, and lineage. Agent-level `autoStart` does not override a resumed branch.
|
|
11
|
+
- **Tree navigation:** Restores the selected checkpoint and recorded state revision without checking out or resetting the shared store.
|
|
12
|
+
- **Abort inference:** Stops generation while already accepted patches remain durable for continued work and corrected direction in the same session. It does not roll back memory or require immediate remote replication.
|
|
13
|
+
- **Stop:** Disables semantic tools and updates immediately on the selected branch; ordinary prompt composition resumes with the next user run. It preserves state and does not create a semantic transition or change automatic-start policy for future new sessions.
|
|
14
|
+
- **Continue after Stop:** The same physical session retains a frozen state handoff, any interrupted current request and tool trajectory (including late results), and post-stop conversation. Completed earlier conversation stays excluded, while other extensions' custom context survives. Reload/resume/tree preserve this projection; new/forked physical sessions do not inherit it. Active restart uses it for one bootstrap run.
|
|
15
|
+
- **Completed-history compaction:** After a sufficiently large accepted run settles without queued input, State Flow asks Pi for a native compaction boundary. No extra model summary is requested; Pi keeps the final accepted answer in active history and retains the complete append-only JSONL/tree. On resume, native `buildContextEntries()` and TUI rendering omit the older completed prefix. Pi may decline small histories. Foreign custom context, bootstrap/fallback/abort/error, Stop and pending input prevent State Flow-owned shortening; ordinary manual/threshold/overflow compaction remains native and may preserve unfinished work not yet patched into memory.
|
|
16
|
+
|
|
17
|
+
State Flow does not undo tool effects. After interruption or returning to an older branch, check the relevant workspace or external system before repeating consequential operations. Restored memory is not restored reality.
|
|
18
|
+
|
|
19
|
+
### Fork support and limits
|
|
20
|
+
|
|
21
|
+
Native fork replacement copies the source session checkpoint, retained patch tail and matching provenance into the new session's own storage. Global/CWD streams and provenance stay current and unchanged. An earlier fork selection copies that point's private state, not the parent's later private work. Parent data/history remain intact; selected enablement is retained, so a stopped source does not become enabled automatically.
|
|
22
|
+
|
|
23
|
+
The child starts at step zero and a new temporal origin. Its copied tail is preserved, but pre-origin records are not seven past aligned `state[n]` boundaries. The child's own transitions build its hot window; owned checkpoints support normal reload/resume. Parent Stop projection is not inherited, including after child reload.
|
|
24
|
+
|
|
25
|
+
Initial copying requires a native fork start event, a regular canonical direct-parent session file, matching CWD/identity, a readable temporal source and an unused child namespace. Missing/unsafe evidence or CAS conflicts leave the copy unavailable rather than importing unrelated or newer private state. Explicit Start can retry an unaccepted copy in the same loaded fork after the cause is corrected.
|
|
26
|
+
|
|
27
|
+
Selecting a copied parent checkpoint through the child's `/tree` does not make it child-owned: State Flow stays disabled without resetting existing child data. Select a child-owned checkpoint or resume the original session. Cold recovery before the first child checkpoint, startup paths lacking the fork event, in-memory parent locators and cross-CWD imports remain outside this slice. File-only copying requires an exact still-available source cohort. See the [contract](fork-contract.md) and [SDK evidence](compatibility.md#native-replacement-witnesses); do not rewrite UUIDs or delete pointers to force recovery.
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
Optional `state-flow.json` beneath Pi's agent directory, normally `~/.pi/agent/state-flow.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"directory": "~/.pi/agent/state-flow",
|
|
36
|
+
"autoStart": false,
|
|
37
|
+
"logging": false,
|
|
38
|
+
"remotePublication": "turn-end"
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `directory`: State store. The default is `state-flow/` beneath the agent directory. Absolute paths, `~`/`~/`, and relative paths are accepted; relative paths resolve from the configuration directory, not project CWD.
|
|
43
|
+
- `autoStart`: Defaults to `false`. When `true`, genuinely new sessions use the same initialization as explicit Start, including fresh CWDs.
|
|
44
|
+
- `logging`: Defaults to `false`. When enabled, records rejected patches and unresolved terminal/fallback diagnostics locally at `tmp/state-flow/logs.jsonl` beneath the agent directory.
|
|
45
|
+
- `remotePublication`: New-runtime policy: `turn-end` queues the newest accepted commit for asynchronous push; `off` keeps commits local; `transition` retains synchronous compatibility behavior. Existing branches keep their persisted policy.
|
|
46
|
+
|
|
47
|
+
Settings are read once at extension load. After editing, use `/reload` or restart Pi. A missing file uses defaults without creating a configuration file; malformed JSON, unknown keys, or invalid values fail loading rather than silently selecting another store.
|
|
48
|
+
|
|
49
|
+
`PI_CODING_AGENT_DIR` changes the agent-directory default. Configuration remains in that directory even when `directory` selects another state store. This override does not move data or redirect Knowledge discovery, whose default remains `knowledge/` beneath the agent directory. SDK overrides are documented under [embedding](architecture.md#embedding).
|
|
50
|
+
|
|
51
|
+
### Diagnostic logging and privacy
|
|
52
|
+
|
|
53
|
+
Rejected-call records may contain exact attempted arguments and useful draft text, plus the error, tool/call identity, and resolution state. Successful patches are not logged; reasoning bodies are excluded. Logs are not semantic state, scope metadata, Pi checkpoints, or publication input. If the log path overlaps a custom state repository, capture fails closed instead of committing it. A logging failure changes no accepted state and produces at most one local warning.
|
|
54
|
+
|
|
55
|
+
Logs remain local unless you move them; rotation/deletion is operator-owned. Treat them and state files as private. Removing a secret from current state does not erase older offsets, Git history, native sessions, or remote copies.
|
|
56
|
+
|
|
57
|
+
## Status and controls
|
|
58
|
+
|
|
59
|
+
`/state-flow-status` separates runtime configuration/metadata from semantic state. It reports:
|
|
60
|
+
|
|
61
|
+
- Selected CWD/session keys, step, temporal head, durable revision, and available hot history.
|
|
62
|
+
- Per-scope retained patch tails and artifact counts, plus one JSON representation of effective global → CWD → session memory. Individual scope JSON is available through `read_state`, not duplicated in status.
|
|
63
|
+
- Discovered Markdown invalidations or unavailable freshness evidence.
|
|
64
|
+
- Remote policy, queued/unconfirmed publication, and relevant errors.
|
|
65
|
+
- Memory-bearing scopes and external-promotion records, including invalid or incompletely evidenced acceptance.
|
|
66
|
+
|
|
67
|
+
Tail counts are not history depth: inherited records may predate the active origin. Failed inspection reports unavailable evidence, not invented empty state or a clean freshness count. Status hashes source bytes as needed for freshness diagnostics; it does not dump Markdown bodies.
|
|
68
|
+
|
|
69
|
+
The terminal indicator is `state-flow #N`. When `pi-telegram` is available, one main-menu section carries the live State Flow status and opens Start/Stop controls. Start requested during a run waits for settlement; Stop currently applies immediately. The adapter is optional and the Pi commands remain available without it. Remaining hardening work is tracked in [BACKLOG.md](../BACKLOG.md).
|
|
70
|
+
|
|
71
|
+
## Storage and recovery
|
|
72
|
+
|
|
73
|
+
Use a dedicated directory. State storage and Knowledge Markdown have separate responsibilities:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
<agentDir>/state-flow/ accepted state and runtime metadata
|
|
77
|
+
<agentDir>/knowledge/ optional Markdown sources for compilation
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Each scope materializes an anchored `checkpoint.json` plus `patches.jsonl`. Scope `meta.json` holds runtime-owned artifact evidence; the session also has `config.json` and temporal/runtime metadata. CWD/session directories mirror Pi's native naming while validating canonical identities separately. See the [storage contract](architecture.md#storage-and-identity) for the exact layout.
|
|
81
|
+
|
|
82
|
+
### With Git
|
|
83
|
+
|
|
84
|
+
Start initializes an exact-root repository when needed, preserving existing contents. Git must have a configured commit identity. A containing ancestor repository is not a substitute. Manual-mode startup/status/restore do not initialize Git; explicit Start and automatic activation of genuinely new sessions may do so.
|
|
85
|
+
|
|
86
|
+
Every effective semantic transition, including a changed answer, receives an immediate local commit. Each commit includes the complete non-ignored worktree delta before overlaying the prepared State Flow outputs, and synchronizes the visible index to the accepted tree. This is why the store must not be an unrelated working repository. Untracked ignored files remain untouched; State Flow-owned active files stay under its publication/CAS contract.
|
|
87
|
+
|
|
88
|
+
A remote is optional and operator-owned. State Flow creates no account, hosted repository, credentials, or remote configuration. Remote failure does not undo an accepted local commit or require regenerating an answer; retry targets that existing commit. With no remote, the store is intentionally local-only.
|
|
89
|
+
|
|
90
|
+
An asynchronous push has a 15-second budget. Pi shutdown, reload and native session replacement cancel owned pushes and wait up to two seconds for cleanup; it leaves unconfirmed targets queued, without advancing semantic history. If child exit cannot be confirmed, a warning reports the retained lease. Do not delete it to force a retry while its owner is still live. A later activation can retry after cleanup; timeouts do not spin an immediate retry loop for the same target. The compatibility `transition` mode keeps its existing synchronous behavior. SDK embedders must deliver the [shutdown event](architecture.md#embedding), not just discard the session object.
|
|
91
|
+
|
|
92
|
+
Inspect a Git-backed store without modifying it:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git -C ~/.pi/agent/state-flow status --short
|
|
96
|
+
git -C ~/.pi/agent/state-flow log --oneline -10
|
|
97
|
+
git -C ~/.pi/agent/state-flow show <selected-revision>:checkpoint.json
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Use the revision reported for the selected Pi branch. Shared live files may belong to a newer branch, so `HEAD` is not automatically that branch's memory.
|
|
101
|
+
|
|
102
|
+
### Without Git
|
|
103
|
+
|
|
104
|
+
Only an absent Git executable selects file-only mode; Git corruption, permission errors, and command failures remain errors. Files retain current materialization and its proven hot history. Their `file:<hash>` checkpoint reference identifies one exact current cohort, not an arbitrary historical snapshot.
|
|
105
|
+
|
|
106
|
+
Restart can restore that reference while its complete cohort remains available. An older branch or a crash between file publication and Pi checkpoint append can leave a reference unavailable even though newer files exist. State Flow must not pass those newer files off as the selected past. Preserve the store and diagnose the reference rather than resetting it.
|
|
107
|
+
|
|
108
|
+
If Git becomes available later, explicit Start can adopt the proven current file cohort. It preserves state, step, and hot lineage; Git cold history begins at adoption rather than inventing earlier commits.
|
|
109
|
+
|
|
110
|
+
### Moving or migrating a store
|
|
111
|
+
|
|
112
|
+
Changing `directory` selects a location; it does not relocate existing state or history. Git-backed Pi checkpoints require their original commit objects. Copying only current checkpoint/tail files cannot preserve old branch recovery. Keep the original store intact until an explicit history-preserving relocation is complete, or use a genuinely new Pi session for an independent store.
|
|
113
|
+
|
|
114
|
+
In-store predecessor-format migration is different: legacy current `state.json` snapshots become initial anchored checkpoints with empty tails. Current snapshots, not explanatory journals, are the recovery basis. Successful migration removes obsolete ownership and preserves only proven history. Let the runtime perform supported migration; manually renaming files is not a valid conversion.
|
|
115
|
+
|
|
116
|
+
Pre-0.4 hashed-path layouts remain readable at their historical revisions and can be adopted into native paths at current HEAD. A missing revision or failed restoration is not permission to import another session or reset existing files.
|
|
117
|
+
|
|
118
|
+
### Conflicts and interrupted publication
|
|
119
|
+
|
|
120
|
+
Cooperating writers use publication locks, exact prepared bytes, and compare-and-swap checks. These are not kernel-atomic multi-file transactions against nonparticipating writers. A busy publication lock fails before writes and is not silently stolen; reconcile the active or interrupted owner before retrying. Do not delete locks or state directories merely because an operation is slow.
|
|
121
|
+
|
|
122
|
+
Fatal process termination during local Git publication can leave attempted worktree files, a private index and publication locks. HEAD may or may not have advanced; a new commit need not have returned success or reached a Pi checkpoint. The [fatal-writer witnesses](temporal-acceptance.md#fatal-writer-interruption) preserve earlier accepted revisions and read-only inspection while new writes and live restore installation remain blocked. A dead PID alone does not establish which effects completed or whether Git children stopped. Before any repair, quiesce all store writers and preserve the complete store, Git/index data and selected Pi references; reconcile the exact interrupted attempt before clearing ownership. No automatic crash repair or power-loss durability is promised.
|
|
123
|
+
|
|
124
|
+
Worker lease recovery is separate from stealing a publication lock: it accepts only a validated regular-file record whose PID is proven gone, then rechecks ownership under the queue writer lock. Malformed and symlink lease records stay untouched. Upgrade/reload all publishing instances together; older still-running code can bypass the updated reclamation gate.
|
|
125
|
+
|
|
126
|
+
An untouched shared scope may be adopted from newer proven live state at a fresh origin. A patch that actually changes an advanced shared scope fails with a named conflict instead of silently overwriting it. Rollback restores only bytes still matching that publisher's output and preserves detected external changes. See [performance evidence](performance.md) for measured contention and the [acceptance map](temporal-acceptance.md) for the tested boundaries; the [backlog](../BACKLOG.md) owns remaining release gates.
|
|
127
|
+
|
|
128
|
+
## Memory and source acquisition
|
|
129
|
+
|
|
130
|
+
The agent should use sufficient materialized knowledge before rereading files. Read for a concrete gap, exact-source/edit operation, evidenced invalidation, contradiction/failure, explicit request, or bounded maintenance—not simply because a new session began.
|
|
131
|
+
|
|
132
|
+
Knowledge discovery finds regular lowercase `*.md` beneath its configured root, hashes opaque bytes, and skips symlinks. Only confirmed missing Markdown paths within an available root are pruned; external/non-Markdown artifacts and state under a missing whole root are preserved. An unavailable root makes freshness unknown in status. Successful reads of stale ordinary candidates require same-path global compilation; Skill reads require CWD compilation. The runtime owns provenance: model patches cannot write or delete individual freshness fields, including legacy spellings. Existing legacy entries remain readable and semantically editable. Missing freshness evidence means unknown-but-usable, not proof that a source was acquired.
|
|
133
|
+
|
|
134
|
+
The optional `state-flow-memory` Skill handles explicit bounded curation and external promotion. It is not a background maintenance loop. Promotion must verify the destination before removing the only accepted source copy. Artifact/compiler details and model-tool contracts belong in the [architecture](architecture.md#artifact-routing).
|
|
@@ -76,9 +76,11 @@ export interface ArtifactInvalidationPlan {
|
|
|
76
76
|
export interface ArtifactInvalidationOptions {
|
|
77
77
|
/** Refresh every source, or only paths in the supplied set. */
|
|
78
78
|
explicitRefresh?: boolean | ReadonlySet<string>;
|
|
79
|
+
/** Explicit absence evidence supplied by the source owner, not inferred from a partial candidate set. */
|
|
80
|
+
removed?: readonly string[];
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
/**
|
|
83
|
+
/** Trusted compilation input; embedded timestamps are accepted here, but never in model patches. */
|
|
82
84
|
export type ArtifactCompilerOutput = JsonObject & {
|
|
83
85
|
description: string;
|
|
84
86
|
compiled_at?: string;
|
|
@@ -327,8 +329,13 @@ export function planArtifactInvalidation(
|
|
|
327
329
|
if (freshness.kind === "fresh") fresh.push(identity);
|
|
328
330
|
else requiresCompilation.push({ ...identity, reason: freshness.reason });
|
|
329
331
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
+
if (options.removed !== undefined && !Array.isArray(options.removed)) throw new Error("Removed artifact paths must be an array");
|
|
333
|
+
const removed = [...new Set(options.removed ?? [])];
|
|
334
|
+
for (const path of removed) {
|
|
335
|
+
if (typeof path !== "string" || path.trim().length === 0) throw new Error("Removed artifact paths must be non-empty");
|
|
336
|
+
if (seen.has(path)) throw new Error(`Artifact source cannot be both present and removed: ${path}`);
|
|
337
|
+
}
|
|
338
|
+
return { fresh, requiresCompilation, removed: removed.filter((path) => Object.hasOwn(registry, path)).sort() };
|
|
332
339
|
}
|
|
333
340
|
|
|
334
341
|
/** Split one compiler output into model-visible semantics and runtime-owned provenance. */
|
|
@@ -412,6 +419,15 @@ export function updateArtifactRegistry(
|
|
|
412
419
|
/** Runtime-owned artifact fields that never belong in ordinary model context. */
|
|
413
420
|
const RUNTIME_ARTIFACT_FIELDS = [...MODEL_FORBIDDEN_PROVENANCE_FIELDS, "compiled_at"] as const;
|
|
414
421
|
|
|
422
|
+
/** Validate authored fields only: legacy retained evidence stays readable but cannot be model-edited. */
|
|
423
|
+
export function validateModelArtifactPatch(patch: JsonObject): void {
|
|
424
|
+
for (const [path, entry] of Object.entries(patch)) {
|
|
425
|
+
if (!isObject(entry)) continue; // Whole-artifact deletion and materialized shape belong to the transition owner.
|
|
426
|
+
const field = RUNTIME_ARTIFACT_FIELDS.find((field) => Object.hasOwn(entry, field));
|
|
427
|
+
if (field !== undefined) throw new Error(`Artifact patch at ${path} cannot set runtime-owned provenance field ${field}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
415
431
|
/** Strip retained runtime bookkeeping from one model-visible artifact entry. */
|
|
416
432
|
export function projectArtifactForModel(entry: unknown): unknown {
|
|
417
433
|
if (!isObject(entry)) return entry;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CompactionResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const STATE_FLOW_COMPACTION_SUMMARY = "State Flow accepted the completed work before this boundary. Current memory is restored from its durable revision and projected separately; use the retained native entries for subsequent work.";
|
|
4
|
+
export const STATE_FLOW_COMPACTION_MIN_ACTIVE_BYTES = 80_000;
|
|
5
|
+
|
|
6
|
+
export interface StateFlowCompactionDetails {
|
|
7
|
+
version: 1;
|
|
8
|
+
owner: "state-flow";
|
|
9
|
+
revision: string;
|
|
10
|
+
step: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StateFlowCompactionPlan {
|
|
14
|
+
leafId: string;
|
|
15
|
+
firstKeptEntryId: string;
|
|
16
|
+
details: StateFlowCompactionDetails;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ActiveEntry = {
|
|
20
|
+
id?: unknown;
|
|
21
|
+
type?: unknown;
|
|
22
|
+
customType?: unknown;
|
|
23
|
+
message?: { role?: unknown; stopReason?: unknown };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function stateFlowEntry(entry: ActiveEntry): boolean {
|
|
27
|
+
return entry.type === "custom"
|
|
28
|
+
&& typeof entry.customType === "string"
|
|
29
|
+
&& entry.customType.startsWith("state-flow-");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Select one completed native boundary without hiding foreign extension context. */
|
|
33
|
+
export function planStateFlowCompaction(
|
|
34
|
+
entries: readonly ActiveEntry[],
|
|
35
|
+
revision: string,
|
|
36
|
+
step: number,
|
|
37
|
+
): StateFlowCompactionPlan | undefined {
|
|
38
|
+
if (!/^[0-9a-f]{40,64}$/.test(revision) && !/^file:[0-9a-f]{64}$/.test(revision)) return undefined;
|
|
39
|
+
if (!Number.isSafeInteger(step) || step < 0 || entries.length === 0) return undefined;
|
|
40
|
+
if (Buffer.byteLength(JSON.stringify(entries), "utf8") < STATE_FLOW_COMPACTION_MIN_ACTIVE_BYTES) return undefined;
|
|
41
|
+
const keep = entries.findLastIndex((entry) => entry.type === "message"
|
|
42
|
+
&& entry.message?.role === "assistant"
|
|
43
|
+
&& entry.message.stopReason !== "aborted"
|
|
44
|
+
&& entry.message.stopReason !== "error"
|
|
45
|
+
&& entry.message.stopReason !== "length");
|
|
46
|
+
if (keep < 0) return undefined;
|
|
47
|
+
if (entries.slice(0, keep).some((entry) => entry.type === "custom" && !stateFlowEntry(entry))) return undefined;
|
|
48
|
+
const firstKeptEntryId = entries[keep]?.id;
|
|
49
|
+
const leafId = entries.at(-1)?.id;
|
|
50
|
+
if (typeof firstKeptEntryId !== "string" || typeof leafId !== "string") return undefined;
|
|
51
|
+
return { leafId, firstKeptEntryId, details: { version: 1, owner: "state-flow", revision, step } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Customize only the extension-owned manual request and only while its planned leaf remains selected. */
|
|
55
|
+
export function stateFlowCompactionResult(
|
|
56
|
+
plan: StateFlowCompactionPlan,
|
|
57
|
+
marker: string,
|
|
58
|
+
event: {
|
|
59
|
+
reason: "manual" | "threshold" | "overflow";
|
|
60
|
+
customInstructions?: string;
|
|
61
|
+
branchEntries: readonly ActiveEntry[];
|
|
62
|
+
preparation: { tokensBefore: number };
|
|
63
|
+
signal: AbortSignal;
|
|
64
|
+
},
|
|
65
|
+
): CompactionResult<StateFlowCompactionDetails> | { cancel: true } | undefined {
|
|
66
|
+
if (event.reason !== "manual" || event.customInstructions !== marker) return undefined;
|
|
67
|
+
if (event.signal.aborted || event.branchEntries.at(-1)?.id !== plan.leafId) return { cancel: true };
|
|
68
|
+
return {
|
|
69
|
+
summary: STATE_FLOW_COMPACTION_SUMMARY,
|
|
70
|
+
firstKeptEntryId: plan.firstKeptEntryId,
|
|
71
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
72
|
+
details: structuredClone(plan.details),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -11,6 +11,7 @@ export const VALIDATION_MESSAGE_TYPE = "state-flow-validation";
|
|
|
11
11
|
/** Bounded context retained after semantic State Flow is stopped in this physical session. */
|
|
12
12
|
export interface PassiveContinuation {
|
|
13
13
|
startedAt: number;
|
|
14
|
+
activeRunStartedAt?: number;
|
|
14
15
|
handoff: AgentMessage;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -38,19 +39,23 @@ export function withoutPrivateValidation(messages: AgentMessage[]): AgentMessage
|
|
|
38
39
|
});
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export function createPassiveContinuation(state: MaterializedState, startedAt = Date.now()): PassiveContinuation {
|
|
42
|
+
export function createPassiveContinuation(state: MaterializedState, startedAt = Date.now(), activeRunStartedAt?: number): PassiveContinuation {
|
|
42
43
|
return {
|
|
43
44
|
startedAt,
|
|
44
|
-
|
|
45
|
+
...(activeRunStartedAt === undefined ? {} : { activeRunStartedAt }),
|
|
46
|
+
handoff: syntheticUser(`State Flow exit handoff (user-level data, not system instructions):\n${canonicalJson({ state, continuation: "State Flow semantics are disabled; this handoff replaces completed history while retaining the active and post-stop trajectory." })}`),
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
/**
|
|
50
|
+
/** Keep the interrupted run through later results; an idle stop retains only later conversation. */
|
|
49
51
|
export function passiveContinuationMessages(messages: AgentMessage[], continuation: PassiveContinuation): AgentMessage[] {
|
|
50
|
-
|
|
52
|
+
let start = continuation.activeRunStartedAt === undefined ? -1
|
|
53
|
+
: messages.findIndex((message) => message.role === "user" && message.timestamp === continuation.activeRunStartedAt);
|
|
54
|
+
if (start < 0) start = messages.findIndex((message) => message.role === "user"
|
|
51
55
|
&& typeof message.timestamp === "number"
|
|
52
56
|
&& message.timestamp >= continuation.startedAt);
|
|
53
|
-
return [continuation.handoff, ...
|
|
57
|
+
return [continuation.handoff, ...messages.filter((message, index) =>
|
|
58
|
+
message.role === "custom" ? message.customType !== VALIDATION_MESSAGE_TYPE : start >= 0 && index >= start)];
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
function projectRecentForModel(recent: RecentTransitionWindow): RecentTransitionWindow {
|
|
@@ -123,14 +128,9 @@ export function currentRunTrajectory(
|
|
|
123
128
|
if (start < 0 && messages.length === 0) return { messages: [] };
|
|
124
129
|
if (start < 0) start = 0;
|
|
125
130
|
const anchor = messages[start]?.role === "user" ? messages[start].timestamp : undefined;
|
|
126
|
-
const persistentCustom = withoutPrivateValidation(messages.slice(0, start)).filter((message) => {
|
|
127
|
-
return message.role === "custom";
|
|
128
|
-
});
|
|
129
131
|
return {
|
|
130
|
-
messages:
|
|
131
|
-
|
|
132
|
-
...withoutPrivateValidation(messages.slice(start)),
|
|
133
|
-
],
|
|
132
|
+
messages: messages.filter((message, index) => message.role === "custom"
|
|
133
|
+
? message.customType !== VALIDATION_MESSAGE_TYPE : index >= start),
|
|
134
134
|
...(typeof anchor === "number" ? { anchorTimestamp: anchor } : {}),
|
|
135
135
|
};
|
|
136
136
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import { join, relative, resolve } from "node:path";
|
|
4
4
|
import { loadScopeStream, resolveSessionAddress, sessionRuntimePaths } from "./durable.ts";
|
|
5
5
|
import { loadTemporalRevision } from "./git.ts";
|
|
@@ -156,8 +156,10 @@ export interface ContinuationCandidateProvenance {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
function readFirstLine(path: string): string {
|
|
159
|
-
|
|
159
|
+
if (realpathSync(path) !== path || !lstatSync(path).isFile()) throw new Error("Native Pi session header requires a regular canonical file");
|
|
160
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
160
161
|
try {
|
|
162
|
+
if (!fstatSync(fd).isFile()) throw new Error("Native Pi session header requires a regular file");
|
|
161
163
|
const bytes: number[] = [];
|
|
162
164
|
const byte = Buffer.allocUnsafe(1);
|
|
163
165
|
while (bytes.length <= MAX_SESSION_HEADER_BYTES) {
|
|
@@ -12,6 +12,7 @@ export interface ArtifactSourceCandidate extends ArtifactSourceIdentity {
|
|
|
12
12
|
export interface GlobalMarkdownDiscoveryResult {
|
|
13
13
|
sources: ArtifactSourceCandidate[];
|
|
14
14
|
removed: string[];
|
|
15
|
+
unavailable?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export function getKnowledgeRoot(agentDir = getAgentDir()): string {
|
|
@@ -96,7 +97,20 @@ export function discoverGlobalMarkdownSources(knowledgeRoot = getKnowledgeRoot()
|
|
|
96
97
|
return sources.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
/**
|
|
100
|
+
/** Prove absence inside the current source root without following excluded symlink components. */
|
|
101
|
+
function missingOwnedMarkdown(root: string, path: string): boolean {
|
|
102
|
+
if (!isAbsolute(path) || path !== resolve(path) || path === root || !isInside(root, path) || !path.endsWith(".md")) return false;
|
|
103
|
+
let candidate = root;
|
|
104
|
+
for (const segment of relative(root, path).split(sep)) {
|
|
105
|
+
candidate = join(candidate, segment);
|
|
106
|
+
const metadata = lstatSync(candidate, { throwIfNoEntry: false });
|
|
107
|
+
if (metadata === undefined) return true;
|
|
108
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) return false;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Source-owner discovery; retained paths let restart/status re-observe removals until publication. */
|
|
100
114
|
export class GlobalMarkdownDiscovery {
|
|
101
115
|
readonly knowledgeRoot: string;
|
|
102
116
|
#knownPaths = new Set<string>();
|
|
@@ -105,11 +119,13 @@ export class GlobalMarkdownDiscovery {
|
|
|
105
119
|
this.knowledgeRoot = resolve(knowledgeRoot);
|
|
106
120
|
}
|
|
107
121
|
|
|
108
|
-
refresh(): GlobalMarkdownDiscoveryResult {
|
|
109
|
-
const
|
|
122
|
+
refresh(retainedPaths: Iterable<string> = this.#knownPaths): GlobalMarkdownDiscoveryResult {
|
|
123
|
+
const root = canonicalExistingPath(this.knowledgeRoot);
|
|
124
|
+
if (root === undefined) return { sources: [], removed: [], unavailable: `Knowledge root is unavailable: ${this.knowledgeRoot}` };
|
|
125
|
+
const sources = discoverGlobalMarkdownSources(root);
|
|
110
126
|
const currentPaths = new Set(sources.map((source) => source.path));
|
|
111
|
-
const removed = [...
|
|
112
|
-
.filter((path) => !currentPaths.has(path))
|
|
127
|
+
const removed = [...new Set(retainedPaths)]
|
|
128
|
+
.filter((path) => !currentPaths.has(path) && missingOwnedMarkdown(root, path))
|
|
113
129
|
.sort();
|
|
114
130
|
this.#knownPaths = currentPaths;
|
|
115
131
|
return { sources, removed };
|