@llblab/pi-kit 0.22.2 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -5
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +4 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +10 -0
  5. package/node_modules/@llblab/pi-actors/README.md +2 -2
  6. package/node_modules/@llblab/pi-actors/dist/index.js +4 -1
  7. package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.js +2 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/paths.d.ts +6 -0
  9. package/node_modules/@llblab/pi-actors/dist/lib/paths.js +19 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/trace-projection.d.ts +2 -0
  11. package/node_modules/@llblab/pi-actors/dist/lib/trace-projection.js +11 -7
  12. package/node_modules/@llblab/pi-actors/dist/scripts/build-dist.mjs +94 -30
  13. package/node_modules/@llblab/pi-actors/docs/actor-inspector.md +1 -1
  14. package/node_modules/@llblab/pi-actors/index.ts +6 -3
  15. package/node_modules/@llblab/pi-actors/lib/inspector-overlay.ts +2 -1
  16. package/node_modules/@llblab/pi-actors/lib/paths.ts +27 -1
  17. package/node_modules/@llblab/pi-actors/lib/trace-projection.ts +13 -6
  18. package/node_modules/@llblab/pi-actors/package.json +3 -8
  19. package/node_modules/@llblab/pi-actors/scripts/build-dist.mjs +94 -30
  20. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +10 -6
  21. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +7 -0
  22. package/node_modules/@llblab/pi-grow-loop/README.md +2 -0
  23. package/node_modules/@llblab/pi-grow-loop/dist/index.d.ts +33 -0
  24. package/node_modules/@llblab/pi-grow-loop/dist/index.js +286 -0
  25. package/node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.d.ts +1 -0
  26. package/node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.js +1 -0
  27. package/node_modules/@llblab/pi-grow-loop/dist/skills/grow-loop/SKILL.md +117 -0
  28. package/node_modules/@llblab/pi-grow-loop/dist/skills/while-true/SKILL.md +233 -0
  29. package/node_modules/@llblab/pi-grow-loop/index.ts +67 -12
  30. package/node_modules/@llblab/pi-grow-loop/package.json +9 -8
  31. package/node_modules/@llblab/pi-state-flow/AGENTS.md +23 -17
  32. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
  33. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
  34. package/node_modules/@llblab/pi-state-flow/README.md +18 -6
  35. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -1
  36. package/node_modules/@llblab/pi-state-flow/dist/index.js +1 -0
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +2 -2
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +5 -5
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +3 -2
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +7 -2
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.d.ts +5 -5
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +54 -40
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +20 -20
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +755 -325
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -2
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +14 -17
  47. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +4 -0
  48. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +65 -23
  49. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +16 -5
  50. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +32 -15
  51. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +28 -2
  52. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +276 -26
  53. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +5 -0
  54. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +36 -2
  55. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +3 -0
  56. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +3 -0
  57. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -0
  58. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +3 -1
  59. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +11 -0
  60. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +150 -24
  61. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +14 -1
  62. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +52 -18
  63. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +8 -8
  64. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  65. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +3 -1
  66. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +53 -7
  67. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +84 -44
  68. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -2
  69. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +6 -4
  70. package/node_modules/@llblab/pi-state-flow/docs/performance.md +2 -2
  71. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +28 -8
  72. package/node_modules/@llblab/pi-state-flow/docs/usage.md +41 -14
  73. package/node_modules/@llblab/pi-state-flow/index.ts +3 -0
  74. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +5 -5
  75. package/node_modules/@llblab/pi-state-flow/lib/context.ts +8 -3
  76. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +57 -40
  77. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +20 -20
  78. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +719 -316
  79. package/node_modules/@llblab/pi-state-flow/lib/git.ts +16 -18
  80. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +60 -24
  81. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +34 -21
  82. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +290 -25
  83. package/node_modules/@llblab/pi-state-flow/lib/session.ts +37 -2
  84. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +3 -0
  85. package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -1
  86. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +141 -22
  87. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +60 -19
  88. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +8 -8
  89. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  90. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +3 -1
  91. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  92. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  93. package/node_modules/@llblab/pi-telegram/README.md +2 -2
  94. package/node_modules/@llblab/pi-telegram/dist/lib/skills.d.ts +7 -1
  95. package/node_modules/@llblab/pi-telegram/dist/lib/skills.js +32 -7
  96. package/node_modules/@llblab/pi-telegram/dist/package.json +3 -8
  97. package/node_modules/@llblab/pi-telegram/lib/skills.ts +43 -7
  98. package/node_modules/@llblab/pi-telegram/package.json +3 -8
  99. package/node_modules/@llblab/pi-telegram/scripts/build-dist.mjs +103 -32
  100. package/package.json +7 -7
