polygram 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/lib/voice.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Voice-to-code transcription.
3
+ *
4
+ * `transcribe(filePath, opts)` turns a voice/audio file into text. Two
5
+ * backends:
6
+ * - openai: POST the file to api.openai.com/v1/audio/transcriptions
7
+ * - local: shell out to whisper.cpp (`-otxt`) and read the .txt sibling
8
+ *
9
+ * The backend is injected so tests can stub it without touching the network.
10
+ * Returns `{ text, language, duration_sec, cost_usd, provider }`.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { spawn } = require('child_process');
16
+
17
+ const OPENAI_COST_PER_MINUTE_USD = 0.006;
18
+
19
+ function isVoiceAttachment(att) {
20
+ if (!att) return false;
21
+ if (att.kind === 'voice') return true;
22
+ if (att.kind === 'audio') return true;
23
+ return false;
24
+ }
25
+
26
+ function isLikelyAudioMime(mime) {
27
+ return typeof mime === 'string' && mime.startsWith('audio/');
28
+ }
29
+
30
+ /**
31
+ * Normalize language hint. "auto" is dropped (let Whisper auto-detect).
32
+ */
33
+ function normaliseLanguage(lang) {
34
+ if (!lang || lang === 'auto') return null;
35
+ return String(lang).slice(0, 5).toLowerCase();
36
+ }
37
+
38
+ async function transcribeOpenAI(filePath, opts) {
39
+ const apiKey = process.env[opts.apiKeyEnv || 'OPENAI_API_KEY'];
40
+ if (!apiKey) throw new Error(`OPENAI_API_KEY env not set (or ${opts.apiKeyEnv})`);
41
+
42
+ // Node 18+ has native FormData + fetch. Keep it dep-free.
43
+ const form = new FormData();
44
+ const buf = fs.readFileSync(filePath);
45
+ form.append('file', new Blob([buf]), path.basename(filePath));
46
+ form.append('model', opts.model || 'whisper-1');
47
+ form.append('response_format', 'verbose_json');
48
+ const lang = normaliseLanguage(opts.language);
49
+ if (lang) form.append('language', lang);
50
+
51
+ const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
52
+ method: 'POST',
53
+ headers: { Authorization: `Bearer ${apiKey}` },
54
+ body: form,
55
+ });
56
+ if (!res.ok) {
57
+ const body = await res.text().catch(() => '');
58
+ throw new Error(`whisper openai ${res.status}: ${body.slice(0, 200)}`);
59
+ }
60
+ const j = await res.json();
61
+ const duration_sec = typeof j.duration === 'number' ? j.duration : 0;
62
+ return {
63
+ text: (j.text || '').trim(),
64
+ language: j.language || null,
65
+ duration_sec,
66
+ cost_usd: (duration_sec / 60) * OPENAI_COST_PER_MINUTE_USD,
67
+ provider: 'openai',
68
+ };
69
+ }
70
+
71
+ async function transcribeLocal(filePath, opts) {
72
+ const binary = opts.binary;
73
+ const model = opts.model;
74
+ if (!binary || !fs.existsSync(binary)) {
75
+ throw new Error(`whisper.cpp binary not found: ${binary}`);
76
+ }
77
+ if (!model || !fs.existsSync(model)) {
78
+ throw new Error(`whisper.cpp model not found: ${model}`);
79
+ }
80
+
81
+ // whisper.cpp writes <input>.txt alongside the input with -otxt.
82
+ const args = ['-m', model, '-f', filePath, '-otxt', '-nt'];
83
+ const lang = normaliseLanguage(opts.language);
84
+ if (lang) args.push('-l', lang);
85
+
86
+ return await new Promise((resolve, reject) => {
87
+ let stderr = '';
88
+ const p = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
89
+ p.stderr.on('data', (d) => { stderr += d.toString(); });
90
+ p.once('error', reject);
91
+ p.once('close', (code) => {
92
+ if (code !== 0) {
93
+ reject(new Error(`whisper.cpp exit ${code}: ${stderr.slice(0, 200)}`));
94
+ return;
95
+ }
96
+ const txtPath = `${filePath}.txt`;
97
+ let text = '';
98
+ try { text = fs.readFileSync(txtPath, 'utf8').trim(); } catch {}
99
+ // Derive duration from stderr (whisper.cpp prints total duration).
100
+ const dMatch = stderr.match(/total duration\s*=\s*([\d.]+)/i);
101
+ const duration_sec = dMatch ? parseFloat(dMatch[1]) : 0;
102
+ const lMatch = stderr.match(/detected language:\s*(\w+)/i);
103
+ resolve({
104
+ text,
105
+ language: lMatch ? lMatch[1] : null,
106
+ duration_sec,
107
+ cost_usd: 0,
108
+ provider: 'local',
109
+ });
110
+ });
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Main entrypoint. `opts.provider` picks the backend ('openai' | 'local').
116
+ * `opts.fetchFn` / `opts.spawnFn` allow test injection.
117
+ */
118
+ async function transcribe(filePath, opts = {}) {
119
+ if (!filePath || !fs.existsSync(filePath)) {
120
+ throw new Error(`file missing: ${filePath}`);
121
+ }
122
+ const stat = fs.statSync(filePath);
123
+ if (!stat.size) throw new Error(`file empty: ${filePath}`);
124
+ if (opts.maxDurationSec && opts.maxDurationBytesPerSec) {
125
+ const estSec = stat.size / opts.maxDurationBytesPerSec;
126
+ if (estSec > opts.maxDurationSec) {
127
+ throw new Error(
128
+ `file likely exceeds max duration (${estSec.toFixed(0)}s > ${opts.maxDurationSec}s)`,
129
+ );
130
+ }
131
+ }
132
+ const provider = opts.provider || 'openai';
133
+ if (provider === 'openai') return transcribeOpenAI(filePath, opts);
134
+ if (provider === 'local') return transcribeLocal(filePath, opts);
135
+ throw new Error(`unknown voice provider: ${provider}`);
136
+ }
137
+
138
+ module.exports = {
139
+ transcribe,
140
+ transcribeOpenAI,
141
+ transcribeLocal,
142
+ isVoiceAttachment,
143
+ isLikelyAudioMime,
144
+ normaliseLanguage,
145
+ OPENAI_COST_PER_MINUTE_USD,
146
+ };
@@ -0,0 +1,93 @@
1
+ -- Telegram Bridge v2 — initial schema
2
+ -- Run once. Subsequent schema changes use additional migration files.
3
+
4
+ CREATE TABLE IF NOT EXISTS sessions (
5
+ session_key TEXT PRIMARY KEY,
6
+ chat_id TEXT NOT NULL,
7
+ thread_id TEXT,
8
+ claude_session_id TEXT NOT NULL,
9
+ agent TEXT,
10
+ cwd TEXT,
11
+ model TEXT,
12
+ effort TEXT,
13
+ created_ts INTEGER NOT NULL,
14
+ last_active_ts INTEGER NOT NULL
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS chat_migrations (
18
+ old_chat_id TEXT PRIMARY KEY,
19
+ new_chat_id TEXT NOT NULL,
20
+ migrated_ts INTEGER NOT NULL
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS messages (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ chat_id TEXT NOT NULL,
26
+ thread_id TEXT,
27
+ msg_id INTEGER NOT NULL,
28
+ user TEXT,
29
+ user_id INTEGER,
30
+ text TEXT,
31
+ reply_to_id INTEGER,
32
+ direction TEXT CHECK(direction IN ('in','out','system')),
33
+ source TEXT,
34
+ bot_name TEXT,
35
+ attachments_json TEXT,
36
+ session_id TEXT,
37
+ model TEXT,
38
+ effort TEXT,
39
+ turn_id TEXT,
40
+ status TEXT CHECK(status IN ('pending','sent','failed','received')) DEFAULT 'received',
41
+ error TEXT,
42
+ cost_usd REAL,
43
+ ts INTEGER NOT NULL,
44
+ edited_ts INTEGER,
45
+ UNIQUE(chat_id, msg_id)
46
+ );
47
+ CREATE INDEX IF NOT EXISTS idx_recent ON messages(chat_id, thread_id, ts DESC);
48
+ CREATE INDEX IF NOT EXISTS idx_reply ON messages(chat_id, reply_to_id);
49
+ CREATE INDEX IF NOT EXISTS idx_turn ON messages(turn_id) WHERE turn_id IS NOT NULL;
50
+ CREATE INDEX IF NOT EXISTS idx_pending ON messages(status, ts) WHERE status = 'pending';
51
+
52
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
53
+ text, user,
54
+ content=messages, content_rowid=id,
55
+ tokenize='unicode61 remove_diacritics 2'
56
+ );
57
+
58
+ -- External-content FTS5: use 'delete' command so the old tokens are purged.
59
+ -- Plain DELETE/UPDATE on messages_fts leaves orphans.
60
+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
61
+ INSERT INTO messages_fts(rowid, text, user) VALUES (new.id, new.text, new.user);
62
+ END;
63
+ CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
64
+ INSERT INTO messages_fts(messages_fts, rowid, text, user) VALUES ('delete', old.id, old.text, old.user);
65
+ END;
66
+ CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
67
+ INSERT INTO messages_fts(messages_fts, rowid, text, user) VALUES ('delete', old.id, old.text, old.user);
68
+ INSERT INTO messages_fts(rowid, text, user) VALUES (new.id, new.text, new.user);
69
+ END;
70
+
71
+ CREATE TABLE IF NOT EXISTS config_changes (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ chat_id TEXT NOT NULL,
74
+ thread_id TEXT,
75
+ field TEXT NOT NULL CHECK(field IN ('model','effort','agent')),
76
+ old_value TEXT,
77
+ new_value TEXT NOT NULL,
78
+ user_id INTEGER,
79
+ user TEXT,
80
+ source TEXT,
81
+ ts INTEGER NOT NULL
82
+ );
83
+ CREATE INDEX IF NOT EXISTS idx_config_recent ON config_changes(chat_id, ts DESC);
84
+
85
+ CREATE TABLE IF NOT EXISTS events (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ ts INTEGER NOT NULL,
88
+ chat_id TEXT,
89
+ kind TEXT NOT NULL,
90
+ detail_json TEXT
91
+ );
92
+ CREATE INDEX IF NOT EXISTS idx_events_recent ON events(ts DESC);
93
+ CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind, ts DESC);
@@ -0,0 +1,24 @@
1
+ -- Fix FTS5 triggers for external-content tables.
2
+ -- The v1 triggers used plain UPDATE/DELETE on messages_fts which doesn't purge
3
+ -- old tokens from the index. Switch to the documented 'delete' command form
4
+ -- and rebuild the FTS index so any orphan tokens from pre-fix writes are gone.
5
+
6
+ DROP TRIGGER IF EXISTS messages_ai;
7
+ DROP TRIGGER IF EXISTS messages_au;
8
+ DROP TRIGGER IF EXISTS messages_ad;
9
+
10
+ CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
11
+ INSERT INTO messages_fts(rowid, text, user) VALUES (new.id, new.text, new.user);
12
+ END;
13
+
14
+ CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
15
+ INSERT INTO messages_fts(messages_fts, rowid, text, user) VALUES ('delete', old.id, old.text, old.user);
16
+ END;
17
+
18
+ CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
19
+ INSERT INTO messages_fts(messages_fts, rowid, text, user) VALUES ('delete', old.id, old.text, old.user);
20
+ INSERT INTO messages_fts(rowid, text, user) VALUES (new.id, new.text, new.user);
21
+ END;
22
+
23
+ -- Rebuild index from scratch to discard any stale tokens.
24
+ INSERT INTO messages_fts(messages_fts) VALUES ('rebuild');
@@ -0,0 +1,33 @@
1
+ -- Feature 1: pairing codes for live onboarding without bridge restarts.
2
+
3
+ -- Pairing codes (single-use, short-lived).
4
+ CREATE TABLE pair_codes (
5
+ code TEXT PRIMARY KEY,
6
+ bot_name TEXT NOT NULL,
7
+ chat_id TEXT, -- NULL = valid in any of the bot's chats
8
+ scope TEXT NOT NULL CHECK(scope IN ('user','chat')),
9
+ issued_by_user_id INTEGER NOT NULL,
10
+ issued_ts INTEGER NOT NULL,
11
+ expires_ts INTEGER NOT NULL,
12
+ used_by_user_id INTEGER,
13
+ used_ts INTEGER,
14
+ note TEXT
15
+ );
16
+ CREATE INDEX idx_pair_codes_expiry ON pair_codes(expires_ts) WHERE used_ts IS NULL;
17
+ CREATE INDEX idx_pair_codes_bot ON pair_codes(bot_name, issued_ts);
18
+
19
+ -- Active pairings: the result of a successful /pair <CODE>.
20
+ -- Soft-deleted via revoked_ts for audit trail.
21
+ CREATE TABLE pairings (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ bot_name TEXT NOT NULL,
24
+ user_id INTEGER NOT NULL,
25
+ chat_id TEXT, -- NULL = valid in any of the bot's chats
26
+ granted_ts INTEGER NOT NULL,
27
+ granted_by_user_id INTEGER NOT NULL,
28
+ revoked_ts INTEGER,
29
+ note TEXT,
30
+ UNIQUE(bot_name, user_id, chat_id)
31
+ );
32
+ CREATE INDEX idx_pairings_lookup ON pairings(bot_name, user_id)
33
+ WHERE revoked_ts IS NULL;
@@ -0,0 +1,28 @@
1
+ -- Feature 2: inline keyboard approvals for destructive tools.
2
+
3
+ CREATE TABLE pending_approvals (
4
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5
+ bot_name TEXT NOT NULL,
6
+ turn_id TEXT, -- joins back to messages.turn_id (nullable: hook might not know)
7
+ requester_chat_id TEXT NOT NULL, -- chat whose Claude is asking
8
+ approver_chat_id TEXT NOT NULL, -- chat where the keyboard landed
9
+ approver_msg_id INTEGER, -- Telegram msg_id of the keyboard (set after send)
10
+ tool_name TEXT NOT NULL, -- "Bash", "mcp__shopify__order_cancel"
11
+ tool_input_json TEXT NOT NULL,
12
+ tool_input_digest TEXT NOT NULL, -- sha256 prefix; dedups repeated hook fires
13
+ callback_token TEXT NOT NULL, -- random token in callback_data to defeat replay
14
+ status TEXT NOT NULL
15
+ CHECK(status IN ('pending','approved','denied','timeout','cancelled'))
16
+ DEFAULT 'pending',
17
+ requested_ts INTEGER NOT NULL,
18
+ decided_ts INTEGER,
19
+ decided_by_user_id INTEGER,
20
+ decided_by_user TEXT,
21
+ timeout_ts INTEGER NOT NULL,
22
+ reason TEXT -- operator-supplied deny reason (future)
23
+ );
24
+ CREATE INDEX idx_approvals_pending ON pending_approvals(status, timeout_ts)
25
+ WHERE status = 'pending';
26
+ CREATE INDEX idx_approvals_turn ON pending_approvals(turn_id);
27
+ CREATE INDEX idx_approvals_dedup ON pending_approvals(bot_name, turn_id, tool_input_digest)
28
+ WHERE status = 'pending';
package/ops/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # ops/ — launchd plists for per-bot process isolation
2
+
3
+ Each bot runs in its own Node process. `--bot <name>` is required on every
4
+ invocation — the bridge refuses to boot without it. These user-scope
5
+ LaunchAgents supervise them individually so a crash in one bot never takes
6
+ down another.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ mkdir -p /Users/$USER/polygram/logs
12
+ # For each bot, render the template to LaunchAgents with the bot's name
13
+ # and your username substituted in:
14
+ cp ops/polygram.plist.example ~/Library/LaunchAgents/com.polygram.my-bot.plist
15
+ sed -i '' "s/BOTNAME/my-bot/g; s|YOURNAME|$USER|g" \
16
+ ~/Library/LaunchAgents/com.polygram.my-bot.plist
17
+ launchctl load ~/Library/LaunchAgents/com.polygram.my-bot.plist
18
+ ```
19
+
20
+ Repeat for each bot.
21
+
22
+ ## Manage
23
+
24
+ ```bash
25
+ # Status of all
26
+ launchctl list | grep polygram
27
+
28
+ # Restart one
29
+ launchctl kickstart -k gui/$(id -u)/com.polygram.my-bot
30
+
31
+ # Stop / start
32
+ launchctl unload ~/Library/LaunchAgents/com.polygram.my-bot.plist
33
+ launchctl load ~/Library/LaunchAgents/com.polygram.my-bot.plist
34
+
35
+ # Tail logs
36
+ tail -f /Users/$USER/polygram/logs/my-bot.log
37
+ ```
38
+
39
+ ## Adding a new bot
40
+
41
+ 1. Render a new plist from `polygram.plist.example` (see Install).
42
+ 2. Add `bots.<new>` and any `chats` entries in `config.json`.
43
+ 3. `launchctl load ~/Library/LaunchAgents/com.polygram.<new>.plist`.
44
+
45
+ Existing bots keep running — no shared process, no restart.
46
+
47
+ ## Design choice: user-level LaunchAgents, not LaunchDaemons
48
+
49
+ - LaunchAgents live in `~/Library/LaunchAgents/`, run as the logged-in user,
50
+ no sudo required.
51
+ - LaunchDaemons (`/Library/LaunchDaemons/`) would run as root at boot — nice
52
+ for headless servers, overkill for a per-user Mac install. LaunchAgents
53
+ fire at login and stay running.
54
+
55
+ ## Local development
56
+
57
+ For running outside launchd:
58
+
59
+ ```bash
60
+ cd /Users/$USER/polygram
61
+ node bridge.js --bot admin-bot # in one tmux window
62
+ node bridge.js --bot partner-bot # in another
63
+ ```
64
+
65
+ Each is independent. Kill one, the other keeps serving. There is no
66
+ "run all bots in one process" mode — `--bot` is required.
67
+
68
+ `--db <path>` overrides the default DB location (`<repo>/<bot>.db`).
69
+ Useful for dry-running new bots against a throwaway DB without touching
70
+ production files.
71
+
72
+ ## One-time: split shared bridge.db into per-bot DBs
73
+
74
+ If you migrated from an earlier build that used a single shared `bridge.db`:
75
+
76
+ ```bash
77
+ cd /Users/$USER/polygram
78
+
79
+ # Dry run first
80
+ node scripts/split-db.js --config config.json --dry-run
81
+
82
+ # For real (archives bridge.db to bridge.db.archived-<stamp>)
83
+ launchctl unload ~/Library/LaunchAgents/com.polygram.*.plist
84
+ node scripts/split-db.js --config config.json
85
+ launchctl load ~/Library/LaunchAgents/com.polygram.my-bot.plist
86
+ # ... repeat load for each bot
87
+ ```
88
+
89
+ The script is idempotent; safe to re-run. It refuses to proceed if a WAL
90
+ file on the source DB indicates a live writer.
91
+
92
+ ## Cron → bridge (IPC, not direct DB write)
93
+
94
+ Cron jobs that want to post to Telegram must address a specific bot:
95
+
96
+ ```js
97
+ const { tell } = require('polygram/lib/ipc-client');
98
+
99
+ await tell('admin-bot', 'sendMessage', {
100
+ chat_id: '111111111',
101
+ text: 'Billing synced.',
102
+ }, { source: 'cron:billing-sync' });
103
+ ```
104
+
105
+ Allowed methods: `sendMessage`, `sendPhoto`, `sendDocument`, `sendSticker`,
106
+ `sendChatAction`, `editMessageText`, `setMessageReaction`. Other methods
107
+ are rejected server-side.
108
+
109
+ If the target bot is down, `tell()` throws. Intentional — cron failures
110
+ should surface, not silently log to a DB the bot isn't watching.
@@ -0,0 +1,58 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!--
3
+ Template: one plist per bot. Replace BOTNAME and YOURNAME.
4
+
5
+ Install:
6
+ cp ops/polygram.plist.example ~/Library/LaunchAgents/com.polygram.BOTNAME.plist
7
+ sed -i '' 's/BOTNAME/my-admin-bot/g; s|YOURNAME|'"$USER"'|g' \
8
+ ~/Library/LaunchAgents/com.polygram.BOTNAME.plist
9
+ launchctl load ~/Library/LaunchAgents/com.polygram.BOTNAME.plist
10
+
11
+ Restart:
12
+ launchctl kickstart -k gui/$(id -u)/com.polygram.BOTNAME
13
+
14
+ Logs:
15
+ tail -f /Users/YOURNAME/polygram/logs/BOTNAME.log
16
+ -->
17
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
18
+ <plist version="1.0">
19
+ <dict>
20
+ <key>Label</key>
21
+ <string>com.polygram.BOTNAME</string>
22
+ <key>ProgramArguments</key>
23
+ <array>
24
+ <string>/opt/homebrew/bin/node</string>
25
+ <string>/Users/YOURNAME/polygram/bridge.js</string>
26
+ <string>--bot</string>
27
+ <string>BOTNAME</string>
28
+ </array>
29
+ <key>WorkingDirectory</key>
30
+ <string>/Users/YOURNAME/polygram</string>
31
+ <key>RunAtLoad</key>
32
+ <true/>
33
+ <key>KeepAlive</key>
34
+ <dict>
35
+ <key>SuccessfulExit</key>
36
+ <false/>
37
+ <key>Crashed</key>
38
+ <true/>
39
+ </dict>
40
+ <key>ThrottleInterval</key>
41
+ <integer>10</integer>
42
+ <key>StandardOutPath</key>
43
+ <string>/Users/YOURNAME/polygram/logs/BOTNAME.log</string>
44
+ <key>StandardErrorPath</key>
45
+ <string>/Users/YOURNAME/polygram/logs/BOTNAME.log</string>
46
+ <key>EnvironmentVariables</key>
47
+ <dict>
48
+ <key>PATH</key>
49
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/Users/YOURNAME/.npm-global/bin</string>
50
+ <key>HOME</key>
51
+ <string>/Users/YOURNAME</string>
52
+ <key>NODE_ENV</key>
53
+ <string>production</string>
54
+ </dict>
55
+ <key>ProcessType</key>
56
+ <string>Interactive</string>
57
+ </dict>
58
+ </plist>
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "polygram",
3
+ "version": "0.1.0",
4
+ "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
+ "main": "lib/ipc-client.js",
6
+ "bin": {
7
+ "polygram": "bridge.js",
8
+ "polygram-split-db": "scripts/split-db.js"
9
+ },
10
+ "files": [
11
+ "bridge.js",
12
+ "bin/",
13
+ "lib/",
14
+ "migrations/",
15
+ "scripts/split-db.js",
16
+ "scripts/ipc-smoke.js",
17
+ "skills/",
18
+ "ops/polygram.plist.example",
19
+ "ops/README.md",
20
+ "config.example.json"
21
+ ],
22
+ "scripts": {
23
+ "test": "node --test tests/*.test.js",
24
+ "start": "node bridge.js",
25
+ "split-db": "node scripts/split-db.js",
26
+ "ipc-smoke": "node scripts/ipc-smoke.js"
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "keywords": [
32
+ "telegram",
33
+ "claude-code",
34
+ "bot",
35
+ "daemon",
36
+ "multi-bot",
37
+ "sqlite",
38
+ "transcript"
39
+ ],
40
+ "author": "Ivan Shumkov <ivan.shumkov@dash.org>",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/shumkov/polygram.git"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/shumkov/polygram/issues"
48
+ },
49
+ "homepage": "https://github.com/shumkov/polygram#readme",
50
+ "type": "commonjs",
51
+ "dependencies": {
52
+ "better-sqlite3": "^12.9.0",
53
+ "grammy": "^1.42.0"
54
+ }
55
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Quick IPC round-trip probe.
4
+ * Usage: node scripts/ipc-smoke.js <bot-name>
5
+ */
6
+
7
+ const { call, socketPathFor } = require('../lib/ipc-client');
8
+
9
+ (async () => {
10
+ const bot = process.argv[2] || 'shumabit';
11
+ const path = socketPathFor(bot);
12
+
13
+ console.log('path:', path);
14
+ console.log('ping:', JSON.stringify(await call({ path, op: 'ping' })));
15
+
16
+ console.log('ungated:', JSON.stringify(await call({
17
+ path, op: 'approval_request',
18
+ payload: {
19
+ bot_name: bot, chat_id: '111111111',
20
+ tool_name: 'Read', tool_input: { path: '/etc/hosts' },
21
+ },
22
+ })));
23
+
24
+ console.log('DONE');
25
+ })().catch((err) => {
26
+ console.error('ERR:', err.message);
27
+ process.exit(1);
28
+ });