@chatpanel/channels 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,168 @@
1
+ # PolyForm Shield License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/shield/1.0.0>
4
+
5
+ Required Notice: Copyright © 2026 ChatPanel (https://chatpanel.net)
6
+
7
+ Licensor Line of Business: ChatPanel — an AI browser side-panel, its local
8
+ bridge, and related developer tools and services (https://chatpanel.net)
9
+
10
+ ## Acceptance
11
+
12
+ In order to get any license under these terms, you must agree
13
+ to them as both strict obligations and conditions to all
14
+ your licenses.
15
+
16
+ ## Copyright License
17
+
18
+ The licensor grants you a copyright license for the
19
+ software to do everything you might do with the software
20
+ that would otherwise infringe the licensor's copyright
21
+ in it for any permitted purpose. However, you may
22
+ only distribute the software according to [Distribution
23
+ License](#distribution-license) and make changes or new works
24
+ based on the software according to [Changes and New Works
25
+ License](#changes-and-new-works-license).
26
+
27
+ ## Distribution License
28
+
29
+ The licensor grants you an additional copyright license to
30
+ distribute copies of the software. Your license to distribute
31
+ covers distributing the software with changes and new works
32
+ permitted by [Changes and New Works License](#changes-and-new-works-license).
33
+
34
+ ## Notices
35
+
36
+ You must ensure that anyone who gets a copy of any part of
37
+ the software from you also gets a copy of these terms or the
38
+ URL for them above, as well as copies of any plain-text lines
39
+ beginning with `Required Notice:` that the licensor provided
40
+ with the software. For example:
41
+
42
+ > Required Notice: Copyright © 2026 ChatPanel (https://chatpanel.net)
43
+
44
+ ## Changes and New Works License
45
+
46
+ The licensor grants you an additional copyright license to
47
+ make changes and new works based on the software for any
48
+ permitted purpose.
49
+
50
+ ## Patent License
51
+
52
+ The licensor grants you a patent license for the software that
53
+ covers patent claims the licensor can license, or becomes able
54
+ to license, that you would infringe by using the software.
55
+
56
+ ## Noncompete
57
+
58
+ Any purpose is a permitted purpose, except for providing any
59
+ product that competes with the software or any product the
60
+ licensor or any of its affiliates provides using the software.
61
+
62
+ ## Competition
63
+
64
+ Goods and services compete even when they provide functionality
65
+ through different kinds of interfaces or for different technical
66
+ platforms. Applications can compete with services, libraries
67
+ with plugins, frameworks with development tools, and so on,
68
+ even if they're written in different programming languages
69
+ or for different computer architectures. Goods and services
70
+ compete even when provided free of charge. If you market a
71
+ product as a practical substitute for the software or another
72
+ product, it definitely competes.
73
+
74
+ ## New Products
75
+
76
+ If you are using the software to provide a product that does
77
+ not compete, but the licensor or any of its affiliates brings
78
+ your product into competition by providing a new version of
79
+ the software or another product using the software, you may
80
+ continue using versions of the software available under these
81
+ terms beforehand to provide your competing product, but not
82
+ any later versions.
83
+
84
+ ## Discontinued Products
85
+
86
+ You may begin using the software to compete with a product
87
+ or service that the licensor or any of its affiliates has
88
+ stopped providing, unless the licensor includes a plain-text
89
+ line beginning with `Licensor Line of Business:` with the
90
+ software that mentions that line of business. For example:
91
+
92
+ > Licensor Line of Business: ChatPanel — an AI browser side-panel, its local
93
+ > bridge, and related developer tools and services (https://chatpanel.net)
94
+
95
+ ## Sales of Business
96
+
97
+ If the licensor or any of its affiliates sells a line of
98
+ business developing the software or using the software
99
+ to provide a product, the buyer can also enforce
100
+ Noncompete for that product.
101
+
102
+ ## Fair Use
103
+
104
+ You may have "fair use" rights for the software under the
105
+ law. These terms do not limit them.
106
+
107
+ ## No Other Rights
108
+
109
+ These terms do not allow you to sublicense or transfer any of
110
+ your licenses to anyone else, or prevent the licensor from
111
+ granting licenses to anyone else. These terms do not imply
112
+ any other licenses.
113
+
114
+ ## Patent Defense
115
+
116
+ If you make any written claim that the software infringes or
117
+ contributes to infringement of any patent, your patent license
118
+ for the software granted under these terms ends immediately. If
119
+ your company makes such a claim, your patent license ends
120
+ immediately for work on behalf of your company.
121
+
122
+ ## Violations
123
+
124
+ The first time you are notified in writing that you have
125
+ violated any of these terms, or done anything with the software
126
+ not covered by your licenses, your licenses can nonetheless
127
+ continue if you come into full compliance with these terms,
128
+ and take practical steps to correct past violations, within
129
+ 32 days of receiving notice. Otherwise, all your licenses
130
+ end immediately.
131
+
132
+ ## No Liability
133
+
134
+ ***As far as the law allows, the software comes as is, without
135
+ any warranty or condition, and the licensor will not be liable
136
+ to you for any damages arising out of these terms or the use
137
+ or nature of the software, under any kind of legal claim.***
138
+
139
+ ## Definitions
140
+
141
+ The **licensor** is the individual or entity offering these
142
+ terms, and the **software** is the software the licensor makes
143
+ available under these terms.
144
+
145
+ A **product** can be a good or service, or a combination
146
+ of them.
147
+
148
+ **You** refers to the individual or entity agreeing to these
149
+ terms.
150
+
151
+ **Your company** is any legal entity, sole proprietorship,
152
+ or other kind of organization that you work for, plus all
153
+ its affiliates.
154
+
155
+ **Affiliates** means the other organizations that an
156
+ organization has control over, is under the control of, or is
157
+ under common control with.
158
+
159
+ **Control** means ownership of substantially all the assets of
160
+ an entity, or the power to direct its management and policies
161
+ by vote, contract, or otherwise. Control can be direct or
162
+ indirect.
163
+
164
+ **Your licenses** are all the licenses granted to you for the
165
+ software under these terms.
166
+
167
+ **Use** means anything you do with the software requiring one
168
+ of your licenses.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @chatpanel/channels
2
+
3
+ Drive your local ChatPanel agent from an external messaging surface — today **Telegram**,
4
+ next **WhatsApp**. A phone message becomes an agent turn on your machine, streamed back to the
5
+ chat, with PII redacted before anything leaves for the model and every run written to an audit
6
+ log.
7
+
8
+ Design doc: [`chatpanel/docs/feature-f7-remote-channels.md`](../chatpanel/docs/feature-f7-remote-channels.md).
9
+
10
+ ## Why this shape
11
+
12
+ The Telegram adapter uses `getUpdates` long-poll, which is **outbound-only** — the same
13
+ property as Claude Code Remote Control: your machine never opens an inbound port, works behind
14
+ NAT, needs no tunnel or public IP. The only hop that reaches the agent is the bridge on
15
+ `127.0.0.1:4319`, and it never leaves the box.
16
+
17
+ ```
18
+ Telegram getUpdates ─long-poll→ @chatpanel/channels ─POST /chat (bridge token)→ chatpanel-bridge
19
+ ↑ editMessageText (throttled) ←── SSE delta/done ──────────────────────────────────┘
20
+ │ normalize → pairing gate → pii.redact → capability(actor.kind:'channel')
21
+ ▼ append capability.invoked / privacy.redacted / privacy.egress
22
+ ```
23
+
24
+ ## Layout
25
+
26
+ | file | role | pure? |
27
+ |---|---|---|
28
+ | `src/normalize.js` | platform message → one normalized shape | ✅ |
29
+ | `src/pairing.js` | who may drive it, and their `reach` ceiling | ✅ |
30
+ | `src/invoke.js` | normalized → capability invocation + pii redact + audit events | ✅ |
31
+ | `src/stream.js` | bridge SSE → folded reply (+ Telegram split/throttle) | ✅ |
32
+ | `src/bridge.js` | POST `/chat` (SSE) + `/cancel` on the local bridge | net |
33
+ | `src/eventlog.js` | append-only JSONL sink over `@chatpanel/events` appender | net |
34
+ | `src/adapters/telegram.js` | `getUpdates` long-poll transport (the LOCAL shape) | net |
35
+ | `bin/chatpanel-channels.js` | CLI that wires it together | — |
36
+
37
+ The pure core is unit-testable without a bot or a bridge (`npm test` = 26 tests, incl. a
38
+ real-socket SSE integration test).
39
+
40
+ Conversations are **multi-turn**: each chat keeps a bounded, redacted history so a follow-up
41
+ ("…and the second one?") resolves against the prior answer. `/new` forgets it.
42
+
43
+ ## Run
44
+
45
+ ```sh
46
+ npm install -g @chatpanel/channels # or npx @chatpanel/channels telegram
47
+ ```
48
+
49
+ 1. Start the bridge once (it writes `~/.chatpanel/bridge-token`): `chatpanel-bridge`.
50
+ 2. Create a bot with [@BotFather](https://t.me/BotFather), copy its token.
51
+ 3. Run the adapter:
52
+
53
+ ```sh
54
+ TELEGRAM_BOT_TOKEN=123:abc chatpanel-channels telegram
55
+ ```
56
+
57
+ 4. Message your bot. It will say *not paired*. Pair the chat:
58
+ - **Bootstrap (dev):** set `CHANNELS_ALLOW=<yourChatId>` before starting, or
59
+ - **Codes (real):** mint a one-time code (`pairing.requestCode()` — surfaced in the
60
+ extension) and send `/pair <code>` from the phone.
61
+
62
+ Chat commands: `/pair <code>`, `/new` (forget the conversation + fresh privacy vault), `/stop`, `/help`.
63
+
64
+ ### Env
65
+
66
+ | var | default | meaning |
67
+ |---|---|---|
68
+ | `TELEGRAM_BOT_TOKEN` | — | BotFather token (required) |
69
+ | `CHANNELS_ALLOW` | — | comma list of chat ids to pre-pair (bootstrap) |
70
+ | `CHANNELS_AGENT` | `claude` | bridge engine id (`claude`, `codex`, …) |
71
+ | `CHANNELS_PRIVACY` | `standard` | `standard` restores real values for you · `strict` keeps `[[PERSON_1]]` in the reply |
72
+ | `CHANNELS_PII_TIER` | `basic` | `basic` regex · `full` (needs a roster) also pseudonymizes people/orgs |
73
+ | `CHATPANEL_BRIDGE_URL` | `http://127.0.0.1:4319` | bridge address |
74
+ | `CHATPANEL_BRIDGE_TOKEN` | — | overrides the token file |
75
+
76
+ ## Security posture (read before shipping)
77
+
78
+ This is the honest §7 of the design doc, made concrete:
79
+
80
+ - **PII redaction on egress is on by default and mandatory.** Telegram bot traffic is **not**
81
+ end-to-end encrypted, so inbound text is redacted (`@chatpanel/pii`) before it reaches the
82
+ agent/model. `CHANNELS_PRIVACY=strict` additionally keeps placeholders in the *reply* so the
83
+ provider never sees a real value either.
84
+ - **Pairing is authentication, not authorization.** A chat-id allowlist + a paired code proves
85
+ *who* sent a message. It does **not** bound *what* that message may do. Prompt-injection →
86
+ tool execution is the real risk, because the bridge `/chat` runs shell/filesystem tools.
87
+ - **`reach` is carried but tool-scoping is the next layer** (not yet built). Until it lands,
88
+ run against a bridge whose agent is read-only / ask-mode for anything destructive. Do not
89
+ point a write-capable, auto-approving agent at an untrusted chat.
90
+ - **Secrets:** the BotFather token and the bridge token are the crown jewels. The bridge token
91
+ is read `0600` from `~/.chatpanel`; keep the bot token out of shell history (use a file / a
92
+ keychain in a shipped build).
93
+
94
+ Every run leaves `capability.invoked` / `privacy.redacted` / `privacy.egress` in
95
+ `~/.chatpanel/channels/events.jsonl` — the audit trail neither Claude Code Remote nor Hermes
96
+ has.
97
+
98
+ ## Status
99
+
100
+ **Local (Telegram) shape: built.** The WhatsApp/Cloudflare relay shape and the
101
+ per-actor tool-authorization policy are designed but not built — see the feature doc, §5 build
102
+ order.
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // chatpanel-channels — run a messaging adapter that drives your local ChatPanel agent.
3
+ //
4
+ // chatpanel-channels telegram
5
+ //
6
+ // Env:
7
+ // TELEGRAM_BOT_TOKEN BotFather token (required) [or BOT_TOKEN]
8
+ // CHANNELS_ALLOW comma list of chat ids to pre-pair (bootstrap; optional)
9
+ // CHANNELS_AGENT bridge engine id (default: claude)
10
+ // CHANNELS_SYSTEM system prompt for the agent (optional)
11
+ // CHANNELS_PRIVACY 'standard' (restore for you) | 'strict' (keep placeholders)
12
+ // CHANNELS_PII_TIER 'basic' (regex) | 'full' (needs a roster) — default basic
13
+ // CHATPANEL_BRIDGE_URL default http://127.0.0.1:4319
14
+ // CHATPANEL_BRIDGE_TOKEN overrides ~/.chatpanel/bridge-token
15
+
16
+ import path from 'node:path';
17
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
18
+ import { bridgeBaseUrl, readBridgeToken, channelsDataDir } from '../src/config.js';
19
+ import { createPairingStore } from '../src/pairing.js';
20
+ import { createEventLog } from '../src/eventlog.js';
21
+ import { startTelegram } from '../src/adapters/telegram.js';
22
+
23
+ async function loadPairing(file) {
24
+ try { return JSON.parse(await readFile(file, 'utf8')); } catch { return {}; }
25
+ }
26
+
27
+ async function main() {
28
+ const cmd = process.argv[2] || 'telegram';
29
+ if (cmd === '--help' || cmd === '-h') { console.log('usage: chatpanel-channels telegram'); return; }
30
+ if (cmd !== 'telegram') { console.error(`unknown command '${cmd}'. try: chatpanel-channels telegram`); process.exit(1); }
31
+
32
+ const botToken = process.env.TELEGRAM_BOT_TOKEN || process.env.BOT_TOKEN;
33
+ if (!botToken) { console.error('set TELEGRAM_BOT_TOKEN (from @BotFather)'); process.exit(1); }
34
+
35
+ const baseUrl = bridgeBaseUrl();
36
+ const token = await readBridgeToken();
37
+ const dataDir = channelsDataDir();
38
+ await mkdir(dataDir, { recursive: true });
39
+
40
+ const pairingFile = path.join(dataDir, 'pairing.json');
41
+ const pairing = createPairingStore(await loadPairing(pairingFile));
42
+ const savePairing = () => writeFile(pairingFile, JSON.stringify(pairing.toJSON(), null, 2), { mode: 0o600 });
43
+
44
+ // Bootstrap allow-list (prototype convenience). Each becomes a paired 'trusted' actor —
45
+ // explicitly, never silent enrollment. Prefer /pair codes for real use.
46
+ for (const chatId of (process.env.CHANNELS_ALLOW || '').split(',').map((s) => s.trim()).filter(Boolean)) {
47
+ pairing.allow(`telegram:${chatId}`);
48
+ }
49
+ await savePairing();
50
+
51
+ const appender = await createEventLog({ file: path.join(dataDir, 'events.jsonl'), host: 'channel' });
52
+
53
+ const controller = new AbortController();
54
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => controller.abort());
55
+
56
+ const agent = process.env.CHANNELS_AGENT || 'claude';
57
+ const privacy = process.env.CHANNELS_PRIVACY || 'standard';
58
+ console.log(`[chatpanel-channels] telegram → bridge ${baseUrl} (agent=${agent}, privacy=${privacy}, tier=${process.env.CHANNELS_PII_TIER || 'basic'})`);
59
+ await startTelegram({
60
+ botToken, baseUrl, token, pairing, savePairing, appender,
61
+ agent,
62
+ system: process.env.CHANNELS_SYSTEM || '',
63
+ redact: { tier: process.env.CHANNELS_PII_TIER || 'basic' },
64
+ privacy,
65
+ signal: controller.signal,
66
+ });
67
+ }
68
+
69
+ main().catch((e) => { console.error(e?.message || e); process.exit(1); });
package/index.js ADDED
@@ -0,0 +1,18 @@
1
+ // @chatpanel/channels — drive ChatPanel agents from external messaging surfaces.
2
+ //
3
+ // The pure core is platform-agnostic and unit-testable without a bot or a bridge:
4
+ // normalize.js platform message → one normalized shape
5
+ // pairing.js who may drive it, and how far a request may travel (reach)
6
+ // invoke.js normalized message → capability invocation + pii redaction + audit events
7
+ // stream.js the bridge's SSE stream → a folded reply (+ Telegram splitting/throttling)
8
+ // Adapters are dumb transport; the CLI (bin/) wires them together.
9
+ //
10
+ // import { normalizeTelegram, createPairingStore, redactInbound } from '@chatpanel/channels';
11
+
12
+ export * from './src/normalize.js';
13
+ export * from './src/pairing.js';
14
+ export * from './src/invoke.js';
15
+ export * from './src/stream.js';
16
+ export * as bridge from './src/bridge.js';
17
+ export { createEventLog, nullEventLog } from './src/eventlog.js';
18
+ export { startTelegram } from './src/adapters/telegram.js';
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@chatpanel/channels",
3
+ "version": "0.1.0",
4
+ "description": "Drive ChatPanel agents from external messaging surfaces (Telegram, WhatsApp). One normalize→invoke→stream core per platform, wired through @chatpanel/pii redaction and the @chatpanel/events audit log. The local Telegram shape is outbound-only (long-poll) and opens no inbound port — the same property as Claude Code Remote Control. Pure, dependency-free ESM.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./normalize.js": "./src/normalize.js",
10
+ "./pairing.js": "./src/pairing.js",
11
+ "./invoke.js": "./src/invoke.js",
12
+ "./stream.js": "./src/stream.js",
13
+ "./bridge.js": "./src/bridge.js",
14
+ "./eventlog.js": "./src/eventlog.js",
15
+ "./adapters/telegram.js": "./src/adapters/telegram.js"
16
+ },
17
+ "bin": {
18
+ "chatpanel-channels": "bin/chatpanel-channels.js"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "src",
23
+ "bin",
24
+ "LICENSE",
25
+ "README.md"
26
+ ],
27
+ "scripts": {
28
+ "test": "node --test tests/*.test.js",
29
+ "start": "node bin/chatpanel-channels.js telegram"
30
+ },
31
+ "dependencies": {
32
+ "@chatpanel/events": "^0.21.0",
33
+ "@chatpanel/pii": "^0.3.0"
34
+ },
35
+ "keywords": [
36
+ "chatpanel",
37
+ "telegram",
38
+ "whatsapp",
39
+ "remote-control",
40
+ "agent",
41
+ "privacy",
42
+ "pii"
43
+ ],
44
+ "license": "SEE LICENSE IN LICENSE",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/chatpanel/chatpanel-channels.git"
48
+ },
49
+ "homepage": "https://chatpanel.net",
50
+ "engines": {
51
+ "node": ">=20"
52
+ },
53
+ "sideEffects": false
54
+ }
@@ -0,0 +1,206 @@
1
+ // Telegram adapter — the LOCAL shape (§3 of feature-f7). getUpdates long-poll is
2
+ // OUTBOUND-ONLY, so this mirrors Claude Code Remote Control's key property: the machine never
3
+ // opens an inbound port, works behind NAT, needs no tunnel. The adapter is dumb transport;
4
+ // normalize → gate → redact → invoke → stream → restore is the shared core it drives.
5
+
6
+ import { normalizeTelegram, actorId } from '../normalize.js';
7
+ import {
8
+ buildInvocation, redactInbound, restoreOutbound, appendTurn,
9
+ appendInvoked, appendRedacted, appendEgress,
10
+ } from '../invoke.js';
11
+ import * as bridge from '../bridge.js';
12
+ import { splitForTelegram, createGate } from '../stream.js';
13
+ import { createVault } from '@chatpanel/pii';
14
+
15
+ const TG_HOST = 'api.telegram.org';
16
+
17
+ const HELP = [
18
+ 'ChatPanel — drive your local agent from here.',
19
+ '',
20
+ 'Send a message and I run it on your machine.',
21
+ '/pair <code> — enroll this chat (get the code in the ChatPanel extension)',
22
+ '/new — start fresh (forget this conversation + new privacy vault)',
23
+ '/stop — stop the current run',
24
+ '/help — this message',
25
+ ].join('\n');
26
+
27
+ /**
28
+ * Start the long-poll loop. Returns the loop promise; abort `signal` to stop it. Everything
29
+ * it needs is injected — a bot token, the bridge address+token, a pairing store, an event sink
30
+ * — so nothing here reads a secret or reaches for global state.
31
+ */
32
+ export function startTelegram({
33
+ botToken,
34
+ baseUrl,
35
+ token, // bridge token
36
+ pairing, // createPairingStore(...)
37
+ savePairing = async () => {},
38
+ appender, // createEventLog(...) or nullEventLog()
39
+ agent = 'claude',
40
+ system = '',
41
+ redact = { tier: 'basic' },
42
+ privacy = 'standard',
43
+ logger = console,
44
+ signal, // AbortSignal to stop the whole loop
45
+ }) {
46
+ const api = `https://${TG_HOST}/bot${botToken}`;
47
+ const fileApi = `https://${TG_HOST}/file/bot${botToken}`;
48
+ // Per-chat session: a persistent vault (stable placeholders across turns), the live run id
49
+ // (/stop), and the redacted conversation history (multi-turn context).
50
+ const chats = new Map(); // chatId -> { vault, runId, history }
51
+ const freshChat = () => ({ vault: createVault(), runId: null, history: [] });
52
+ const chatState = (id) => {
53
+ if (!chats.has(id)) chats.set(id, freshChat());
54
+ return chats.get(id);
55
+ };
56
+
57
+ async function tg(method, body) {
58
+ const res = await fetch(`${api}/${method}`, {
59
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal,
60
+ });
61
+ return res.json();
62
+ }
63
+ const send = (chatId, text) => tg('sendMessage', { chat_id: chatId, text });
64
+ const edit = (chatId, messageId, text) => tg('editMessageText', { chat_id: chatId, message_id: messageId, text });
65
+
66
+ // Resolve Telegram photos to the { dataUrl } shape the bridge's engines expect (Claude Code
67
+ // writes them to temp files and reads them as vision). A failed fetch drops that one image
68
+ // rather than failing the whole turn.
69
+ async function toBridgeImages(photos) {
70
+ const out = [];
71
+ for (const p of photos || []) {
72
+ try {
73
+ const info = await tg('getFile', { file_id: p.fileId });
74
+ const fp = info?.result?.file_path;
75
+ if (!fp) continue;
76
+ const bin = await fetch(`${fileApi}/${fp}`, { signal });
77
+ const buf = Buffer.from(await bin.arrayBuffer());
78
+ const mime = /\.png$/i.test(fp) ? 'image/png' : /\.webp$/i.test(fp) ? 'image/webp' : 'image/jpeg';
79
+ out.push({ dataUrl: `data:${mime};base64,${buf.toString('base64')}` });
80
+ } catch (e) { logger.warn?.(`[telegram] image fetch failed: ${e?.message || e}`); }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ async function handleCommand(norm) {
86
+ const id = actorId('telegram', norm.chatId);
87
+ const { name, args } = norm.command;
88
+ if (name === 'help' || name === 'start') return void send(norm.chatId, HELP);
89
+ if (name === 'pair') {
90
+ const r = pairing.redeem(id, args);
91
+ await savePairing();
92
+ return void send(norm.chatId, r.ok ? `✅ paired (reach: ${r.reach}). Send me anything.` : `⛔ ${r.reason}`);
93
+ }
94
+ if (name === 'new') {
95
+ chats.set(norm.chatId, freshChat());
96
+ return void send(norm.chatId, '🧹 fresh conversation.');
97
+ }
98
+ if (name === 'stop') {
99
+ const st = chatState(norm.chatId);
100
+ const ok = await bridge.cancel(st.runId, { baseUrl, token });
101
+ st.runId = null;
102
+ return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
103
+ }
104
+ return void send(norm.chatId, `unknown command /${name} — try /help`);
105
+ }
106
+
107
+ async function handleMessage(norm) {
108
+ const id = actorId('telegram', norm.chatId);
109
+ // AUTHENTICATION gate: an unpaired sender cannot drive anything. This proves WHO, not WHAT
110
+ // — tool scoping (reach → per-actor allowlist) is the next layer and lives above the bridge.
111
+ const reach = pairing.reachOf(id);
112
+ if (!reach) {
113
+ return void send(norm.chatId, '🔒 not paired. Get a code in the ChatPanel extension, then send: /pair <code>');
114
+ }
115
+ if (!norm.text && !norm.photos.length) return;
116
+ const st = chatState(norm.chatId);
117
+
118
+ // Contract + audit BEFORE anything leaves for the agent.
119
+ let invocation;
120
+ try { invocation = buildInvocation(norm); }
121
+ catch (e) { return void send(norm.chatId, `⚠️ ${e.message}`); }
122
+
123
+ const { redacted, counts } = redactInbound(norm.text, st.vault, redact);
124
+ await appendInvoked(appender, invocation);
125
+ await appendRedacted(appender, counts);
126
+
127
+ const images = await toBridgeImages(norm.photos);
128
+ const placeholder = await tg('sendMessage', { chat_id: norm.chatId, text: '…' });
129
+ const replyId = placeholder?.result?.message_id;
130
+ const gate = createGate(1200); // ~1 edit/sec, Telegram's ceiling for a chat
131
+ let shown = '';
132
+
133
+ // Replay prior turns as context; the new (redacted) message is the live one. buildCliPrompt
134
+ // on the bridge renders all-but-last as history and the last as "answer this now".
135
+ const messages = [...st.history, { role: 'user', content: redacted }];
136
+
137
+ try {
138
+ const finalState = await bridge.chat(
139
+ { agent, system, messages, images, options: { reach } },
140
+ {
141
+ baseUrl, token, signal,
142
+ onEvent: (ev, state) => {
143
+ if (ev.type === 'run') st.runId = state.runId;
144
+ // Throttled live edit: restore placeholders so the user watches real text stream in.
145
+ if ((ev.type === 'delta' || ev.type === 'done') && replyId && (ev.type === 'done' || gate.ready())) {
146
+ const text = restoreOutbound(state.text, st.vault, { privacy });
147
+ const first = splitForTelegram(text || '…')[0];
148
+ if (first && first !== shown) { shown = first; edit(norm.chatId, replyId, first).catch(() => {}); }
149
+ }
150
+ },
151
+ },
152
+ );
153
+ st.runId = null;
154
+
155
+ const restored = restoreOutbound(finalState.text, st.vault, { privacy });
156
+ const chunks = splitForTelegram(finalState.error ? `⚠️ ${finalState.error}` : (restored || '(no output)'));
157
+ if (replyId) await edit(norm.chatId, replyId, chunks[0]);
158
+ else await send(norm.chatId, chunks[0]);
159
+ for (const extra of chunks.slice(1)) await send(norm.chatId, extra);
160
+
161
+ // Remember the exchange for follow-ups — the REDACTED forms, so what we replay next turn
162
+ // never carries a real value and stays consistent with the vault. A failed turn (error /
163
+ // no text) records nothing, so history never implies an answer that didn't happen.
164
+ if (!finalState.error && finalState.text) st.history = appendTurn(st.history, redacted, finalState.text);
165
+
166
+ // Egress to Telegram, recorded: 'standard' restores real values (a third party sees
167
+ // them → redacted:false); 'strict' keeps placeholders (redacted:true). controlled:false
168
+ // either way — Telegram is not ours.
169
+ await appendEgress(appender, { host: TG_HOST, redacted: privacy === 'strict', controlled: false });
170
+ } catch (e) {
171
+ st.runId = null;
172
+ const msg = `⚠️ ${e?.message || e}`;
173
+ if (replyId) await edit(norm.chatId, replyId, msg).catch(() => {});
174
+ else await send(norm.chatId, msg).catch(() => {});
175
+ }
176
+ }
177
+
178
+ async function loop() {
179
+ let offset = 0;
180
+ logger.log?.('[telegram] long-poll started (outbound-only; no inbound port).');
181
+ while (!signal?.aborted) {
182
+ let updates;
183
+ try {
184
+ const res = await tg('getUpdates', { offset, timeout: 30, allowed_updates: ['message'] });
185
+ updates = res?.result || [];
186
+ } catch (e) {
187
+ if (signal?.aborted) break;
188
+ logger.warn?.(`[telegram] getUpdates failed: ${e?.message || e}; retrying in 2s`);
189
+ await new Promise((r) => setTimeout(r, 2000));
190
+ continue;
191
+ }
192
+ for (const u of updates) {
193
+ offset = u.update_id + 1;
194
+ const norm = normalizeTelegram(u);
195
+ if (!norm) continue;
196
+ // Fire-and-forget per message so one slow turn doesn't stall the poll; errors are
197
+ // caught so a single bad message never kills the loop.
198
+ const run = norm.command ? handleCommand(norm) : handleMessage(norm);
199
+ Promise.resolve(run).catch((e) => logger.error?.(`[telegram] handler error: ${e?.message || e}`));
200
+ }
201
+ }
202
+ logger.log?.('[telegram] stopped.');
203
+ }
204
+
205
+ return loop();
206
+ }
package/src/bridge.js ADDED
@@ -0,0 +1,50 @@
1
+ // The transport to the local agent: POST /chat (SSE) and POST /cancel on the bridge. The
2
+ // bridge binds 127.0.0.1:4319 and runs Claude Code / Codex / etc. as CLIs — so this is the ONE
3
+ // hop where the (already-redacted) conversation reaches the agent, and it never leaves the
4
+ // machine. A channel adapter is a non-browser local client, so it presents the per-install
5
+ // bridge token, exactly like any privileged caller.
6
+
7
+ import { parseSse, foldEvent, initialState } from './stream.js';
8
+
9
+ /**
10
+ * Drive one turn. Streams bridge events to onEvent(ev, state) as they arrive AND folds them
11
+ * into a final reply state it returns. `agent` is a bridge engine id ('claude','codex',…).
12
+ */
13
+ export async function chat({ agent = 'claude', system = '', messages, images = [], options = {} }, {
14
+ baseUrl, token, signal, onEvent = () => {},
15
+ } = {}) {
16
+ const res = await fetch(`${baseUrl}/chat`, {
17
+ method: 'POST',
18
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
19
+ body: JSON.stringify({ agent, system, messages, images, options }),
20
+ signal,
21
+ });
22
+ if (!res.ok || !res.body) {
23
+ const detail = await res.text().catch(() => '');
24
+ throw new Error(`bridge /chat ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
25
+ }
26
+ let state = initialState();
27
+ let buffer = '';
28
+ const decoder = new TextDecoder();
29
+ for await (const chunk of res.body) {
30
+ buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
31
+ const { events, rest } = parseSse(buffer);
32
+ buffer = rest;
33
+ for (const ev of events) { state = foldEvent(state, ev); onEvent(ev, state); }
34
+ }
35
+ return state;
36
+ }
37
+
38
+ /** Stop a run by the id the bridge emitted as its first {type:'run'} event. Best-effort. */
39
+ export async function cancel(runId, { baseUrl, token } = {}) {
40
+ if (!runId) return false;
41
+ try {
42
+ const res = await fetch(`${baseUrl}/cancel`, {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
45
+ body: JSON.stringify({ id: runId }),
46
+ });
47
+ const out = await res.json().catch(() => ({}));
48
+ return !!out.cancelled;
49
+ } catch { return false; }
50
+ }
package/src/config.js ADDED
@@ -0,0 +1,35 @@
1
+ // Where things live and how the adapter authenticates. Kept tiny and in one place so a
2
+ // deployment overrides everything with env, and so no secret is read anywhere but here.
3
+
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { readFile } from 'node:fs/promises';
7
+
8
+ // The bridge binds a FIXED 127.0.0.1:4319 (see chatpanel-bridge/src/server.js) so the
9
+ // extension always finds it; the adapter is just another local client of the same port.
10
+ export const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:4319';
11
+
12
+ export function bridgeBaseUrl(env = process.env) {
13
+ return (env.CHATPANEL_BRIDGE_URL || DEFAULT_BRIDGE_URL).replace(/\/+$/, '');
14
+ }
15
+
16
+ export function channelsHome(env = process.env) {
17
+ return env.CHATPANEL_HOME || path.join(os.homedir(), '.chatpanel');
18
+ }
19
+
20
+ export function channelsDataDir(env = process.env) {
21
+ return path.join(channelsHome(env), 'channels');
22
+ }
23
+
24
+ // The bridge writes ~/.chatpanel/bridge-token 0600 on first start. A channel adapter is a
25
+ // non-browser local client, so — like any privileged caller — it presents this token instead
26
+ // of an Origin header. Read it fresh each start; never cache a secret longer than needed.
27
+ export async function readBridgeToken(env = process.env) {
28
+ if (env.CHATPANEL_BRIDGE_TOKEN) return env.CHATPANEL_BRIDGE_TOKEN.trim();
29
+ const p = path.join(channelsHome(env), 'bridge-token');
30
+ try {
31
+ const t = (await readFile(p, 'utf8')).trim();
32
+ if (t) return t;
33
+ } catch { /* fall through to a clear, actionable error */ }
34
+ throw new Error(`no bridge token — start chatpanel-bridge once (it writes ${p}), or set CHATPANEL_BRIDGE_TOKEN`);
35
+ }
@@ -0,0 +1,48 @@
1
+ // A file-backed sink for the capability/privacy events a channel run produces — the audit
2
+ // trail neither Claude Code Remote nor Hermes has. Events are metadata only (counts, ids;
3
+ // never message content — see chatpanel-events/event.js), so this JSONL is safe to keep and
4
+ // replicate. One line per event, append-only. seq is owned by the appender and recovered from
5
+ // the file on restart so ordering survives a bounce.
6
+
7
+ import { appendFile, readFile, mkdir } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { createAppender } from '@chatpanel/events/event.js';
10
+
11
+ async function nextSeq(file) {
12
+ try {
13
+ const txt = await readFile(file, 'utf8');
14
+ let max = -1;
15
+ for (const l of txt.split('\n')) {
16
+ if (!l) continue;
17
+ try { const e = JSON.parse(l); if (Number.isInteger(e.seq)) max = Math.max(max, e.seq); } catch { /* skip */ }
18
+ }
19
+ return max + 1;
20
+ } catch { return 0; }
21
+ }
22
+
23
+ export async function createEventLog({ file, host = 'channel' }) {
24
+ await mkdir(path.dirname(file), { recursive: true });
25
+ const seq = await nextSeq(file);
26
+ const appender = createAppender({ host, seq, newId: () => globalThis.crypto.randomUUID() });
27
+ return {
28
+ host,
29
+ get seq() { return appender.seq; },
30
+ // Same signature as the raw appender, but persists. Returns the validated event.
31
+ async append(type, payload, causes = []) {
32
+ const e = appender.append(type, payload, causes);
33
+ await appendFile(file, JSON.stringify(e) + '\n');
34
+ return e;
35
+ },
36
+ };
37
+ }
38
+
39
+ // A no-op sink for the allow-list prototype or tests — same interface, writes nothing but
40
+ // still builds + validates each event, so a bad payload fails loudly here too.
41
+ export function nullEventLog({ host = 'channel' } = {}) {
42
+ const appender = createAppender({ host, seq: 0, newId: () => globalThis.crypto.randomUUID() });
43
+ return {
44
+ host,
45
+ get seq() { return appender.seq; },
46
+ async append(type, payload, causes = []) { return appender.append(type, payload, causes); },
47
+ };
48
+ }
package/src/invoke.js ADDED
@@ -0,0 +1,119 @@
1
+ // The invariant core: an inbound message becomes ONE capability invocation with
2
+ // actor.kind:'channel', its text is redacted before it can leave for the agent, and both facts
3
+ // are written to the event log. Everything here is pure given a vault + an appender — the
4
+ // adapter owns the network, this owns the contract.
5
+
6
+ import { validateInvocation } from '@chatpanel/events/capability.js';
7
+ import { redactText, restoreText, redactionSummary } from '@chatpanel/pii';
8
+
9
+ // One capability every channel message invokes: "run an agent turn on the user's behalf".
10
+ // Effects are non-replayable — a turn runs shell/filesystem tools, so replaying it would
11
+ // repeat side effects; that is exactly why validateInvocation demands an idempotencyKey.
12
+ export const CHANNEL_CAPABILITY = 'channel.chat';
13
+ export const CHANNEL_EFFECTS = 'non-replayable';
14
+
15
+ /** Build + validate the invocation for one inbound message. Throws EventError on a bad shape. */
16
+ export function buildInvocation({ platform, chatId, messageId }, causes = []) {
17
+ const id = `${platform}:${chatId}`;
18
+ const inv = {
19
+ capability: CHANNEL_CAPABILITY,
20
+ actor: { kind: 'channel', id },
21
+ scope: { kind: 'session', id },
22
+ causes,
23
+ effects: CHANNEL_EFFECTS,
24
+ // Same platform + chat + message = same turn. Retried delivery must not run it twice.
25
+ idempotencyKey: `${platform}:${chatId}:${messageId}`,
26
+ };
27
+ return validateInvocation(inv);
28
+ }
29
+
30
+ // Entries per entity type in a vault, so we can diff before/after and report how many NEW
31
+ // values a turn redacted — never the values (privacy.redacted is counts-only, by contract).
32
+ function countsByType(vault) {
33
+ const counts = {};
34
+ for (const t of redactionSummary(vault).types) counts[t.type] = t.count;
35
+ return counts;
36
+ }
37
+
38
+ /**
39
+ * Redact inbound text into the chat's vault. Returns the redacted text plus the per-turn
40
+ * counts delta (for a privacy.redacted event). The vault persists across turns, so PERSON_1
41
+ * means the same person every message.
42
+ *
43
+ * `tier:'basic'` (regex: emails, phones, cards, keys, IPs) is the safe default with no roster;
44
+ * pass `tier:'full'` + `entities` to also pseudonymize known people/orgs.
45
+ */
46
+ export function redactInbound(text, vault, { tier = 'basic', entities = [], dictionary = [] } = {}) {
47
+ const before = countsByType(vault);
48
+ const redacted = redactText(text ?? '', vault, { tier, entities, dictionary });
49
+ const after = countsByType(vault);
50
+ const counts = {};
51
+ for (const type of Object.keys(after)) {
52
+ const d = after[type] - (before[type] || 0);
53
+ if (d > 0) counts[type] = d;
54
+ }
55
+ return { redacted, counts };
56
+ }
57
+
58
+ /**
59
+ * Restore the agent's reply for the user. Two honest modes:
60
+ * - 'standard' (default): swap placeholders back to real values. The user reads their own
61
+ * data on their own phone — but Telegram/Meta bot traffic is NOT end-to-end encrypted, so
62
+ * the provider carries it in the clear. That is the accepted trade for a readable reply.
63
+ * - 'strict': leave placeholders in the outbound message. The provider never sees a real
64
+ * value; the user sees [[PERSON_1]]. Choose per deployment.
65
+ */
66
+ export function restoreOutbound(text, vault, { privacy = 'standard' } = {}) {
67
+ return privacy === 'strict' ? String(text ?? '') : restoreText(text ?? '', vault);
68
+ }
69
+
70
+ // Conversation memory. The adapter keeps ONE array of REDACTED turns per chat and replays it
71
+ // as context, so "…and the second one?" resolves against the prior answer. The bridge's
72
+ // buildCliPrompt already renders all-but-last as a labelled history transcript and the last as
73
+ // the live message — so multi-turn just works once the array is threaded through.
74
+ //
75
+ // We store the redacted user text and the agent's (already-placeholdered) reply — never real
76
+ // values — so history is exactly as safe to hold as the event log, and the vault stays the one
77
+ // source of truth for what PERSON_1 means. The window is bounded so a long chat can't grow the
78
+ // prompt without end.
79
+ export const DEFAULT_HISTORY_MESSAGES = 16;
80
+
81
+ /**
82
+ * Append one exchange to a chat's history and return the new, bounded array (never mutates).
83
+ * `assistantText` is optional — a failed turn stores nothing, so history never implies the
84
+ * agent answered when it didn't.
85
+ */
86
+ export function appendTurn(history, userText, assistantText, { maxMessages = DEFAULT_HISTORY_MESSAGES } = {}) {
87
+ const next = Array.isArray(history) ? history.slice() : [];
88
+ next.push({ role: 'user', content: String(userText ?? '') });
89
+ const reply = assistantText == null ? '' : String(assistantText);
90
+ if (reply) next.push({ role: 'assistant', content: reply });
91
+ // Keep only the last maxMessages, and never let the window open on an assistant turn — a
92
+ // transcript that starts with "Assistant:" reads as if the agent spoke first.
93
+ let windowed = maxMessages > 0 ? next.slice(-maxMessages) : next;
94
+ while (windowed.length && windowed[0].role === 'assistant') windowed = windowed.slice(1);
95
+ return windowed;
96
+ }
97
+
98
+ // Event builders — thin, so the adapter appends without knowing payload shapes. Each returns
99
+ // whatever the appender's append() returns (a validated event, or a promise of one).
100
+ export function appendInvoked(appender, invocation, causes = []) {
101
+ return appender.append('capability.invoked', {
102
+ capability: invocation.capability,
103
+ actor: invocation.actor,
104
+ scope: invocation.scope,
105
+ effects: invocation.effects,
106
+ idempotencyKey: invocation.idempotencyKey,
107
+ }, causes);
108
+ }
109
+
110
+ export function appendRedacted(appender, counts, causes = []) {
111
+ // Nothing redacted → nothing to record. An empty privacy.redacted still validates, but a
112
+ // log line that says "0 of nothing" is noise.
113
+ if (!counts || !Object.keys(counts).length) return null;
114
+ return appender.append('privacy.redacted', { counts }, causes);
115
+ }
116
+
117
+ export function appendEgress(appender, { host, redacted, controlled }, causes = []) {
118
+ return appender.append('privacy.egress', { host, redacted: !!redacted, controlled: !!controlled }, causes);
119
+ }
@@ -0,0 +1,49 @@
1
+ // Platform message → one normalized shape the invoke/stream core understands. PURE: no
2
+ // network, no token — a Telegram update object in, a plain record out — so the whole gate
3
+ // (pairing, redaction, command routing) is unit-testable without a bot.
4
+ //
5
+ // One shape for every platform is the same discipline capability.js keeps for actors: the
6
+ // adapters are dumb transport, and everything above them speaks 'normalized message'.
7
+
8
+ export const CHANNEL_ACTOR_KIND = 'channel';
9
+
10
+ /** The actor id a paired surface invokes under: '<platform>:<chatId>'. Stable per chat. */
11
+ export function actorId(platform, chatId) {
12
+ return `${platform}:${chatId}`;
13
+ }
14
+
15
+ // A leading "/word" is a command to US (pair/stop/new/help), not a prompt for the agent.
16
+ // Telegram appends "@BotName" to commands in groups; strip it so "/stop@mybot" === "/stop".
17
+ function parseCommand(text) {
18
+ const m = /^\/([a-zA-Z0-9_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/.exec(text.trim());
19
+ if (!m) return null;
20
+ return { name: m[1].toLowerCase(), args: (m[2] || '').trim() };
21
+ }
22
+
23
+ /**
24
+ * Normalize a Telegram getUpdates result item. Returns null for updates we don't act on
25
+ * (edited messages, channel posts, join/leave events) so the caller skips them uniformly.
26
+ * A photo's caption is treated as its text, matching how a person reads the message.
27
+ */
28
+ export function normalizeTelegram(update) {
29
+ const msg = update?.message;
30
+ if (!msg || !msg.chat) return null;
31
+ const text = typeof msg.text === 'string' ? msg.text
32
+ : typeof msg.caption === 'string' ? msg.caption : '';
33
+ // Largest photo variant only — Telegram sends a size ladder, last is the biggest.
34
+ const photos = Array.isArray(msg.photo) && msg.photo.length
35
+ ? [{ fileId: msg.photo[msg.photo.length - 1].file_id }]
36
+ : [];
37
+ const command = text.startsWith('/') ? parseCommand(text) : null;
38
+ return {
39
+ platform: 'telegram',
40
+ chatId: String(msg.chat.id),
41
+ chatType: msg.chat.type || 'private',
42
+ messageId: String(msg.message_id),
43
+ from: { id: String(msg.from?.id ?? msg.chat.id), name: msg.from?.first_name || msg.from?.username || '' },
44
+ text,
45
+ command,
46
+ photos,
47
+ replyToMessageId: msg.reply_to_message ? String(msg.reply_to_message.message_id) : null,
48
+ };
49
+ }
package/src/pairing.js ADDED
@@ -0,0 +1,71 @@
1
+ // Pairing — who may drive an agent from a phone, and how far their requests may travel.
2
+ //
3
+ // This is the AUTHENTICATION half of §7: it proves WHO sent a message (Telegram authenticates
4
+ // the sender's chat id; a one-time code makes enrollment deliberate, not silent). It says
5
+ // nothing about WHAT a message may do — a paired-but-injected message is still injected, so
6
+ // `reach` is a ceiling, never a licence. Tool authorization is the next layer up.
7
+ //
8
+ // Reach reuses the router's tiers verbatim (device < trusted < any), so "a paired phone is
9
+ // trusted" means the exact same thing here as it does to the model router downstream.
10
+
11
+ import { REACH } from '@chatpanel/events/router.js';
12
+
13
+ export { REACH };
14
+
15
+ // 6 digits: enough entropy for a short-lived, single-use enrollment code shown on a screen,
16
+ // short enough to thumb into a phone. It is NOT a password — it expires and burns on first use.
17
+ function sixDigits(randomInt) {
18
+ return String(randomInt(0, 1_000_000)).padStart(6, '0');
19
+ }
20
+
21
+ /**
22
+ * A pairing store over a plain-JSON state object, with clock and RNG injected so enrollment
23
+ * is deterministic in tests. Persistence is the caller's job: load the JSON at start, call
24
+ * toJSON() after a mutation, write it back — the same shape as pii's createVault/vaultToJSON.
25
+ *
26
+ * state: { paired: { [actorId]: { reach, at } }, pending: { [code]: { at, ttlMs } } }
27
+ */
28
+ export function createPairingStore(state = {}, {
29
+ now = () => Date.now(),
30
+ randomInt = (min, max) => min + Math.floor(Math.random() * (max - min)),
31
+ } = {}) {
32
+ const paired = new Map(Object.entries(state.paired || {}));
33
+ const pending = new Map(Object.entries(state.pending || {}));
34
+
35
+ const prune = () => {
36
+ const t = now();
37
+ for (const [code, p] of pending) if (t - p.at > (p.ttlMs || 0)) pending.delete(code);
38
+ };
39
+
40
+ return {
41
+ /** Owner-side: mint a one-time code to read out to the phone. Shown in the extension/CLI. */
42
+ requestCode({ ttlMs = 10 * 60_000 } = {}) {
43
+ prune();
44
+ let code;
45
+ do { code = sixDigits(randomInt); } while (pending.has(code));
46
+ pending.set(code, { at: now(), ttlMs });
47
+ return code;
48
+ },
49
+ /** Phone-side: "/pair 123456". Burns the code and pairs the actor at 'trusted'. */
50
+ redeem(actorId, code, { reach = 'trusted' } = {}) {
51
+ prune();
52
+ const c = String(code || '').trim();
53
+ if (!pending.has(c)) return { ok: false, reason: 'unknown or expired code' };
54
+ if (!REACH.includes(reach)) return { ok: false, reason: `unknown reach '${reach}'` };
55
+ pending.delete(c);
56
+ paired.set(actorId, { reach, at: now() });
57
+ return { ok: true, reach };
58
+ },
59
+ /** Bootstrap without a code — for an operator-supplied allow list. Explicit, not silent. */
60
+ allow(actorId, { reach = 'trusted' } = {}) {
61
+ if (!REACH.includes(reach)) throw new Error(`unknown reach '${reach}'`);
62
+ paired.set(actorId, { reach, at: now() });
63
+ },
64
+ revoke(actorId) { return paired.delete(actorId); },
65
+ isPaired(actorId) { return paired.has(actorId); },
66
+ /** The reach ceiling for this actor, or null when it isn't paired (→ refuse the message). */
67
+ reachOf(actorId) { return paired.get(actorId)?.reach || null; },
68
+ list() { return [...paired.entries()].map(([id, v]) => ({ actorId: id, ...v })); },
69
+ toJSON() { return { paired: Object.fromEntries(paired), pending: Object.fromEntries(pending) }; },
70
+ };
71
+ }
package/src/stream.js ADDED
@@ -0,0 +1,77 @@
1
+ // The model's reply, assembled from the bridge's SSE stream — PURE so accumulation and the
2
+ // token-restore boundary are testable without a socket. Per chatpanel-bridge /chat, the bridge
3
+ // emits `data: <json>\n\n` frames of:
4
+ // {type:'run', id} a cancel-by-name handle, emitted first
5
+ // {type:'workdir'|'status'|'reasoning'|'tool', ...} progress a caller MAY show
6
+ // {type:'delta', text} incremental assistant text
7
+ // {type:'done', text?} text only when it wasn't streamed
8
+ // {type:'error', error}
9
+ // We only need run/delta/done/error to build a reply.
10
+
11
+ export function initialState() {
12
+ return { runId: null, text: '', status: '', error: null, done: false };
13
+ }
14
+
15
+ /** Fold one bridge event into the running reply. Returns a NEW state (never mutates). */
16
+ export function foldEvent(state, ev) {
17
+ switch (ev?.type) {
18
+ case 'run': return { ...state, runId: ev.id || state.runId };
19
+ case 'delta': return { ...state, text: state.text + (ev.text || '') };
20
+ case 'status': return { ...state, status: ev.text || state.status };
21
+ // A `done` may carry the whole text (engines that don't stream). Only take it when we
22
+ // streamed nothing, or the reply doubles.
23
+ case 'done': return { ...state, done: true, text: (!state.text && ev.text) ? ev.text : state.text };
24
+ case 'error': return { ...state, done: true, error: ev.error || 'unknown error' };
25
+ default: return state;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Pull complete SSE events out of a growing buffer. Returns the parsed events and the
31
+ * UNCONSUMED tail (a partial frame still arriving), which the caller prepends next read.
32
+ */
33
+ export function parseSse(buffer) {
34
+ const events = [];
35
+ let rest = String(buffer);
36
+ let idx;
37
+ while ((idx = rest.indexOf('\n\n')) >= 0) {
38
+ const raw = rest.slice(0, idx);
39
+ rest = rest.slice(idx + 2);
40
+ for (const line of raw.split('\n')) {
41
+ if (!line.startsWith('data:')) continue;
42
+ const json = line.slice(5).trim();
43
+ if (!json) continue;
44
+ try { events.push(JSON.parse(json)); } catch { /* skip a malformed frame */ }
45
+ }
46
+ }
47
+ return { events, rest };
48
+ }
49
+
50
+ // Telegram rejects a message body over 4096 chars. Split on paragraph, then line, then a hard
51
+ // cut, so a long answer arrives as several messages instead of one API error.
52
+ export function splitForTelegram(text, max = 4096) {
53
+ let s = String(text ?? '');
54
+ const out = [];
55
+ while (s.length > max) {
56
+ let cut = s.lastIndexOf('\n\n', max);
57
+ if (cut < max * 0.5) cut = s.lastIndexOf('\n', max);
58
+ if (cut < max * 0.5) cut = max;
59
+ out.push(s.slice(0, cut));
60
+ s = s.slice(cut).replace(/^\n+/, '');
61
+ }
62
+ if (s) out.push(s);
63
+ return out.length ? out : [''];
64
+ }
65
+
66
+ // A minimal time gate for throttling live edits — Telegram allows ~1 edit/sec to a chat.
67
+ // `now` injected for tests. ready() returns true and advances only when `ms` has elapsed.
68
+ export function createGate(ms, { now = () => Date.now() } = {}) {
69
+ // -Infinity, not 0, so the FIRST call always fires (replace the "…" placeholder promptly)
70
+ // regardless of the clock's magnitude — then throttle. With last=0 this only worked because
71
+ // the real Date.now() dwarfs `ms`; an injected test clock at t=0 exposed the latent bug.
72
+ let last = -Infinity;
73
+ return {
74
+ ready() { const t = now(); if (t - last >= ms) { last = t; return true; } return false; },
75
+ reset() { last = -Infinity; },
76
+ };
77
+ }