@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +4 -4
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +10 -6
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +7 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +2 -0
  6. package/node_modules/@llblab/pi-grow-loop/dist/index.d.ts +33 -0
  7. package/node_modules/@llblab/pi-grow-loop/dist/index.js +286 -0
  8. package/node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.d.ts +1 -0
  9. package/node_modules/@llblab/pi-grow-loop/dist/pi-grow-loop/index.js +1 -0
  10. package/node_modules/@llblab/pi-grow-loop/dist/skills/grow-loop/SKILL.md +117 -0
  11. package/node_modules/@llblab/pi-grow-loop/dist/skills/while-true/SKILL.md +233 -0
  12. package/node_modules/@llblab/pi-grow-loop/index.ts +67 -12
  13. package/node_modules/@llblab/pi-grow-loop/package.json +9 -8
  14. package/node_modules/@llblab/pi-state-flow/AGENTS.md +23 -17
  15. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +5 -3
  16. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +11 -0
  17. package/node_modules/@llblab/pi-state-flow/README.md +18 -6
  18. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -1
  19. package/node_modules/@llblab/pi-state-flow/dist/index.js +1 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.d.ts +2 -2
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/artifact.js +5 -5
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +3 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +7 -2
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.d.ts +5 -5
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +54 -40
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +20 -20
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +755 -325
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +2 -2
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +14 -17
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +4 -0
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +65 -23
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.d.ts +16 -5
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/recovery.js +32 -15
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +28 -2
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +276 -26
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +5 -0
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +36 -2
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +3 -0
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +3 -0
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -0
  41. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +3 -1
  42. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +11 -0
  43. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +150 -24
  44. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +14 -1
  45. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +52 -18
  46. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +8 -8
  47. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  48. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +3 -1
  49. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +53 -7
  50. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +84 -44
  51. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +12 -2
  52. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +6 -4
  53. package/node_modules/@llblab/pi-state-flow/docs/performance.md +2 -2
  54. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +28 -8
  55. package/node_modules/@llblab/pi-state-flow/docs/usage.md +41 -14
  56. package/node_modules/@llblab/pi-state-flow/index.ts +3 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +5 -5
  58. package/node_modules/@llblab/pi-state-flow/lib/context.ts +8 -3
  59. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +57 -40
  60. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +20 -20
  61. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +719 -316
  62. package/node_modules/@llblab/pi-state-flow/lib/git.ts +16 -18
  63. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +60 -24
  64. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +34 -21
  65. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +290 -25
  66. package/node_modules/@llblab/pi-state-flow/lib/session.ts +37 -2
  67. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +3 -0
  68. package/node_modules/@llblab/pi-state-flow/lib/status.ts +4 -1
  69. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +141 -22
  70. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +60 -19
  71. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +8 -8
  72. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  73. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +3 -1
  74. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  75. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  76. package/node_modules/@llblab/pi-telegram/README.md +2 -2
  77. package/node_modules/@llblab/pi-telegram/dist/lib/skills.d.ts +7 -1
  78. package/node_modules/@llblab/pi-telegram/dist/lib/skills.js +32 -7
  79. package/node_modules/@llblab/pi-telegram/dist/package.json +3 -8
  80. package/node_modules/@llblab/pi-telegram/lib/skills.ts +43 -7
  81. package/node_modules/@llblab/pi-telegram/package.json +3 -8
  82. package/node_modules/@llblab/pi-telegram/scripts/build-dist.mjs +103 -32
  83. 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 { assertOwnedFileUpdates, captureTemporalFileBases, parseScopeProvenance, parseScopeStream, restoreDurableFileBases, serializeScopeMetadata, sessionRuntimePaths, temporalScopePaths, temporalStateFileUpdates, writeOwnedFileUpdates, } from "./durable.js";
7
9
  import { MAX_HISTORY_LIMIT } from "./history.js";
8
10
  import { hashJson, sameJson } from "./json.js";