@@ -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 liveForeignLockOwner(path: string): boolean {
42
+ function publicationLockOwner(path: string, allowCurrentProcess: boolean): "live" | "pending" | "absent" | "unavailable" {
41
43
  let owner: string;
42
- try { owner = readFileSync(path, "utf8").trim(); }
43
- catch { return true; }
44
- if (owner.length === 0) return true;
45
- if (!/^[1-9]\d*$/.test(owner)) return false;
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 false;
48
- try { process.kill(pid, 0); return true; }
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" || !liveForeignLockOwner(path) || Date.now() >= deadline) throw unavailable(error);
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}; reconcile the active or interrupted publisher before retrying`, { cause },
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
- const current = { files: captureTemporalFileBases(cwd, sessionId, locked, sessionKey) };
199
- assertTemporalFileBase(base, current);
200
- const { updates } = planTemporalPublication(cwd, sessionId, view, scopes, current, locked, runtime, runtimeOnly, sessionKey, provenance);
201
- const next = { files: temporalFileReceipts(current, updates) };
202
- decodeFileCohort(cwd, sessionId, locked, next, sessionKey);
203
- const revision = fileRevision(next, locked);
204
- publishFileUpdates(current.files, updates, locked);
205
- return { base: next, revision, changed: updates.length > 0 };
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
- const state = port.state(ctx.payload);
251
- const live = port.snapshot();
252
- const revisions = port.revisions?.() ?? live.revisions ?? { global: live.step, cwd: live.step, session: live.step };
253
- await ctx.openRich(renderStateFlowRichState(ctx.payload, revisions, state));
254
- await ctx.answerCallback();
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
- if (ctx.action === "start") {
258
- if (port.canStartNow()) notice = port.start().message;
259
- else {
260
- port.deferStart();
261
- notice = "State Flow will start after the current turn";
262
- }
263
- } else if (ctx.action === "stop") {
264
- notice = port.stop().message;
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 = error instanceof Error ? error.message : String(error);
305
+ notice = diagnosticText(error);
271
306
  }
272
- await ctx.answerCallback(notice);
273
- await ctx.edit(buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action)));
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}`;
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 a non-empty string");
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 a non-empty object");
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 compiler output at ${target} is invalid: ${problems.join("; ")}. Example: {${JSON.stringify(scope)}:{"artifacts":{${JSON.stringify(path)}:{"description":"What this Skill provides","kind":"skill","compilation":{"rules":["Operational rule retained from the 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 {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -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 can select individual Skills. A raw TypeScript checkout placed directly under Pi's `extensions` directory instead contributes its adjacent source Skills at runtime; the two discovery paths are mutually exclusive.
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; reloading source without rebuilding can leave the running extension on stale compiled code.
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 { extname } from "node:path";
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 const TELEGRAM_SKILLS_PATH = fileURLToPath(new URL("../skills", import.meta.url));
10
- export function registerTelegramSkillDiscovery(pi, modulePath = TELEGRAM_SKILLS_MODULE_PATH) {
11
- // A raw source extension has no package manifest owner. Compiled npm/git
12
- // packages do, so contributing again would bypass their resource filters.
13
- if (extname(modulePath) !== ".ts")
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: [TELEGRAM_SKILLS_PATH],
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",
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": {