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.
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Phase 8 - one-time migration: split shared `bridge.db` into per-bot DBs.
4
+ *
5
+ * Usage:
6
+ * node scripts/split-db.js --config config.json [--src bridge.db] [--dry-run]
7
+ *
8
+ * Behaviour:
9
+ * 1. Reads config.json to learn the bot set and which chat belongs to whom.
10
+ * 2. For each bot:
11
+ * - Creates <bot>.db by opening it (runs all migrations via lib/db.open).
12
+ * - Copies sessions, messages, events, chat_migrations, config_changes,
13
+ * pair_codes, pairings, pending_approvals scoped to that bot.
14
+ * 3. Renames the source DB to bridge.db.archived-<ISO-date> unless --dry-run.
15
+ *
16
+ * Idempotent: INSERT OR IGNORE / INSERT OR REPLACE throughout. Safe to re-run.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const { open } = require('../lib/db');
23
+
24
+ function parseArg(argv, flag, required = false) {
25
+ const i = argv.indexOf(flag);
26
+ if (i === -1) {
27
+ if (required) { console.error(`${flag} required`); process.exit(2); }
28
+ return null;
29
+ }
30
+ const v = argv[i + 1];
31
+ if (!v || v.startsWith('--')) {
32
+ console.error(`${flag} requires a value`); process.exit(2);
33
+ }
34
+ return v;
35
+ }
36
+
37
+ function hasFlag(argv, flag) {
38
+ return argv.includes(flag);
39
+ }
40
+
41
+ function main() {
42
+ const configPath = parseArg(process.argv, '--config', true);
43
+ const srcPath = parseArg(process.argv, '--src') || path.join(path.dirname(path.resolve(configPath)), 'bridge.db');
44
+ const dryRun = hasFlag(process.argv, '--dry-run');
45
+
46
+ if (!fs.existsSync(configPath)) { console.error(`config missing: ${configPath}`); process.exit(2); }
47
+ if (!fs.existsSync(srcPath)) { console.error(`src missing: ${srcPath}`); process.exit(2); }
48
+
49
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
50
+ const bots = Object.keys(config.bots || {});
51
+ if (!bots.length) { console.error('no bots in config'); process.exit(2); }
52
+
53
+ const chatToBot = {};
54
+ for (const [chatId, chat] of Object.entries(config.chats || {})) {
55
+ chatToBot[chatId] = chat.bot;
56
+ }
57
+
58
+ console.log(`[split-db] src: ${srcPath}`);
59
+ console.log(`[split-db] bots: ${bots.join(', ')}`);
60
+ if (dryRun) console.log('[split-db] DRY RUN - no files written or renamed');
61
+
62
+ // Refuse to split if a live bridge is writing to srcPath. The WAL file's
63
+ // presence + recent mtime is a strong proxy: SQLite WAL checkpoints after
64
+ // ~1000 pages or a clean close, so a hot WAL means an active writer.
65
+ refuseIfActiveWriter(srcPath);
66
+
67
+ const src = open(srcPath);
68
+ const stats = {};
69
+
70
+ // One BEGIN IMMEDIATE transaction over the source gives us a consistent
71
+ // read snapshot across all per-bot copies. Without this, rows inserted
72
+ // between bot-A's SELECT and bot-B's SELECT could vanish in the archive.
73
+ const srcTx = src.raw.transaction(() => {
74
+ for (const bot of bots) {
75
+ const target = path.join(path.dirname(srcPath), `${bot}.db`);
76
+ console.log(`[split-db] ${bot} -> ${target}`);
77
+ if (dryRun) {
78
+ stats[bot] = count(src, bot, chatToBot);
79
+ continue;
80
+ }
81
+ const dst = open(target);
82
+ try {
83
+ stats[bot] = copy(src, dst, bot, chatToBot);
84
+ } finally {
85
+ dst.raw.close();
86
+ }
87
+ }
88
+ });
89
+ // `transaction` runs deferred by default; we want immediate write-lock
90
+ // on the source to block any concurrent bridge from slipping in.
91
+ srcTx.immediate();
92
+
93
+ src.raw.close();
94
+
95
+ console.log('\n[split-db] copied rows:');
96
+ for (const [bot, s] of Object.entries(stats)) {
97
+ console.log(` ${bot}: messages=${s.messages} sessions=${s.sessions} pairings=${s.pairings} approvals=${s.approvals} pair_codes=${s.pair_codes} config_changes=${s.config_changes} events=${s.events} chat_migrations=${s.chat_migrations}`);
98
+ }
99
+
100
+ if (!dryRun) {
101
+ const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
102
+ const archivePath = `${srcPath}.archived-${stamp}`;
103
+ fs.renameSync(srcPath, archivePath);
104
+ for (const suf of ['-wal', '-shm']) {
105
+ if (fs.existsSync(srcPath + suf)) fs.renameSync(srcPath + suf, archivePath + suf);
106
+ }
107
+ console.log(`\n[split-db] archived source -> ${archivePath}`);
108
+ }
109
+ }
110
+
111
+ function refuseIfActiveWriter(srcPath) {
112
+ const wal = `${srcPath}-wal`;
113
+ if (!fs.existsSync(wal)) return;
114
+ const age = Date.now() - fs.statSync(wal).mtimeMs;
115
+ // 60s is generous — a clean bridge shutdown checkpoints the WAL.
116
+ // A hot WAL (< 60s) strongly suggests a live writer.
117
+ if (age < 60_000) {
118
+ console.error(`[split-db] refusing: ${wal} is active (mtime ${Math.round(age/1000)}s ago)`);
119
+ console.error('[split-db] stop the bridge process(es) first: launchctl unload ...');
120
+ process.exit(3);
121
+ }
122
+ }
123
+
124
+ function count(src, bot, chatToBot) {
125
+ const chatIds = chatIdsForBot(chatToBot, bot);
126
+ const q = (sql, params = []) => src.raw.prepare(sql).get(...params).n;
127
+ return {
128
+ messages: chatIds.length ? q(`SELECT COUNT(*) AS n FROM messages WHERE chat_id IN (${ph(chatIds.length)}) OR bot_name = ?`, [...chatIds, bot]) : 0,
129
+ sessions: chatIds.length ? q(`SELECT COUNT(*) AS n FROM sessions WHERE chat_id IN (${ph(chatIds.length)})`, chatIds) : 0,
130
+ pair_codes: q(`SELECT COUNT(*) AS n FROM pair_codes WHERE bot_name = ?`, [bot]),
131
+ pairings: q(`SELECT COUNT(*) AS n FROM pairings WHERE bot_name = ?`, [bot]),
132
+ approvals: q(`SELECT COUNT(*) AS n FROM pending_approvals WHERE bot_name = ?`, [bot]),
133
+ config_changes: chatIds.length ? q(`SELECT COUNT(*) AS n FROM config_changes WHERE chat_id IN (${ph(chatIds.length)})`, chatIds) : 0,
134
+ events: q(`SELECT COUNT(*) AS n FROM events`),
135
+ chat_migrations: q(`SELECT COUNT(*) AS n FROM chat_migrations`),
136
+ };
137
+ }
138
+
139
+ function copy(src, dst, bot, chatToBot) {
140
+ const chatIds = chatIdsForBot(chatToBot, bot);
141
+ const stats = {
142
+ messages: 0, sessions: 0, pair_codes: 0, pairings: 0, approvals: 0,
143
+ config_changes: 0, events: 0, chat_migrations: 0,
144
+ };
145
+
146
+ const tx = dst.raw.transaction(() => {
147
+ if (chatIds.length) {
148
+ const rows = src.raw.prepare(
149
+ `SELECT * FROM messages WHERE chat_id IN (${ph(chatIds.length)}) OR bot_name = ?`,
150
+ ).all(...chatIds, bot);
151
+ const ins = dst.raw.prepare(`
152
+ INSERT OR IGNORE INTO messages
153
+ (id, chat_id, thread_id, msg_id, user, user_id, text, reply_to_id,
154
+ direction, source, bot_name, attachments_json, session_id,
155
+ model, effort, turn_id, status, error, cost_usd, ts, edited_ts)
156
+ VALUES
157
+ (@id, @chat_id, @thread_id, @msg_id, @user, @user_id, @text, @reply_to_id,
158
+ @direction, @source, @bot_name, @attachments_json, @session_id,
159
+ @model, @effort, @turn_id, @status, @error, @cost_usd, @ts, @edited_ts)
160
+ `);
161
+ for (const r of rows) { if (ins.run(r).changes) stats.messages++; }
162
+
163
+ const srows = src.raw.prepare(`SELECT * FROM sessions WHERE chat_id IN (${ph(chatIds.length)})`).all(...chatIds);
164
+ const sins = dst.raw.prepare(`
165
+ INSERT OR REPLACE INTO sessions
166
+ (session_key, chat_id, thread_id, claude_session_id,
167
+ agent, cwd, model, effort, created_ts, last_active_ts)
168
+ VALUES
169
+ (@session_key, @chat_id, @thread_id, @claude_session_id,
170
+ @agent, @cwd, @model, @effort, @created_ts, @last_active_ts)
171
+ `);
172
+ for (const r of srows) { if (sins.run(r).changes) stats.sessions++; }
173
+
174
+ const crows = src.raw.prepare(`SELECT * FROM config_changes WHERE chat_id IN (${ph(chatIds.length)})`).all(...chatIds);
175
+ const cins = dst.raw.prepare(`
176
+ INSERT OR IGNORE INTO config_changes
177
+ (id, chat_id, thread_id, field, old_value, new_value, user_id, user, source, ts)
178
+ VALUES
179
+ (@id, @chat_id, @thread_id, @field, @old_value, @new_value, @user_id, @user, @source, @ts)
180
+ `);
181
+ for (const r of crows) { if (cins.run(r).changes) stats.config_changes++; }
182
+ }
183
+
184
+ copyTable(src, dst,
185
+ `SELECT * FROM pair_codes WHERE bot_name = ?`, [bot],
186
+ `INSERT OR IGNORE INTO pair_codes
187
+ (code, bot_name, chat_id, scope, issued_by_user_id, issued_ts,
188
+ expires_ts, used_by_user_id, used_ts, note)
189
+ VALUES
190
+ (@code, @bot_name, @chat_id, @scope, @issued_by_user_id, @issued_ts,
191
+ @expires_ts, @used_by_user_id, @used_ts, @note)`,
192
+ stats, 'pair_codes');
193
+
194
+ copyTable(src, dst,
195
+ `SELECT * FROM pairings WHERE bot_name = ?`, [bot],
196
+ `INSERT OR IGNORE INTO pairings
197
+ (id, bot_name, user_id, chat_id, granted_ts, granted_by_user_id, revoked_ts, note)
198
+ VALUES
199
+ (@id, @bot_name, @user_id, @chat_id, @granted_ts, @granted_by_user_id, @revoked_ts, @note)`,
200
+ stats, 'pairings');
201
+
202
+ copyTable(src, dst,
203
+ `SELECT * FROM pending_approvals WHERE bot_name = ?`, [bot],
204
+ `INSERT OR IGNORE INTO pending_approvals
205
+ (id, bot_name, turn_id, requester_chat_id, approver_chat_id, approver_msg_id,
206
+ tool_name, tool_input_json, tool_input_digest, callback_token,
207
+ status, requested_ts, decided_ts, decided_by_user_id, decided_by_user,
208
+ timeout_ts, reason)
209
+ VALUES
210
+ (@id, @bot_name, @turn_id, @requester_chat_id, @approver_chat_id, @approver_msg_id,
211
+ @tool_name, @tool_input_json, @tool_input_digest, @callback_token,
212
+ @status, @requested_ts, @decided_ts, @decided_by_user_id, @decided_by_user,
213
+ @timeout_ts, @reason)`,
214
+ stats, 'approvals');
215
+
216
+ copyTable(src, dst,
217
+ `SELECT * FROM events`, [],
218
+ `INSERT OR IGNORE INTO events
219
+ (id, ts, chat_id, kind, detail_json)
220
+ VALUES (@id, @ts, @chat_id, @kind, @detail_json)`,
221
+ stats, 'events');
222
+
223
+ copyTable(src, dst,
224
+ `SELECT * FROM chat_migrations`, [],
225
+ `INSERT OR IGNORE INTO chat_migrations
226
+ (old_chat_id, new_chat_id, migrated_ts)
227
+ VALUES (@old_chat_id, @new_chat_id, @migrated_ts)`,
228
+ stats, 'chat_migrations');
229
+ });
230
+ tx();
231
+
232
+ return stats;
233
+ }
234
+
235
+ function copyTable(src, dst, selectSql, selectParams, insertSql, stats, statKey) {
236
+ const rows = src.raw.prepare(selectSql).all(...selectParams);
237
+ const ins = dst.raw.prepare(insertSql);
238
+ for (const r of rows) {
239
+ if (ins.run(r).changes) stats[statKey]++;
240
+ }
241
+ }
242
+
243
+ function chatIdsForBot(chatToBot, bot) {
244
+ return Object.entries(chatToBot).filter(([, b]) => b === bot).map(([id]) => id);
245
+ }
246
+
247
+ function ph(n) { return new Array(n).fill('?').join(','); }
248
+
249
+ if (require.main === module) main();
250
+
251
+ module.exports = { copy, count, chatIdsForBot };
@@ -0,0 +1,57 @@
1
+ ---
2
+ name: telegram-history
3
+ description: Query the Telegram transcript database. Use when asked about past chat activity, summaries of topics, who said what, historical references to old messages, or searches across conversation history. Not needed for replies to directly-quoted messages (bridge already embeds them).
4
+ ---
5
+
6
+ # Usage
7
+
8
+ Invoke via: `node skills/telegram-history/scripts/query.js <subcmd> [args]`
9
+
10
+ Subcommands return JSON unless `--format pretty`. All chat IDs and thread IDs are strings.
11
+
12
+ Bot scope: the skill filters results to the current bot's chat allowlist. Scope is derived from `process.cwd()` — each bot's Claude project dir maps to a chat.cwd in `config.json`. When invoked from an unmapped cwd the skill refuses to run unless `BRIDGE_ADMIN=1` is set (admin-only override).
13
+
14
+ DB resolution (post Phase 8): the skill reads the bot's own `<bot>.db` file when scope is known. With `BRIDGE_ADMIN=1` it opens every `<bot>.db` that exists and unions results (sorted by ts desc, re-capped at `--limit`). If no per-bot DB is found the skill falls back to a legacy `bridge.db` (pre-cutover). Override the resolution with `BRIDGE_DB=/abs/path.db` for one-off queries against an archived file.
15
+
16
+ ## recent <chat_id> [thread_id]
17
+ Last N messages. Default limit 20, hard cap 500.
18
+ Flags: `--limit N`, `--since 6h|1d|7d`, `--include-outbound` (default true), `--format pretty`
19
+
20
+ ## around --chat X --msg-id N
21
+ Context window around a specific message.
22
+ Flags: `--before 5`, `--after 5`, `--format pretty`
23
+
24
+ ## search <term> [chat_id] [thread_id]
25
+ FTS5 search on text + user. Operators (AND/OR/*) are treated as literal tokens.
26
+ Flags: `--user U` (substring), `--days 30`, `--limit 20`, `--format pretty`
27
+
28
+ ## by-user <user_display_name> [chat_id] [thread_id]
29
+ All messages by a user. Substring match on display name.
30
+ Flags: `--days 7`, `--limit 50`, `--format pretty`
31
+
32
+ ## msg <msg_id> [chat_id]
33
+ Fetch single message. Useful when a reply_to_id surfaces in context.
34
+
35
+ ## stats [chat_id] [thread_id]
36
+ Per-user + per-direction counts within the window.
37
+ Flags: `--days 7`
38
+
39
+ # Examples
40
+
41
+ "Summarize Orders topic today" →
42
+ `node skills/telegram-history/scripts/query.js recent -1000000000001 5379 --since 24h --format pretty`
43
+
44
+ "When did Maria first mention the collaboration?" →
45
+ `node skills/telegram-history/scripts/query.js search "collaboration" --chat -1000000000001 --user Maria --format pretty`
46
+
47
+ "What was said around message 12345?" →
48
+ `node skills/telegram-history/scripts/query.js around --chat -1000000000001 --msg-id 12345 --before 10 --after 10 --format pretty`
49
+
50
+ "Who's been posting the most in UMI Group this week?" →
51
+ `node skills/telegram-history/scripts/query.js stats -1000000000001 --days 7`
52
+
53
+ # Notes
54
+
55
+ - DB opened read-only — safe to run alongside the live bridge.
56
+ - Output capped at 500 rows. Narrow with `--since` or `--days` for wide queries.
57
+ - Times in `ts` are ms epoch. `formatPretty` shows local HH:MM.
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * telegram-history skill CLI.
4
+ *
5
+ * node query.js <subcmd> [positional args] [--flag value]
6
+ *
7
+ * Subcommands: recent | around | search | by-user | msg | stats
8
+ *
9
+ * Opens bridge.db read-only. Bot scope is derived from process.cwd() —
10
+ * each bot's Claude project dir maps to a chat.cwd in config.json, so a
11
+ * partner-spawned skill invocation cannot escape its bot's chat allowlist.
12
+ * Set BRIDGE_ADMIN=1 for unrestricted queries from unmapped cwd.
13
+ *
14
+ * Default output: JSON (one row per message). Pass --format pretty for
15
+ * human-readable lines.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const Database = require('better-sqlite3');
21
+
22
+ const history = require('../lib/history');
23
+
24
+ const BRIDGE_DIR = process.env.BRIDGE_DIR || path.resolve(__dirname, '../../../../polygram');
25
+ const CONFIG_PATH = process.env.BRIDGE_CONFIG || path.join(BRIDGE_DIR, 'config.json');
26
+ // BRIDGE_DB overrides auto-resolution. Otherwise the skill reads one DB per
27
+ // bot (<bot>.db) when the bot scope is known, or all bot DBs for admin.
28
+ // Legacy `bridge.db` is used as a fallback when per-bot DBs don't exist yet.
29
+ const DB_OVERRIDE = process.env.BRIDGE_DB || null;
30
+
31
+ function die(msg, code = 1) {
32
+ process.stderr.write(`history: ${msg}\n`);
33
+ process.exit(code);
34
+ }
35
+
36
+ function parseArgs(argv) {
37
+ const positional = [];
38
+ const flags = {};
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a.startsWith('--')) {
42
+ const key = a.slice(2);
43
+ const next = argv[i + 1];
44
+ if (next === undefined || next.startsWith('--')) {
45
+ flags[key] = true;
46
+ } else {
47
+ flags[key] = next;
48
+ i++;
49
+ }
50
+ } else {
51
+ positional.push(a);
52
+ }
53
+ }
54
+ return { positional, flags };
55
+ }
56
+
57
+ function loadConfig() {
58
+ try {
59
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
60
+ } catch (err) {
61
+ die(`cannot read config at ${CONFIG_PATH}: ${err.message}`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Derive bot scope from the current working directory.
67
+ * Each chat in config.json has a `cwd` pointing at the bot's Claude project
68
+ * root. process.cwd() is set by the bridge when it spawns Claude, so this
69
+ * cannot be spoofed from inside a prompt. Fails closed: if no chat's cwd
70
+ * matches and no admin override is set, we refuse to run.
71
+ */
72
+ function deriveBotScope(cfg) {
73
+ const cwd = path.resolve(process.cwd());
74
+ const matching = Object.entries(cfg.chats || {})
75
+ .filter(([, c]) => c.cwd && path.resolve(c.cwd) === cwd);
76
+
77
+ if (matching.length) {
78
+ const bots = new Set(matching.map(([, c]) => c.bot).filter(Boolean));
79
+ if (bots.size !== 1) {
80
+ die(`cwd ${cwd} maps to multiple bots (${[...bots].join(', ')}) — refusing`);
81
+ }
82
+ return {
83
+ bot: [...bots][0],
84
+ allowedChatIds: matching.map(([id]) => id),
85
+ };
86
+ }
87
+
88
+ // No cwd match. Allow explicit admin override via env var, which the bridge
89
+ // never sets and thus cannot be triggered from a bot-spawned subprocess.
90
+ if (process.env.BRIDGE_ADMIN === '1') {
91
+ return { bot: null, allowedChatIds: null };
92
+ }
93
+
94
+ // Legacy fallback: respect CLAUDE_CHANNEL_BOT ONLY if it matches a known bot
95
+ // in the config. This preserves manual shumabit/umi-assistant invocation via
96
+ // the bridge env var without opening an admin-by-default hole.
97
+ const envBot = process.env.CLAUDE_CHANNEL_BOT;
98
+ if (envBot && cfg.bots?.[envBot]) {
99
+ const allowed = Object.entries(cfg.chats || {})
100
+ .filter(([, c]) => c.bot === envBot)
101
+ .map(([id]) => id);
102
+ if (allowed.length) return { bot: envBot, allowedChatIds: allowed };
103
+ }
104
+
105
+ die(`cannot determine bot scope for cwd ${cwd}; set BRIDGE_ADMIN=1 for unrestricted access`);
106
+ }
107
+
108
+ function openDbReadOnly(dbPath) {
109
+ if (!fs.existsSync(dbPath)) die(`bridge DB not found at ${dbPath}`);
110
+ const raw = new Database(dbPath, { readonly: true, fileMustExist: true });
111
+ return { raw };
112
+ }
113
+
114
+ /**
115
+ * Post-Phase-8: pick the right DB file(s) to query.
116
+ * - If BRIDGE_DB is set, use it (explicit override).
117
+ * - If bot scope is known and <bot>.db exists, use that single file.
118
+ * - If bot scope is known but per-bot DB is missing, fall back to legacy
119
+ * bridge.db (pre-cutover state).
120
+ * - If admin (bot null): open every <bot>.db that exists; if none, fall
121
+ * back to bridge.db.
122
+ */
123
+ function resolveDbPaths(cfg, bot) {
124
+ if (DB_OVERRIDE) return [DB_OVERRIDE];
125
+ const perBot = (b) => path.join(BRIDGE_DIR, `${b}.db`);
126
+ const legacy = path.join(BRIDGE_DIR, 'bridge.db');
127
+
128
+ if (bot) {
129
+ const p = perBot(bot);
130
+ if (fs.existsSync(p)) return [p];
131
+ if (fs.existsSync(legacy)) return [legacy];
132
+ die(`no DB found for bot "${bot}" (tried ${p} and ${legacy})`);
133
+ }
134
+
135
+ // Admin: union across all bots that have a DB.
136
+ const paths = Object.keys(cfg.bots || {})
137
+ .map(perBot)
138
+ .filter((p) => fs.existsSync(p));
139
+ if (paths.length) return paths;
140
+ if (fs.existsSync(legacy)) return [legacy];
141
+ die(`no per-bot DBs found in ${BRIDGE_DIR} and no legacy bridge.db either`);
142
+ }
143
+
144
+ /**
145
+ * Call a history helper once per DB and merge results.
146
+ * - For array-returning helpers (recent/around/search/by-user/stats),
147
+ * concat, re-sort by ts desc, and re-apply the requested limit.
148
+ * - For single-row helpers (msg), return the first non-null hit.
149
+ */
150
+ function queryAcross(dbs, fn, { limit } = {}) {
151
+ const out = [];
152
+ for (const db of dbs) {
153
+ const rows = fn(db);
154
+ if (Array.isArray(rows)) out.push(...rows);
155
+ else if (rows) return rows;
156
+ }
157
+ if (!out.length) return out;
158
+ if (out[0] && typeof out[0].ts === 'number') {
159
+ out.sort((a, b) => b.ts - a.ts);
160
+ }
161
+ if (limit && out.length > limit) out.length = limit;
162
+ return out;
163
+ }
164
+
165
+ function emit(rows, format) {
166
+ if (format === 'pretty') {
167
+ if (!Array.isArray(rows)) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return; }
168
+ process.stdout.write(history.formatPretty(rows) + '\n');
169
+ return;
170
+ }
171
+ process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
172
+ }
173
+
174
+ function runSub(sub, positional, flags, dbs, allowedChatIds) {
175
+ const format = flags.format || 'json';
176
+ const limit = flags.limit ? Number(flags.limit) : undefined;
177
+
178
+ switch (sub) {
179
+ case 'recent': {
180
+ const chatId = positional[0];
181
+ const threadId = positional[1] || null;
182
+ if (!chatId) die('recent: <chat_id> required');
183
+ const rows = queryAcross(dbs, (db) => history.recent(db, {
184
+ chatId,
185
+ threadId,
186
+ limit: flags.limit,
187
+ since: flags.since || null,
188
+ includeOutbound: flags['include-outbound'] !== false && flags['include-outbound'] !== 'false',
189
+ allowedChatIds,
190
+ }), { limit });
191
+ return emit(rows, format);
192
+ }
193
+
194
+ case 'around': {
195
+ const chatId = flags.chat;
196
+ const msgId = Number(flags['msg-id']);
197
+ if (!chatId) die('around: --chat required');
198
+ if (!msgId) die('around: --msg-id required');
199
+ const rows = queryAcross(dbs, (db) => history.around(db, {
200
+ chatId,
201
+ msgId,
202
+ before: Number(flags.before) || 5,
203
+ after: Number(flags.after) || 5,
204
+ allowedChatIds,
205
+ }));
206
+ return emit(rows, format);
207
+ }
208
+
209
+ case 'search': {
210
+ const query = positional[0];
211
+ const chatId = positional[1] || flags.chat || null;
212
+ const threadId = positional[2] || flags.thread || null;
213
+ if (!query) die('search: <term> required');
214
+ const rows = queryAcross(dbs, (db) => history.search(db, {
215
+ query,
216
+ chatId,
217
+ threadId,
218
+ user: flags.user || null,
219
+ days: flags.days ? Number(flags.days) : null,
220
+ limit: flags.limit,
221
+ allowedChatIds,
222
+ }), { limit });
223
+ return emit(rows, format);
224
+ }
225
+
226
+ case 'by-user': {
227
+ const user = positional[0];
228
+ const chatId = positional[1] || flags.chat || null;
229
+ const threadId = positional[2] || flags.thread || null;
230
+ if (!user) die('by-user: <user> required');
231
+ const rows = queryAcross(dbs, (db) => history.byUser(db, {
232
+ user,
233
+ chatId,
234
+ threadId,
235
+ days: flags.days !== undefined ? Number(flags.days) : 7,
236
+ limit: flags.limit,
237
+ allowedChatIds,
238
+ }), { limit });
239
+ return emit(rows, format);
240
+ }
241
+
242
+ case 'msg': {
243
+ const msgId = Number(positional[0]);
244
+ const chatId = positional[1] || flags.chat || null;
245
+ if (!msgId) die('msg: <msg_id> required');
246
+ const row = queryAcross(dbs, (db) => history.getMsg(db, { msgId, chatId, allowedChatIds }));
247
+ return emit(row ? [row] : [], format);
248
+ }
249
+
250
+ case 'stats': {
251
+ const chatId = positional[0] || flags.chat || null;
252
+ const threadId = positional[1] || flags.thread || null;
253
+ // stats returns aggregates — merging naively concatenates the per-DB
254
+ // breakdown. That's what we want when admin queries across bots.
255
+ const rows = queryAcross(dbs, (db) => history.stats(db, {
256
+ chatId,
257
+ threadId,
258
+ days: flags.days !== undefined ? Number(flags.days) : 7,
259
+ allowedChatIds,
260
+ }));
261
+ return emit(rows, format);
262
+ }
263
+
264
+ default:
265
+ die(`unknown subcommand: ${sub}. Try: recent|around|search|by-user|msg|stats`);
266
+ }
267
+ }
268
+
269
+ function main() {
270
+ const argv = process.argv.slice(2);
271
+ if (!argv.length || argv[0] === '-h' || argv[0] === '--help') {
272
+ process.stdout.write(`usage: query.js <recent|around|search|by-user|msg|stats> [args]\n`);
273
+ process.exit(0);
274
+ }
275
+ const sub = argv[0];
276
+ const { positional, flags } = parseArgs(argv.slice(1));
277
+
278
+ const cfg = loadConfig();
279
+ const { bot, allowedChatIds } = deriveBotScope(cfg);
280
+ const dbPaths = resolveDbPaths(cfg, bot);
281
+ const dbs = dbPaths.map(openDbReadOnly);
282
+ try {
283
+ runSub(sub, positional, flags, dbs, allowedChatIds);
284
+ } finally {
285
+ for (const db of dbs) { try { db.raw.close(); } catch {} }
286
+ }
287
+ }
288
+
289
+ main();