@llblab/pi-telegram 0.51.2 → 0.51.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -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.
package/CHANGELOG.md CHANGED
@@ -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.
package/README.md CHANGED
@@ -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
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.2",
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": {
@@ -151,7 +151,7 @@ A registered instance exposes:
151
151
 
152
152
  ## Approved Next Contract: Directory Names And Reclaimable Slots
153
153
 
154
- Status: approved design with a locally tested pure selection policy in `lib/workspace-slots.ts` and profile-isolated display preference persistence/default resolution in `lib/config.ts`. Workspace claims now reserve global letters before provisioning and preserve legacy binding keys. An exact claim assigns the first free letter to a missing-slot binding or the selected member of a duplicate-slot set, but persistence waits for successful target recovery; unresolved duplicates block unrelated fresh allocation. Sticky suffix metadata and acknowledged `displayTitle` persist in Workspace bindings. `lib/thread-display.ts` provides the three-mode projection plus serialized title reconciliation wired into leader startup and follower registration. Heartbeat ACKs carry acknowledged display titles to followers and the current-thread/TUI projection uses them without changing restoration identity. Settings now exposes Letters (default), Names, and Directories; follower changes use the capability-gated leader-owned setting path. Live bot chooser/notice labels and cross-instance agent-target resolution use acknowledged titles without granting routing authority. The Thread store now persists the first proven `inactiveSinceMs` transition with exact confirmed target absence or fenced non-destructive detachment of a confirmed-dead follower or quiescent quitting leader whose tab is preserved; successful active provisioning clears it. Pressure selection, intents, mocked execution, and recovery are implemented. A 2/2 same-model independent post-fix quorum cleared the admission-composition blocker at 0.96 confidence per reviewer, and the operator has since authorized demand-driven rotation. Fresh allocation now invokes the existing retirement lifecycle under full capacity; `BACKLOG.md` owns remaining disposable-client acceptance.
154
+ Status: approved design with a locally tested pure selection policy in `lib/workspace-slots.ts` and profile-isolated display preference persistence/default resolution in `lib/config.ts`. Workspace claims now reserve global letters before provisioning and preserve legacy binding keys. An exact claim assigns the first free letter to a missing-slot binding or the selected member of a duplicate-slot set, but persistence waits for successful target recovery; unresolved duplicates block unrelated fresh allocation. If authenticated follower re-registration finds that its retained target record carries another letter, the exact claim is canonical: registration repairs that record before binding commit and leaves the unrelated binding that owns the stale letter intact. Sticky suffix metadata and acknowledged `displayTitle` persist in Workspace bindings. `lib/thread-display.ts` provides the three-mode projection plus serialized title reconciliation wired into leader startup and follower registration. Heartbeat ACKs carry acknowledged display titles to followers and the current-thread/TUI projection uses them without changing restoration identity. Settings now exposes Letters (default), Names, and Directories; follower changes use the capability-gated leader-owned setting path. Live bot chooser/notice labels and cross-instance agent-target resolution use acknowledged titles without granting routing authority. The Thread store now persists the first proven `inactiveSinceMs` transition with exact confirmed target absence or fenced non-destructive detachment of a confirmed-dead follower or quiescent quitting leader whose tab is preserved; successful active provisioning clears it. Pressure selection, intents, mocked execution, and recovery are implemented. A 2/2 same-model independent post-fix quorum cleared the admission-composition blocker at 0.96 confidence per reviewer, and the operator has since authorized demand-driven rotation. Fresh allocation now invokes the existing retirement lifecycle under full capacity; `BACKLOG.md` owns remaining disposable-client acceptance.
155
155
 
156
156
  The pure policy distinguishes a free letter, a proposed pressure-reclamation victim, and protected/invalid capacity. Its caller must supply a validated profile-wide snapshot, reservations, proven inactivity start, and explicit protection classification; duplicate legacy letters block selection. The policy performs no filesystem or Telegram operations and does not establish liveness or deletion authority. It proposes a victim only when every profile-wide letter is occupied or reserved; elapsed time alone never triggers retirement.
157
157
 
@@ -71,7 +71,7 @@ Every assistant-authored HTML comment is transport-private on Telegram: previews
71
71
  - `telegram_channel_post(action, operation_id, markdown?)` edits or deletes one exact `published` record returned by `telegram_channel_posts`. Edit requires Markdown and delete forbids it. A media-post edit replaces the caption through `editMessageCaption`, while a text post uses `editMessageText`; both render Markdown formatting, including spoilers, as Telegram HTML. The direct leader fences the tool call as outcome-unknown before the mutation call, so ambiguous failures are never replayed automatically.
