@llblab/pi-kit 0.22.1 → 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 (87) hide show
  1. package/CHANGELOG.md +13 -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 +3 -2
  75. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +10 -0
  76. package/node_modules/@llblab/pi-telegram/README.md +3 -1
  77. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +22 -3
  78. package/node_modules/@llblab/pi-telegram/dist/lib/skills.d.ts +8 -2
  79. package/node_modules/@llblab/pi-telegram/dist/lib/skills.js +36 -4
  80. package/node_modules/@llblab/pi-telegram/dist/package.json +3 -8
  81. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  82. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  83. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +27 -3
  84. package/node_modules/@llblab/pi-telegram/lib/skills.ts +49 -5
  85. package/node_modules/@llblab/pi-telegram/package.json +3 -8
  86. package/node_modules/@llblab/pi-telegram/scripts/build-dist.mjs +103 -32
  87. 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 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
 
@@ -47,6 +47,7 @@ Keep each fact in one authoritative layer:
47
47
  - `/skills/generated-control-surface`: Optional state-derived, late-bound interface over truthful domain evidence, capabilities, workflows, and choices; it remains renderer-neutral, independent from the bridge skill, and owns no parallel state.
48
48
  - `/skills/generative-apps`: Agent operating contract for compiling stable repeated Telegram interaction into deterministic standalone applications or bounded view/controller adapters whose buttons bypass model inference.
49
49
  - `/skills/show-me`: Portable visual-explanation protocol with Telegram-aware phone-width Markdown and self-contained browser artifact guidance; it owns explanation shape and evidence honesty, not bridge transport.
50
+ - `Skill discovery`: Compiled npm/git packages expose bundled Skills only through `pi.skills`, preserving Pi package filters. A raw TypeScript extension checkout may contribute the source Skill root through `resources_discover`; compiled runtime and source runtime must never both own discovery.
50
51
  - `/.agents/skills/telegram-bot`: Bot API lookup guidance and vendored `api.md`; keep the reference intact.
51
52
  - `/.agents/skills/domain-dag`: Repository architecture guidance and validator.
52
53
 
@@ -82,7 +83,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
82
83
  - Admission is journal-first: validate and persist the complete `getUpdates` response before one monotonic offset commit, then signal an independent worker without awaiting semantic execution. Missing cursor with a non-empty journal, malformed/foreign authority, or capacity exhaustion fails closed. “Durable” means process-crash recovery after atomic rename, not unflushed host/kernel/filesystem/device/power-loss survival.
83
84
  - Storage cutovers must reconcile actual consumer locations before correcting path adapters. Never normalize a relative historical reference into new authority or treat equal reference strings / empty canonical storage as source completeness. Exact-path preflight is lexical only; physical identity, historical coverage, writer closure and migration remain separate proofs.
84
85
  - Workspace mutations acquire cross-process admission before their shared process-local gate and hold it through asynchronous API work and durable settlement. Topic lifecycle, complete unbound/reroute target handling, manual disconnect, and session-restart cleanup use profile-wide scope; either retained retirement-fence phase rejects them before state access. Cleanup scope spans intent publication, target mutation, persistence, and transport release. Detached reconciliation that mutates Thread state must reacquire fresh profile admission through the same gate; it cannot inherit a caller lease that ended before its timer runs. A live operation ID has one process-local caller: concurrent reuse is rejected before lease acquisition, while retry after the caller exits may resume exact durable authority.
