polygram 0.7.9 → 0.8.0-rc.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.7.9",
4
+ "version": "0.8.0-rc.1",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Per-chat agent loader for the SDK migration (Phase 1 step 14 /
3
+ * v4 plan §6.5.5).
4
+ *
5
+ * Background: today's CLI pm passes `--agent <name>` on spawn; the
6
+ * Claude CLI then loads that agent's directory under
7
+ * `~/.claude/agents/<name>/` (system prompt from `CLAUDE.md`,
8
+ * skills, mcpServers from settings.json). Phase 0 gate 15 is DEFER —
9
+ * the SDK's `Options.agents` is for in-memory subagent definitions
10
+ * (the Task tool), NOT a "run THIS query AS this agent" mechanism.
11
+ *
12
+ * This module provides a polygram-side loader so buildSdkOptions
13
+ * can compose the per-chat agent's settings into the chat's
14
+ * SdkOptions: read the agent's CLAUDE.md (system prompt), enumerate
15
+ * its skills, pick up its mcpServers from settings.json. Then merge
16
+ * into the per-chat SdkOptions with chat-level overrides taking
17
+ * precedence (chatConfig wins over agent wins over defaults).
18
+ *
19
+ * Used by `polygram.js` `buildSdkOptions(sessionKey, ctx)` —
20
+ * Phase 1 step 14.
21
+ *
22
+ * Cache: agentName → resolved AgentBundle. Invalidated on SIGHUP
23
+ * (callable via `clearCache()`). Phase 5 acceptance includes "agent
24
+ * config edits don't require daemon restart" — but for 0.8.0
25
+ * initial release, restart-on-edit is acceptable; clearCache hook
26
+ * is forward-compat.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ const cache = new Map(); // agentName → AgentBundle
35
+
36
+ /**
37
+ * Load an agent bundle from disk.
38
+ *
39
+ * @param {string} agentName — e.g. 'shumabit-finance'
40
+ * @param {object} opts
41
+ * @param {string} [opts.homeDir] — defaults to process.env.HOME.
42
+ * Resolves agent at `${homeDir}/.claude/agents/${agentName}/`.
43
+ * @param {object} [opts.logger] — error logger.
44
+ *
45
+ * @returns {AgentBundle}
46
+ * { agentName, agentDir, systemPrompt, skills: string[],
47
+ * mcpServers: object, raw: settingsJson }
48
+ *
49
+ * Throws `{ code: 'AGENT_NOT_FOUND' }` if the agent dir doesn't
50
+ * exist. Does NOT throw on partial agents (missing CLAUDE.md or
51
+ * skills/ etc — fields just default to null/empty).
52
+ */
53
+ function loadAgent(agentName, { homeDir = process.env.HOME, logger = console } = {}) {
54
+ if (cache.has(agentName)) return cache.get(agentName);
55
+
56
+ const agentDir = path.join(homeDir, '.claude', 'agents', agentName);
57
+ if (!fs.existsSync(agentDir)) {
58
+ throw Object.assign(
59
+ new Error(`agent not found: ${agentName} (looked in ${agentDir})`),
60
+ { code: 'AGENT_NOT_FOUND', agentDir },
61
+ );
62
+ }
63
+
64
+ // System prompt: prefer CLAUDE.md (the standard polygram convention),
65
+ // fall back to AGENTS.md (OpenClaw legacy), then to a single-line
66
+ // file `system-prompt.txt` if either of the markdown files is
67
+ // absent. Whichever is present, read as UTF-8 string.
68
+ let systemPrompt = null;
69
+ for (const fname of ['CLAUDE.md', 'AGENTS.md', 'system-prompt.txt']) {
70
+ const p = path.join(agentDir, fname);
71
+ if (fs.existsSync(p)) {
72
+ try {
73
+ systemPrompt = fs.readFileSync(p, 'utf8');
74
+ break;
75
+ } catch (err) {
76
+ logger.error?.(`[agent-loader] reading ${p}: ${err.message}`);
77
+ }
78
+ }
79
+ }
80
+
81
+ // Settings: optional `settings.json` for per-agent overrides
82
+ // (mcpServers, model, effort defaults, etc.).
83
+ let settings = {};
84
+ const settingsPath = path.join(agentDir, 'settings.json');
85
+ if (fs.existsSync(settingsPath)) {
86
+ try {
87
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
88
+ } catch (err) {
89
+ logger.error?.(`[agent-loader] parsing ${settingsPath}: ${err.message}`);
90
+ }
91
+ }
92
+
93
+ // Skills: enumerate `${agentDir}/skills/*` directories. SDK's
94
+ // `Options.skills` accepts a string[] of skill names.
95
+ const skillsDir = path.join(agentDir, 'skills');
96
+ let skills = [];
97
+ if (fs.existsSync(skillsDir)) {
98
+ try {
99
+ skills = fs.readdirSync(skillsDir, { withFileTypes: true })
100
+ .filter((d) => d.isDirectory())
101
+ .map((d) => d.name);
102
+ } catch (err) {
103
+ logger.error?.(`[agent-loader] enumerating ${skillsDir}: ${err.message}`);
104
+ }
105
+ }
106
+
107
+ const mcpServers = settings.mcpServers ?? {};
108
+
109
+ const bundle = {
110
+ agentName,
111
+ agentDir,
112
+ systemPrompt,
113
+ skills,
114
+ mcpServers,
115
+ // Pass through extra settings for callers that want them
116
+ // (e.g. agent-level model/effort defaults).
117
+ raw: settings,
118
+ };
119
+ cache.set(agentName, bundle);
120
+ return bundle;
121
+ }
122
+
123
+ /**
124
+ * Compose a chat's final SdkOptions from defaults + agent + per-chat
125
+ * overrides. Precedence: chatConfig > agent > defaults.
126
+ *
127
+ * @param {object} chatConfig — config.chats[chatId].
128
+ * @param {AgentBundle|null} agentBundle — null if chat has no agent.
129
+ * @param {object} defaults — config.defaults.
130
+ *
131
+ * @returns {object} SdkOptions for `query({ options: ... })`.
132
+ */
133
+ function composeSdkOptions(chatConfig = {}, agentBundle = null, defaults = {}) {
134
+ // Start with defaults — these are the lowest-priority.
135
+ const opts = { ...defaults };
136
+
137
+ // Layer agent on top.
138
+ if (agentBundle) {
139
+ if (agentBundle.systemPrompt) opts.systemPrompt = agentBundle.systemPrompt;
140
+ if (agentBundle.skills?.length) opts.skills = agentBundle.skills;
141
+ if (agentBundle.mcpServers && Object.keys(agentBundle.mcpServers).length) {
142
+ opts.mcpServers = { ...(opts.mcpServers || {}), ...agentBundle.mcpServers };
143
+ }
144
+ // Agent-level model/effort/etc — only if chatConfig doesn't
145
+ // override.
146
+ for (const key of ['model', 'effort', 'thinking', 'permissionMode']) {
147
+ if (agentBundle.raw?.[key] != null && chatConfig[key] == null) {
148
+ opts[key] = agentBundle.raw[key];
149
+ }
150
+ }
151
+ }
152
+
153
+ // Chat-level overrides (highest priority).
154
+ for (const [k, v] of Object.entries(chatConfig)) {
155
+ if (v == null) continue;
156
+ // Don't override the spread system-prompt with `agent` config
157
+ // string — that's a polygram concept, not an SdkOptions field.
158
+ if (k === 'agent') continue;
159
+ opts[k] = v;
160
+ }
161
+
162
+ return opts;
163
+ }
164
+
165
+ function clearCache() {
166
+ cache.clear();
167
+ }
168
+
169
+ module.exports = { loadAgent, composeSdkOptions, clearCache, _cache: cache };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Parked-Promise Map for canUseTool's async user-approval flow.
3
+ *
4
+ * Per v4 plan §6.5.3 / Phase 1 step 8.
5
+ *
6
+ * Background: under SDK migration, canUseTool is an in-process
7
+ * callback (replaces today's `bin/approval-hook.js` IPC). When a
8
+ * gated tool fires, polygram posts a Telegram inline-keyboard card
9
+ * to the admin chat and PARKS a Promise that resolves on user click.
10
+ * The SDK awaits that Promise — so the in-flight tool sleeps until
11
+ * the user decides.
12
+ *
13
+ * This module owns the waiter Map. Five cleanup paths are wired:
14
+ * 1. resolveByClick(toolUseId, decision) — user pressed a button
15
+ * 2. signal abort — SDK called Query.interrupt() / Query.close();
16
+ * AbortSignal fires → Promise rejects with code:'ABORTED'
17
+ * 3. timeout — periodic sweeper rejects waiters parked > timeoutMs
18
+ * 4. rejectAllForSession(sessionKey) — pm.resetSession or kill
19
+ * 5. shutdown — daemon SIGTERM; reject all
20
+ *
21
+ * Memory bound: MAX_WAITERS (200). Park beyond cap throws a typed
22
+ * error so the caller can return `{behavior:'deny'}` to the SDK
23
+ * instead of accumulating garbage.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const DEFAULT_MAX_WAITERS = 200;
29
+ const DEFAULT_TIMEOUT_MS = 60_000; // 60s; matches OpenClaw cancel window
30
+ const DEFAULT_SWEEP_INTERVAL_MS = 5_000;
31
+
32
+ function createApprovalWaiters({
33
+ logger = console,
34
+ maxWaiters = DEFAULT_MAX_WAITERS,
35
+ timeoutMs = DEFAULT_TIMEOUT_MS,
36
+ sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS,
37
+ } = {}) {
38
+ // toolUseId → entry { resolve, reject, signal, sigCleanup,
39
+ // parkedAt, sessionKey }
40
+ const waiters = new Map();
41
+ let sweepTimer = null;
42
+
43
+ /**
44
+ * Park the canUseTool Promise; return a Promise that resolves on
45
+ * user click / rejects on signal-abort / timeout / shutdown.
46
+ *
47
+ * @param {object} args
48
+ * @param {string} args.toolUseId — SDK opts.toolUseID. Required.
49
+ * @param {string} args.sessionKey — for rejectAllForSession routing.
50
+ * @param {AbortSignal} [args.signal] — opts.signal from canUseTool.
51
+ *
52
+ * @returns {Promise<PermissionResult>}
53
+ * @throws {Error{code:'WAITER_CAP'}} if cap exceeded.
54
+ */
55
+ function park({ toolUseId, sessionKey, signal }) {
56
+ if (!toolUseId) {
57
+ throw Object.assign(new Error('toolUseId required'),
58
+ { code: 'NO_TOOL_USE_ID' });
59
+ }
60
+ if (waiters.size >= maxWaiters) {
61
+ logger.error?.(`[approval-waiters] cap reached (${maxWaiters}); rejecting`);
62
+ throw Object.assign(
63
+ new Error(`approval waiter cap exceeded (${maxWaiters})`),
64
+ { code: 'WAITER_CAP' },
65
+ );
66
+ }
67
+ if (waiters.has(toolUseId)) {
68
+ // Concurrent canUseTool with same toolUseID — SDK doesn't
69
+ // typically retry the same call, but handle defensively by
70
+ // resolving the old one with a deny first.
71
+ logger.error?.(`[approval-waiters] duplicate toolUseId ${toolUseId}; abandoning prior waiter`);
72
+ const prior = waiters.get(toolUseId);
73
+ prior.reject(Object.assign(new Error('superseded'), { code: 'SUPERSEDED' }));
74
+ }
75
+
76
+ return new Promise((resolve, reject) => {
77
+ // signal-abort cleanup wired here so signal-fires always
78
+ // unparks the waiter, even if user click never arrives.
79
+ const sigCleanup = signal
80
+ ? () => {
81
+ const e = waiters.get(toolUseId);
82
+ if (e) {
83
+ waiters.delete(toolUseId);
84
+ e.reject(Object.assign(new Error('aborted'), { code: 'ABORTED' }));
85
+ }
86
+ }
87
+ : null;
88
+ if (signal && sigCleanup) {
89
+ signal.addEventListener('abort', sigCleanup, { once: true });
90
+ }
91
+
92
+ waiters.set(toolUseId, {
93
+ resolve: (decision) => {
94
+ if (signal && sigCleanup) {
95
+ try { signal.removeEventListener('abort', sigCleanup); }
96
+ catch { /* swallow */ }
97
+ }
98
+ waiters.delete(toolUseId);
99
+ resolve(decision);
100
+ },
101
+ reject: (err) => {
102
+ if (signal && sigCleanup) {
103
+ try { signal.removeEventListener('abort', sigCleanup); }
104
+ catch { /* swallow */ }
105
+ }
106
+ waiters.delete(toolUseId);
107
+ reject(err);
108
+ },
109
+ signal,
110
+ parkedAt: Date.now(),
111
+ sessionKey,
112
+ });
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Path 1: user clicked a button. `decision` is the
118
+ * SDK-shape PermissionResult.
119
+ */
120
+ function resolveByClick(toolUseId, decision) {
121
+ const e = waiters.get(toolUseId);
122
+ if (!e) return false;
123
+ e.resolve(decision);
124
+ return true;
125
+ }
126
+
127
+ /**
128
+ * Path 4: pm.resetSession or kill. Reject every waiter whose
129
+ * sessionKey matches.
130
+ */
131
+ function rejectAllForSession(sessionKey, code = 'RESET_SESSION') {
132
+ let count = 0;
133
+ for (const [id, e] of [...waiters.entries()]) {
134
+ if (e.sessionKey === sessionKey) {
135
+ e.reject(Object.assign(new Error('session reset'), { code }));
136
+ count++;
137
+ }
138
+ }
139
+ return count;
140
+ }
141
+
142
+ /**
143
+ * Path 5: daemon shutdown. Reject every waiter.
144
+ */
145
+ function rejectAll(code = 'DAEMON_SHUTDOWN') {
146
+ let count = 0;
147
+ for (const [id, e] of [...waiters.entries()]) {
148
+ e.reject(Object.assign(new Error('daemon shutdown'), { code }));
149
+ count++;
150
+ }
151
+ return count;
152
+ }
153
+
154
+ /**
155
+ * Path 3: timeout sweeper. Periodically reject waiters parked
156
+ * longer than timeoutMs.
157
+ */
158
+ function startTimeoutSweeper() {
159
+ if (sweepTimer) return;
160
+ const sweep = () => {
161
+ const cutoff = Date.now() - timeoutMs;
162
+ for (const [id, e] of [...waiters.entries()]) {
163
+ if (e.parkedAt < cutoff) {
164
+ e.reject(Object.assign(new Error('approval timeout'),
165
+ { code: 'TIMEOUT' }));
166
+ }
167
+ }
168
+ };
169
+ sweepTimer = setInterval(sweep, sweepIntervalMs);
170
+ sweepTimer.unref?.();
171
+ }
172
+
173
+ function stopTimeoutSweeper() {
174
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
175
+ }
176
+
177
+ return {
178
+ park,
179
+ resolveByClick,
180
+ rejectAllForSession,
181
+ rejectAll,
182
+ startTimeoutSweeper,
183
+ stopTimeoutSweeper,
184
+ get size() { return waiters.size; },
185
+ // Test introspection only:
186
+ _waiters: waiters,
187
+ };
188
+ }
189
+
190
+ module.exports = {
191
+ createApprovalWaiters,
192
+ DEFAULT_MAX_WAITERS,
193
+ DEFAULT_TIMEOUT_MS,
194
+ };
package/lib/db.js CHANGED
@@ -8,13 +8,18 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const Database = require('better-sqlite3');
10
10
 
11
- // 0.7.8: bumped from 89. 0.7.6 added migration 009-turn-metrics.sql
12
- // but failed to bump SCHEMA_VERSION; the early-return on line ~36
13
- // skipped the migration loop on any DB already at user_version=8 (any
14
- // upgraded install) turn_metrics table never created INSERT prepare
15
- // at startup crashed polygram. Both 0.7.6 and 0.7.7 shipped with the
16
- // bug. Fixed by bumping the constant.
17
- const SCHEMA_VERSION = 9;
11
+ // 0.8.0 Phase 1: bumped from 910. Adds migration
12
+ // 010-tool-use-id.sql (pending_approvals.tool_use_id column for the
13
+ // SDK canUseTool stable per-call ID + chat_tool_decisions table for
14
+ // "always allow / always deny" persistence under the new in-process
15
+ // approval flow).
16
+ //
17
+ // 0.7.8 (history): bumped from 8 → 9 to fix a regression where 0.7.6
18
+ // added migration 009-turn-metrics.sql but forgot to bump
19
+ // SCHEMA_VERSION; the early-return on line ~42 then skipped the
20
+ // migration loop on any DB already at user_version=8 → turn_metrics
21
+ // table never created → INSERT prepare at startup crashed polygram.
22
+ const SCHEMA_VERSION = 10;
18
23
 
19
24
  // Sentinel `error` value for outbound rows whose API call may or may not
20
25
  // have reached Telegram. markStalePending writes it; hasOutboundReplyTo
@@ -188,6 +193,35 @@ function wrap(db) {
188
193
  )
189
194
  `);
190
195
 
196
+ // 0.8.0 Phase 1 step 8 — chat_tool_decisions persistence for the
197
+ // SDK canUseTool flow. Queried at the START of canUseTool to
198
+ // short-circuit "always allow / always deny" decisions before
199
+ // posting a Telegram inline-keyboard card. Migration 010 created
200
+ // the table; queries here. See v4 plan §6.5.4.
201
+ const lookupChatToolDecisionsStmt = db.prepare(`
202
+ SELECT match_type, input_pattern, decision, expires_ts
203
+ FROM chat_tool_decisions
204
+ WHERE bot_name = @bot_name
205
+ AND chat_id = @chat_id
206
+ AND tool_name = @tool_name
207
+ AND (expires_ts IS NULL OR expires_ts > @now)
208
+ `);
209
+ const insertChatToolDecisionStmt = db.prepare(`
210
+ INSERT INTO chat_tool_decisions (
211
+ bot_name, chat_id, tool_name, match_type,
212
+ input_pattern, decision,
213
+ issued_ts, issued_by_user_id, expires_ts
214
+ ) VALUES (
215
+ @bot_name, @chat_id, @tool_name, @match_type,
216
+ @input_pattern, @decision,
217
+ @issued_ts, @issued_by_user_id, @expires_ts
218
+ )
219
+ `);
220
+ const deleteChatToolDecisionStmt = db.prepare(`
221
+ DELETE FROM chat_tool_decisions
222
+ WHERE bot_name = ? AND chat_id = ? AND id = ?
223
+ `);
224
+
191
225
  const markStalePendingStmt = db.prepare(`
192
226
  UPDATE messages SET status = 'failed', error = '${CRASHED_MID_SEND}'
193
227
  WHERE status = 'pending' AND ts < ?
@@ -326,6 +360,58 @@ function wrap(db) {
326
360
  });
327
361
  },
328
362
 
363
+ /**
364
+ * 0.8.0 Phase 1 step 8 — chat_tool_decisions persistence.
365
+ *
366
+ * Look up "always allow / always deny" decisions for a tool
367
+ * call. Returns the FIRST matching decision (by id ASC) whose
368
+ * match_type accepts the canonical input. Pattern matching is
369
+ * done in-process here so the SQL query stays simple.
370
+ *
371
+ * Canonical input: keys sorted alphabetically, no whitespace.
372
+ * Done by the caller (canUseTool wrapper) — we accept the
373
+ * pre-canonicalised string as `canonical_input`.
374
+ */
375
+ lookupChatToolDecision({ bot_name, chat_id, tool_name, canonical_input, now }) {
376
+ const rows = lookupChatToolDecisionsStmt.all({
377
+ bot_name: String(bot_name),
378
+ chat_id: String(chat_id),
379
+ tool_name: String(tool_name),
380
+ now: now || Date.now(),
381
+ });
382
+ for (const r of rows) {
383
+ if (r.match_type === 'exact') {
384
+ if (r.input_pattern === canonical_input) return r;
385
+ } else if (r.match_type === 'prefix') {
386
+ if (canonical_input?.startsWith?.(r.input_pattern)) return r;
387
+ } else if (r.match_type === 'regex') {
388
+ try {
389
+ if (new RegExp(r.input_pattern).test(canonical_input || '')) return r;
390
+ } catch { /* malformed regex — ignore */ }
391
+ }
392
+ }
393
+ return null;
394
+ },
395
+
396
+ insertChatToolDecision(row) {
397
+ return insertChatToolDecisionStmt.run({
398
+ bot_name: String(row.bot_name),
399
+ chat_id: String(row.chat_id),
400
+ tool_name: String(row.tool_name),
401
+ match_type: row.match_type,
402
+ input_pattern: row.input_pattern,
403
+ decision: row.decision,
404
+ issued_ts: row.issued_ts || Date.now(),
405
+ issued_by_user_id: row.issued_by_user_id != null
406
+ ? String(row.issued_by_user_id) : null,
407
+ expires_ts: row.expires_ts ?? null,
408
+ });
409
+ },
410
+
411
+ deleteChatToolDecision({ bot_name, chat_id, id }) {
412
+ return deleteChatToolDecisionStmt.run(String(bot_name), String(chat_id), id);
413
+ },
414
+
329
415
  logConfigChange(row) {
330
416
  return logConfigChangeStmt.run({
331
417
  chat_id: String(row.chat_id),