@llblab/pi-kit 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +7 -7
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +8 -9
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +26 -0
- package/node_modules/@llblab/pi-state-flow/README.md +1 -3
- package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +21 -0
- package/node_modules/@llblab/pi-state-flow/dist/index.js +20 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.d.ts +39 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/acquisition.js +78 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +110 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +334 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.d.ts +49 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +67 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +11 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +53 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +23 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +109 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.d.ts +111 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +189 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.d.ts +21 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/discovery.js +125 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +102 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +507 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/episode.d.ts +8 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/episode.js +27 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +22 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +1263 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +72 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +565 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/history.d.ts +22 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +79 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/json.d.ts +12 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +109 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +25 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +24 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.d.ts +36 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/maintenance.js +98 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/memory.d.ts +15 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/memory.js +42 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +13 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +133 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +7 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +59 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +69 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +335 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +27 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +35 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +8 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +27 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.d.ts +36 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/rehydration.js +38 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +142 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +529 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +21 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +44 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +25 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +131 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +88 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +255 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +55 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +31 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +38 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +79 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +46 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +217 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +98 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +231 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +39 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +203 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.d.ts +25 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +204 -0
- package/node_modules/@llblab/pi-state-flow/dist/package.json +79 -0
- package/node_modules/@llblab/pi-state-flow/dist/pi-state-flow/index.js +1 -0
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +138 -0
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +11 -5
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +5 -1
- package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +3 -3
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +6 -4
- package/node_modules/@llblab/pi-state-flow/index.ts +1 -0
- package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +17 -1
- package/node_modules/@llblab/pi-state-flow/lib/config.ts +6 -1
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +88 -96
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +64 -12
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +32 -188
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +84 -48
- package/node_modules/@llblab/pi-state-flow/lib/query.ts +40 -0
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +7 -30
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +48 -50
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +41 -97
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +17 -12
- package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +4 -4
- package/node_modules/@llblab/pi-state-flow/package.json +23 -6
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +3 -1
- package/package.json +4 -4
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { parseArtifactProvenanceRegistry, serializeArtifactProvenanceRegistry, } from "./artifact.js";
|
|
6
|
+
import { canonicalJson, isJsonValue, isObject } from "./json.js";
|
|
7
|
+
import { validateScopeStream, validateTemporalState } from "./temporal.js";
|
|
8
|
+
const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
9
|
+
const STATE_FILE = "state.json";
|
|
10
|
+
const CHECKPOINT_FILE = "checkpoint.json";
|
|
11
|
+
const PATCHES_FILE = "patches.jsonl";
|
|
12
|
+
const META_FILE = "meta.json";
|
|
13
|
+
/** Semantic files contain no runtime envelope; temporal boundaries and CWD ownership live in meta.json. */
|
|
14
|
+
export function serializeScopeStream(stream, scope, cwdIdentity) {
|
|
15
|
+
validateScopeStream(stream, scope);
|
|
16
|
+
if (scope === "cwd" && cwdIdentity === undefined)
|
|
17
|
+
throw new Error("State Flow CWD scope serialization requires its canonical identity");
|
|
18
|
+
if (scope !== "cwd" && cwdIdentity !== undefined)
|
|
19
|
+
throw new Error("Only State Flow CWD scope serialization accepts a CWD identity");
|
|
20
|
+
return {
|
|
21
|
+
checkpoint: `${canonicalJson(stream.checkpoint.state)}\n`,
|
|
22
|
+
patches: stream.patches.map((record) => `${canonicalJson(record.patch)}\n`).join(""),
|
|
23
|
+
temporal: {
|
|
24
|
+
checkpoint: structuredClone(stream.checkpoint.through),
|
|
25
|
+
patches: stream.patches.map((record) => structuredClone(record.transition)),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Distinguish a wholly absent semantic cohort from partial or malformed surviving authority. */
|
|
30
|
+
export function classifyScopeStream(checkpointSource, patchesSource, scope, expectedCwd, metaSource) {
|
|
31
|
+
if (checkpointSource === undefined && patchesSource === undefined)
|
|
32
|
+
return { kind: "absent" };
|
|
33
|
+
if (checkpointSource === undefined || patchesSource === undefined) {
|
|
34
|
+
throw new Error(`State Flow ${scope} scope has an incomplete checkpoint/tail pair`);
|
|
35
|
+
}
|
|
36
|
+
let checkpoint;
|
|
37
|
+
try {
|
|
38
|
+
checkpoint = JSON.parse(checkpointSource);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new Error(`State Flow ${scope} checkpoint contains invalid JSON`);
|
|
42
|
+
}
|
|
43
|
+
let meta;
|
|
44
|
+
if (metaSource !== undefined) {
|
|
45
|
+
try {
|
|
46
|
+
meta = JSON.parse(metaSource);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error(`State Flow ${scope} metadata contains invalid JSON`);
|
|
50
|
+
}
|
|
51
|
+
if (!isObject(meta))
|
|
52
|
+
throw new Error(`Invalid State Flow ${scope} metadata`);
|
|
53
|
+
}
|
|
54
|
+
const patches = [];
|
|
55
|
+
for (const [index, line] of patchesSource.split(/\r?\n/).entries()) {
|
|
56
|
+
if (line.trim().length === 0)
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
patches.push(JSON.parse(line));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`State Flow ${scope} tail contains invalid JSON at line ${index + 1}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const temporal = meta?.temporal;
|
|
66
|
+
if (isObject(temporal) && Object.hasOwn(temporal, "checkpoint") && Array.isArray(temporal.patches)) {
|
|
67
|
+
const owner = meta?.owner;
|
|
68
|
+
if (scope === "cwd" && expectedCwd !== undefined
|
|
69
|
+
&& (!isObject(owner) || Object.keys(owner).join(",") !== "cwd" || owner.cwd !== resolve(expectedCwd))) {
|
|
70
|
+
throw new Error(owner === undefined ? "State Flow CWD scope identity is missing" : "State Flow CWD scope identity mismatch");
|
|
71
|
+
}
|
|
72
|
+
const boundaries = temporal.patches;
|
|
73
|
+
if (boundaries.length !== patches.length)
|
|
74
|
+
throw new Error(`State Flow ${scope} temporal metadata does not match its semantic tail`);
|
|
75
|
+
const stream = {
|
|
76
|
+
checkpoint: { through: temporal.checkpoint, state: checkpoint },
|
|
77
|
+
patches: patches.map((patch, index) => ({ transition: boundaries[index], patch })),
|
|
78
|
+
};
|
|
79
|
+
validateScopeStream(stream, scope);
|
|
80
|
+
return { kind: "present", stream };
|
|
81
|
+
}
|
|
82
|
+
// Complete predecessor envelopes are the only no-meta form that may be unwrapped.
|
|
83
|
+
let legacyCheckpoint = checkpoint;
|
|
84
|
+
if (isObject(checkpoint) && Object.hasOwn(checkpoint, "owner")) {
|
|
85
|
+
const { owner, ...semantic } = checkpoint;
|
|
86
|
+
if (scope !== "cwd" || !isObject(owner) || Object.keys(owner).join(",") !== "cwd" || typeof owner.cwd !== "string") {
|
|
87
|
+
throw new Error("Invalid State Flow CWD scope identity");
|
|
88
|
+
}
|
|
89
|
+
if (expectedCwd !== undefined && owner.cwd !== resolve(expectedCwd))
|
|
90
|
+
throw new Error("State Flow CWD scope identity mismatch");
|
|
91
|
+
legacyCheckpoint = semantic;
|
|
92
|
+
}
|
|
93
|
+
else if (scope === "cwd" && expectedCwd !== undefined) {
|
|
94
|
+
throw new Error("State Flow CWD scope identity is missing");
|
|
95
|
+
}
|
|
96
|
+
const stream = { checkpoint: legacyCheckpoint, patches };
|
|
97
|
+
validateScopeStream(stream, scope);
|
|
98
|
+
return { kind: "present", stream };
|
|
99
|
+
}
|
|
100
|
+
/** Decode the entire bounded replay input before accepting any materialized state. */
|
|
101
|
+
export function parseScopeStream(checkpointSource, patchesSource, scope, expectedCwd, metaSource) {
|
|
102
|
+
const presence = classifyScopeStream(checkpointSource, patchesSource, scope, expectedCwd, metaSource);
|
|
103
|
+
return presence.kind === "present" ? presence.stream : undefined;
|
|
104
|
+
}
|
|
105
|
+
export function temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey = sessionId) {
|
|
106
|
+
const directory = scope === "global" ? resolve(repositoryRoot)
|
|
107
|
+
: scope === "cwd" ? cwdScopePaths(cwd, repositoryRoot).directory
|
|
108
|
+
: scope === "session" ? sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory
|
|
109
|
+
: undefined;
|
|
110
|
+
if (directory === undefined)
|
|
111
|
+
throw new Error("Unknown temporal scope");
|
|
112
|
+
return { directory, checkpoint: join(directory, CHECKPOINT_FILE), patches: join(directory, PATCHES_FILE), meta: join(directory, META_FILE) };
|
|
113
|
+
}
|
|
114
|
+
export function sessionRuntimePaths(cwd, sessionId, repositoryRoot, sessionKey = sessionId) {
|
|
115
|
+
const directory = sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).directory;
|
|
116
|
+
return { config: join(directory, "config.json"), meta: join(directory, META_FILE) };
|
|
117
|
+
}
|
|
118
|
+
/** Unsupported state.json presence never becomes an anchored checkpoint. */
|
|
119
|
+
export function loadScopeStream(cwd, sessionId, scope, repositoryRoot, sessionKey = sessionId) {
|
|
120
|
+
const paths = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
|
|
121
|
+
if (readRegularBytes(join(paths.directory, STATE_FILE), repositoryRoot) !== undefined) {
|
|
122
|
+
throw new Error(`Legacy State Flow storage requires explicit migration: ${paths.directory}`);
|
|
123
|
+
}
|
|
124
|
+
return parseScopeStream(readRegularFile(paths.checkpoint, repositoryRoot), readRegularFile(paths.patches, repositoryRoot), scope, scope === "cwd" ? cwd : undefined, readRegularFile(paths.meta, repositoryRoot));
|
|
125
|
+
}
|
|
126
|
+
/** Include legacy names in the CAS basis solely to prevent format races during cutover. */
|
|
127
|
+
export function captureTemporalFileBases(cwd, sessionId, repositoryRoot, sessionKey = sessionId) {
|
|
128
|
+
const paths = ["global", "cwd", "session"].flatMap((scope) => {
|
|
129
|
+
const pair = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
|
|
130
|
+
const runtime = scope === "session" ? sessionRuntimePaths(cwd, sessionId, repositoryRoot, sessionKey) : undefined;
|
|
131
|
+
return [pair.checkpoint, pair.patches, join(pair.directory, STATE_FILE), ...(runtime === undefined ? [pair.meta] : [runtime.config, runtime.meta])];
|
|
132
|
+
});
|
|
133
|
+
return captureOwnedFileBases(paths, repositoryRoot);
|
|
134
|
+
}
|
|
135
|
+
/** Select exact serialized scope updates from one validated active temporal cohort. */
|
|
136
|
+
export function temporalStateFileUpdates(cwd, sessionId, view, scopes, repositoryRoot, sessionKey = sessionId) {
|
|
137
|
+
validateTemporalState(view);
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
return scopes.flatMap((scope) => {
|
|
140
|
+
if (seen.has(scope))
|
|
141
|
+
throw new Error(`Duplicate temporal scope update: ${scope}`);
|
|
142
|
+
seen.add(scope);
|
|
143
|
+
const paths = temporalScopePaths(cwd, sessionId, scope, repositoryRoot, sessionKey);
|
|
144
|
+
if (readRegularBytes(join(paths.directory, STATE_FILE), repositoryRoot) !== undefined) {
|
|
145
|
+
throw new Error(`Legacy State Flow storage requires explicit migration: ${paths.directory}`);
|
|
146
|
+
}
|
|
147
|
+
const sources = serializeScopeStream(view.scopes[scope], scope, scope === "cwd" ? cwd : undefined);
|
|
148
|
+
return [{ path: paths.checkpoint, content: sources.checkpoint }, { path: paths.patches, content: sources.patches }];
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Merge authoritative owned leaves while preserving forward-compatible metadata siblings. */
|
|
152
|
+
export function serializeScopeMetadata(registry, stream, scope, cwdIdentity, existingSource) {
|
|
153
|
+
const existing = parseMetadataDocument(existingSource, `State Flow metadata`);
|
|
154
|
+
const sources = serializeScopeStream(stream, scope, cwdIdentity);
|
|
155
|
+
const value = {
|
|
156
|
+
...existing,
|
|
157
|
+
version: 1,
|
|
158
|
+
...(registry === undefined ? {} : { artifacts: serializeArtifactProvenanceRegistry(registry) }),
|
|
159
|
+
temporal: sources.temporal,
|
|
160
|
+
...(scope === "cwd" ? { owner: { cwd: resolve(cwdIdentity) } } : {}),
|
|
161
|
+
};
|
|
162
|
+
return `${canonicalJson(value)}\n`;
|
|
163
|
+
}
|
|
164
|
+
/** Compatibility serializer retained for metadata-only callers. */
|
|
165
|
+
export function serializeScopeProvenance(registry) {
|
|
166
|
+
return `${canonicalJson({ version: 1, artifacts: serializeArtifactProvenanceRegistry(registry) })}\n`;
|
|
167
|
+
}
|
|
168
|
+
function parseMetadataDocument(source, label) {
|
|
169
|
+
if (source === undefined)
|
|
170
|
+
return {};
|
|
171
|
+
let value;
|
|
172
|
+
try {
|
|
173
|
+
value = JSON.parse(source);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new Error(`${label} contains invalid JSON`);
|
|
177
|
+
}
|
|
178
|
+
if (!isObject(value) || !isJsonValue(value))
|
|
179
|
+
throw new Error(`Invalid ${label}`);
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
/** Missing provenance is unavailable evidence, never corrupt state. Unknown metadata is preserved by writers. */
|
|
183
|
+
export function parseScopeProvenance(source, path) {
|
|
184
|
+
const value = parseMetadataDocument(source, `State Flow provenance file: ${path}`);
|
|
185
|
+
if (Object.keys(value).length === 0)
|
|
186
|
+
return {};
|
|
187
|
+
if (value.version !== 1)
|
|
188
|
+
throw new Error(`Invalid State Flow provenance document: ${path}`);
|
|
189
|
+
if (!Object.hasOwn(value, "artifacts"))
|
|
190
|
+
return {};
|
|
191
|
+
return parseArtifactProvenanceRegistry(value.artifacts, `State Flow provenance at ${path}`);
|
|
192
|
+
}
|
|
193
|
+
/** Dedicated runtime storage, independent from Markdown source discovery. */
|
|
194
|
+
export function getDurableRepositoryRoot(agentDir = getAgentDir()) {
|
|
195
|
+
return resolve(agentDir, "state-flow");
|
|
196
|
+
}
|
|
197
|
+
/** Legacy snapshot paths retained only for one-way migration and exact ownership checks. */
|
|
198
|
+
export function durablePaths(repositoryRoot = getDurableRepositoryRoot()) {
|
|
199
|
+
const root = resolve(repositoryRoot);
|
|
200
|
+
return {
|
|
201
|
+
repositoryRoot: root,
|
|
202
|
+
globalState: join(root, STATE_FILE),
|
|
203
|
+
globalPatches: join(root, PATCHES_FILE),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Match Pi's native project-session directory convention exactly. */
|
|
207
|
+
export function cwdScopeKey(cwd) {
|
|
208
|
+
const canonical = resolve(cwd);
|
|
209
|
+
return `--${canonical.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
210
|
+
}
|
|
211
|
+
/** One safe directory segment, normally the native Pi session filename stem. */
|
|
212
|
+
export function sessionScopeKey(key) {
|
|
213
|
+
if (!SESSION_KEY_PATTERN.test(key))
|
|
214
|
+
throw new Error("State Flow session storage key must be one Pi-safe path segment");
|
|
215
|
+
return key;
|
|
216
|
+
}
|
|
217
|
+
/** Prefer the actual native file stem; reproduce it from the immutable header when in-memory. */
|
|
218
|
+
export function sessionStorageKey(sessionFile, sessionId, timestamp) {
|
|
219
|
+
if (sessionFile !== undefined) {
|
|
220
|
+
const name = basename(sessionFile);
|
|
221
|
+
if (!name.endsWith(".jsonl"))
|
|
222
|
+
throw new Error("State Flow session file must use Pi's .jsonl format");
|
|
223
|
+
return sessionScopeKey(name.slice(0, -".jsonl".length));
|
|
224
|
+
}
|
|
225
|
+
if (timestamp !== undefined)
|
|
226
|
+
return sessionScopeKey(`${timestamp.replace(/[:.]/g, "-")}_${sessionId}`);
|
|
227
|
+
return sessionScopeKey(sessionId);
|
|
228
|
+
}
|
|
229
|
+
export function resolveSessionAddress(sessionFile, sessionId, timestamp) {
|
|
230
|
+
if (sessionId.trim().length === 0 || sessionId !== sessionId.trim())
|
|
231
|
+
throw new Error("State Flow session identity must be non-empty and trimmed");
|
|
232
|
+
return Object.freeze({ id: sessionId, key: sessionStorageKey(sessionFile, sessionId, timestamp) });
|
|
233
|
+
}
|
|
234
|
+
export function cwdScopePaths(cwd, repositoryRoot = getDurableRepositoryRoot()) {
|
|
235
|
+
const directory = join(resolve(repositoryRoot), cwdScopeKey(cwd));
|
|
236
|
+
return { directory, state: join(directory, STATE_FILE), patches: join(directory, PATCHES_FILE) };
|
|
237
|
+
}
|
|
238
|
+
export function sessionScopePaths(cwd, sessionId, repositoryRoot = getDurableRepositoryRoot(), sessionKey = sessionId) {
|
|
239
|
+
const directory = join(cwdScopePaths(cwd, repositoryRoot).directory, sessionScopeKey(sessionKey));
|
|
240
|
+
return { directory, state: join(directory, STATE_FILE), patches: join(directory, PATCHES_FILE) };
|
|
241
|
+
}
|
|
242
|
+
export function cwdStatePath(cwd, repositoryRoot = getDurableRepositoryRoot()) {
|
|
243
|
+
return cwdScopePaths(cwd, repositoryRoot).state;
|
|
244
|
+
}
|
|
245
|
+
export function cwdPatchesPath(cwd, repositoryRoot = getDurableRepositoryRoot()) {
|
|
246
|
+
return cwdScopePaths(cwd, repositoryRoot).patches;
|
|
247
|
+
}
|
|
248
|
+
export function sessionStatePath(cwd, sessionId, repositoryRoot = getDurableRepositoryRoot(), sessionKey = sessionId) {
|
|
249
|
+
return sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).state;
|
|
250
|
+
}
|
|
251
|
+
export function sessionPatchesPath(cwd, sessionId, repositoryRoot = getDurableRepositoryRoot(), sessionKey = sessionId) {
|
|
252
|
+
return sessionScopePaths(cwd, sessionId, repositoryRoot, sessionKey).patches;
|
|
253
|
+
}
|
|
254
|
+
/** Exact semantic file shapes, including legacy snapshots only during the storage cutover. */
|
|
255
|
+
export function isStateFlowOwnedPath(candidate, repositoryRoot = getDurableRepositoryRoot()) {
|
|
256
|
+
const root = resolve(repositoryRoot);
|
|
257
|
+
const absolute = resolve(candidate);
|
|
258
|
+
const global = durablePaths(root);
|
|
259
|
+
if (absolute === global.globalState || absolute === global.globalPatches
|
|
260
|
+
|| absolute === join(root, CHECKPOINT_FILE) || absolute === join(root, META_FILE))
|
|
261
|
+
return true;
|
|
262
|
+
const segments = relative(root, absolute).split(sep);
|
|
263
|
+
const cwdKey = (value) => value.startsWith("--") && value.endsWith("--");
|
|
264
|
+
const sessionKey = (value) => {
|
|
265
|
+
try {
|
|
266
|
+
return sessionScopeKey(value) === value;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
if (segments.length === 2) {
|
|
273
|
+
return cwdKey(segments[0]) && (segments[1] === STATE_FILE || segments[1] === CHECKPOINT_FILE || segments[1] === PATCHES_FILE || segments[1] === META_FILE);
|
|
274
|
+
}
|
|
275
|
+
if (segments.length === 3) {
|
|
276
|
+
return cwdKey(segments[0])
|
|
277
|
+
&& sessionKey(segments[1])
|
|
278
|
+
&& (segments[2] === STATE_FILE || segments[2] === CHECKPOINT_FILE || segments[2] === PATCHES_FILE
|
|
279
|
+
|| segments[2] === "config.json" || segments[2] === META_FILE);
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
function missing(error) {
|
|
284
|
+
return error instanceof Error
|
|
285
|
+
&& "code" in error
|
|
286
|
+
&& error.code === "ENOENT";
|
|
287
|
+
}
|
|
288
|
+
function assertWithinRepository(path, repositoryRoot) {
|
|
289
|
+
const child = relative(repositoryRoot, path);
|
|
290
|
+
if (child === "" || child === ".." || child.startsWith(`..${sep}`)) {
|
|
291
|
+
throw new Error(`Durable State Flow path escapes its repository: ${path}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Reject symlinked directory components instead of following them during reads or writes. */
|
|
295
|
+
function assertDirectoryChain(repositoryRoot, directory, create) {
|
|
296
|
+
const root = resolve(repositoryRoot);
|
|
297
|
+
const target = resolve(directory);
|
|
298
|
+
if (target !== root)
|
|
299
|
+
assertWithinRepository(target, root);
|
|
300
|
+
const relativeDirectory = relative(root, target);
|
|
301
|
+
const directories = [root];
|
|
302
|
+
if (relativeDirectory.length > 0) {
|
|
303
|
+
let current = root;
|
|
304
|
+
for (const segment of relativeDirectory.split(sep)) {
|
|
305
|
+
current = join(current, segment);
|
|
306
|
+
directories.push(current);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
for (const current of directories) {
|
|
310
|
+
try {
|
|
311
|
+
const metadata = lstatSync(current);
|
|
312
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
313
|
+
throw new Error(`Durable State Flow directory is not a regular directory: ${current}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (!missing(error))
|
|
318
|
+
throw error;
|
|
319
|
+
if (!create)
|
|
320
|
+
return false;
|
|
321
|
+
mkdirSync(current);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
function readRegularBytes(path, repositoryRoot) {
|
|
327
|
+
if (!assertDirectoryChain(repositoryRoot, dirname(path), false))
|
|
328
|
+
return undefined;
|
|
329
|
+
let descriptor;
|
|
330
|
+
try {
|
|
331
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
332
|
+
if (!fstatSync(descriptor).isFile()) {
|
|
333
|
+
throw new Error(`Durable State Flow path is not a regular file: ${path}`);
|
|
334
|
+
}
|
|
335
|
+
return readFileSync(descriptor);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (missing(error))
|
|
339
|
+
return undefined;
|
|
340
|
+
if (error instanceof Error
|
|
341
|
+
&& "code" in error
|
|
342
|
+
&& error.code === "ELOOP") {
|
|
343
|
+
throw new Error(`Durable State Flow path is not a regular file: ${path}`);
|
|
344
|
+
}
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
if (descriptor !== undefined)
|
|
349
|
+
closeSync(descriptor);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function readRegularFile(path, repositoryRoot) {
|
|
353
|
+
return readRegularBytes(path, repositoryRoot)?.toString("utf8");
|
|
354
|
+
}
|
|
355
|
+
function byteIdentity(bytes) {
|
|
356
|
+
return bytes === undefined ? "missing" : `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
357
|
+
}
|
|
358
|
+
function fileBase(path, repositoryRoot) {
|
|
359
|
+
const bytes = readRegularBytes(path, repositoryRoot);
|
|
360
|
+
if (bytes === undefined)
|
|
361
|
+
return { path, identity: "missing" };
|
|
362
|
+
return { path, identity: byteIdentity(bytes), content: bytes.toString("utf8"), bytes };
|
|
363
|
+
}
|
|
364
|
+
function assertCurrentBytes(path, root, expected) {
|
|
365
|
+
if (byteIdentity(readRegularBytes(path, root)) !== byteIdentity(expected)) {
|
|
366
|
+
throw new Error(`State Flow file conflict at ${path}; preserve concurrent bytes and reconcile before retrying`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Capture exact owned bytes for one compare-and-swap publication or migration cohort. */
|
|
370
|
+
export function captureOwnedFileBases(paths, repositoryRoot) {
|
|
371
|
+
const root = resolve(repositoryRoot);
|
|
372
|
+
return paths.map((path) => {
|
|
373
|
+
if (!isStateFlowOwnedPath(path, root))
|
|
374
|
+
throw new Error(`Cannot capture a non-State Flow path: ${path}`);
|
|
375
|
+
return fileBase(resolve(path), root);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function prepareFile(path, repositoryRoot, content, expected) {
|
|
379
|
+
assertWithinRepository(path, repositoryRoot);
|
|
380
|
+
assertDirectoryChain(repositoryRoot, dirname(path), true);
|
|
381
|
+
const original = readRegularBytes(path, repositoryRoot);
|
|
382
|
+
if (expected !== undefined && byteIdentity(original) !== expected.identity) {
|
|
383
|
+
throw new Error(`State Flow file conflict during preparation: ${path}`);
|
|
384
|
+
}
|
|
385
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
386
|
+
const next = content === undefined ? undefined : Buffer.from(content);
|
|
387
|
+
if (next !== undefined)
|
|
388
|
+
writeFileSync(temporary, next, { flag: "wx", mode: 0o600 });
|
|
389
|
+
return { path, repositoryRoot, temporary, original, next };
|
|
390
|
+
}
|
|
391
|
+
function restorePrepared(prepared) {
|
|
392
|
+
assertCurrentBytes(prepared.path, prepared.repositoryRoot, prepared.next);
|
|
393
|
+
if (prepared.original === undefined) {
|
|
394
|
+
rmSync(prepared.path, { force: true });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const rollback = join(dirname(prepared.path), `.${basename(prepared.path)}.${process.pid}.${randomUUID()}.rollback`);
|
|
398
|
+
try {
|
|
399
|
+
writeFileSync(rollback, prepared.original, { flag: "wx", mode: 0o600 });
|
|
400
|
+
renameSync(rollback, prepared.path);
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
rmSync(rollback, { force: true });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function publishPrepared(prepared) {
|
|
407
|
+
let published = 0;
|
|
408
|
+
try {
|
|
409
|
+
for (const item of prepared) {
|
|
410
|
+
assertCurrentBytes(item.path, item.repositoryRoot, item.original);
|
|
411
|
+
if (item.next === undefined)
|
|
412
|
+
rmSync(item.path, { force: true });
|
|
413
|
+
else
|
|
414
|
+
renameSync(item.temporary, item.path);
|
|
415
|
+
published += 1;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
let rollbackError;
|
|
420
|
+
for (let index = published - 1; index >= 0; index--) {
|
|
421
|
+
try {
|
|
422
|
+
restorePrepared(prepared[index]);
|
|
423
|
+
}
|
|
424
|
+
catch (failure) {
|
|
425
|
+
rollbackError ??= failure;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (rollbackError !== undefined) {
|
|
429
|
+
throw new AggregateError([error, rollbackError], "Durable State Flow transition publication and rollback failed");
|
|
430
|
+
}
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
for (const item of prepared)
|
|
435
|
+
rmSync(item.temporary, { force: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Verify the publisher's exact output before commit or rollback, without trusting changed worktree bytes. */
|
|
439
|
+
export function assertOwnedFileUpdates(updates, repositoryRoot) {
|
|
440
|
+
const root = resolve(repositoryRoot);
|
|
441
|
+
for (const update of updates) {
|
|
442
|
+
if (!isStateFlowOwnedPath(update.path, root))
|
|
443
|
+
throw new Error(`Cannot inspect a non-State Flow path: ${update.path}`);
|
|
444
|
+
assertCurrentBytes(update.path, root, update.content === undefined ? undefined : Buffer.from(update.content));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Publish a prevalidated file cohort, preserving original bytes for failed preparation/publication. */
|
|
448
|
+
export function writeOwnedFileUpdates(updates, bases, repositoryRoot) {
|
|
449
|
+
const root = resolve(repositoryRoot);
|
|
450
|
+
const byPath = new Map(bases.map((base) => [resolve(base.path), base]));
|
|
451
|
+
const seen = new Set();
|
|
452
|
+
const prepared = [];
|
|
453
|
+
try {
|
|
454
|
+
for (const update of updates) {
|
|
455
|
+
const path = resolve(update.path);
|
|
456
|
+
if (!isStateFlowOwnedPath(path, root))
|
|
457
|
+
throw new Error(`Cannot update a non-State Flow path: ${path}`);
|
|
458
|
+
if (seen.has(path))
|
|
459
|
+
throw new Error(`Duplicate State Flow file update: ${path}`);
|
|
460
|
+
seen.add(path);
|
|
461
|
+
const base = byPath.get(path);
|
|
462
|
+
if (base === undefined)
|
|
463
|
+
throw new Error(`State Flow file update has no captured base: ${path}`);
|
|
464
|
+
prepared.push(prepareFile(path, root, update.content, base));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
for (const item of prepared)
|
|
469
|
+
rmSync(item.temporary, { force: true });
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
publishPrepared(prepared);
|
|
473
|
+
return prepared.map(({ path }) => path);
|
|
474
|
+
}
|
|
475
|
+
/** Restore exact pre-transition bytes only while files still match this publisher's output. */
|
|
476
|
+
export function restoreDurableFileBases(bases, repositoryRoot, expectedCurrent) {
|
|
477
|
+
const root = resolve(repositoryRoot);
|
|
478
|
+
const expected = new Map(expectedCurrent.map((update) => [resolve(update.path), update]));
|
|
479
|
+
for (const base of bases) {
|
|
480
|
+
assertWithinRepository(base.path, root);
|
|
481
|
+
if (!isStateFlowOwnedPath(base.path, root)) {
|
|
482
|
+
throw new Error(`Cannot restore a non-State Flow path: ${base.path}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (const base of bases) {
|
|
486
|
+
const update = expected.get(resolve(base.path));
|
|
487
|
+
if (update === undefined)
|
|
488
|
+
throw new Error(`Rollback has no published basis: ${base.path}`);
|
|
489
|
+
assertCurrentBytes(base.path, root, update.content === undefined ? undefined : Buffer.from(update.content));
|
|
490
|
+
if (base.identity === "missing") {
|
|
491
|
+
rmSync(base.path, { force: true });
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const original = base.bytes ?? (base.content === undefined ? undefined : Buffer.from(base.content));
|
|
495
|
+
if (original === undefined)
|
|
496
|
+
throw new Error(`Durable State Flow base content is missing: ${base.path}`);
|
|
497
|
+
assertDirectoryChain(root, dirname(base.path), true);
|
|
498
|
+
const temporary = join(dirname(base.path), `.${basename(base.path)}.${process.pid}.${randomUUID()}.restore`);
|
|
499
|
+
try {
|
|
500
|
+
writeFileSync(temporary, original, { flag: "wx", mode: 0o600 });
|
|
501
|
+
renameSync(temporary, base.path);
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
rmSync(temporary, { force: true });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Snapshot } from "./snapshot.ts";
|
|
2
|
+
export declare function startEpisode(bootstrap: boolean): Snapshot;
|
|
3
|
+
/** Re-enable a branch checkpoint without discarding its runtime config or provenance. */
|
|
4
|
+
export declare function resumeEpisode(snapshot: Snapshot, bootstrap: boolean): Snapshot;
|
|
5
|
+
/** Disable only this branch; durable defaults and session history remain intact. */
|
|
6
|
+
export declare function stopEpisode(snapshot: Snapshot): Snapshot;
|
|
7
|
+
/** Apply one user-run boundary while preserving checkpoint-owned runtime state. */
|
|
8
|
+
export declare function prepareRun(snapshot: Snapshot, prompt: string): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { emptySnapshot } from "./snapshot.js";
|
|
2
|
+
export function startEpisode(bootstrap) {
|
|
3
|
+
const snapshot = emptySnapshot(true);
|
|
4
|
+
if (bootstrap)
|
|
5
|
+
snapshot.meta.bootstrap = true;
|
|
6
|
+
return snapshot;
|
|
7
|
+
}
|
|
8
|
+
/** Re-enable a branch checkpoint without discarding its runtime config or provenance. */
|
|
9
|
+
export function resumeEpisode(snapshot, bootstrap) {
|
|
10
|
+
const next = structuredClone(snapshot);
|
|
11
|
+
next.config.enabled = true;
|
|
12
|
+
if (bootstrap)
|
|
13
|
+
next.meta.bootstrap = true;
|
|
14
|
+
return next;
|
|
15
|
+
}
|
|
16
|
+
/** Disable only this branch; durable defaults and session history remain intact. */
|
|
17
|
+
export function stopEpisode(snapshot) {
|
|
18
|
+
const next = structuredClone(snapshot);
|
|
19
|
+
next.config.enabled = false;
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
/** Apply one user-run boundary while preserving checkpoint-owned runtime state. */
|
|
23
|
+
export function prepareRun(snapshot, prompt) {
|
|
24
|
+
snapshot.meta.specification = prompt;
|
|
25
|
+
snapshot.meta.validation = undefined;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type StateFlowTelegramLoader } from "./telegram.ts";
|
|
3
|
+
import { type MaterializedState, type StateScope } from "./state.ts";
|
|
4
|
+
export interface StateFlowExtensionOptions {
|
|
5
|
+
agentDir?: string;
|
|
6
|
+
repositoryRoot?: string;
|
|
7
|
+
knowledgeRoot?: string;
|
|
8
|
+
onRuntime?: (accessor: {
|
|
9
|
+
read(offset?: number, scope?: StateScope): MaterializedState;
|
|
10
|
+
}) => void;
|
|
11
|
+
telegram?: {
|
|
12
|
+
load?: StateFlowTelegramLoader;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare const PATCH_STATE_TOOL_NAME = "patch_state";
|
|
16
|
+
export declare const READ_STATE_TOOL_NAME = "read_state";
|
|
17
|
+
export declare const MAX_FALLBACK_ATTEMPTS: number;
|
|
18
|
+
/** Keep successful patch JSON valid while separating adjacent scopes and memory sections visually. */
|
|
19
|
+
export declare function formatPatchStateArguments(args: unknown): string;
|
|
20
|
+
/** Normalize a bounded compatibility superset without advertising aliases in the model-facing contract. */
|
|
21
|
+
export declare function normalizePatchStateArguments(args: unknown): any;
|
|
22
|
+
export default function stateFlowExtension(pi: ExtensionAPI, options?: StateFlowExtensionOptions): void;
|