85
- - Workspace retirement is capacity-pressure-only. Elapsed time and heartbeat silence never trigger deletion; only complete `A`–`Z` exhaustion may propose the oldest continuously proven inactive, fully unprotected binding. Exact deletion, durable retirement, and fence completion precede slot reuse. Authorized demand-driven rotation retries one failed fresh allocation after retirement; restore-only follower startup never evicts. Release ordinary registration/provisioning leases before acquiring the destructive fence, retain the shared mutation gate across retirement, and validate the exact permit immediately before one non-retried deletion. Persist an exact method/target-matched rejection before withdrawing its intent; release that fence only after durable withdrawal, retaining the binding. A later attempt requires fresh operation authority. Protection reads must never repair, quarantine, or reset journals. Non-destructive owner detachment must atomically retain one exact Workspace binding and its letter while removing only its uniquely matched owner record and stamping first inactivity; it never manufactures Thread-deletion evidence or clears accepted work. Retained prune observations are bounded, non-routing and registration/profile/epoch/runtime-fenced. One unfinished preservation operation retains its admission identity across fresh-PID-proof retries; it can never become deletion authority. Leader quit requires completed delivery/polling/worker teardown under the captured session generation, profile and epoch; reload/new/resume/fork never establish inactivity. Unknown deletion outcomes retain their fence; only confirmed durable completion permits reuse.
86
+ - Workspace retirement is capacity-pressure-only. Elapsed time and heartbeat silence never trigger deletion; only complete `A`–`Z` exhaustion may propose the oldest continuously proven inactive, fully unprotected binding. Exact deletion, durable retirement, and fence completion precede slot reuse. Authorized demand-driven rotation retries one failed fresh allocation after retirement; restore-only follower startup never evicts. Release ordinary registration/provisioning leases before acquiring the destructive fence, retain the shared mutation gate across retirement, and validate the exact permit immediately before one non-retried deletion. Persist an exact method/target-matched rejection before withdrawing its intent; release that fence only after durable withdrawal, retaining the binding. A later attempt requires fresh operation authority. Protection reads must never repair, quarantine, or reset journals. Non-destructive owner detachment must atomically retain one exact Workspace binding and its letter while removing only its uniquely matched owner record and stamping first inactivity; it never manufactures Thread-deletion evidence or clears accepted work. During authenticated follower registration, the exact Workspace claim owns the letter: a mismatched retained target record is repaired to that claim before binding commit, while any unrelated binding that owns the stale letter remains untouched. Retained prune observations are bounded, non-routing and registration/profile/epoch/runtime-fenced. One unfinished preservation operation retains its admission identity across fresh-PID-proof retries; it can never become deletion authority. Leader quit requires completed delivery/polling/worker teardown under the captured session generation, profile and epoch; reload/new/resume/fork never establish inactivity. Unknown deletion outcomes retain their fence; only confirmed durable completion permits reuse.
86
87
  - Foreign forwarding settles as `accepted`, `retryable`, or `terminal-rejected`. Only an authenticated acknowledgement carrying the expected `deliveryId` and `sourceUpdateId` releases leader journal authority. Negative, missing, stale, mismatched, or capacity-failed settlement remains durable; callback error answers are side effects only.
87
88
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
88
89
  - A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Cached presence is not current execution proof: prepared custody must revalidate the exact queued owner/group without recovery and refuse offers or uncertain reads. Completion requires an exact removal acknowledgement, never merely `!ready`; a retained acknowledgement permits local cleanup only, not replay. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
@@ -151,7 +152,7 @@ Before non-trivial work:
151
152
  While working:
152
153
 
153
154
  - Keep changes inside this repository; updating an installed Pi checkout is a separate operator action.
154
- - 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.
155
156
  - Read large artifacts search-first and range-bounded. For `CHANGELOG.md`, inspect only the current release section unless older history is relevant.
156
157
  - Keep successful validation output compact; inspect focused failure tails. Prefer focused tests/typecheck during iteration and broad validation at a stable gate.
157
158
  - Preserve unrelated work and do not commit, publish, tag, deploy, or perform external actions without explicit authorization.
@@ -4,6 +4,16 @@
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
+
12
+ ## 0.51.3: Follower recovery and filterable Skills
13
+
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.
15
+ - `Filterable packaged Skills`: Compiled npm/git installations now leave bundled Skill discovery to the `pi.skills` manifest, so Pi package filters can select or disable individual Skills. A raw TypeScript checkout under Pi's `extensions` directory still contributes its source Skill root through `resources_discover`, preserving local-repository development without double-owning installed resources.
16
+
7
17
  ## 0.51.2: Model switching and typing continuity
8
18
 
9
19
  - `In-flight model switching`: Telegram model selection can again stop, switch, and continue any interruptible agent run in the current Pi session, including local/TUI work without an active Telegram prompt. The exact model-menu chat/Thread/message supplies fallback continuation ownership, active tools defer abort until settlement, and cancellation/session boundaries clear both selection and target state instead of returning a false busy response.
@@ -26,6 +26,8 @@ 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 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
+
29
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.
30
32
 
31
33
  Pi is the primary and only officially supported host. Narrow host-neutral adapters preserve ordered prompt blocks and normalize synchronous or asynchronous legacy/generic settings services for Pi-compatible hosts, but this is best-effort compatibility rather than an OMP support guarantee. Alternate-host shims must still reproduce required Pi lifecycle semantics—especially `agent_settled`—and their maintainers own ongoing validation.
