@llblab/pi-kit 0.22.2 → 0.23.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 +7 -0
- package/README.md +4 -4
- package/node_modules/@llblab/pi-grow-loop/AGENTS.md +10 -6
- package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +7 -0
- package/node_modules/@llblab/pi-grow-loop/README.md +2 -0
- package/node_modules/@llblab/pi-grow-loop/dist/index.d.ts +33 -0
- package/node_modules/@llblab/pi-grow-loop/dist/index.js +286 -0
- package/node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.d.ts +1 -0
- package/node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.js +1 -0
- package/node_modules/@llblab/pi-grow-loop/dist/skills/grow-loop/SKILL.md +117 -0
- package/node_modules/@llblab/pi-grow-loop/dist/skills/while-true/SKILL.md +233 -0
- package/node_modules/@llblab/pi-grow-loop/index.ts +67 -12
- package/node_modules/@llblab/pi-grow-loop/package.json +9 -8
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +23 -17
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
- package/node_modules/@llblab/pi-state-flow/README.md +18 -6
- package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -1
- package/node_modules/@llblab/pi-state-flow/dist/index.js +1 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +5 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +3 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +7 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.d.ts +5 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +54 -40
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +20 -20
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +755 -325
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +14 -17
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +4 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +65 -23
- package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +16 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +32 -15
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +28 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +276 -26
- package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +5 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +36 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +3 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +3 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +11 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +150 -24
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +14 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +52 -18
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +8 -8
- package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +3 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +53 -7
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +84 -44
- package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -2
- package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +6 -4
- package/node_modules/@llblab/pi-state-flow/docs/performance.md +2 -2
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +28 -8
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +41 -14
- package/node_modules/@llblab/pi-state-flow/index.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +5 -5
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +8 -3
- package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +57 -40
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +20 -20
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +719 -316
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +16 -18
- package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +60 -24
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +34 -21
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +290 -25
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +37 -2
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +3 -0
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -1
- package/node_modules/@llblab/pi-state-flow/lib/storage.ts +141 -22
- package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +60 -19
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +8 -8
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +3 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
- package/node_modules/@llblab/pi-telegram/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/skills.d.ts +7 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/skills.js +32 -7
- package/node_modules/@llblab/pi-telegram/dist/package.json +3 -8
- package/node_modules/@llblab/pi-telegram/lib/skills.ts +43 -7
- package/node_modules/@llblab/pi-telegram/package.json +3 -8
- package/node_modules/@llblab/pi-telegram/scripts/build-dist.mjs +103 -32
- package/package.json +6 -6
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Domain: exact file-cohort publication, current-only recovery, and cooperating worktree exclusion.
|
|
2
2
|
// Excludes: temporal algebra, Pi lifecycle, Git objects/remotes, and backend fallback policy.
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { dirname, relative, resolve } from "node:path";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
8
|
import {
|
|
7
9
|
assertOwnedFileUpdates, captureTemporalFileBases, parseScopeProvenance, parseScopeStream, restoreDurableFileBases,
|
|
8
10
|
serializeScopeMetadata, sessionRuntimePaths, temporalScopePaths, temporalStateFileUpdates, writeOwnedFileUpdates,
|
|
@@ -24,7 +26,7 @@ export function assertStorageDirectory(path: string): void {
|
|
|
24
26
|
const parent = dirname(root);
|
|
25
27
|
if (parent !== root) assertStorageDirectory(parent);
|
|
26
28
|
const stat = lstatSync(root, { throwIfNoEntry: false });
|
|
27
|
-
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`State Flow repository path is not a regular directory: ${root}`);
|
|
29
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`State Flow repository path is not a regular directory: ${JSON.stringify(root)}`);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/** Explicit creation only; existing bytes and unrelated files are never adopted or rewritten here. */
|
|
@@ -37,16 +39,23 @@ const PUBLICATION_LOCK_WAIT_MS = 2_000;
|
|
|
37
39
|
const PUBLICATION_LOCK_POLL_MS = 25;
|
|
38
40
|
const publicationLockWait = new Int32Array(new SharedArrayBuffer(4));
|
|
39
41
|
|
|
40
|
-
function
|
|
42
|
+
function publicationLockOwner(path: string, allowCurrentProcess: boolean): "live" | "pending" | "absent" | "unavailable" {
|
|
41
43
|
let owner: string;
|
|
42
|
-
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
try {
|
|
45
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
46
|
+
if (!stat) return "absent";
|
|
47
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return "unavailable";
|
|
48
|
+
owner = readFileSync(path, { encoding: "utf8", flag: constants.O_RDONLY | constants.O_NOFOLLOW }).trim();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent";
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (owner.length === 0) return "pending";
|
|
54
|
+
if (!/^[1-9]\d*$/.test(owner)) return "unavailable";
|
|
46
55
|
const pid = Number(owner);
|
|
47
|
-
if (!Number.isSafeInteger(pid) || pid === process.pid) return
|
|
48
|
-
try { process.kill(pid, 0); return
|
|
49
|
-
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
|
56
|
+
if (!Number.isSafeInteger(pid) || (pid === process.pid && !allowCurrentProcess)) return "unavailable";
|
|
57
|
+
try { process.kill(pid, 0); return "live"; }
|
|
58
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM" ? "live" : "unavailable"; }
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
/** Wait only for a cooperating live owner; interrupted or malformed locks remain explicit recovery errors. */
|
|
@@ -55,7 +64,11 @@ export function acquirePublicationLock(path: string, unavailable: (cause: unknow
|
|
|
55
64
|
while (true) {
|
|
56
65
|
try { return openSync(path, "wx", 0o600); }
|
|
57
66
|
catch (error) {
|
|
58
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST"
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw unavailable(error);
|
|
68
|
+
let owner: ReturnType<typeof publicationLockOwner>;
|
|
69
|
+
try { owner = publicationLockOwner(path, false); }
|
|
70
|
+
catch (cause) { throw unavailable(cause); }
|
|
71
|
+
if (owner === "unavailable" || Date.now() >= deadline) throw unavailable(error);
|
|
59
72
|
Atomics.wait(publicationLockWait, 0, 0, Math.min(PUBLICATION_LOCK_POLL_MS, deadline - Date.now()));
|
|
60
73
|
}
|
|
61
74
|
}
|
|
@@ -67,7 +80,7 @@ export function withStoragePublicationLock<T>(repositoryRoot: string, action: (r
|
|
|
67
80
|
assertStorageDirectory(root);
|
|
68
81
|
const path = resolve(root, ".state-flow-publication.lock");
|
|
69
82
|
const descriptor = acquirePublicationLock(path, (cause) => new RevisionUnavailableError(
|
|
70
|
-
`State Flow publication lock is unavailable at ${path}
|
|
83
|
+
`State Flow publication lock is unavailable at ${JSON.stringify(path)}`, { cause },
|
|
71
84
|
));
|
|
72
85
|
try {
|
|
73
86
|
writeFileSync(descriptor, `${process.pid}\n`);
|
|
@@ -78,6 +91,103 @@ export function withStoragePublicationLock<T>(repositoryRoot: string, action: (r
|
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
93
|
|
|
94
|
+
export interface StorageTransaction {
|
|
95
|
+
readonly capture: typeof captureTemporalFileBase;
|
|
96
|
+
readonly publish: typeof publishTemporalStateToFiles;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class PublicationBusyError extends Error {
|
|
100
|
+
constructor(path: string) { super(`State Flow publication lock is busy at ${JSON.stringify(path)}`); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const storageLockContext = new AsyncLocalStorage<readonly { path: string; active: boolean }[]>();
|
|
104
|
+
|
|
105
|
+
/** Await an exact file mutex; shared by canonical transactions and the independent Git backup owner. */
|
|
106
|
+
export async function withFilePublicationLock<T>(
|
|
107
|
+
lockPath: string, action: () => T | Promise<T>, signal?: AbortSignal,
|
|
108
|
+
unavailable: (cause: unknown) => Error = (cause) => new RevisionUnavailableError(
|
|
109
|
+
`State Flow publication lock is unavailable at ${JSON.stringify(resolve(lockPath))}`, { cause },
|
|
110
|
+
), waitForLock = true,
|
|
111
|
+
): Promise<T> {
|
|
112
|
+
const path = resolve(lockPath);
|
|
113
|
+
const inherited = storageLockContext.getStore();
|
|
114
|
+
if (inherited?.some((held) => held.active && held.path === path)) throw new Error(`Recursive State Flow publication lock at ${JSON.stringify(path)}`);
|
|
115
|
+
let descriptor: number;
|
|
116
|
+
let pendingSince: number | undefined;
|
|
117
|
+
while (true) {
|
|
118
|
+
signal?.throwIfAborted();
|
|
119
|
+
assertStorageDirectory(dirname(path));
|
|
120
|
+
try { descriptor = openSync(path, "wx", 0o600); break; }
|
|
121
|
+
catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw unavailable(error);
|
|
123
|
+
let owner: ReturnType<typeof publicationLockOwner>;
|
|
124
|
+
try { owner = publicationLockOwner(path, true); }
|
|
125
|
+
catch (cause) { throw unavailable(cause); }
|
|
126
|
+
if (owner === "unavailable") throw unavailable(error);
|
|
127
|
+
if (!waitForLock) throw new PublicationBusyError(path);
|
|
128
|
+
// An empty file may be between exclusive creation and PID publication, but not forever.
|
|
129
|
+
if (owner === "pending") pendingSince ??= performance.now();
|
|
130
|
+
else pendingSince = undefined;
|
|
131
|
+
if (pendingSince !== undefined && performance.now() - pendingSince >= PUBLICATION_LOCK_WAIT_MS) throw unavailable(error);
|
|
132
|
+
}
|
|
133
|
+
await delay(PUBLICATION_LOCK_POLL_MS, undefined, { signal });
|
|
134
|
+
}
|
|
135
|
+
let identity: ReturnType<typeof fstatSync> | undefined;
|
|
136
|
+
const held = { path, active: true };
|
|
137
|
+
let failed: { error: unknown } | undefined;
|
|
138
|
+
try {
|
|
139
|
+
identity = fstatSync(descriptor);
|
|
140
|
+
writeFileSync(descriptor, `${process.pid}\n`);
|
|
141
|
+
signal?.throwIfAborted();
|
|
142
|
+
return await storageLockContext.run([...(inherited?.filter((owner) => owner.active) ?? []), held], action);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
failed = { error };
|
|
145
|
+
throw error;
|
|
146
|
+
} finally {
|
|
147
|
+
held.active = false;
|
|
148
|
+
try {
|
|
149
|
+
const current = lstatSync(path, { throwIfNoEntry: false });
|
|
150
|
+
if (!identity || !current?.isFile() || current.dev !== identity.dev || current.ino !== identity.ino
|
|
151
|
+
|| readFileSync(path, { encoding: "utf8", flag: constants.O_RDONLY | constants.O_NOFOLLOW }) !== `${process.pid}\n`) {
|
|
152
|
+
throw new Error(`State Flow publication lock changed during transaction at ${JSON.stringify(path)}; current owner preserved`);
|
|
153
|
+
}
|
|
154
|
+
rmSync(path);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (failed) throw new AggregateError([failed.error, error], "State Flow storage transaction and lock release failed");
|
|
157
|
+
throw error;
|
|
158
|
+
} finally {
|
|
159
|
+
closeSync(descriptor);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Await store-wide exclusion, then capture/apply/publish through callback-scoped operations. */
|
|
165
|
+
export async function withStorageTransaction<T>(
|
|
166
|
+
repositoryRoot: string, action: (transaction: StorageTransaction) => T | Promise<T>, signal?: AbortSignal, waitForLock = true,
|
|
167
|
+
): Promise<T> {
|
|
168
|
+
const root = resolve(repositoryRoot);
|
|
169
|
+
return withFilePublicationLock(resolve(root, ".state-flow-publication.lock"), async () => {
|
|
170
|
+
let active = true;
|
|
171
|
+
const guard = (requestedRoot: string): void => {
|
|
172
|
+
if (!active) throw new Error("State Flow storage transaction has ended");
|
|
173
|
+
if (resolve(requestedRoot) !== root) throw new Error("State Flow storage transaction belongs to a different store");
|
|
174
|
+
signal?.throwIfAborted();
|
|
175
|
+
};
|
|
176
|
+
const transaction = Object.freeze<StorageTransaction>({
|
|
177
|
+
capture: (cwd, sessionId, requestedRoot, sessionKey = sessionId) => {
|
|
178
|
+
guard(requestedRoot);
|
|
179
|
+
return { files: captureTemporalFileBases(cwd, sessionId, root, sessionKey) };
|
|
180
|
+
},
|
|
181
|
+
publish: (cwd, sessionId, view, scopes, base, requestedRoot, runtime, sessionKey, provenance, runtimeOnly) => {
|
|
182
|
+
guard(requestedRoot);
|
|
183
|
+
return publishLockedTemporalStateToFiles(cwd, sessionId, view, scopes, base, root, runtime, sessionKey, provenance, runtimeOnly);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
try { return await action(transaction); }
|
|
187
|
+
finally { active = false; }
|
|
188
|
+
}, signal, undefined, waitForLock);
|
|
189
|
+
}
|
|
190
|
+
|
|
81
191
|
export function assertTemporalFileBase(expected: TemporalFileBase, current: TemporalFileBase): void {
|
|
82
192
|
if (expected.files.length !== current.files.length || current.files.some((file, index) => {
|
|
83
193
|
const previous = expected.files[index]!;
|
|
@@ -194,16 +304,25 @@ export function publishTemporalStateToFiles(
|
|
|
194
304
|
base: TemporalFileBase, root: string, runtime: SessionRuntime, sessionKey = sessionId,
|
|
195
305
|
provenance?: Readonly<Record<StateScope, ArtifactProvenanceRegistry>>, runtimeOnly = false,
|
|
196
306
|
): { base: TemporalFileBase; revision: FileRevision; changed: boolean } {
|
|
197
|
-
return withStoragePublicationLock(root, (locked) =>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
307
|
+
return withStoragePublicationLock(root, (locked) => publishLockedTemporalStateToFiles(
|
|
308
|
+
cwd, sessionId, view, scopes, base, locked, runtime, sessionKey, provenance, runtimeOnly,
|
|
309
|
+
));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Shared publication owner; callers must hold the matching store lock for the complete cohort. */
|
|
313
|
+
function publishLockedTemporalStateToFiles(
|
|
314
|
+
cwd: string, sessionId: string, view: TemporalState, scopes: readonly StateScope[],
|
|
315
|
+
base: TemporalFileBase, root: string, runtime: SessionRuntime, sessionKey = sessionId,
|
|
316
|
+
provenance?: Readonly<Record<StateScope, ArtifactProvenanceRegistry>>, runtimeOnly = false,
|
|
317
|
+
): { base: TemporalFileBase; revision: FileRevision; changed: boolean } {
|
|
318
|
+
const current = { files: captureTemporalFileBases(cwd, sessionId, root, sessionKey) };
|
|
319
|
+
assertTemporalFileBase(base, current);
|
|
320
|
+
const { updates } = planTemporalPublication(cwd, sessionId, view, scopes, current, root, runtime, runtimeOnly, sessionKey, provenance);
|
|
321
|
+
const next = { files: temporalFileReceipts(current, updates) };
|
|
322
|
+
decodeFileCohort(cwd, sessionId, root, next, sessionKey);
|
|
323
|
+
const revision = fileRevision(next, root);
|
|
324
|
+
publishFileUpdates(current.files, updates, root);
|
|
325
|
+
return { base: next, revision, changed: updates.length > 0 };
|
|
207
326
|
}
|
|
208
327
|
|
|
209
328
|
function publishFileUpdates(bases: readonly DurableFileBase[], updates: readonly OwnedFileUpdate[], root: string): void {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// This is a leaf adapter. Core semantics, storage, and inference never depend on it; when
|
|
4
4
|
// pi-telegram is absent or its registry is not ready, registration fails open and retries.
|
|
5
5
|
|
|
6
|
+
import { conciseDiagnostic, diagnosticText } from "./protocol.ts";
|
|
6
7
|
import { formatScopeRevisionVector } from "./status.ts";
|
|
7
8
|
import type { ScopeRevisions } from "./temporal.ts";
|
|
8
9
|
|
|
@@ -93,6 +94,15 @@ export type StateFlowTelegramLoader = () => Promise<StateFlowTelegramModules>;
|
|
|
93
94
|
export interface StateFlowTelegramControlResult {
|
|
94
95
|
ok: boolean;
|
|
95
96
|
message: string;
|
|
97
|
+
/** A completed control can lose its presentation authority after a mode or selection change. */
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface StateFlowTelegramInspection {
|
|
102
|
+
state: StateFlowTelegramState;
|
|
103
|
+
revisions: ScopeRevisions;
|
|
104
|
+
/** A selection can revoke a completed observation before the adapter presents it. */
|
|
105
|
+
signal?: AbortSignal;
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
export interface StateFlowTelegramPort {
|
|
@@ -107,6 +117,12 @@ export interface StateFlowTelegramPort {
|
|
|
107
117
|
cancelStart(): void;
|
|
108
118
|
}
|
|
109
119
|
|
|
120
|
+
export interface StateFlowTelegramInspectionPort extends Omit<StateFlowTelegramPort, "state" | "revisions" | "start" | "stop"> {
|
|
121
|
+
inspect(scope: StateFlowTelegramScope): StateFlowTelegramInspection | Promise<StateFlowTelegramInspection>;
|
|
122
|
+
start(): StateFlowTelegramControlResult | Promise<StateFlowTelegramControlResult>;
|
|
123
|
+
stop(): StateFlowTelegramControlResult | Promise<StateFlowTelegramControlResult>;
|
|
124
|
+
}
|
|
125
|
+
|
|
110
126
|
export interface StateFlowTelegramAdapter {
|
|
111
127
|
ensure(): Promise<boolean>;
|
|
112
128
|
dispose(): void;
|
|
@@ -228,7 +244,8 @@ function isStateFlowTelegramScope(value: string): value is StateFlowTelegramScop
|
|
|
228
244
|
return value === "global" || value === "cwd" || value === "session" || value === "effective";
|
|
229
245
|
}
|
|
230
246
|
|
|
231
|
-
function buildStateFlowTelegramSection(port: StateFlowTelegramPort) {
|
|
247
|
+
function buildStateFlowTelegramSection(port: StateFlowTelegramPort | StateFlowTelegramInspectionPort, isActive: () => boolean) {
|
|
248
|
+
let interaction = 0;
|
|
232
249
|
return {
|
|
233
250
|
id: STATE_FLOW_TELEGRAM_ID,
|
|
234
251
|
label: "🌀 State Flow",
|
|
@@ -238,7 +255,9 @@ function buildStateFlowTelegramSection(port: StateFlowTelegramPort) {
|
|
|
238
255
|
handleCallback: async (ctx: StateFlowTelegramCallbackContext) => {
|
|
239
256
|
// cancel/refresh remain routable for keyboards sent by earlier versions.
|
|
240
257
|
if (ctx.action !== "start" && ctx.action !== "stop" && ctx.action !== "cancel" && ctx.action !== "refresh" && ctx.action !== "show-state" && ctx.action !== "inspect" && ctx.action !== "back") return "pass" as const;
|
|
258
|
+
const request = ++interaction;
|
|
241
259
|
let notice: string | undefined;
|
|
260
|
+
let acknowledged = false;
|
|
242
261
|
try {
|
|
243
262
|
if (ctx.action === "show-state") {
|
|
244
263
|
await ctx.answerCallback();
|
|
@@ -247,30 +266,52 @@ function buildStateFlowTelegramSection(port: StateFlowTelegramPort) {
|
|
|
247
266
|
}
|
|
248
267
|
if (ctx.action === "inspect") {
|
|
249
268
|
if (!isStateFlowTelegramScope(ctx.payload)) throw new Error("Unknown State Flow scope");
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
269
|
+
let observation: StateFlowTelegramInspection;
|
|
270
|
+
if ("inspect" in port) {
|
|
271
|
+
await ctx.answerCallback("Reading State Flow memory");
|
|
272
|
+
acknowledged = true;
|
|
273
|
+
observation = await port.inspect(ctx.payload);
|
|
274
|
+
} else {
|
|
275
|
+
const state = port.state(ctx.payload);
|
|
276
|
+
const live = port.snapshot();
|
|
277
|
+
observation = { state, revisions: port.revisions?.() ?? live.revisions ?? { global: live.step, cwd: live.step, session: live.step } };
|
|
278
|
+
}
|
|
279
|
+
observation.signal?.throwIfAborted();
|
|
280
|
+
if (request !== interaction || !isActive()) return "handled" as const;
|
|
281
|
+
await ctx.openRich(renderStateFlowRichState(ctx.payload, observation.revisions, observation.state));
|
|
282
|
+
if (!acknowledged) await ctx.answerCallback();
|
|
255
283
|
return "handled" as const;
|
|
256
284
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
285
|
+
const action = ctx.action === "stop" || (ctx.action === "start" && port.canStartNow()) ? ctx.action : undefined;
|
|
286
|
+
if (action) {
|
|
287
|
+
if ("inspect" in port) {
|
|
288
|
+
acknowledged = true;
|
|
289
|
+
// Start the control immediately and acknowledge in parallel; neither promise can reject unobserved.
|
|
290
|
+
const [, result] = await Promise.all([
|
|
291
|
+
ctx.answerCallback(action === "stop" ? "Stopping State Flow" : "Starting State Flow"),
|
|
292
|
+
Promise.resolve().then(() => port[action]()),
|
|
293
|
+
]);
|
|
294
|
+
if (result.signal?.aborted) return "handled" as const;
|
|
295
|
+
notice = result.message;
|
|
296
|
+
} else notice = port[action]().message;
|
|
297
|
+
} else if (ctx.action === "start") {
|
|
298
|
+
port.deferStart();
|
|
299
|
+
notice = "State Flow will start after the current turn";
|
|
265
300
|
} else if (ctx.action === "cancel") {
|
|
266
301
|
port.cancelStart();
|
|
267
302
|
notice = "Pending start cancelled";
|
|
268
303
|
}
|
|
269
304
|
} catch (error) {
|
|
270
|
-
notice =
|
|
305
|
+
notice = diagnosticText(error);
|
|
271
306
|
}
|
|
272
|
-
|
|
273
|
-
|
|
307
|
+
if (request !== interaction || !isActive()) return "handled" as const;
|
|
308
|
+
const summary = notice === undefined ? undefined : conciseDiagnostic(notice, 200);
|
|
309
|
+
const view = buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action));
|
|
310
|
+
if (acknowledged && summary !== undefined) {
|
|
311
|
+
// Callback queries can expire during storage waits; retain errors in the existing menu instead.
|
|
312
|
+
view.text += `\n\n${summary.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")}`;
|
|
313
|
+
} else if (!acknowledged) await ctx.answerCallback(summary);
|
|
314
|
+
await ctx.edit(view);
|
|
274
315
|
return "handled" as const;
|
|
275
316
|
},
|
|
276
317
|
};
|
|
@@ -302,7 +343,7 @@ export async function loadStateFlowTelegramModules(): Promise<StateFlowTelegramM
|
|
|
302
343
|
}
|
|
303
344
|
|
|
304
345
|
export function createStateFlowTelegramAdapter(options: {
|
|
305
|
-
port: StateFlowTelegramPort;
|
|
346
|
+
port: StateFlowTelegramPort | StateFlowTelegramInspectionPort;
|
|
306
347
|
load?: StateFlowTelegramLoader;
|
|
307
348
|
}): StateFlowTelegramAdapter {
|
|
308
349
|
const load = options.load ?? loadStateFlowTelegramModules;
|
|
@@ -323,7 +364,7 @@ export function createStateFlowTelegramAdapter(options: {
|
|
|
323
364
|
if (epoch !== generation) return false;
|
|
324
365
|
if (!sectionRegistered && modules.sections) {
|
|
325
366
|
try {
|
|
326
|
-
const dispose = modules.sections.registerTelegramSection(buildStateFlowTelegramSection(options.port));
|
|
367
|
+
const dispose = modules.sections.registerTelegramSection(buildStateFlowTelegramSection(options.port, () => epoch === generation));
|
|
327
368
|
if (epoch === generation) {
|
|
328
369
|
disposers.push(dispose);
|
|
329
370
|
sectionRegistered = true;
|
|
@@ -68,13 +68,13 @@ function validateSkillCompilerOutput(scope: StateScope, path: string, output: un
|
|
|
68
68
|
const problems: string[] = [];
|
|
69
69
|
if (!isObject(output)) problems.push("artifact entry is missing");
|
|
70
70
|
else {
|
|
71
|
-
if (typeof output.description !== "string" || output.description.trim().length === 0) problems.push("description must be
|
|
71
|
+
if (typeof output.description !== "string" || output.description.trim().length === 0) problems.push("description must be non-empty");
|
|
72
72
|
if (output.kind !== "skill") problems.push('kind must be "skill"');
|
|
73
|
-
if (!isObject(output.compilation) || Object.keys(output.compilation).length === 0) problems.push("compilation must be
|
|
73
|
+
if (!isObject(output.compilation) || Object.keys(output.compilation).length === 0) problems.push("compilation object must be non-empty");
|
|
74
74
|
}
|
|
75
75
|
if (problems.length === 0) return;
|
|
76
76
|
const target = `${scope}.artifacts[${JSON.stringify(path)}]`;
|
|
77
|
-
throw new Error(`Skill
|
|
77
|
+
throw new Error(`Invalid Skill at ${target}: ${problems.join("; ")}`);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
function validateSkillCompilerTargets(
|
|
@@ -107,7 +107,7 @@ function compileReadSkills(
|
|
|
107
107
|
const output = patch.artifacts[read.path];
|
|
108
108
|
validateSkillCompilerOutput(scope, read.path, output);
|
|
109
109
|
const compiled = compileArtifact({
|
|
110
|
-
source: { path: read.path, hash: read.hash },
|
|
110
|
+
source: { path: read.path, scope, hash: read.hash },
|
|
111
111
|
compiler: SKILL_ARTIFACT_COMPILER,
|
|
112
112
|
output: { ...structuredClone(output), kind: "skill" } as ArtifactCompilerOutput,
|
|
113
113
|
});
|
|
@@ -125,11 +125,11 @@ function compileReadSkills(
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
function validateMaterializedTransition(nextState: StateDocument): void {
|
|
128
|
+
function validateMaterializedTransition(nextState: StateDocument, scope: StateScope): void {
|
|
129
129
|
if (containsNull(nextState)) {
|
|
130
130
|
throw new Error("Materialized state cannot contain null; use null only as an object-key deletion marker");
|
|
131
131
|
}
|
|
132
|
-
validateArtifactRegistry(nextState.artifacts);
|
|
132
|
+
validateArtifactRegistry(nextState.artifacts, `${scope}.artifacts`);
|
|
133
133
|
if (Object.hasOwn(nextState.contract, "compiled_skills")) {
|
|
134
134
|
throw new Error("contract.compiled_skills is retired; Skill compilations belong only in source-addressed artifacts");
|
|
135
135
|
}
|
|
@@ -153,7 +153,7 @@ function validateScopePatch(scope: unknown, patch: unknown): asserts patch is Sc
|
|
|
153
153
|
if (Object.hasOwn(patch, "lazy") && !isObject(patch.lazy)) {
|
|
154
154
|
throw new Error("Scoped State Flow patch field lazy must be a JSON object");
|
|
155
155
|
}
|
|
156
|
-
if (isObject(patch.artifacts)) validateModelArtifactPatch(patch.artifacts);
|
|
156
|
+
if (isObject(patch.artifacts)) validateModelArtifactPatch(patch.artifacts, `${scope}.artifacts`);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
function completePatch(patch: ScopePatch, response: string): StatePatch {
|
|
@@ -209,7 +209,7 @@ function stageScopedSemanticTransition(
|
|
|
209
209
|
provenanceUpdates[scope],
|
|
210
210
|
);
|
|
211
211
|
compileReadSkills(scope, nextState, { artifacts: authored.artifacts ?? {} }, skillReads.filter((read) => read.scope === scope), provenanceUpdates[scope]);
|
|
212
|
-
validateMaterializedTransition(nextState);
|
|
212
|
+
validateMaterializedTransition(nextState, scope);
|
|
213
213
|
nextStates[scope] = nextState;
|
|
214
214
|
}
|
|
215
215
|
return {
|
|
@@ -48,7 +48,9 @@ Unscoped paths use effective state. `cwd[1].working` reads the preceding causal
|
|
|
48
48
|
|
|
49
49
|
## Write
|
|
50
50
|
|
|
51
|
-
Call `patch_state` alone per assistant response; await acceptance before dependent work. Supply one or more of `global`, `cwd`, and `session`; supplied scopes commit atomically. Omit unchanged scopes.
|
|
51
|
+
Call `patch_state` alone per assistant response; await acceptance before dependent work. Supply one or more of `global`, `cwd`, and `session`; supplied scopes commit atomically. Default to Session for current work, CWD for reusable project knowledge, and Global for established cross-project knowledge. Omit unchanged scopes.
|
|
52
|
+
|
|
53
|
+
The runtime waits cancelably for publication ownership, then applies authored Global/CWD operations to current canonical values. Untouched fields survive; overlapping targets follow successful acceptance order. Correct repeats succeed as `State already current.` without another semantic revision. Do not repeat external actions during a memory wait, or rebuild an entire scope from an older snapshot. Session ownership/history fences remain private, not a universal merge.
|
|
52
54
|
|
|
53
55
|
Semantic planes `intents`, `contract`, `working`, `artifacts`, and the required `lazy` root are objects; nested lazy values may contain ordinary JSON without stored nulls. Objects merge, arrays/scalars replace, omitted fields persist. Nested `null` removes an owned object key; inherited content may reappear. Canonical `"[N]"` keys patch array elements; indexed deletion is forbidden.
|
|
54
56
|
|
|
@@ -152,7 +152,7 @@ Before non-trivial work:
|
|
|
152
152
|
While working:
|
|
153
153
|
|
|
154
154
|
- Keep changes inside this repository; updating an installed Pi checkout is a separate operator action.
|
|
155
|
-
- Rebuild the package with `npm run build` after edits. Pi loads `dist/pi-telegram/index.js`, so source-only changes are not live and `/reload` or process restart alone will reload stale compiled output.
|
|
155
|
+
- Rebuild the package with `npm run build` after edits. Pi loads `dist/pi-telegram/index.js`, so source-only changes are not live and `/reload` or process restart alone will reload stale compiled output. Keep the committed `dist/` synchronized for Git installs; builds use a temporary candidate and rollback-safe swap, while `npm run build:check` rejects drift without rewriting the tree.
|
|
156
156
|
- Read large artifacts search-first and range-bounded. For `CHANGELOG.md`, inspect only the current release section unless older history is relevant.
|
|
157
157
|
- Keep successful validation output compact; inspect focused failure tails. Prefer focused tests/typecheck during iteration and broad validation at a stable gate.
|
|
158
158
|
- Preserve unrelated work and do not commit, publish, tag, deploy, or perform external actions without explicit authorization.
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.51.4: Resolver-owned Skills and drift-safe Git installs
|
|
8
|
+
|
|
9
|
+
- `Resolver-owned Skills`: Auto-discovered user/project checkouts now contribute source Skills even when Pi selects their compiled entrypoint, while manifest-loaded npm, Git, and Pi Kit packages retain `pi.skills` filters and package provenance. Resolver regressions cover checkout and filtered-package behavior; unsupported source manifest aliases are removed.
|
|
10
|
+
- `Drift-safe distributive`: The compiled runtime and Skill tree are now committed for self-contained Git installs. Builds compile into a temporary candidate and use a rollback-safe swap; validation rejects stale committed output without silently repairing it.
|
|
11
|
+
|
|
7
12
|
## 0.51.3: Follower recovery and filterable Skills
|
|
8
13
|
|
|
9
14
|
- `Follower slot reconciliation`: Re-registration now treats the exact Workspace claim as canonical when a retained follower record carries a different slot. It repairs the record before binding commit, preserves the unrelated binding that owns the stale letter, and makes repeated registration idempotent instead of returning `Telegram Workspace binding claim changed.` forever.
|
|
@@ -26,7 +26,7 @@ From git:
|
|
|
26
26
|
pi install git:github.com/llblab/pi-telegram
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
Installed npm/git packages expose bundled Skills through their `pi.skills` manifest, so Pi package filters
|
|
29
|
+
Installed npm/git packages expose bundled Skills through their `pi.skills` manifest, so Pi package filters and package provenance remain authoritative. A checkout auto-discovered directly under Pi's user or project `extensions` directory contributes its adjacent source Skills even when Pi selects the checkout's compiled entrypoint; the two discovery paths are mutually exclusive.
|
|
30
30
|
|
|
31
31
|
The extension requires Pi `0.84.4` or newer, matching the package's peer dependencies. Its Activity API uses the public `agent_settled` lifecycle event to keep retries/continuations under one activity identity and release that identity only after the run fully settles.
|
|
32
32
|
|
|
@@ -319,7 +319,7 @@ The docs index lives at [docs/README.md](./docs/README.md).
|
|
|
319
319
|
|
|
320
320
|
## Development
|
|
321
321
|
|
|
322
|
-
Pi loads the compiled `dist/pi-telegram/index.js` entrypoint. After every project change, run `npm run build` before `/reload`, restart, or live verification;
|
|
322
|
+
Pi loads the compiled `dist/pi-telegram/index.js` entrypoint. The committed distributive also makes Git installs self-contained. After every project change, run `npm run build` before `/reload`, restart, or live verification; `npm run build:check` verifies source/artifact synchronization without rewriting it.
|
|
323
323
|
|
|
324
324
|
```bash
|
|
325
325
|
npm run build
|
|
@@ -4,5 +4,11 @@
|
|
|
4
4
|
* Owns source-checkout skill contribution; installed packages use their manifest
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI } from "./pi.ts";
|
|
7
|
+
export declare function getTelegramExtensionPackageRoot(modulePath: string): string;
|
|
8
|
+
export interface TelegramRawExtensionCheckoutOptions {
|
|
9
|
+
agentDir?: string;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function isRawTelegramExtensionCheckout(modulePath: string, options?: TelegramRawExtensionCheckoutOptions): boolean;
|
|
7
13
|
export declare const TELEGRAM_SKILLS_PATH: string;
|
|
8
|
-
export declare function registerTelegramSkillDiscovery(pi: Pick<ExtensionAPI, "on">, modulePath?: string): boolean;
|
|
14
|
+
export declare function registerTelegramSkillDiscovery(pi: Pick<ExtensionAPI, "on">, modulePath?: string, options?: TelegramRawExtensionCheckoutOptions): boolean;
|
|
@@ -3,17 +3,42 @@
|
|
|
3
3
|
* Zones: pi agent, telegram guidance
|
|
4
4
|
* Owns source-checkout skill contribution; installed packages use their manifest
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { resolveAgentDir } from "./paths.js";
|
|
8
10
|
const TELEGRAM_SKILLS_MODULE_PATH = fileURLToPath(import.meta.url);
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
export function getTelegramExtensionPackageRoot(modulePath) {
|
|
12
|
+
let current = dirname(modulePath);
|
|
13
|
+
while (true) {
|
|
14
|
+
if (existsSync(join(current, "package.json"))) {
|
|
15
|
+
const parent = dirname(current);
|
|
16
|
+
if (basename(current) === "dist" && existsSync(join(parent, "package.json"))) {
|
|
17
|
+
return parent;
|
|
18
|
+
}
|
|
19
|
+
return current;
|
|
20
|
+
}
|
|
21
|
+
const parent = dirname(current);
|
|
22
|
+
if (parent === current)
|
|
23
|
+
return dirname(modulePath);
|
|
24
|
+
current = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function isRawTelegramExtensionCheckout(modulePath, options = {}) {
|
|
28
|
+
const packageRoot = resolve(getTelegramExtensionPackageRoot(modulePath));
|
|
29
|
+
const agentDir = resolve(options.agentDir ?? resolveAgentDir());
|
|
30
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
31
|
+
return dirname(packageRoot) === join(agentDir, "extensions") ||
|
|
32
|
+
dirname(packageRoot) === join(cwd, ".pi", "extensions");
|
|
33
|
+
}
|
|
34
|
+
export const TELEGRAM_SKILLS_PATH = join(getTelegramExtensionPackageRoot(TELEGRAM_SKILLS_MODULE_PATH), "skills");
|
|
35
|
+
export function registerTelegramSkillDiscovery(pi, modulePath = TELEGRAM_SKILLS_MODULE_PATH, options = {}) {
|
|
36
|
+
// Pi auto-discovers extension entrypoints but not their manifest Skills.
|
|
37
|
+
// Manifest-loaded packages own Skills and filters through `pi.skills`.
|
|
38
|
+
if (!isRawTelegramExtensionCheckout(modulePath, options))
|
|
14
39
|
return false;
|
|
15
40
|
pi.on("resources_discover", () => ({
|
|
16
|
-
skillPaths: [
|
|
41
|
+
skillPaths: [join(getTelegramExtensionPackageRoot(modulePath), "skills")],
|
|
17
42
|
}));
|
|
18
43
|
return true;
|
|
19
44
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-telegram",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -31,12 +31,13 @@
|
|
|
31
31
|
"test:verbose": "node --experimental-strip-types --test --test-reporter=spec tests/*.test.ts",
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
33
|
"build": "node scripts/build-dist.mjs",
|
|
34
|
+
"build:check": "node scripts/build-dist.mjs --check",
|
|
34
35
|
"prepack": "npm run build",
|
|
35
36
|
"check": "node -e \"await import('./dist/pi-telegram/index.js'); console.log('pi-telegram: extension import ok')\"",
|
|
36
37
|
"audit": "npm audit --omit=peer",
|
|
37
38
|
"audit:host": "npm audit",
|
|
38
39
|
"pack:check": "npm pack --dry-run",
|
|
39
|
-
"validate": "npm run build && npm run typecheck && npm test && npm run audit && npm run check && npm run pack:check"
|
|
40
|
+
"validate": "npm run build:check && npm run typecheck && npm test && npm run audit && npm run check && npm run pack:check"
|
|
40
41
|
},
|
|
41
42
|
"files": [
|
|
42
43
|
"index.ts",
|
|
@@ -102,15 +103,9 @@
|
|
|
102
103
|
"extensions": [
|
|
103
104
|
"./dist/pi-telegram/index.js"
|
|
104
105
|
],
|
|
105
|
-
"sourceExtensions": [
|
|
106
|
-
"./index.ts"
|
|
107
|
-
],
|
|
108
106
|
"skills": [
|
|
109
107
|
"./dist/skills"
|
|
110
108
|
],
|
|
111
|
-
"sourceSkills": [
|
|
112
|
-
"./skills"
|
|
113
|
-
],
|
|
114
109
|
"image": "https://raw.githubusercontent.com/llblab/pi-telegram/main/screenshot.png"
|
|
115
110
|
},
|
|
116
111
|
"peerDependencies": {
|