@@ -17,7 +19,7 @@ export function assertStorageDirectory(path) {
17
19
  assertStorageDirectory(parent);
18
20
  const stat = lstatSync(root, { throwIfNoEntry: false });
19
21
  if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
20
- throw new Error(`State Flow repository path is not a regular directory: ${root}`);
22
+ throw new Error(`State Flow repository path is not a regular directory: ${JSON.stringify(root)}`);
21
23
  }
22
24
  /** Explicit creation only; existing bytes and unrelated files are never adopted or rewritten here. */
23
25
  export function initializeFileStore(root) {
@@ -27,27 +29,34 @@ export function initializeFileStore(root) {
27
29
  const PUBLICATION_LOCK_WAIT_MS = 2_000;
28
30
  const PUBLICATION_LOCK_POLL_MS = 25;
29
31
  const publicationLockWait = new Int32Array(new SharedArrayBuffer(4));
30
- function liveForeignLockOwner(path) {
32
+ function publicationLockOwner(path, allowCurrentProcess) {
31
33
  let owner;
32
34
  try {
33
- owner = readFileSync(path, "utf8").trim();
35
+ const stat = lstatSync(path, { throwIfNoEntry: false });
36
+ if (!stat)
37
+ return "absent";
38
+ if (!stat.isFile() || stat.isSymbolicLink())
39
+ return "unavailable";
40
+ owner = readFileSync(path, { encoding: "utf8", flag: constants.O_RDONLY | constants.O_NOFOLLOW }).trim();
34
41
  }
35
- catch {
36
- return true;
42
+ catch (error) {
43
+ if (error.code === "ENOENT")
44
+ return "absent";
45
+ throw error;
37
46
  }
38
47
  if (owner.length === 0)
39
- return true;
48
+ return "pending";
40
49
  if (!/^[1-9]\d*$/.test(owner))
41
- return false;
50
+ return "unavailable";
42
51
  const pid = Number(owner);
43
- if (!Number.isSafeInteger(pid) || pid === process.pid)
44
- return false;
52
+ if (!Number.isSafeInteger(pid) || (pid === process.pid && !allowCurrentProcess))
53
+ return "unavailable";
45
54
  try {
46
55
  process.kill(pid, 0);
47
- return true;
56
+ return "live";
48
57
  }
49
58
  catch (error) {
50
- return error.code === "EPERM";
59
+ return error.code === "EPERM" ? "live" : "unavailable";
51
60
  }
52
61
  }
53
62
  /** Wait only for a cooperating live owner; interrupted or malformed locks remain explicit recovery errors. */
@@ -58,7 +67,16 @@ export function acquirePublicationLock(path, unavailable) {
58
67
  return openSync(path, "wx", 0o600);
59
68
  }
60
69
  catch (error) {
61
- if (error.code !== "EEXIST" || !liveForeignLockOwner(path) || Date.now() >= deadline)
70
+ if (error.code !== "EEXIST")
71
+ throw unavailable(error);
72
+ let owner;
73
+ try {
74
+ owner = publicationLockOwner(path, false);
75
+ }
76
+ catch (cause) {
77
+ throw unavailable(cause);
78
+ }
79
+ if (owner === "unavailable" || Date.now() >= deadline)
62
80
  throw unavailable(error);
63
81
  Atomics.wait(publicationLockWait, 0, 0, Math.min(PUBLICATION_LOCK_POLL_MS, deadline - Date.now()));
64
82
  }
@@ -69,7 +87,7 @@ export function withStoragePublicationLock(repositoryRoot, action) {
69
87
  const root = resolve(repositoryRoot);
70
88
  assertStorageDirectory(root);
71
89
  const path = resolve(root, ".state-flow-publication.lock");
72
- const descriptor = acquirePublicationLock(path, (cause) => new RevisionUnavailableError(`State Flow publication lock is unavailable at ${path}; reconcile the active or interrupted publisher before retrying`, { cause }));
90
+ const descriptor = acquirePublicationLock(path, (cause) => new RevisionUnavailableError(`State Flow publication lock is unavailable at ${JSON.stringify(path)}`, { cause }));
73
91
  try {
74
92
  writeFileSync(descriptor, `${process.pid}\n`);
75
93
  return action(root);
@@ -79,6 +97,112 @@ export function withStoragePublicationLock(repositoryRoot, action) {
79
97
  rmSync(path);
80
98
  }
81
99
  }
100
+ export class PublicationBusyError extends Error {
101
+ constructor(path) { super(`State Flow publication lock is busy at ${JSON.stringify(path)}`); }
102
+ }
103
+ const storageLockContext = new AsyncLocalStorage();
104
+ /** Await an exact file mutex; shared by canonical transactions and the independent Git backup owner. */
105
+ export async function withFilePublicationLock(lockPath, action, signal, unavailable = (cause) => new RevisionUnavailableError(`State Flow publication lock is unavailable at ${JSON.stringify(resolve(lockPath))}`, { cause }), waitForLock = true) {
106
+ const path = resolve(lockPath);
107
+ const inherited = storageLockContext.getStore();
108
+ if (inherited?.some((held) => held.active && held.path === path))
109
+ throw new Error(`Recursive State Flow publication lock at ${JSON.stringify(path)}`);
110
+ let descriptor;
111
+ let pendingSince;
112
+ while (true) {
113
+ signal?.throwIfAborted();
114
+ assertStorageDirectory(dirname(path));
115
+ try {
116
+ descriptor = openSync(path, "wx", 0o600);
117
+ break;
118
+ }
119
+ catch (error) {
120
+ if (error.code !== "EEXIST")
121
+ throw unavailable(error);
122
+ let owner;
123
+ try {
124
+ owner = publicationLockOwner(path, true);
125
+ }
126
+ catch (cause) {
127
+ throw unavailable(cause);
128
+ }
129
+ if (owner === "unavailable")
130
+ throw unavailable(error);
131
+ if (!waitForLock)
132
+ throw new PublicationBusyError(path);
133
+ // An empty file may be between exclusive creation and PID publication, but not forever.
134
+ if (owner === "pending")
135
+ pendingSince ??= performance.now();
136
+ else
137
+ pendingSince = undefined;
138
+ if (pendingSince !== undefined && performance.now() - pendingSince >= PUBLICATION_LOCK_WAIT_MS)
139
+ throw unavailable(error);
140
+ }
141
+ await delay(PUBLICATION_LOCK_POLL_MS, undefined, { signal });
142
+ }
143
+ let identity;
144
+ const held = { path, active: true };
145
+ let failed;
146
+ try {
147
+ identity = fstatSync(descriptor);
148
+ writeFileSync(descriptor, `${process.pid}\n`);
149
+ signal?.throwIfAborted();
150
+ return await storageLockContext.run([...(inherited?.filter((owner) => owner.active) ?? []), held], action);
151
+ }
152
+ catch (error) {
153
+ failed = { error };
154
+ throw error;
155
+ }
156
+ finally {
157
+ held.active = false;
158
+ try {
159
+ const current = lstatSync(path, { throwIfNoEntry: false });
160
+ if (!identity || !current?.isFile() || current.dev !== identity.dev || current.ino !== identity.ino
161
+ || readFileSync(path, { encoding: "utf8", flag: constants.O_RDONLY | constants.O_NOFOLLOW }) !== `${process.pid}\n`) {
162
+ throw new Error(`State Flow publication lock changed during transaction at ${JSON.stringify(path)}; current owner preserved`);
163
+ }
164
+ rmSync(path);
165
+ }
166
+ catch (error) {
167
+ if (failed)
168
+ throw new AggregateError([failed.error, error], "State Flow storage transaction and lock release failed");
169
+ throw error;
170
+ }
171
+ finally {
172
+ closeSync(descriptor);
173
+ }
174
+ }
175
+ }
176
+ /** Await store-wide exclusion, then capture/apply/publish through callback-scoped operations. */
177
+ export async function withStorageTransaction(repositoryRoot, action, signal, waitForLock = true) {
178
+ const root = resolve(repositoryRoot);
179
+ return withFilePublicationLock(resolve(root, ".state-flow-publication.lock"), async () => {
180
+ let active = true;
181
+ const guard = (requestedRoot) => {
182
+ if (!active)
183
+ throw new Error("State Flow storage transaction has ended");
184
+ if (resolve(requestedRoot) !== root)
185
+ throw new Error("State Flow storage transaction belongs to a different store");
186
+ signal?.throwIfAborted();
187
+ };
188
+ const transaction = Object.freeze({
189
+ capture: (cwd, sessionId, requestedRoot, sessionKey = sessionId) => {
190
+ guard(requestedRoot);
191
+ return { files: captureTemporalFileBases(cwd, sessionId, root, sessionKey) };
192
+ },
193
+ publish: (cwd, sessionId, view, scopes, base, requestedRoot, runtime, sessionKey, provenance, runtimeOnly) => {
194
+ guard(requestedRoot);
195
+ return publishLockedTemporalStateToFiles(cwd, sessionId, view, scopes, base, root, runtime, sessionKey, provenance, runtimeOnly);
196
+ },
197
+ });
198
+ try {
199
+ return await action(transaction);
200
+ }
201
+ finally {
202
+ active = false;
203
+ }
204
+ }, signal, undefined, waitForLock);
205
+ }
82
206
  export function assertTemporalFileBase(expected, current) {
83
207
  if (expected.files.length !== current.files.length || current.files.some((file, index) => {
84
208
  const previous = expected.files[index];
@@ -191,16 +315,18 @@ export function loadTemporalFileRevision(cwd, sessionId, root, revision, session
191
315
  }
192
316
  /** Publish a validated canonical cohort; runtimeOnly owns only session config/runtime files. */
193
317
  export function publishTemporalStateToFiles(cwd, sessionId, view, scopes, base, root, runtime, sessionKey = sessionId, provenance, runtimeOnly = false) {
194
- return withStoragePublicationLock(root, (locked) => {
195
- const current = { files: captureTemporalFileBases(cwd, sessionId, locked, sessionKey) };
196
- assertTemporalFileBase(base, current);
197
- const { updates } = planTemporalPublication(cwd, sessionId, view, scopes, current, locked, runtime, runtimeOnly, sessionKey, provenance);
198
- const next = { files: temporalFileReceipts(current, updates) };
199
- decodeFileCohort(cwd, sessionId, locked, next, sessionKey);
200
- const revision = fileRevision(next, locked);
201
- publishFileUpdates(current.files, updates, locked);
202
- return { base: next, revision, changed: updates.length > 0 };
203
- });
318
+ return withStoragePublicationLock(root, (locked) => publishLockedTemporalStateToFiles(cwd, sessionId, view, scopes, base, locked, runtime, sessionKey, provenance, runtimeOnly));
319
+ }
320
+ /** Shared publication owner; callers must hold the matching store lock for the complete cohort. */
321
+ function publishLockedTemporalStateToFiles(cwd, sessionId, view, scopes, base, root, runtime, sessionKey = sessionId, provenance, runtimeOnly = false) {
322
+ const current = { files: captureTemporalFileBases(cwd, sessionId, root, sessionKey) };
323
+ assertTemporalFileBase(base, current);
324
+ const { updates } = planTemporalPublication(cwd, sessionId, view, scopes, current, root, runtime, runtimeOnly, sessionKey, provenance);
325
+ const next = { files: temporalFileReceipts(current, updates) };
326
+ decodeFileCohort(cwd, sessionId, root, next, sessionKey);
327
+ const revision = fileRevision(next, root);
328
+ publishFileUpdates(current.files, updates, root);
329
+ return { base: next, revision, changed: updates.length > 0 };
204
330
  }
205
331
  function publishFileUpdates(bases, updates, root) {
206
332
  writeOwnedFileUpdates(updates, bases, root);
@@ -78,6 +78,14 @@ export type StateFlowTelegramLoader = () => Promise<StateFlowTelegramModules>;
78
78
  export interface StateFlowTelegramControlResult {
79
79
  ok: boolean;
80
80
  message: string;
81
+ /** A completed control can lose its presentation authority after a mode or selection change. */
82
+ signal?: AbortSignal;
83
+ }
84
+ export interface StateFlowTelegramInspection {
85
+ state: StateFlowTelegramState;
86
+ revisions: ScopeRevisions;
87
+ /** A selection can revoke a completed observation before the adapter presents it. */
88
+ signal?: AbortSignal;
81
89
  }
82
90
  export interface StateFlowTelegramPort {
83
91
  snapshot(): StateFlowTelegramSnapshot;
@@ -90,6 +98,11 @@ export interface StateFlowTelegramPort {
90
98
  deferStart(): void;
91
99
  cancelStart(): void;
92
100
  }
101
+ export interface StateFlowTelegramInspectionPort extends Omit<StateFlowTelegramPort, "state" | "revisions" | "start" | "stop"> {
102
+ inspect(scope: StateFlowTelegramScope): StateFlowTelegramInspection | Promise<StateFlowTelegramInspection>;
103
+ start(): StateFlowTelegramControlResult | Promise<StateFlowTelegramControlResult>;
104
+ stop(): StateFlowTelegramControlResult | Promise<StateFlowTelegramControlResult>;
105
+ }
93
106
  export interface StateFlowTelegramAdapter {
94
107
  ensure(): Promise<boolean>;
95
108
  dispose(): void;
@@ -103,6 +116,6 @@ export declare function renderStateFlowRichState(scope: StateFlowTelegramScope,
103
116
  /** Default loader; injectable so tests and embedded hosts can control transport presence. */
104
117
  export declare function loadStateFlowTelegramModules(): Promise<StateFlowTelegramModules>;
105
118
  export declare function createStateFlowTelegramAdapter(options: {
106
- port: StateFlowTelegramPort;
119
+ port: StateFlowTelegramPort | StateFlowTelegramInspectionPort;
107
120
  load?: StateFlowTelegramLoader;
108
121
  }): StateFlowTelegramAdapter;
@@ -10,6 +10,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
10
10
  }
11
11
  return path;
12
12
  };
13
+ import { conciseDiagnostic, diagnosticText } from "./protocol.js";
13
14
  import { formatScopeRevisionVector } from "./status.js";
14
15
  export const STATE_FLOW_TELEGRAM_ID = "@llblab/pi-state-flow";
15
16
  /** Resolve the package export or the compiled sibling-extension layout used in local development. */
@@ -124,7 +125,8 @@ export function renderStateFlowRichState(scope, revisions, state) {
124
125
  function isStateFlowTelegramScope(value) {
125
126
  return value === "global" || value === "cwd" || value === "session" || value === "effective";
126
127
  }
127
- function buildStateFlowTelegramSection(port) {
128
+ function buildStateFlowTelegramSection(port, isActive) {
129
+ let interaction = 0;
128
130
  return {
129
131
  id: STATE_FLOW_TELEGRAM_ID,
130
132
  label: "🌀 State Flow",
@@ -134,7 +136,9 @@ function buildStateFlowTelegramSection(port) {
134
136
  // cancel/refresh remain routable for keyboards sent by earlier versions.
135
137
  if (ctx.action !== "start" && ctx.action !== "stop" && ctx.action !== "cancel" && ctx.action !== "refresh" && ctx.action !== "show-state" && ctx.action !== "inspect" && ctx.action !== "back")
136
138
  return "pass";
139
+ const request = ++interaction;
137
140
  let notice;
141
+ let acknowledged = false;
138
142
  try {
139
143
  if (ctx.action === "show-state") {
140
144
  await ctx.answerCallback();
@@ -144,23 +148,44 @@ function buildStateFlowTelegramSection(port) {
144
148
  if (ctx.action === "inspect") {
145
149
  if (!isStateFlowTelegramScope(ctx.payload))
146
150
  throw new Error("Unknown State Flow scope");
147
- const state = port.state(ctx.payload);
148
- const live = port.snapshot();
149
- const revisions = port.revisions?.() ?? live.revisions ?? { global: live.step, cwd: live.step, session: live.step };
150
- await ctx.openRich(renderStateFlowRichState(ctx.payload, revisions, state));
151
- await ctx.answerCallback();
151
+ let observation;
152
+ if ("inspect" in port) {
153
+ await ctx.answerCallback("Reading State Flow memory");
154
+ acknowledged = true;
155
+ observation = await port.inspect(ctx.payload);
156
+ }
157
+ else {
158
+ const state = port.state(ctx.payload);
159
+ const live = port.snapshot();
160
+ observation = { state, revisions: port.revisions?.() ?? live.revisions ?? { global: live.step, cwd: live.step, session: live.step } };
161
+ }
162
+ observation.signal?.throwIfAborted();
163
+ if (request !== interaction || !isActive())
164
+ return "handled";
165
+ await ctx.openRich(renderStateFlowRichState(ctx.payload, observation.revisions, observation.state));
166
+ if (!acknowledged)
167
+ await ctx.answerCallback();
152
168
  return "handled";
153
169
  }
154
- if (ctx.action === "start") {
155
- if (port.canStartNow())
156
- notice = port.start().message;
157
- else {
158
- port.deferStart();
159
- notice = "State Flow will start after the current turn";
170
+ const action = ctx.action === "stop" || (ctx.action === "start" && port.canStartNow()) ? ctx.action : undefined;
171
+ if (action) {
172
+ if ("inspect" in port) {
173
+ acknowledged = true;
174
+ // Start the control immediately and acknowledge in parallel; neither promise can reject unobserved.
175
+ const [, result] = await Promise.all([
176
+ ctx.answerCallback(action === "stop" ? "Stopping State Flow" : "Starting State Flow"),
177
+ Promise.resolve().then(() => port[action]()),
178
+ ]);
179
+ if (result.signal?.aborted)
180
+ return "handled";
181
+ notice = result.message;
160
182
  }
183
+ else
184
+ notice = port[action]().message;
161
185
  }
162
- else if (ctx.action === "stop") {
163
- notice = port.stop().message;
186
+ else if (ctx.action === "start") {
187
+ port.deferStart();
188
+ notice = "State Flow will start after the current turn";
164
189
  }
165
190
  else if (ctx.action === "cancel") {
166
191
  port.cancelStart();
@@ -168,10 +193,19 @@ function buildStateFlowTelegramSection(port) {
168
193
  }
169
194
  }
170
195
  catch (error) {
171
- notice = error instanceof Error ? error.message : String(error);
196
+ notice = diagnosticText(error);
197
+ }
198
+ if (request !== interaction || !isActive())
199
+ return "handled";
200
+ const summary = notice === undefined ? undefined : conciseDiagnostic(notice, 200);
201
+ const view = buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action));
202
+ if (acknowledged && summary !== undefined) {
203
+ // Callback queries can expire during storage waits; retain errors in the existing menu instead.
204
+ view.text += `\n\n${summary.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}`;
172
205
  }
173
- await ctx.answerCallback(notice);
174
- await ctx.edit(buildStateFlowSectionView(port.snapshot(), (action) => ctx.callbackData(action)));
206
+ else if (!acknowledged)
207
+ await ctx.answerCallback(summary);
208
+ await ctx.edit(view);
175
209
  return "handled";
176
210
  },
177
211
  };
@@ -214,7 +248,7 @@ export function createStateFlowTelegramAdapter(options) {
214
248
  return false;
215
249
  if (!sectionRegistered && modules.sections) {
216
250
  try {
217
- const dispose = modules.sections.registerTelegramSection(buildStateFlowTelegramSection(options.port));
251
+ const dispose = modules.sections.registerTelegramSection(buildStateFlowTelegramSection(options.port, () => epoch === generation));
218
252
  if (epoch === generation) {
219
253
  disposers.push(dispose);
220
254
  sectionRegistered = true;
@@ -31,16 +31,16 @@ function validateSkillCompilerOutput(scope, path, output) {
31
31
  problems.push("artifact entry is missing");
32
32
  else {
33
33
  if (typeof output.description !== "string" || output.description.trim().length === 0)
34
- problems.push("description must be a non-empty string");
34
+ problems.push("description must be non-empty");
35
35
  if (output.kind !== "skill")
36
36
  problems.push('kind must be "skill"');
37
37
  if (!isObject(output.compilation) || Object.keys(output.compilation).length === 0)
38
- problems.push("compilation must be a non-empty object");
38
+ problems.push("compilation object must be non-empty");
39
39
  }
40
40
  if (problems.length === 0)
41
41
  return;
42
42
  const target = `${scope}.artifacts[${JSON.stringify(path)}]`;
43
- 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"]}}}}}`);
43
+ throw new Error(`Invalid Skill at ${target}: ${problems.join("; ")}`);
44
44
  }
45
45
  function validateSkillCompilerTargets(patches, successfulSkillReads) {
46
46
  for (const read of successfulSkillReads) {
@@ -64,7 +64,7 @@ function compileReadSkills(scope, nextState, patch, successfulSkillReads, proven
64
64
  const output = patch.artifacts[read.path];
65
65
  validateSkillCompilerOutput(scope, read.path, output);
66
66
  const compiled = compileArtifact({
67
- source: { path: read.path, hash: read.hash },
67
+ source: { path: read.path, scope, hash: read.hash },
68
68
  compiler: SKILL_ARTIFACT_COMPILER,
69
69
  output: { ...structuredClone(output), kind: "skill" },
70
70
  });
@@ -81,11 +81,11 @@ function compileReadSkills(scope, nextState, patch, successfulSkillReads, proven
81
81
  }
82
82
  }
83
83
  }
84
- function validateMaterializedTransition(nextState) {
84
+ function validateMaterializedTransition(nextState, scope) {
85
85
  if (containsNull(nextState)) {
86
86
  throw new Error("Materialized state cannot contain null; use null only as an object-key deletion marker");
87
87
  }
88
- validateArtifactRegistry(nextState.artifacts);
88
+ validateArtifactRegistry(nextState.artifacts, `${scope}.artifacts`);
89
89
  if (Object.hasOwn(nextState.contract, "compiled_skills")) {
90
90
  throw new Error("contract.compiled_skills is retired; Skill compilations belong only in source-addressed artifacts");
91
91
  }
@@ -109,7 +109,7 @@ function validateScopePatch(scope, patch) {
109
109
  throw new Error("Scoped State Flow patch field lazy must be a JSON object");
110
110
  }
111
111
  if (isObject(patch.artifacts))
112
- validateModelArtifactPatch(patch.artifacts);
112
+ validateModelArtifactPatch(patch.artifacts, `${scope}.artifacts`);
113
113
  }
114
114
  function completePatch(patch, response) {
115
115
  return {
@@ -153,7 +153,7 @@ function stageScopedSemanticTransition(currentStates, transition, successfulSkil
153
153
  const nextState = applyPatch(currentStates[scope], patch);
154
154
  compileReadArtifacts(nextState, { artifacts: authored.artifacts ?? {} }, artifactReads.filter((read) => (read.scope ?? "global") === scope), provenanceUpdates[scope]);
155
155
  compileReadSkills(scope, nextState, { artifacts: authored.artifacts ?? {} }, skillReads.filter((read) => read.scope === scope), provenanceUpdates[scope]);
156
- validateMaterializedTransition(nextState);
156
+ validateMaterializedTransition(nextState, scope);
157
157
  nextStates[scope] = nextState;
158
158
  }
159
159
  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