@@ -317,7 +319,7 @@ The docs index lives at [docs/README.md](./docs/README.md).
317
319
 
318
320
  ## Development
319
321
 
320
- 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.
321
323
 
322
324
  ```bash
323
325
  npm run build
@@ -722,12 +722,30 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
722
722
  : recoverableTarget && !pendingTargetRecovery
723
723
  ? await recoverRequestedTarget()
724
724
  : await provisionTarget();
725
+ const alignResultWithWorkspaceSlot = () => {
726
+ if (!workspaceIdentity ||
727
+ result.record.slot === workspaceIdentity.slot)
728
+ return;
729
+ deps.recordRuntimeEvent("bus", "Telegram follower record slot reconciled to its Workspace claim", {
730
+ phase: "follower-register-slot-reconcile",
731
+ instanceId: registration.instanceId,
732
+ chatId: result.target.chatId,
733
+ threadId: result.target.threadId,
734
+ previousSlot: result.record.slot,
735
+ slot: workspaceIdentity.slot,
736
+ });
737
+ result = {
738
+ ...result,
739
+ record: { ...result.record, slot: workspaceIdentity.slot },
740
+ };
741
+ };
742
+ alignResultWithWorkspaceSlot();
725
743
  const crossSessionReuse = !!reconnectRecord &&
726
744
  reconnectRecord.instanceId !== registration.instanceId;
727
745
  if (reconnectRecord && !crossSessionReuse) {
728
746
  const nowMs = getNowMs();
729
747
  const refreshedRecord = deps.topicTargetStore.upsert({
730
- ...reconnectRecord,
748
+ ...result.record,
731
749
  instanceId: registration.instanceId,
732
750
  updatedAtMs: nowMs,
733
751
  lastSyncObservedAtMs: nowMs,
@@ -804,7 +822,7 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
804
822
  else if (crossSessionReuse && reconnectRecord) {
805
823
  const nowMs = getNowMs();
806
824
  const transferredRecord = deps.topicTargetStore.upsert({
807
- ...reconnectRecord,
825
+ ...result.record,
808
826
  profileKey: followerProfileKey,
809
827
  owner: followerOwner.kind === "manual-follower"
810
828
  ? followerOwner
@@ -861,6 +879,7 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
861
879
  }
862
880
  }
863
881
  if (workspaceIdentity) {
882
+ alignResultWithWorkspaceSlot();
864
883
  const workspaceCommit = Threads.commitTelegramWorkspaceProvisionBinding({
865
884
  store: deps.topicTargetStore,
866
885
  instanceId: registration.instanceId,
@@ -872,7 +891,7 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
872
891
  ...(result.record.threadName
873
892
  ? { threadName: result.record.threadName }
874
893
  : {}),
875
- ...(result.record.slot ? { slot: result.record.slot } : {}),
894
+ slot: workspaceIdentity.slot,
876
895
  journalBindingKeys: [followerProfileKey],
877
896
  journalBindingsComplete: true,
878
897
  updatedAtMs: getNowMs(),
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * Bundled Telegram skill discovery
3
3
  * Zones: pi agent, telegram guidance
4
- * Owns source-checkout and installed-package skill path contribution
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">): void;
14
+ export declare function registerTelegramSkillDiscovery(pi: Pick<ExtensionAPI, "on">, modulePath?: string, options?: TelegramRawExtensionCheckoutOptions): boolean;
@@ -1,12 +1,44 @@
1
1
  /**
2
2
  * Bundled Telegram skill discovery
3
3
  * Zones: pi agent, telegram guidance
4
- * Owns source-checkout and installed-package skill path contribution
4
+ * Owns source-checkout skill contribution; installed packages use their manifest
5
5
  */
6
+ import { existsSync } from "node:fs";
7
+ import { basename, dirname, join, resolve } from "node:path";
6
8
  import { fileURLToPath } from "node:url";
7
- export const TELEGRAM_SKILLS_PATH = fileURLToPath(new URL("../skills", import.meta.url));
8
- export function registerTelegramSkillDiscovery(pi) {
9
+ import { resolveAgentDir } from "./paths.js";
10
+ const TELEGRAM_SKILLS_MODULE_PATH = fileURLToPath(import.meta.url);
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))
39
+ return false;
9
40
  pi.on("resources_discover", () => ({
10
- skillPaths: [TELEGRAM_SKILLS_PATH],
41
+ skillPaths: [join(getTelegramExtensionPackageRoot(modulePath), "skills")],
11
42
  }));
43
+ return true;
12
44
  }