72
72
  - `telegram_channel_posts(chat_id?, limit?)` lists newest bounded records from the active profile's agent-owned post journal. It returns publication, edit/delete outcome-unknown, confirmed, and deleted local records only, including retained media kind/file name/size/SHA-256 identity; it never reads or claims completeness for Telegram channel history. This explicit successful listing is the only tool response that exposes retained authored Markdown; channel tool failures use fixed redacted messages, while pre-issuance media/caption validation errors stay actionable.
73
73
  - `telegram_message(text, chat_id?, media?, channel?, thread_id?, thread?)` sends a direct Telegram Markdown message when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus. A public `@username`, or an exact negative numeric channel ID with `channel: true`, is passed as `chat_id` without a local registry; channel delivery requires the direct leader, and Telegram enforces whether the bot has channel posting permission. Channel delivery accepts `media` as one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video (photo ≤ 10 MiB, video ≤ 50 MiB), uploaded through multipart `sendPhoto`/`sendVideo` with `text` as its HTML caption (≤ 1024 visible characters); unsupported media types and albums are rejected before issuance, and the durable channel-post journal binds media identity and caption so duplicate or lost-acknowledgement retries never re-upload. `thread` accepts a live numeric Thread id or its current acknowledged display title; name matching is case-insensitive and fails closed when absent or ambiguous, while delivery captures the numeric target. During an active Telegram turn, omitted targeting and an explicit target equal to that turn are rejected so the ordinary final-reply path remains the sole current-target response; an explicit different chat/thread target remains allowed. Outside active turns, paired/default local/TUI delivery remains unchanged. Top-level `telegram_button` comments inside `text` are parsed with the same planner used for normal replies and attached to that message; buttons are never standalone Telegram messages.
74
- - The bundled `telegram-bridge` Skill owns action syntax, target routing, Threaded Mode, formatting, Generative App operation, and profile-specific debugging guidance. The bundled `show-me` Skill owns portable evidence-honest explanations and adapts them to phone-width Markdown or self-contained HTML artifacts when Telegram is the active surface. The regular prompt routes applicable turns to these and the other bundled Skills. `telegram_attach`, `telegram_bind`, and `telegram_message` remain registered but are model-active only while this instance owns direct transport or holds a live follower registration; disconnect/loss suppresses their schemas and prompt metadata, and recovery restores only the operator's previously active pi-telegram subset.
74
+ - The bundled `telegram-bridge` Skill owns action syntax, target routing, Threaded Mode, formatting, Generative App operation, and profile-specific debugging guidance. The bundled `show-me` Skill owns portable evidence-honest explanations and adapts them to phone-width Markdown or self-contained HTML artifacts when Telegram is the active surface. The regular prompt routes applicable turns to these and the other bundled Skills. Compiled npm/git installations expose them through the package `pi.skills` manifest so Pi resource filters remain authoritative; a raw TypeScript checkout under Pi's `extensions` directory contributes its source Skill root at runtime instead. `telegram_attach`, `telegram_bind`, and `telegram_message` remain registered but are model-active only while this instance owns direct transport or holds a live follower registration; disconnect/loss suppresses their schemas and prompt metadata, and recovery restores only the operator's previously active pi-telegram subset.
75
75
  - `telegram_voice` hidden comments request Telegram-native voice delivery through `{text}`, `{text|lang}`, `{text|lang|rate}`, or a JSON object. JSON is the fallback for multiline content, named fields, or escaping; equivalent `text` or `value` supplies the spoken payload, with explicit `text` taking precedence.
