pi-ui-extend 0.1.17 → 0.1.18

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.
@@ -176,6 +176,12 @@ export interface DcpState {
176
176
  tokensSaved: number
177
177
  /** Number of discrete pruning operations performed */
178
178
  totalPruneCount: number
179
+ /**
180
+ * Total number of tool calls observed during the session lifetime.
181
+ * Persisted so `/dcp stats` can show an approximate total even when the
182
+ * toolCalls map has been trimmed for compactness.
183
+ */
184
+ totalToolCallCount: number
179
185
  /** Compression block IDs already counted in tokensSaved/totalPruneCount. */
180
186
  accountedCompressionBlockIds: Set<number>
181
187
  /** compressionBlockId → raw active-token savings estimate for that block. */
@@ -229,6 +235,7 @@ export function createState(): DcpState {
229
235
  currentTurn: 0,
230
236
  tokensSaved: 0,
231
237
  totalPruneCount: 0,
238
+ totalToolCallCount: 0,
232
239
  accountedCompressionBlockIds: new Set(),
233
240
  compressionTokenSavings: new Map(),
234
241
  accountedPrunedToolIds: new Set(),
@@ -257,6 +264,7 @@ export function resetState(state: DcpState): void {
257
264
  state.currentTurn = 0
258
265
  state.tokensSaved = 0
259
266
  state.totalPruneCount = 0
267
+ state.totalToolCallCount = 0
260
268
  state.accountedCompressionBlockIds.clear()
261
269
  state.compressionTokenSavings.clear()
262
270
  state.accountedPrunedToolIds.clear()
@@ -268,12 +276,59 @@ export function resetState(state: DcpState): void {
268
276
  state.lastNudge = undefined
269
277
  }
270
278
 
279
+ /**
280
+ * Compact tool record for persistence — strips outputText, outputDetails,
281
+ * and truncates/summarises inputArgs to keep serialized state bounded.
282
+ */
283
+ export interface CompactToolRecord {
284
+ toolCallId: string
285
+ toolName: string
286
+ inputFingerprint: string
287
+ isError: boolean
288
+ turnIndex: number
289
+ timestamp: number
290
+ tokenEstimate: number
291
+ /**
292
+ * Extracted string values from inputArgs that could match file-protection
293
+ * patterns, persisted so `isProtectedByFilePattern` still works after
294
+ * session restore. Capped to avoid bloating state with huge arg values.
295
+ */
296
+ inputStringValues?: string[]
297
+ }
298
+
299
+ /**
300
+ * Maximum number of recent tool records retained in persisted state.
301
+ * Older records are still kept when referenced by active compression blocks,
302
+ * pruned tool IDs, or accounted prune IDs.
303
+ */
304
+ export const PERSISTED_TOOL_CALLS_MAX_RECENT = 200
305
+
306
+ /**
307
+ * Maximum length of individual string values extracted from inputArgs
308
+ * for file-pattern matching. Longer values are truncated.
309
+ */
310
+ const INPUT_STRING_VALUE_MAX_LENGTH = 512
311
+
312
+ /**
313
+ * Maximum number of inputStringValues to keep per tool record.
314
+ */
315
+ const INPUT_STRING_VALUES_MAX_COUNT = 20
316
+
271
317
  export interface SerializedDcpState {
272
318
  compressionBlocks: CompressionBlock[]
273
319
  nextBlockId: number
274
320
  prunedToolIds: string[]
275
321
  prunedToolReasons: Array<[string, string]>
276
- toolCalls: ToolRecord[]
322
+ /** Full tool records — present in legacy snapshots. */
323
+ toolCalls?: ToolRecord[]
324
+ /** Compact tool records — present in new compact snapshots. */
325
+ compactToolCalls?: CompactToolRecord[]
326
+ /**
327
+ * Total number of tool calls observed during the session, including those
328
+ * trimmed from the persisted snapshot. Allows `/dcp stats` to report
329
+ * approximate totals.
330
+ */
331
+ totalToolCallCount?: number
277
332
  tokensSaved: number
278
333
  totalPruneCount: number
279
334
  accountedCompressionBlockIds: number[]
@@ -297,6 +352,8 @@ export interface SerializedDcpState {
297
352
  nudgeCounter?: number
298
353
  /** Persisted since v?.?. Diagnostic turn of the last emitted nudge. */
299
354
  lastNudgeTurn?: number
355
+ /** Hash of the last persisted serialized state, used for dedup. */
356
+ _stateHash?: string
300
357
  }
301
358
 
302
359
  function isToolRecord(value: unknown): value is ToolRecord {
@@ -309,6 +366,16 @@ function isToolRecord(value: unknown): value is ToolRecord {
309
366
  )
310
367
  }
311
368
 
369
+ function isCompactToolRecord(value: unknown): value is CompactToolRecord {
370
+ if (!value || typeof value !== "object") return false
371
+ const record = value as Partial<CompactToolRecord>
372
+ return (
373
+ typeof record.toolCallId === "string" &&
374
+ typeof record.toolName === "string" &&
375
+ typeof record.inputFingerprint === "string"
376
+ )
377
+ }
378
+
312
379
  function isNudgeAnchor(value: unknown): value is DcpNudgeAnchor {
313
380
  if (!value || typeof value !== "object") return false
314
381
  const anchor = value as Partial<DcpNudgeAnchor>
@@ -334,14 +401,116 @@ function isLastNudge(value: unknown): value is DcpLastNudge {
334
401
  )
335
402
  }
336
403
 
404
+ // ---------------------------------------------------------------------------
405
+ // Compact tool-record helpers
406
+ // ---------------------------------------------------------------------------
407
+
408
+ /**
409
+ * Recursively extract string values from a nested object, matching the
410
+ * logic in `pruner-tools.ts::collectStringValues`. Depth-limited to 6.
411
+ */
412
+ function extractStringValues(value: unknown, out: string[] = [], depth = 0): string[] {
413
+ if (depth > 6) return out
414
+ if (typeof value === "string") {
415
+ out.push(value)
416
+ return out
417
+ }
418
+ if (Array.isArray(value)) {
419
+ for (const item of value) extractStringValues(item, out, depth + 1)
420
+ return out
421
+ }
422
+ if (value !== null && typeof value === "object") {
423
+ for (const item of Object.values(value as Record<string, unknown>)) {
424
+ extractStringValues(item, out, depth + 1)
425
+ }
426
+ }
427
+ return out
428
+ }
429
+
430
+ /**
431
+ * Produce a compact tool record for persistence: strip outputText,
432
+ * outputDetails, and reduce inputArgs to just extracted string values
433
+ * for file-pattern protection checking.
434
+ */
435
+ export function compactifyToolRecord(record: ToolRecord): CompactToolRecord {
436
+ const stringValues = extractStringValues(record.inputArgs)
437
+ // Truncate individual values and limit total count
438
+ const cappedValues = stringValues
439
+ .slice(0, INPUT_STRING_VALUES_MAX_COUNT)
440
+ .map((v) => (v.length > INPUT_STRING_VALUE_MAX_LENGTH ? v.slice(0, INPUT_STRING_VALUE_MAX_LENGTH) : v))
441
+
442
+ const compact: CompactToolRecord = {
443
+ toolCallId: record.toolCallId,
444
+ toolName: record.toolName,
445
+ inputFingerprint: record.inputFingerprint,
446
+ isError: record.isError,
447
+ turnIndex: record.turnIndex,
448
+ timestamp: record.timestamp,
449
+ tokenEstimate: record.tokenEstimate,
450
+ }
451
+ if (cappedValues.length > 0) {
452
+ compact.inputStringValues = cappedValues
453
+ }
454
+ return compact
455
+ }
456
+
457
+ /**
458
+ * Determine which tool-call IDs are referenced by active compression blocks
459
+ * (via createdByToolCallId) or by pruned/accounted sets, and therefore must
460
+ * be retained even when trimming old records.
461
+ */
462
+ function referencedToolCallIds(state: DcpState): Set<string> {
463
+ const refs = new Set<string>()
464
+ // Tool IDs referenced by active compression blocks
465
+ for (const block of state.compressionBlocks) {
466
+ if (block.active && block.createdByToolCallId) {
467
+ refs.add(block.createdByToolCallId)
468
+ }
469
+ }
470
+ // Pruned tool IDs
471
+ for (const id of state.prunedToolIds) refs.add(id)
472
+ // Accounted pruned tool IDs (superset of prunedToolIds in some cases)
473
+ for (const id of state.accountedPrunedToolIds) refs.add(id)
474
+ return refs
475
+ }
476
+
337
477
  /** Serialize runtime state into a JSON-safe object for pi.appendEntry(). */
338
478
  export function serializeState(state: DcpState): SerializedDcpState {
479
+ // Build compact tool records, keeping referenced + recent ones.
480
+ const allRecords = Array.from(state.toolCalls.values())
481
+ const refs = referencedToolCallIds(state)
482
+
483
+ // Sort by timestamp descending so we can pick the most recent ones
484
+ const sorted = allRecords
485
+ .slice()
486
+ .sort((a, b) => b.timestamp - a.timestamp)
487
+
488
+ const compactToolCalls: CompactToolRecord[] = []
489
+ const seen = new Set<string>()
490
+
491
+ // First pass: always include referenced records
492
+ for (const record of sorted) {
493
+ if (refs.has(record.toolCallId)) {
494
+ compactToolCalls.push(compactifyToolRecord(record))
495
+ seen.add(record.toolCallId)
496
+ }
497
+ }
498
+
499
+ // Second pass: add recent records up to the limit
500
+ for (const record of sorted) {
501
+ if (seen.has(record.toolCallId)) continue
502
+ if (compactToolCalls.length >= PERSISTED_TOOL_CALLS_MAX_RECENT) break
503
+ compactToolCalls.push(compactifyToolRecord(record))
504
+ seen.add(record.toolCallId)
505
+ }
506
+
339
507
  return {
340
508
  compressionBlocks: state.compressionBlocks,
341
509
  nextBlockId: state.nextBlockId,
342
510
  prunedToolIds: Array.from(state.prunedToolIds),
343
511
  prunedToolReasons: Array.from(state.prunedToolReasons.entries()),
344
- toolCalls: Array.from(state.toolCalls.values()),
512
+ compactToolCalls,
513
+ totalToolCallCount: allRecords.length,
345
514
  tokensSaved: state.tokensSaved,
346
515
  totalPruneCount: state.totalPruneCount,
347
516
  accountedCompressionBlockIds: Array.from(state.accountedCompressionBlockIds),
@@ -397,7 +566,34 @@ export function restoreState(state: DcpState, data: unknown): void {
397
566
  )
398
567
  }
399
568
 
400
- if (Array.isArray(saved.toolCalls)) {
569
+ if (Array.isArray(saved.compactToolCalls)) {
570
+ // New compact format: restore CompactToolRecords as ToolRecords with
571
+ // synthetic inputArgs derived from inputStringValues.
572
+ state.toolCalls = new Map(
573
+ saved.compactToolCalls
574
+ .filter(isCompactToolRecord)
575
+ .map((compact) => {
576
+ const record: ToolRecord = {
577
+ toolCallId: compact.toolCallId,
578
+ toolName: compact.toolName,
579
+ // Reconstruct minimal inputArgs from persisted string values
580
+ // so isProtectedByFilePattern still works.
581
+ inputArgs: compact.inputStringValues
582
+ ? { _restoredValues: compact.inputStringValues }
583
+ : {},
584
+ inputFingerprint: compact.inputFingerprint,
585
+ isError: compact.isError,
586
+ turnIndex: compact.turnIndex,
587
+ timestamp: compact.timestamp,
588
+ tokenEstimate: compact.tokenEstimate,
589
+ // outputText and outputDetails intentionally not restored —
590
+ // they are only used during live compression block creation.
591
+ }
592
+ return [record.toolCallId, record] as const
593
+ }),
594
+ )
595
+ } else if (Array.isArray(saved.toolCalls)) {
596
+ // Legacy full format: restore as-is for backward compatibility.
401
597
  state.toolCalls = new Map(
402
598
  saved.toolCalls
403
599
  .filter(isToolRecord)
@@ -408,6 +604,14 @@ export function restoreState(state: DcpState, data: unknown): void {
408
604
  if (typeof saved.tokensSaved === "number") state.tokensSaved = saved.tokensSaved
409
605
  if (typeof saved.totalPruneCount === "number") state.totalPruneCount = saved.totalPruneCount
410
606
 
607
+ // Restore totalToolCallCount from the persisted snapshot, or fall back to
608
+ // the number of restored tool records (which may be a trimmed subset).
609
+ if (typeof saved.totalToolCallCount === "number" && saved.totalToolCallCount >= 0) {
610
+ state.totalToolCallCount = saved.totalToolCallCount
611
+ } else {
612
+ state.totalToolCallCount = state.toolCalls.size
613
+ }
614
+
411
615
  if (Array.isArray(saved.accountedCompressionBlockIds)) {
412
616
  state.accountedCompressionBlockIds = new Set(
413
617
  saved.accountedCompressionBlockIds.filter((id): id is number => typeof id === "number"),
@@ -519,3 +723,21 @@ export function createInputFingerprint(
519
723
  const sorted = sortObjectKeys(args)
520
724
  return `${toolName}::${JSON.stringify(sorted)}`
521
725
  }
726
+
727
+ // ---------------------------------------------------------------------------
728
+ // State hashing for save deduplication
729
+ // ---------------------------------------------------------------------------
730
+
731
+ /**
732
+ * Compute a fast hash of serialized state for deduplication.
733
+ * Uses a simple DJB2-like hash over the JSON string. This is not
734
+ * cryptographic — it's only used to avoid writing identical snapshots.
735
+ */
736
+ export function hashSerializedState(serialized: SerializedDcpState): string {
737
+ const json = JSON.stringify(serialized)
738
+ let hash = 5381
739
+ for (let i = 0; i < json.length; i++) {
740
+ hash = ((hash << 5) + hash + json.charCodeAt(i)) | 0
741
+ }
742
+ return (hash >>> 0).toString(36)
743
+ }
@@ -9,6 +9,11 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
9
9
  // module will switch/restore Pi's thinking level as in-progress tasks change.
10
10
  "todoThinking": false,
11
11
  "terminalBell": { "sound": true },
12
+ // "telegramMirror": {
13
+ // "enabled": true,
14
+ // "botToken": "123456789:ABCdef...",
15
+ // "chatId": 123456789
16
+ // },
12
17
  "dcp": {
13
18
  "enabled": true,
14
19
  "manualMode": { "enabled": false, "automaticStrategies": true },
@@ -22,6 +22,7 @@ const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
22
22
  { name: "web-search", load: () => import("./web-search/index") },
23
23
  { name: "dcp", load: () => import("./dcp/index") },
24
24
  { name: "prompt-commands", load: () => import("./prompt-commands/index") },
25
+ { name: "telegram-mirror", load: () => import("./telegram-mirror/index") },
25
26
  ];
26
27
 
27
28
  export default async function piToolsSuite(pi: ExtensionAPI) {
@@ -0,0 +1,168 @@
1
+ # telegram-mirror
2
+
3
+ A pi-tools-suite module that exposes one or more running pi sessions as a
4
+ single Telegram chat. Pi stays as the source of truth; Telegram is a remote
5
+ second screen.
6
+
7
+ ## Opt-in
8
+
9
+ The module is a no-op until you add a `telegramMirror` block to
10
+ `~/.config/pi/pi-tools-suite.jsonc`:
11
+
12
+ ```jsonc
13
+ {
14
+ // …other pi-tools-suite settings…
15
+ "telegramMirror": {
16
+ "enabled": true,
17
+ "botToken": "123456789:ABCdef…", // from @BotFather
18
+ "chatId": 123456789 // numeric chat id of your private chat
19
+ }
20
+ }
21
+ ```
22
+
23
+ | Field | Type | Required | Notes |
24
+ |-------------|-------------------|----------|------------------------------------------------------------------------------------------------|
25
+ | `enabled` | boolean | no | Defaults to `true` when the block is present and `botToken` + `chatId` are valid. |
26
+ | `botToken` | string | yes | Telegram Bot API token from [@BotFather](https://t.me/BotFather). Empty string disables. |
27
+ | `chatId` | number or string | yes | Numeric chat id of the private chat allowed to control the bot. Non-integer disables. |
28
+
29
+ When the block is present and valid, the module connects on the next `pi`
30
+ start (or `/reload`).
31
+
32
+ ## How to get your chat id
33
+
34
+ Open this URL in a browser (replace `<TOKEN>` with your bot token):
35
+
36
+ ```
37
+ https://api.telegram.org/bot<TOKEN>/getUpdates
38
+ ```
39
+
40
+ Send any message to your bot in Telegram, then refresh the URL. The JSON
41
+ response contains `"chat": { "id": 123456789, … }` — that number is your
42
+ `chatId`.
43
+
44
+ Alternative: message [@userinfobot](https://t.me/userinfobot).
45
+
46
+ The bot silently ignores every message from any other chat.
47
+
48
+ ## Multi-instance setup
49
+
50
+ Telegram allows exactly one concurrent `getUpdates` call per bot token,
51
+ so this module elects a **leader** when N pi processes share one bot:
52
+
53
+ 1. The first pi to start binds the unix socket at
54
+ `~/.pi/agent/extensions/pi-tools-suite/.run/telegram-mirror.sock`,
55
+ connects the bot, and starts polling.
56
+ 2. Subsequent pi processes connect to that socket as **followers**. They
57
+ forward their pix events to the leader over IPC and execute commands
58
+ received from the leader.
59
+ 3. If the leader dies (process exit, socket close, or heartbeat timeout),
60
+ followers race to bind the socket; the first to win becomes the new
61
+ leader. `activeId` resets on failover — run `/use N` again.
62
+
63
+ No setup needed: this is fully automatic. Just run more `pi` processes.
64
+
65
+ When you start a new pi, it logs `[telegram-mirror] registered with
66
+ leader <label>` on stderr. The leader logs `[telegram-mirror] connected
67
+ as @<botname> (leader)`.
68
+
69
+ ### Selecting the active instance
70
+
71
+ In Telegram, use `/list` and `/use`:
72
+
73
+ ```
74
+ /list
75
+
76
+ 1. pi-ui-extend (#12345) (leader) \[active\]
77
+ 2. opencode (#67890)
78
+ 3. other-repo (#99999)
79
+
80
+ Use /use N or /use <id> to switch.
81
+ ```
82
+
83
+ ```
84
+ /use 2
85
+ → ✅ Active: opencode (#67890)
86
+ ```
87
+
88
+ `/use` accepts a 1-based index from `/list` or a substring of the id/label.
89
+ Events from non-active instances are dropped (silent).
90
+
91
+ ### Cleanup
92
+
93
+ Socket file: `~/.pi/agent/extensions/pi-tools-suite/.run/telegram-mirror.sock`.
94
+
95
+ If a pi crashes hard and leaves a stale socket, the next pi to start will
96
+ unlink it automatically (bind fails → connect fails → unlink → retry).
97
+
98
+ ## Telegram → pix
99
+
100
+ | Command | Effect |
101
+ |-------------------|--------------------------------------------------------|
102
+ | Free text | forwarded to the active pi instance as user message |
103
+ | `/list` | show all known pi instances, mark active |
104
+ | `/use N` `/use X` | switch active instance (by index or id/label substring)|
105
+ | `/abort` `/stop` | cancel current turn on active |
106
+ | `/compact` | trigger context compaction on active |
107
+ | `/status` | show idle / streaming state of active |
108
+ | `/say <msg>` | explicit send (escape hatch for `/`-prefixed text) |
109
+ | `/disconnect` | stop the bot cluster-wide (resume with `/reload` in pi)|
110
+ | `/new` | not supported via extension API — run `/new` in pi |
111
+ | `/help` | show command list |
112
+
113
+ ## Pix → Telegram
114
+
115
+ The leader subscribes to pix streaming events (its own + followers' via IPC)
116
+ and renders one Telegram message per agent turn — but **only for the active
117
+ instance**:
118
+
119
+ - `before_agent_start` → `user: <prompt>`
120
+ - `message_update` (`text_delta`) → appended to the active message, edited
121
+ in place at ~1.2 s throttle (Telegram rate-limit friendly).
122
+ - `tool_execution_start` → `🔧 tool: <args>` line.
123
+ - `tool_execution_end` → `✅ tool: <summary>` or `❌` on error.
124
+ - `agent_end` → final flush + `— done —` trailer.
125
+
126
+ Messages are paginated at 4096 chars (Telegram's per-message limit).
127
+ Markdown is converted to Telegram HTML with `**bold**`, `*italic*`,
128
+ `` `code` ``, and fenced blocks.
129
+
130
+ ## Disable
131
+
132
+ Either set `"enabled": false` in the `telegramMirror` block, remove the
133
+ block entirely, or add `telegram-mirror` to the `disabledModules` array
134
+ in the same config file, then `/reload` pi.
135
+
136
+ ## Known limitations
137
+
138
+ - `/new` cannot start a fresh session from Telegram. The ExtensionAPI
139
+ exposes `newSession()` only on slash-command handler contexts, not on
140
+ event-handler contexts. Workaround: type `/new` in the pi TUI.
141
+ - `pi.sendUserMessage` does not expand pi's own slash commands (calls
142
+ `prompt(..., { expandPromptTemplates: false })` internally), so text
143
+ starting with `/` is sent verbatim to the LLM. The module's own
144
+ `/abort`, `/compact`, `/list`, `/use`, etc. are intercepted before
145
+ `sendUserMessage` is called, so they work.
146
+ - The leader uses long polling (35 s timeout) and keeps one outbound
147
+ request open. If your network blocks Telegram, you'll see repeating
148
+ `[telegram-mirror] polling: …` errors in stderr and the bot will back
149
+ off up to 60 s between retries.
150
+ - On leader failover, the in-flight streaming output for the active turn
151
+ is lost (the new leader's renderer starts empty). `activeId` also
152
+ resets to the new leader; run `/use N` to switch back to a follower.
153
+ - The cluster is single-host only (unix socket). To mirror across
154
+ machines, use separate bot tokens.
155
+ - IPC events between session_start and leader-registration can be lost
156
+ for a brief window. Mid-stream output may be cut off.
157
+
158
+ ## Files
159
+
160
+ | File | Purpose |
161
+ |-----------------|--------------------------------------------------------|
162
+ | `index.ts` | module factory: role selection (leader/follower) + lifecycle |
163
+ | `bot.ts` | Telegram Bot API fetch client + long-poll loop |
164
+ | `ipc.ts` | unix socket JSON-lines IPC + leader election |
165
+ | `multiplexer.ts`| leader-side registry + active-instance routing |
166
+ | `events.ts` | pix event → sink adapters + ctx capture |
167
+ | `renderer.ts` | per-turn buffer, throttled edit, pagination |
168
+ | `format.ts` | markdown → Telegram HTML, chunking |