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.
- package/apps/desktop-tauri/README.md +103 -0
- package/apps/desktop-tauri/bin/pix-desktop.mjs +89 -0
- package/dist/app/input/input-controller.d.ts +1 -0
- package/dist/app/input/input-controller.js +29 -0
- package/dist/app/input/input-paste-handler.d.ts +1 -1
- package/dist/app/input/input-paste-handler.js +6 -5
- package/dist/app/model/model-usage-status.js +4 -27
- package/dist/app/rendering/render-controller.js +12 -8
- package/dist/app/screen/mouse-controller.d.ts +1 -0
- package/dist/app/screen/mouse-controller.js +13 -16
- package/external/pi-tools-suite/src/config.ts +43 -0
- package/external/pi-tools-suite/src/dcp/commands.ts +1 -1
- package/external/pi-tools-suite/src/dcp/index.ts +21 -1
- package/external/pi-tools-suite/src/dcp/state.ts +225 -3
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +5 -0
- package/external/pi-tools-suite/src/index.ts +1 -0
- package/external/pi-tools-suite/src/telegram-mirror/README.md +168 -0
- package/external/pi-tools-suite/src/telegram-mirror/bot.ts +228 -0
- package/external/pi-tools-suite/src/telegram-mirror/events.ts +94 -0
- package/external/pi-tools-suite/src/telegram-mirror/format.ts +120 -0
- package/external/pi-tools-suite/src/telegram-mirror/index.ts +424 -0
- package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +419 -0
- package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +408 -0
- package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +214 -0
- package/package.json +14 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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 |
|