76
76
  - `telegram_button` hidden comments create footer buttons; standalone column-zero triple-backtick `telegram_button` blocks create button rows between paragraphs in Native Rich Markdown. Both accept the same singleton or mixed JSON/CML matrix and share prompt/app routing; fenced blocks also accept adjacent top-level JSON/CML objects without an outer array or commas as vertical singleton rows. Native rows allow at most eight buttons and must fit one Rich Message chunk; invalid or incomplete blocks register nothing. Drafts hide action fences. HTML compatibility projects fenced controls into the footer. In-body clicks acknowledge without recoloring the Rich body; selected-style highlighting remains footer-only. One marker accepts a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row; separators are optional and one trailing comma is tolerated at matrix, row, and JSON-object boundaries. Top-level cells become full-width rows, while nested rows group one or more buttons horizontally without an artificial parser-width cap. CML uses `{value}`, `{label|prompt}`, prompt-only `{|prompt}`, or the corresponding three-atom form with `selected_style`; an omitted label uses the existing prompt-as-label fallback, and the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling; an omitted fourth position stays enabled, and the third atom may be empty in this form (`{|Next||1}`). JSON uses boolean `disabled`. Disabled cells need no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank (JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`). The Telegram renderer supplies a non-breaking space only when the label is empty. Disabled cells stay visible but carry `disabled: {}` instead of callback data and register no prompt or bound action; invalid disabled values reject the candidate matrix. It trims atom boundaries and supports only the minimal escapes `\|`, `\}`, and `\\`. Prefer one matrix comment for multiple buttons. Use JSON `label` plus `prompt`, or `value` when both strings are identical. Action markers are colon-free; colon-prefixed payloads are rejected. Use top-level column-zero action wrappers, outside quotes, lists, or enclosing code examples. Ordinary code fences and larger outer fences preserve literal examples; bare JSON/CML in prose never activates.
77
77
 
package/lib/bus-leader.ts CHANGED
@@ -1210,13 +1210,36 @@ export function createTelegramBusFollowerTargetProvisioner(
1210
1210
  : recoverableTarget && !pendingTargetRecovery
1211
1211
  ? await recoverRequestedTarget()
1212
1212
  : await provisionTarget();
1213
+ const alignResultWithWorkspaceSlot = (): void => {
1214
+ if (
1215
+ !workspaceIdentity ||
1216
+ result.record.slot === workspaceIdentity.slot
1217
+ ) return;
1218
+ deps.recordRuntimeEvent(
1219
+ "bus",
1220
+ "Telegram follower record slot reconciled to its Workspace claim",
1221
+ {
1222
+ phase: "follower-register-slot-reconcile",
1223
+ instanceId: registration.instanceId,
1224
+ chatId: result.target.chatId,
1225
+ threadId: result.target.threadId,
1226
+ previousSlot: result.record.slot,
1227
+ slot: workspaceIdentity.slot,
1228
+ },
1229
+ );
1230
+ result = {
1231
+ ...result,
1232
+ record: { ...result.record, slot: workspaceIdentity.slot },
1233
+ };
1234
+ };
1235
+ alignResultWithWorkspaceSlot();
1213
1236
  const crossSessionReuse =
1214
1237
  !!reconnectRecord &&
1215
1238
  reconnectRecord.instanceId !== registration.instanceId;
1216
1239
  if (reconnectRecord && !crossSessionReuse) {
1217
1240
  const nowMs = getNowMs();
1218
1241
  const refreshedRecord = deps.topicTargetStore.upsert({
1219
- ...reconnectRecord,
1242
+ ...result.record,
1220
1243
  instanceId: registration.instanceId,
1221
1244
  updatedAtMs: nowMs,
1222
1245
  lastSyncObservedAtMs: nowMs,
@@ -1300,7 +1323,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1300
1323
  } else if (crossSessionReuse && reconnectRecord) {
1301
1324
  const nowMs = getNowMs();
1302
1325
  const transferredRecord = deps.topicTargetStore.upsert({
1303
- ...reconnectRecord,
1326
+ ...result.record,
1304
1327
  profileKey: followerProfileKey,
1305
1328
  owner:
1306
1329
  followerOwner.kind === "manual-follower"
@@ -1361,6 +1384,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1361
1384
  }
1362
1385
  }
1363
1386
  if (workspaceIdentity) {
1387
+ alignResultWithWorkspaceSlot();
1364
1388
  const workspaceCommit =
1365
1389
  Threads.commitTelegramWorkspaceProvisionBinding({
1366
1390
  store: deps.topicTargetStore,
@@ -1373,7 +1397,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1373
1397
  ...(result.record.threadName
1374
1398
  ? { threadName: result.record.threadName }
1375
1399
  : {}),
1376
- ...(result.record.slot ? { slot: result.record.slot } : {}),
1400
+ slot: workspaceIdentity.slot,
1377
1401
  journalBindingKeys: [followerProfileKey],
1378
1402
  journalBindingsComplete: true,
1379
1403
  updatedAtMs: getNowMs(),
package/lib/skills.ts CHANGED
@@ -1,21 +1,65 @@
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
 
7
+ import { existsSync } from "node:fs";
8
+ import { basename, dirname, join, resolve } from "node:path";
7
9
  import { fileURLToPath } from "node:url";
8
10
 
9
11
  import type { ExtensionAPI } from "./pi.ts";
12
+ import { resolveAgentDir } from "./paths.ts";
10
13
 
11
- export const TELEGRAM_SKILLS_PATH = fileURLToPath(
12
- new URL("../skills", import.meta.url),
14
+ const TELEGRAM_SKILLS_MODULE_PATH = fileURLToPath(import.meta.url);
15
+
16
+ export function getTelegramExtensionPackageRoot(modulePath: string): string {
17
+ let current = dirname(modulePath);
18
+ while (true) {
19
+ if (existsSync(join(current, "package.json"))) {
20
+ const parent = dirname(current);
21
+ if (basename(current) === "dist" && existsSync(join(parent, "package.json"))) {
22
+ return parent;
23
+ }
24
+ return current;
25
+ }
26
+ const parent = dirname(current);
27
+ if (parent === current) return dirname(modulePath);
28
+ current = parent;
29
+ }
30
+ }
31
+
32
+ export interface TelegramRawExtensionCheckoutOptions {
33
+ agentDir?: string;
34
+ cwd?: string;
35
+ }
36
+
37
+ export function isRawTelegramExtensionCheckout(
38
+ modulePath: string,
39
+ options: TelegramRawExtensionCheckoutOptions = {},
40
+ ): boolean {
41
+ const packageRoot = resolve(getTelegramExtensionPackageRoot(modulePath));
42
+ const agentDir = resolve(options.agentDir ?? resolveAgentDir());
43
+ const cwd = resolve(options.cwd ?? process.cwd());
44
+ return dirname(packageRoot) === join(agentDir, "extensions") ||
45
+ dirname(packageRoot) === join(cwd, ".pi", "extensions");
46
+ }
47
+
48
+ export const TELEGRAM_SKILLS_PATH = join(
49
+ getTelegramExtensionPackageRoot(TELEGRAM_SKILLS_MODULE_PATH),
50
+ "skills",
13
51
  );
14
52
 
15
53
  export function registerTelegramSkillDiscovery(
16
54
  pi: Pick<ExtensionAPI, "on">,
17
- ): void {
55
+ modulePath = TELEGRAM_SKILLS_MODULE_PATH,
56
+ options: TelegramRawExtensionCheckoutOptions = {},
57
+ ): boolean {
58
+ // Pi auto-discovers extension entrypoints but not their manifest Skills.
59
+ // Manifest-loaded packages own Skills and filters through `pi.skills`.
60
+ if (!isRawTelegramExtensionCheckout(modulePath, options)) return false;
18
61
  pi.on("resources_discover", () => ({
19
- skillPaths: [TELEGRAM_SKILLS_PATH],
62
+ skillPaths: [join(getTelegramExtensionPackageRoot(modulePath), "skills")],
20
63
  }));
64
+ return true;
21
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.51.2",
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": {
@@ -1,44 +1,115 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Builds the distributive JavaScript, declarations, Pi entrypoint, and Skills.
5
- * Usage: node scripts/build-dist.mjs
6
- *
7
- * The build replaces `dist`, compiles with `tsconfig.build.json`, then copies
8
- * package-owned runtime assets. It does not publish, pack, or modify sources.
4
+ * Builds or verifies the distributive JavaScript, declarations, Pi entrypoint,
5
+ * Skills, and runtime assets without exposing a partial tree.
9
6
  */
10
7
 
11
8
  import { spawnSync } from "node:child_process";
12
- import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
13
- import { join } from "node:path";
9
+ import {
10
+ cpSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ renameSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { join, relative } from "node:path";
21
+
22
+ const DIST_DIR = "dist";
23
+ const checkOnly = process.argv.includes("--check");
14
24
 
15
25
  function run(command, args) {
16
26
  const result = spawnSync(command, args, { stdio: "inherit" });
17
27
  if (result.error) throw result.error;
18
- if (result.status !== 0) process.exit(result.status ?? 1);
28
+ if (result.status !== 0) {
29
+ throw new Error(`${command} exited with status ${result.status ?? "unknown"}.`);
30
+ }
31
+ }
32
+
33
+ function listFiles(root, current = root) {
34
+ return readdirSync(current, { withFileTypes: true }).flatMap((entry) => {
35
+ const path = join(current, entry.name);
36
+ return entry.isDirectory() ? listFiles(root, path) : [relative(root, path)];
37
+ }).sort();
38
+ }
39
+
40
+ function normalizeTextFiles(root) {
41
+ const textSuffixes = [".js", ".ts", ".mjs", ".json", ".md"];
42
+ for (const path of listFiles(root)) {
43
+ if (!textSuffixes.some((suffix) => path.endsWith(suffix))) continue;
44
+ const absolutePath = join(root, path);
45
+ const source = readFileSync(absolutePath, "utf8");
46
+ const normalized = source.replace(/\r\n?/g, "\n");
47
+ if (normalized !== source) writeFileSync(absolutePath, normalized, "utf8");
48
+ }
49
+ }
50
+
51
+ function assertTreesEqual(expectedRoot, actualRoot) {
52
+ if (!existsSync(expectedRoot)) {
53
+ throw new Error(`${expectedRoot} is missing; run npm run build.`);
54
+ }
55
+ const expectedFiles = listFiles(expectedRoot);
56
+ const actualFiles = listFiles(actualRoot);
57
+ if (JSON.stringify(expectedFiles) !== JSON.stringify(actualFiles)) {
58
+ throw new Error("dist file inventory is stale; run npm run build.");
59
+ }
60
+ for (const path of expectedFiles) {
61
+ if (!readFileSync(join(expectedRoot, path)).equals(readFileSync(join(actualRoot, path)))) {
62
+ throw new Error(`dist/${path} is stale; run npm run build.`);
63
+ }
64
+ }
65
+ }
66
+
67
+ function replaceDist(candidate) {
68
+ const backup = `.dist-backup-${process.pid}-${Date.now()}`;
69
+ const hadDist = existsSync(DIST_DIR);
70
+ if (hadDist) renameSync(DIST_DIR, backup);
71
+ try {
72
+ renameSync(candidate, DIST_DIR);
73
+ if (hadDist) rmSync(backup, { recursive: true, force: true });
74
+ } catch (error) {
75
+ if (hadDist && existsSync(backup) && !existsSync(DIST_DIR)) {
76
+ renameSync(backup, DIST_DIR);
77
+ }
78
+ throw error;
79
+ }
19
80
  }
20
81
 
21
- rmSync("dist", { recursive: true, force: true });
22
- mkdirSync("dist", { recursive: true });
23
-
24
- run(process.execPath, [
25
- join("node_modules", "typescript", "bin", "tsc"),
26
- "-p",
27
- "tsconfig.build.json",
28
- ]);
29
-
30
- mkdirSync(join("dist", "pi-telegram"), { recursive: true });
31
- writeFileSync(
32
- join("dist", "pi-telegram", "index.js"),
33
- 'export { default } from "../index.js";\n',
34
- "utf8",
35
- );
36
-
37
- cpSync("skills", join("dist", "skills"), { recursive: true });
38
- cpSync("package.json", join("dist", "package.json"));
39
- cpSync(
40
- join("lib", "generative-app-worker.mjs"),
41
- join("dist", "lib", "generative-app-worker.mjs"),
42
- );
43
-
44
- run(process.execPath, ["--check", join("dist", "pi-telegram", "index.js")]);
82
+ const candidate = mkdtempSync(join(process.cwd(), ".dist-build-"));
83
+ try {
84
+ run(process.execPath, [
85
+ join("node_modules", "typescript", "bin", "tsc"),
86
+ "-p",
87
+ "tsconfig.build.json",
88
+ "--outDir",
89
+ candidate,
90
+ ]);
91
+
92
+ mkdirSync(join(candidate, "pi-telegram"), { recursive: true });
93
+ writeFileSync(
94
+ join(candidate, "pi-telegram", "index.js"),
95
+ 'export { default } from "../index.js";\n',
96
+ "utf8",
97
+ );
98
+ normalizeTextFiles(candidate);
99
+ cpSync("skills", join(candidate, "skills"), { recursive: true });
100
+ cpSync("package.json", join(candidate, "package.json"));
101
+ cpSync(
102
+ join("lib", "generative-app-worker.mjs"),
103
+ join(candidate, "lib", "generative-app-worker.mjs"),
104
+ );
105
+ run(process.execPath, ["--check", join(candidate, "pi-telegram", "index.js")]);
106
+
107
+ if (checkOnly) {
108
+ assertTreesEqual(DIST_DIR, candidate);
109
+ console.log("pi-telegram: dist is current");
110
+ } else {
111
+ replaceDist(candidate);
112
+ }
113
+ } finally {
114
+ rmSync(candidate, { recursive: true, force: true });
115
+ }