@llblab/pi-telegram 0.25.4 → 0.25.6
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 +2 -2
- package/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/docs/architecture.md +1 -1
- package/docs/multi-instance-bus.md +1 -1
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +2 -2
- package/lib/activity-verbosity.ts +16 -3
- package/lib/bus-leader.ts +43 -3
- package/lib/rendering.ts +19 -3
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -82,14 +82,14 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
82
82
|
- `/telegram-connect` is the bounded liveness-recovery boundary for unclean-shutdown corruption in disposable `tmp/telegram` state. It may quarantine only classifier-approved malformed `owners.json`, active-profile `state*.json`, and `owners.json.transaction` after serializing recoverers and revalidating under ownership fencing; it must preserve `telegram.json`, diagnostics, unrelated artifacts, and every verifiable live owner/transaction holder. Stale heartbeats older than eight seconds do not gain protection through PID reuse. Require confirmed local polling suspension before quarantine mutation; a suspension failure blocks recovery, while an ownership-release failure may continue only into final guarded live-owner revalidation. Use the established bounded Windows sharing-violation rename retries. Retry polling exactly once after recovery, preserve unrelated startup errors, and replace blocked/repeated recovery failure with one explicit restart instruction rather than broad `tmp/` deletion or an automatic loop.
|
|
83
83
|
- Telegram profiles are parallel bot/runtime realities, not merely alternate credentials. Persist every bot/session field (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) under `telegram.json` `profiles.<name>`, including `profiles.default`; keep shared handlers and assistant/voice/time/thread-lifecycle settings top-level. Bare and explicit `default` setup/connect commands address the same profile. Legacy root identity may normalize atomically only when unambiguous; conflicts fail closed without mutation. Any owner slot, IPC endpoint, state snapshot, diagnostics log, thread/bus binding, runtime cache, ownership map, or operator-visible status surface that could mix bot identities must be deliberately classified as global, session-local, or profile-scoped. Use the `default` owner slot for the default profile and validated profile names for named slots; give named profiles isolated observable surfaces unless there is an explicit top-level shared-config reason not to.
|
|
84
84
|
- Telegram private-chat Threaded Mode is the public switch for the local multi-instance bus. There is no separate public `telegram.json` bus switch: classic/private-chat singleton DM behavior is the base product mode, and when Telegram reports Threaded Mode available for the bot the bridge enables leader/follower bus behavior. If Telegram reports Threaded Mode unavailable or thread operations prove it unavailable, the bridge returns to classic singleton behavior as a first-class mode. During a live Threaded Mode downgrade, the current transport leader keeps the singleton classic polling role and followers disconnect instead of taking over or offering takeover while active thread bindings prove this is a downgrade from a live bus organism. Do not inject slot/thread rename guidance in classic mode. Blocked instances should not silently register as followers unless the effective runtime mode is Threaded Mode.
|
|
85
|
-
- In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Heartbeat acknowledgements carry the authenticated live follower-slot roster: on leader loss, the lowest observed live slot attempts the atomic lock first, while higher slots defer for one bounded grace and then re-check so a missing lower follower cannot block recovery indefinitely. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote only through an atomic expected-owner transition after the exact observed leader lease becomes stale or inactive. An unreachable IPC endpoint does not authorize replacing a still-live lock owner; remain disconnected/retrying rather than creating split-brain polling. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission by itself to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for confirmed manual disconnect, graceful Pi quit, or confirmed reconciliation cases. A stale follower may become a confirmed reconciliation case only when Thread cleanup is enabled, the OS reports the exact registered PID absent, and cleanup serializes ahead of replacement registration; live/unknown PID state remains non-destructive. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` retains destructive confirmation; graceful Pi `quit` invokes the same teardown without prompting when top-level `threads.automaticCleanup` is enabled (default), while disabling it and `reload`, `new`, `resume`, or `fork` preserve the binding through safe handoff/suspension. A leader deletes its own bound thread before releasing transport ownership, while a follower sends an authenticated exact-registration-generation disconnect envelope and waits for the leader to delete its assigned thread, mark the durable binding offline, and remove live routing authority before unregistering. Cleanup must be confirmed by successful deletion or explicit already-gone evidence. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before destructive API work; interruption preserves that intent for the current or successor leader to replay under its own epoch, and
|
|
85
|
+
- In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Heartbeat acknowledgements carry the authenticated live follower-slot roster: on leader loss, the lowest observed live slot attempts the atomic lock first, while higher slots defer for one bounded grace and then re-check so a missing lower follower cannot block recovery indefinitely. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote only through an atomic expected-owner transition after the exact observed leader lease becomes stale or inactive. An unreachable IPC endpoint does not authorize replacing a still-live lock owner; remain disconnected/retrying rather than creating split-brain polling. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission by itself to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for confirmed manual disconnect, graceful Pi quit, or confirmed reconciliation cases. A stale follower may become a confirmed reconciliation case only when Thread cleanup is enabled, the OS reports the exact registered PID absent, and cleanup serializes ahead of replacement registration; live/unknown PID state remains non-destructive. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` retains destructive confirmation; graceful Pi `quit` invokes the same teardown without prompting when top-level `threads.automaticCleanup` is enabled (default), while disabling it and `reload`, `new`, `resume`, or `fork` preserve the binding through safe handoff/suspension. A leader deletes its own bound thread before releasing transport ownership, while a follower sends an authenticated exact-registration-generation disconnect envelope and waits for the leader to delete its assigned thread, mark the durable binding offline, and remove live routing authority before unregistering. Cleanup must be confirmed by successful deletion or explicit already-gone evidence. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before destructive API work; interruption preserves that intent for the current or successor leader to replay under its own epoch, while leader startup must first adopt a still-active same-profile binding and cancel its superseded cleanup before any destructive API call. Confirmation removes both the binding and intent atomically from the next persisted snapshot. Silence, heartbeat expiry, process absence, IPC failure, and auth mismatch create no cleanup intent by themselves; only stale heartbeat combined with enabled cleanup and OS-confirmed absence of the exact registered follower PID may authorize fallback intent. Incomplete manual cleanup preserves binding/routing state, keeps the session open, and returns actionable retry guidance. A promoted leader authorizes inherited follower-thread cleanup by its current leader epoch rather than the historical owner label. Status is reactive to the effective runtime role and bot capability: classic/non-thread mode shows ordinary `telegram connected`, active bus leadership shows `telegram leader`, and registered followers show `telegram follower`; stable multi-instance truth belongs in the bus role (`leader`/`follower`), while lifecycle is reserved for transient recovery state such as `electing` and must not duplicate stable roles with labels like `leader-active`. When leadership or Threaded Mode availability changes at runtime, the status line and `/telegram-status` must converge promptly instead of preserving a stale role label.
|
|
86
86
|
- Runtime routing binds to a live `instanceId` plus its exact follower registration generation; current thread identity should use explicit owner records (`leader`, `manual-follower`, `pending-topic`) rather than hiding ownership direction in legacy string keys. Persisted manual-follower records may act as stable restart hints, but only an authenticated live registry entry authorizes inbound forwarding or outbound transport; stale session records must not be treated as live owners. A live instance owns at most one active bound thread; provisioning a new target for the same `instanceId` must remove older current-state bindings and close duplicate Telegram threads when possible. A reload creates a new runtime `instanceId`; if the previous leader slot is still known/alive, keep it occupied in reservations and assign the new leader the next free slot instead of creating a duplicate same-letter tab or blocking startup on Telegram UI convergence.
|
|
87
87
|
- Follower registration readiness should stay on the smallest critical path that makes the follower routable: create or reuse the Telegram target, persist the active binding, mark target-bindings fresh, and return the target to the follower. Registration requires a present exact generation. Cross-session target reuse and any carried target absent from current bindings require one synchronous visibility probe: explicit stale evidence may provision a replacement, while ambiguous/non-stale failure must reject registration without replay, binding invalidation, or speculative replacement. Persist an ambiguously observed absent target only as non-routable `probe-required` restoration evidence so targetless retries and leader reloads must probe the exact target again before activation or replacement. Registration and explicit disconnect mutations for one durable follower profile must serialize across awaited cleanup so an old runtime's disconnect cannot delete or unregister a replacement generation. Manual follower identity must survive a Pi process reload in the same terminal so reload is not treated as a new follower/thread; do not key durable follower ownership only by the short-lived Pi process id. Telegram-visible connected notices, duplicate/replaced thread cleanup, and other reconciliation work are important but non-critical; run them after registration and record slow/failing background steps as runtime events rather than delaying follower usability.
|
|
88
88
|
- Instance slots are extension-owned ring-order metadata. Allocate fresh slots from the `bot.lastSlot` cursor as `A` → `B` → ... → `Z` → `A`, allowing the cursor to move to lower letters after wraparound. Only current live/recovering bindings plus unexpired pending provisions and explicit reservations occupy slots; historical records must not block allocation indefinitely. Preserve a slot on ordinary current binding/thread reuse without advancing or rewinding the fresh-allocation cursor. Follower bindings survive process absence as stable restoration hints unless enabled Thread cleanup turns stale heartbeat plus exact-PID death confirmation into fenced cleanup authority; otherwise only explicit stale/deleted/offline evidence invalidates them. Successful target reuse must refresh the binding timestamp and replacement runtime identity; generic leader startup or heartbeat pruning alone must not erase the target merely because no follower registration is currently live. An authenticated live follower whose carried exact target is missing from current persisted bindings should recover that target instead of creating another Telegram thread; preserve a carried slot only when it does not collide with another current record. Preserve a cursor that belongs to a live or recoverable binding and continue skipping retained restart hints plus unexpired pending/reserved slots. Explicit stale/deleted/offline reconciliation may release that slot; generic process absence may not. The alphabet cap is a feature — compact single-letter tabs are scannable.
|
|
89
89
|
- Instance thread names are provisioner-authored identity metadata, not model-authored output. Fresh Threaded Mode threads select one of five curated compact 4-6 letter Latin names for the assigned slot letter using provisioning timestamp entropy, then create the Telegram thread with that thread name immediately. The title is the thread name itself, not `{slot} {threadName}` or `{slot} — {threadName}`. Do not register or reintroduce an agent-facing thread rename tool; removing the extra rename prompt/tool turn is an intentional simplicity constraint. Thread names must mirror into terminal status, live diagnostics, and `[telegram|thread:name]` through one target-aware current-instance identity resolver. Registered follower/leader metadata takes precedence over a stale shared record for the same target, with the record used only as fallback; do not hide a valid baked/custom thread name behind old slot-prefix validation. A follower that later becomes leader keeps its existing name; leadership changes are transport role changes, not identity resets.
|
|
90
90
|
- Telegram private-chat Threaded Mode treats `All` as an aggregate/control surface, not a durable `General` thread or process launcher, and leader activation proactively creates or reuses its private-DM thread. When the owner writes in an unknown thread, the leader reclaims and routes the first one only if it lacks an active binding; later unknown threads remain intact and receive an in-thread chooser to reroute the captured prompt or restore a stale binding. Destructive cleanup requires explicit confirmation through `thread-reconciler`; another Pi instance still requires a manually started process followed by `/telegram-connect`. Without Threaded Mode, process the message through classic routing.
|
|
91
91
|
- Telegram extension work should not hold Pi's core agent lifecycle hostage once Pi has produced the semantic result. For Telegram-originated turns, final Telegram delivery, attachment upload, and transport cleanup are extension-owned side effects; schedule them off the critical `agent_end` path when ordering can still be preserved, record failures as runtime events, and keep dispatch of the next Telegram turn behind the delivery task when needed to avoid interleaving previews/finals. Treat a low-level error `agent_end` as retryable while its Telegram turn remains active; only a later successful `agent_end` or Pi's `agent_settled` boundary may finalize and release that turn. Public activity handlers run through isolated asynchronous per-handler queues; lifecycle hooks enqueue normalized events and never await consumer work. Proactive push defaults enabled and requires explicit `assistant.proactivePush: false` to opt out. While enabled, core delivery projects each completed Pi-visible assistant text block from local/autonomous work once and in order; bind admitted blocks to the exact target, profile/token transport stamp, direct leader epoch or follower registration generation, and session generation. Token deltas, hidden reasoning, tool traffic, stale authority, and Telegram-owned turns never enter that projection.
|
|
92
|
-
- Core thinking/tool visibility is controlled by `assistant.activity: "quiet" | "thinking" | "tools" | "verbose"`. It remains separate from proactive public prose and assistant Markdown: provider-exposed thinking and completed executed tools use durable ordinary HTML messages with standard expandable blockquotes and never use Rich Messages. Thinking uses the `🧠` header with the current Pi thinking level instead of a generic lifecycle status
|
|
92
|
+
- Core thinking/tool visibility is controlled by `assistant.activity: "quiet" | "thinking" | "tools" | "verbose"`. It remains separate from proactive public prose and assistant Markdown: provider-exposed thinking and completed executed tools use durable ordinary HTML messages with standard expandable blockquotes and never use Rich Messages. Thinking uses the `🧠` header with the current Pi thinking level instead of a generic lifecycle status and renders inline Markdown as Telegram HTML inside its expandable blockquote; tools use `🛠`, `verbose` enables both, and every activity send/edit disables Telegram link previews while technical evidence also neutralizes HTTP(S) auto-link detection as a client-safe fallback. Keep it quiet by default, exact-target/transport-generation fenced, start-order preserving, boundary-aware, memory/transport bounded, and non-blocking; final delivery waits for admitted technical activity only inside the extension-owned background task, and session replacement abandons queued old-generation work without waiting on old transport. Read legacy `assistant.activityVerbosity` only when `assistant.activity` is absent and remove it on the next Settings write. Never invent unavailable provider-private thinking or replay a possibly committed technical message.
|
|
93
93
|
- Telegram runtime state should be treated as event-driven reconciliation of extension assumptions against observable Telegram signals, not as a full Telegram bot read-model and not as a reason to query Telegram on every action. Keep a local assumption model for bot identity/capabilities, pairing assumptions, thread support, known thread lifecycle, bound instance targets, reservations, and transport health. Invalidate and reconcile the relevant slice on meaningful events: startup/reload, lifecycle service messages, stale-send/API errors, setup/pairing changes, follower register/prune, explicit status/debug requests, and bounded low-frequency leader health ticks. `lib/sync.ts` owns sync slices, observation intake, invalidation triggers, status/debug freshness, and reconciliation scheduling; it must not promise complete bot-state mirroring because Bot API lacks a complete topic listing surface. `lib/thread-reconciler.ts` owns pure Threaded Mode lifecycle planning and should become the only policy authority for destructive thread cleanup decisions. `threads` owns current Telegram UI thread/tab binding primitives and thread-name helpers mapped to Bot API topic transport, `status` renders projections, and `index.ts` only wires ports. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging and resets on extension start / runtime scope changes; it is diagnostics only, not routing/provisioning authority. `state.json` should be an observable/debug snapshot aligned with `/telegram-status` (extension runtime, bot capabilities including `bot.lastSlot`, runtime role, live roster projection, reservations, diagnostics), not routing/provisioning authority. Because every process on one Telegram profile shares this file, only the active transport lock owner may persist it; followers read current state and gain write authority only after promotion. Status-only writes must refresh disk-backed bindings before serialization so a stale loaded snapshot cannot erase newer leader state. Live bus/runtime state is authoritative; file reservations and fresh capability observations may be startup hints/collision guards only; stale snapshots must re-probe before suppressing bus/topic behavior. Do not persist stale/offline/failed thread target history as source of truth.
|
|
94
94
|
- Thread bindings are bidirectional. From the Pi-instance side, an instance knows and preserves its target, slot, thread name, lifecycle state, and direct-delivery defaults. From the Telegram-client/bot side, the bridge observes thread creation/close/reopen/stale-send signals and reconciles them into instance binding state. Lifecycle transitions that affect operator understanding should be Telegram-visible when a live transport remains: every successful follower registration/re-registration gets a compact connected notice in the assigned thread, while heartbeat pruning stays silent because it is only liveness suspicion. These notices should use the instance thread name when known and fall back to the slot letter only while the thread is still unnamed. New unknown or command-created temporary threads receive the complete forward plus replace/restore control set and remain preserved until the user chooses one. Successful forward deletes the chooser and temporary thread; successful replace/restore always deletes the chooser, rebinds the temporary thread, and deletes only the replaced old thread. Any destructive cleanup must go through `thread-reconciler`. Unknown `forum_topic_created` service events are observations, not destructive cleanup proof.
|
|
95
95
|
- The multi-instance bus uses private bot DMs with Telegram private-chat Threaded Mode enabled for the bot. No supergroup, group, or forum configuration is needed; the thread chat is always the private chat with the paired owner. Threaded capability checks must use bot/private-chat evidence such as `getMe.has_topics_enabled`, incoming `message_thread_id`, and topic operation success/failure; do not use group chat metadata as the control-plane truth for Telegram private-chat Threaded Mode.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.6: Same-Profile Thread Reuse Hotfix
|
|
4
|
+
|
|
5
|
+
- `Leader Restart`: Deferred a pending leader cleanup when its target still belongs to the replacement's active stable profile, restored that existing binding first, and then cancelled the superseded cleanup intent. Unrelated cleanup intents still execute before provisioning. Impact: an interrupted graceful shutdown no longer causes the next launch of the same Pi instance to delete and recreate its reusable Telegram thread.
|
|
6
|
+
|
|
7
|
+
## 0.25.5: Thinking HTML Rendering Hotfix
|
|
8
|
+
|
|
9
|
+
- `Thinking Presentation`: Rendered inline Markdown emphasis and code in provider-exposed thinking as Telegram HTML inside the existing expandable blockquote while keeping links inert and HTTP(S) auto-link neutralization intact. Impact: reasoning such as `**Reviewing data models**` appears as formatted collapsed technical evidence instead of leaking raw Markdown markers.
|
|
10
|
+
|
|
3
11
|
## 0.25.4: Technical Link Preview Hotfix
|
|
4
12
|
|
|
5
13
|
- `Activity Evidence`: Kept Bot API link previews disabled on every thinking/tool send and edit, and added an invisible separator after HTTP(S) schemes inside technical evidence so Telegram clients cannot auto-link URL-like arguments, updates, results, or reasoning when rendering expandable blockquotes. Impact: incidental URLs no longer create distracting preview cards in persistent activity messages.
|
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ The first Telegram user to message the bot becomes the allowed owner. Other user
|
|
|
103
103
|
| Draft previews | Show Telegram's native `…typing` indicator whenever the connected instance is doing agent work, or enable Rich Draft previews for streamed answer text. | Local prompts, Telegram turns, and autonomous continuations remain visibly active while draft visibility stays independent from final rendering. |
|
|
104
104
|
| Activity | Keep the default `quiet` answer-only surface, show only `thinking`, show only `tools`, or select `verbose` for both. Thinking headers report the current Pi thinking level instead of a generic completion state. | Persistent collapsed technical activity stays bounded, redacted, target-fenced, density-aware, free of URL previews, and visually separate from semantic assistant answers. |
|
|
105
105
|
| Assistant rendering | Choose Native Rich Markdown or legacy Markdown-to-HTML for final assistant replies. | Renderer compatibility is explicit instead of being conflated with draft previews. |
|
|
106
|
-
| Bridge UI rendering | Render thinking and tool activity through ordinary expandable HTML messages, and menus, queue controls, status, settings, diagnostics, and sections through Telegram HTML/plain UI. | Harness-owned surfaces remain operationally predictable and visually distinct from model-authored answers. |
|
|
106
|
+
| Bridge UI rendering | Render thinking and tool activity through ordinary expandable HTML messages, including inline emphasis/code formatting for thinking evidence, and menus, queue controls, status, settings, diagnostics, and sections through Telegram HTML/plain UI. | Harness-owned surfaces remain operationally predictable and visually distinct from model-authored answers. |
|
|
107
107
|
| Inbound files | Download inbound files to the Pi agent temp directory with size limits. | Screenshots, PDFs, datasets, and artifacts enter Pi as inspectable local files. |
|
|
108
108
|
| Outbound artifacts | Return generated files through `telegram_attach` during active turns or explicit direct delivery. | Agents send real artifacts as files, not pasted blobs. |
|
|
109
109
|
| Voice input | Route audio through configured command-template handlers, programmatic handlers, or STT providers. | Voice notes become usable prompt context. |
|
|
@@ -158,7 +158,7 @@ Run these inside Pi.
|
|
|
158
158
|
| `/telegram-disconnect` | Confirm, then stop polling, release ownership, and delete this instance's Threaded Mode tab; graceful Pi quit does the same without prompting when automatic cleanup is enabled |
|
|
159
159
|
| `/telegram-status` | Inspect connection, mode, queue, transport, and recent diagnostics |
|
|
160
160
|
|
|
161
|
-
Named profile identifiers contain only lowercase ASCII letters and digits (maximum 32 characters); `default`, `main`, and `active` remain reserved.
|
|
161
|
+
Named profile identifiers contain only lowercase ASCII letters and digits (maximum 32 characters); `default`, `main`, and `active` remain reserved. If graceful thread deletion was interrupted, a same-profile replacement reuses its still-active thread and cancels the superseded cleanup instead of deleting and recreating the tab during startup.
|
|
162
162
|
|
|
163
163
|
## Main Surfaces
|
|
164
164
|
|
package/docs/architecture.md
CHANGED
|
@@ -315,7 +315,7 @@ Queue reactions are shortcut controls for waiting turns. Promotion reactions (`
|
|
|
315
315
|
|
|
316
316
|
When `assistant.proactivePush` is enabled and this instance has exact direct or follower transport authority, completed public assistant text blocks from local/autonomous work are sent once and in source order to the instance's authorized target. Visible commentary/checkpoints and the final block use the configured Rich or HTML renderer. Proactive projection excludes reasoning, tool traffic, token deltas, local prompt text, Telegram-owned turns, and stale generations. Each admitted block remains fenced to its exact target, profile/token stamp, leader epoch or follower registration generation, and session generation; non-idempotent acknowledgement ambiguity never authorizes replay.
|
|
317
317
|
|
|
318
|
-
`assistant.activity` is an independent bridge-owned projection over normalized Activity events. Omitted or invalid values resolve to `quiet`, which emits no thinking or tool traffic; `thinking` and `tools` select one technical class, while `verbose` enables both. Provider-exposed thinking and completed executed tools use persistent ordinary HTML messages with standard expandable blockquotes and never use Rich Messages. Thinking uses a `🧠` header carrying the current Pi thinking level and a bounded redacted latest-text window; tools use `🛠` headers and bounded redacted execution evidence. The projection captures the exact target and transport stamp at activity admission, serializes updates, preserves tool-start order, closes coalescing across assistant/thinking boundaries, bounds retained text/update memory plus edit frames and message/tool size, disables previews and HTTP(S) auto-link recognition inside technical evidence, and never replays a possibly committed send. Session generations own independent queues, so replacement drops queued old work without waiting on an old call. Both proactive prose and active-turn final delivery wait for the admitted activity queue inside their extension-owned delivery tasks, preserving technical-before-semantic ordering without delaying Pi lifecycle completion. Settlement, replacement, disconnect, failure, or stale authority clears only local ownership; already-sent activity messages remain in chat.
|
|
318
|
+
`assistant.activity` is an independent bridge-owned projection over normalized Activity events. Omitted or invalid values resolve to `quiet`, which emits no thinking or tool traffic; `thinking` and `tools` select one technical class, while `verbose` enables both. Provider-exposed thinking and completed executed tools use persistent ordinary HTML messages with standard expandable blockquotes and never use Rich Messages. Thinking uses a `🧠` header carrying the current Pi thinking level and a bounded redacted latest-text window with inline Markdown rendered as Telegram HTML inside the expandable quote; tools use `🛠` headers and bounded redacted execution evidence. The projection captures the exact target and transport stamp at activity admission, serializes updates, preserves tool-start order, closes coalescing across assistant/thinking boundaries, bounds retained text/update memory plus edit frames and message/tool size, disables previews and HTTP(S) auto-link recognition inside technical evidence, and never replays a possibly committed send. Session generations own independent queues, so replacement drops queued old work without waiting on an old call. Both proactive prose and active-turn final delivery wait for the admitted activity queue inside their extension-owned delivery tasks, preserving technical-before-semantic ordering without delaying Pi lifecycle completion. Settlement, replacement, disconnect, failure, or stale authority clears only local ownership; already-sent activity messages remain in chat.
|
|
319
319
|
|
|
320
320
|
Telegram prompt guidance is context- and authority-aware. Only an exact direct owner or live registered follower exposes the three pi-telegram model tools, their active-tool metadata, and the compact local bridge suffix. Disconnect or authority loss removes those surfaces for subsequent requests without touching foreign tools; reconnect/recovery restores only the pi-telegram subset that was active before suspension, including across same-process reload. Telegram-originated turns receive a compact pointer to `telegram_help` plus dynamic prompt blocks such as `[voice] delivery: automatic voice`; full voice/button/direct-delivery/Threaded Mode syntax stays in the help tool rather than every system prompt.
|
|
321
321
|
|
|
@@ -193,7 +193,7 @@ Manual smoke checklist:
|
|
|
193
193
|
7. From the follower thread, request a voice reply and/or attachment; verify upload routes through the leader transport into the follower thread.
|
|
194
194
|
8. Force a live Threaded Mode capability downgrade and verify the current leader keeps classic polling while followers disconnect instead of attempting takeover; restore capability and reconnect explicitly.
|
|
195
195
|
9. With `🧹 Thread cleanup` enabled (default), quit the follower Pi normally without an explicit disconnect; verify graceful shutdown deletes its current tab through the leader before local suspension. Repeat with a double `Ctrl+C`; if Pi misses the graceful envelope, verify stale-heartbeat recovery deletes the same exact tab only after the OS confirms that follower PID has exited. Disable the setting, quit another follower normally or abruptly, and verify its tab remains as a restart hint. Reconnect, run `/telegram-disconnect`, confirm the prompt, and verify the leader confirms deletion before local polling stops.
|
|
196
|
-
10. With `🧹 Thread cleanup` enabled, quit the leader normally and verify it deletes only its own tab before releasing transport; a remaining follower may then promote without recreating the deleted leader tab. If deletion is interrupted after intent persistence, start or promote a successor and verify it completes the exact pending cleanup before publishing its follower endpoint or provisioning its own tab;
|
|
196
|
+
10. With `🧹 Thread cleanup` enabled, quit the leader normally and verify it deletes only its own tab before releasing transport; a remaining follower may then promote without recreating the deleted leader tab. If deletion is interrupted after intent persistence, start or promote a successor and verify it completes the exact pending cleanup before publishing its follower endpoint or provisioning its own tab; when the successor has the same stable leader profile and its old binding remains active, it must adopt that binding first and cancel the superseded cleanup without calling Telegram close, delete, or create APIs.
|
|
197
197
|
11. Generate enough diagnostics to cross the rotation threshold, reload the leader, and verify `logs.jsonl`/`logs._prev.jsonl` preserve complete ordered records. Confirm ordinary status hides raw pipe internals while explicit debug diagnostics show the active `pipe` endpoint and classified request failures.
|
|
198
198
|
|
|
199
199
|
If any step fails, capture `telegram-status --debug`, `tmp/telegram/state.json`, `tmp/telegram/logs.jsonl`, and, after a reload, `tmp/telegram/logs._prev.jsonl`. Debug status prints local leader/follower endpoints with their active transport kind (`pipe` or `socket`), while the runtime log records request-scoped transport failures with envelope kind, request id, retry attempt, endpoint, and classified IPC error. Reloads preserve the prior JSONL log as `logs._prev.jsonl` so the evidence that caused the reload is not immediately overwritten.
|
package/docs/outbound.md
CHANGED
|
@@ -16,7 +16,7 @@ Proactive blocks use `assistant.rendering` independently of voice policy. Rich m
|
|
|
16
16
|
|
|
17
17
|
`assistant.activity` defaults to `quiet` and accepts four modes: `quiet`, `thinking`, `tools`, and `verbose`. `thinking` shows only provider-exposed thinking, `tools` shows only completed executed tools, and `verbose` shows both without mixing technical UI into assistant Markdown.
|
|
18
18
|
|
|
19
|
-
- Provider-exposed thinking updates one persistent ordinary HTML message. Its `🧠 thinking: <level>` header reports the current Pi thinking level, such as `high`, above a standard Telegram `<blockquote expandable>` containing a bounded redacted latest-text window. The bridge sends the message once, edits it only as thinking grows, and leaves the disclosure in chat; it never replaces useful density metadata with a generic completion state or uses Rich drafts/Rich Messages. Providers that expose no thinking produce none.
|
|
19
|
+
- Provider-exposed thinking updates one persistent ordinary HTML message. Its `🧠 thinking: <level>` header reports the current Pi thinking level, such as `high`, above a standard Telegram `<blockquote expandable>` containing a bounded redacted latest-text window whose inline Markdown emphasis and code render as Telegram HTML instead of leaking raw markers. The bridge sends the message once, edits it only as thinking grows, and leaves the disclosure in chat; it never replaces useful density metadata with a generic completion state or uses Rich drafts/Rich Messages. Providers that expose no thinking produce none.
|
|
20
20
|
- Completed executed tools use ordinary `sendMessage` HTML, never `sendRichMessage`. Each tool header renders as `🛠 <tool>: <status>`, with a stable two-space visual gap after the icon and the `running`, `done`, or `failed` status in monospace. One standard Telegram `<blockquote expandable>` then contains bounded redacted evidence in chronological order. JSON-style labels share a line with their serialized value opening, for example `"arguments": {`, `"update 1": {`, and `"result": {` or `"error": {`; arrays of object entries use the denser `[{ ... }, { ... }]` layout while retaining indented fields.
|
|
21
21
|
- Every thinking/tool `sendMessage` and `editMessageText` sets `link_preview_options.is_disabled: true` and breaks auto-link recognition inside technical evidence with an invisible separator after HTTP(S) schemes, so URL-like arguments, updates, results, and reasoning cannot expand into a web preview even when a Telegram client ignores preview options inside expandable blockquotes. Consecutive tools coalesce by editing one message only while target, activity, generation, ordering boundary, tool count, and serialized-size bounds still match. Assistant or thinking content closes the batch. A non-idempotent send with unknown commit state is never replayed; failed or ambiguous edits start no fallback send.
|
|
22
22
|
|
package/docs/public-api.md
CHANGED
|
@@ -117,9 +117,9 @@ The file is global across Pi instances. Cooperating instances serialize recursiv
|
|
|
117
117
|
|
|
118
118
|
Hidden/default semantics are represented by absence:
|
|
119
119
|
|
|
120
|
-
- `threads.automaticCleanup` defaults to `true`; graceful Pi quit deletes the instance's bound Threaded Mode tab without prompting. Set it to `false`, or use `🧹 Thread cleanup` in Telegram Settings, to preserve the tab as a restart hint. Settings views and cleanup reload shared config before evaluating this switch, so another live Pi instance's update takes effect without restarting. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before Telegram deletion; an interrupted attempt remains retryable by the current or successor leader under current authority and clears only after confirmed deletion. If a follower's graceful envelope is missed, the leader may create the same fenced cleanup only after its heartbeat is stale, the OS confirms the exact registered PID no longer exists, cleanup remains enabled, and no replacement registration can overtake deletion. Heartbeat loss alone, live/unknown process liveness, IPC failure, and auth failure remain non-destructive. Invalid-config recovery makes the setting unresolved and therefore skips destructive cleanup. Manual `/telegram-disconnect` keeps its confirmation and teardown behavior regardless of this setting.
|
|
120
|
+
- `threads.automaticCleanup` defaults to `true`; graceful Pi quit deletes the instance's bound Threaded Mode tab without prompting. Set it to `false`, or use `🧹 Thread cleanup` in Telegram Settings, to preserve the tab as a restart hint. Settings views and cleanup reload shared config before evaluating this switch, so another live Pi instance's update takes effect without restarting. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before Telegram deletion; an interrupted attempt remains retryable by the current or successor leader under current authority and clears only after confirmed deletion. A same-profile replacement leader first adopts any still-active binding and cancels its superseded cleanup, so startup never deletes and recreates a reusable thread. If a follower's graceful envelope is missed, the leader may create the same fenced cleanup only after its heartbeat is stale, the OS confirms the exact registered PID no longer exists, cleanup remains enabled, and no replacement registration can overtake deletion. Heartbeat loss alone, live/unknown process liveness, IPC failure, and auth failure remain non-destructive. Invalid-config recovery makes the setting unresolved and therefore skips destructive cleanup. Manual `/telegram-disconnect` keeps its confirmation and teardown behavior regardless of this setting.
|
|
121
121
|
- `assistant.proactivePush` defaults to `true`; omit it to keep projection enabled, or set it explicitly to `false` to disable it. When enabled, each completed public assistant text block from local or autonomous work is projected to the authorized Telegram target once and in source order. This includes visible intermediate commentary/checkpoints and the final block. It excludes token deltas, hidden reasoning, tool calls/arguments/results, Telegram-owned turns, empty blocks, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. The old top-level `proactivePush` key is ignored; move the setting manually under `assistant`.
|
|
122
|
-
- `assistant.activity` accepts exactly `"quiet"`, `"thinking"`, `"tools"`, or `"verbose"` and resolves omitted or invalid values to `"quiet"`. `thinking` shows only provider-exposed thinking, `tools` shows only completed tool activity, and `verbose` shows both. Thinking and tools use persistent ordinary HTML `sendMessage`/`editMessageText` disclosures with standard expandable blockquotes; thinking carries a `🧠` header with the current Pi thinking level and bounded redacted text, while each tool contains bounded redacted arguments, retained updates, results, and errors. Activity output never uses Rich Messages, disables link previews on every send/edit, and neutralizes HTTP(S) auto-link detection inside technical evidence as a client-safe fallback. Consecutive tools coalesce only inside the same ordered activity segment and bounded message. Legacy `assistant.activityVerbosity` is read only when `assistant.activity` is absent and is removed by the next Activity Settings write.
|
|
122
|
+
- `assistant.activity` accepts exactly `"quiet"`, `"thinking"`, `"tools"`, or `"verbose"` and resolves omitted or invalid values to `"quiet"`. `thinking` shows only provider-exposed thinking, `tools` shows only completed tool activity, and `verbose` shows both. Thinking and tools use persistent ordinary HTML `sendMessage`/`editMessageText` disclosures with standard expandable blockquotes; thinking carries a `🧠` header with the current Pi thinking level and bounded redacted text whose inline Markdown renders as Telegram HTML inside the expandable quote, while each tool contains bounded redacted arguments, retained updates, results, and errors. Activity output never uses Rich Messages, disables link previews on every send/edit, and neutralizes HTTP(S) auto-link detection inside technical evidence as a client-safe fallback. Consecutive tools coalesce only inside the same ordered activity segment and bounded message. Legacy `assistant.activityVerbosity` is read only when `assistant.activity` is absent and is removed by the next Activity Settings write.
|
|
123
123
|
- Voice Reply `hidden`: no `voice.replyMode` key is persisted; legacy `manual` resolves to this silent default. `mirror` adds `[voice] delivery: automatic voice` only to voice/audio-input turns, while `always` adds the same effective line to every Telegram turn.
|
|
124
124
|
- Agent activity status is not configurable in this release. Telegram uses native `sendChatAction(typing)` / product `...active` status as the only automatic in-chat work signal before the final reply.
|
|
125
125
|
- `assistant.timeInjection` accepts `hidden`, `always`, or `interval` and defaults to `hidden`. Settings writes the selected value there, including `hidden`; the old `time.injectionMode` key is ignored and is not migrated. `time.interval` remains the optional interval duration in milliseconds.
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TelegramActivityEvent } from "./activity.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
escapeHtml,
|
|
10
|
+
renderTelegramInlineMarkdownHtml,
|
|
11
|
+
} from "./rendering.ts";
|
|
9
12
|
import type {
|
|
10
13
|
TelegramEditMessageTextBody,
|
|
11
14
|
TelegramSendMessageBody,
|
|
@@ -147,8 +150,18 @@ function serializeActivityValue(value: unknown): string {
|
|
|
147
150
|
return `${redacted.slice(0, TELEGRAM_ACTIVITY_DETAIL_MAX_CHARS)}\n… [${omitted} chars truncated]`;
|
|
148
151
|
}
|
|
149
152
|
|
|
153
|
+
function neutralizeActivityAutoLinks(text: string): string {
|
|
154
|
+
return text.replace(/\b(https?:\/\/)(?=\S)/gi, "$1\u200b");
|
|
155
|
+
}
|
|
156
|
+
|
|
150
157
|
function escapeActivityEvidenceHtml(text: string): string {
|
|
151
|
-
return escapeHtml(text
|
|
158
|
+
return escapeHtml(neutralizeActivityAutoLinks(text));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderThinkingActivityEvidenceHtml(text: string): string {
|
|
162
|
+
return renderTelegramInlineMarkdownHtml(neutralizeActivityAutoLinks(text), {
|
|
163
|
+
allowLinks: false,
|
|
164
|
+
});
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
function renderToolActivityHtml(tool: ToolActivity): string {
|
|
@@ -191,7 +204,7 @@ export function renderTelegramThinkingActivityHtml(
|
|
|
191
204
|
): string {
|
|
192
205
|
return [
|
|
193
206
|
`<b>${TELEGRAM_THINKING_ACTIVITY_ICON}  thinking:</b> <code>${escapeHtml(thinkingLevel)}</code>`,
|
|
194
|
-
`<blockquote expandable>${
|
|
207
|
+
`<blockquote expandable>${renderThinkingActivityEvidenceHtml(text)}</blockquote>`,
|
|
195
208
|
].join("\n");
|
|
196
209
|
}
|
|
197
210
|
|
package/lib/bus-leader.ts
CHANGED
|
@@ -795,22 +795,48 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
|
|
|
795
795
|
);
|
|
796
796
|
}
|
|
797
797
|
await deps.topicTargetStore.load();
|
|
798
|
+
const profileKey = Threads.getTelegramThreadOwnerKey({
|
|
799
|
+
kind: "leader",
|
|
800
|
+
cwd: deps.getCwd?.(ctx),
|
|
801
|
+
instanceId: deps.instanceId,
|
|
802
|
+
telegramProfile: deps.getTelegramProfile?.(),
|
|
803
|
+
});
|
|
804
|
+
const reusableOwnRecord = deps.topicTargetStore.getByProfileKey(profileKey);
|
|
805
|
+
const pendingCleanups = deps.topicTargetStore.listPendingCleanups();
|
|
806
|
+
const deferredOwnCleanups =
|
|
807
|
+
reusableOwnRecord?.status === "active"
|
|
808
|
+
? pendingCleanups.filter(
|
|
809
|
+
(cleanup) =>
|
|
810
|
+
cleanup.owner === "leader" &&
|
|
811
|
+
cleanup.target.chatId === reusableOwnRecord.target.chatId &&
|
|
812
|
+
cleanup.target.threadId === reusableOwnRecord.target.threadId,
|
|
813
|
+
)
|
|
814
|
+
: [];
|
|
815
|
+
const deferredOwnCleanupIds = new Set(
|
|
816
|
+
deferredOwnCleanups.map((cleanup) => cleanup.id),
|
|
817
|
+
);
|
|
798
818
|
const pendingCleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
799
819
|
nowMs: getNowMs(),
|
|
800
820
|
currentLeaderEpoch: leaderEpoch,
|
|
801
821
|
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
802
822
|
records: deps.topicTargetStore.list(),
|
|
803
|
-
pendingCleanups:
|
|
823
|
+
pendingCleanups: pendingCleanups.filter(
|
|
824
|
+
(cleanup) => !deferredOwnCleanupIds.has(cleanup.id),
|
|
825
|
+
),
|
|
804
826
|
});
|
|
805
827
|
deps.recordThreadReconciliationPlan?.(pendingCleanupPlan);
|
|
806
|
-
|
|
828
|
+
const cleanupPorts = {
|
|
807
829
|
callApi: deps.callApi,
|
|
808
830
|
markStaleByTarget: deps.topicTargetStore.markStaleByTarget,
|
|
809
831
|
removeCleanupIntentById: deps.topicTargetStore.removePendingCleanup,
|
|
810
832
|
persist: deps.topicTargetStore.persist,
|
|
811
833
|
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
812
834
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
813
|
-
}
|
|
835
|
+
};
|
|
836
|
+
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
837
|
+
pendingCleanupPlan,
|
|
838
|
+
cleanupPorts,
|
|
839
|
+
);
|
|
814
840
|
deps.onProvisioningStart?.();
|
|
815
841
|
let ownTarget: Threads.TelegramOwnTopicProvisionResult | undefined;
|
|
816
842
|
try {
|
|
@@ -832,6 +858,20 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
|
|
|
832
858
|
} finally {
|
|
833
859
|
deps.onProvisioningEnd?.();
|
|
834
860
|
}
|
|
861
|
+
if (deferredOwnCleanups.length > 0) {
|
|
862
|
+
const supersededCleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
863
|
+
nowMs: getNowMs(),
|
|
864
|
+
currentLeaderEpoch: leaderEpoch,
|
|
865
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
866
|
+
records: deps.topicTargetStore.list(),
|
|
867
|
+
pendingCleanups: deferredOwnCleanups,
|
|
868
|
+
});
|
|
869
|
+
deps.recordThreadReconciliationPlan?.(supersededCleanupPlan);
|
|
870
|
+
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
871
|
+
supersededCleanupPlan,
|
|
872
|
+
cleanupPorts,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
835
875
|
if (
|
|
836
876
|
deps.getCurrentLeaderEpoch &&
|
|
837
877
|
deps.getCurrentLeaderEpoch() !== leaderEpoch
|
package/lib/rendering.ts
CHANGED
|
@@ -499,11 +499,12 @@ function makeInlineMarkdownToken(
|
|
|
499
499
|
function stashInlineMarkdownLinks(
|
|
500
500
|
text: string,
|
|
501
501
|
state: InlineMarkdownTokenState,
|
|
502
|
+
allowLinks: boolean,
|
|
502
503
|
): string {
|
|
503
504
|
return replaceMarkdownLink(text, {
|
|
504
505
|
renderInlineLink: (link, supported) => {
|
|
505
506
|
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
506
|
-
if (!supported)
|
|
507
|
+
if (!allowLinks || !supported)
|
|
507
508
|
return plainLabel.length > 0 ? plainLabel : link.destination;
|
|
508
509
|
const renderedLabel =
|
|
509
510
|
plainLabel.length > 0 ? plainLabel : link.destination;
|
|
@@ -513,6 +514,7 @@ function stashInlineMarkdownLinks(
|
|
|
513
514
|
);
|
|
514
515
|
},
|
|
515
516
|
renderAutolink: (link) => {
|
|
517
|
+
if (!allowLinks) return link.destination;
|
|
516
518
|
return makeInlineMarkdownToken(
|
|
517
519
|
state,
|
|
518
520
|
`<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(link.destination)}</a>`,
|
|
@@ -564,9 +566,16 @@ function restoreInlineMarkdownTokens(
|
|
|
564
566
|
);
|
|
565
567
|
}
|
|
566
568
|
|
|
567
|
-
function renderInlineMarkdown(
|
|
569
|
+
function renderInlineMarkdown(
|
|
570
|
+
text: string,
|
|
571
|
+
options: { allowLinks?: boolean } = {},
|
|
572
|
+
): string {
|
|
568
573
|
const tokenState: InlineMarkdownTokenState = { tokens: [] };
|
|
569
|
-
let result = stashInlineMarkdownLinks(
|
|
574
|
+
let result = stashInlineMarkdownLinks(
|
|
575
|
+
text,
|
|
576
|
+
tokenState,
|
|
577
|
+
options.allowLinks !== false,
|
|
578
|
+
);
|
|
570
579
|
result = stashInlineMarkdownCodeSpans(result, tokenState);
|
|
571
580
|
result = escapeHtml(result);
|
|
572
581
|
result = applyInlineMarkdownStyles(result);
|
|
@@ -574,6 +583,13 @@ function renderInlineMarkdown(text: string): string {
|
|
|
574
583
|
return restoreInlineMarkdownTokens(result, tokenState);
|
|
575
584
|
}
|
|
576
585
|
|
|
586
|
+
export function renderTelegramInlineMarkdownHtml(
|
|
587
|
+
text: string,
|
|
588
|
+
options: { allowLinks?: boolean } = {},
|
|
589
|
+
): string {
|
|
590
|
+
return renderInlineMarkdown(text, options);
|
|
591
|
+
}
|
|
592
|
+
|
|
577
593
|
function buildListIndent(level: number): string {
|
|
578
594
|
return "\u00A0".repeat(Math.max(0, level) * 2);
|
|
579
595
|
}
|