polygram 0.9.0 → 0.10.0-rc.10

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.9.0",
4
+ "version": "0.10.0-rc.10",
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
package/lib/db.js CHANGED
@@ -19,7 +19,7 @@ const Database = require('better-sqlite3');
19
19
  // SCHEMA_VERSION; the early-return on line ~42 then skipped the
20
20
  // migration loop on any DB already at user_version=8 → turn_metrics
21
21
  // table never created → INSERT prepare at startup crashed polygram.
22
- const SCHEMA_VERSION = 10;
22
+ const SCHEMA_VERSION = 11;
23
23
 
24
24
  // Sentinel `error` value for outbound rows whose API call may or may not
25
25
  // have reached Telegram. markStalePending writes it; hasOutboundReplyTo
@@ -118,10 +118,10 @@ function wrap(db) {
118
118
  const upsertSessionStmt = db.prepare(`
119
119
  INSERT INTO sessions (
120
120
  session_key, chat_id, thread_id, claude_session_id,
121
- agent, cwd, model, effort, created_ts, last_active_ts
121
+ agent, cwd, model, effort, pm_backend, created_ts, last_active_ts
122
122
  ) VALUES (
123
123
  @session_key, @chat_id, @thread_id, @claude_session_id,
124
- @agent, @cwd, @model, @effort, @ts, @ts
124
+ @agent, @cwd, @model, @effort, @pm_backend, @ts, @ts
125
125
  )
126
126
  ON CONFLICT(session_key) DO UPDATE SET
127
127
  chat_id = excluded.chat_id,
@@ -131,12 +131,14 @@ function wrap(db) {
131
131
  cwd = excluded.cwd,
132
132
  model = excluded.model,
133
133
  effort = excluded.effort,
134
+ pm_backend = excluded.pm_backend,
134
135
  last_active_ts = excluded.last_active_ts
135
136
  `);
136
137
 
137
138
  const getSessionStmt = db.prepare(`SELECT * FROM sessions WHERE session_key = ?`);
138
139
  const touchSessionStmt = db.prepare(`UPDATE sessions SET last_active_ts = ? WHERE session_key = ?`);
139
140
  const clearSessionIdStmt = db.prepare(`DELETE FROM sessions WHERE session_key = ?`);
141
+ const setSessionBackendStmt = db.prepare(`UPDATE sessions SET pm_backend = ? WHERE session_key = ?`);
140
142
 
141
143
  const getMessageStmt = db.prepare(`
142
144
  SELECT * FROM messages WHERE chat_id = ? AND msg_id = ?
@@ -291,6 +293,8 @@ function wrap(db) {
291
293
  cwd: row.cwd || null,
292
294
  model: row.model || null,
293
295
  effort: row.effort || null,
296
+ // 0.10.0: pm_backend defaults to 'sdk' if caller doesn't set it.
297
+ pm_backend: row.pm_backend || 'sdk',
294
298
  ts: row.ts || Date.now(),
295
299
  });
296
300
  },
@@ -307,6 +311,13 @@ function wrap(db) {
307
311
  return clearSessionIdStmt.run(sessionKey);
308
312
  },
309
313
 
314
+ // 0.10.0: backend reassignment without resetting other session fields.
315
+ // Used when ProcessManager spawns a Process with a different backend
316
+ // than the persisted row says (drift event fires too).
317
+ setSessionBackend(sessionKey, backend) {
318
+ return setSessionBackendStmt.run(backend, sessionKey);
319
+ },
320
+
310
321
  getMessage(chatId, msgId) {
311
322
  return getMessageStmt.get(String(chatId), msgId);
312
323
  },
@@ -69,9 +69,15 @@ function createAutosteerHandlers({
69
69
  if (!entry?.inFlight) return { autosteered: false };
70
70
 
71
71
  const priority = priorityFor(chatConfig, config);
72
+ // rc.7: pass the autosteered msg_id through to the backend so the
73
+ // tmux backend can route an extra-turn reply back to Telegram if
74
+ // the TUI dequeues the paste as a fresh user turn (NEW-TURN path).
75
+ // SDK backend ignores msgId — its PostToolBatch fold path
76
+ // guarantees one combined reply via the primary pm.send.
72
77
  const ok = pm.injectUserMessage(sessionKey, {
73
78
  content: prompt,
74
79
  priority,
80
+ msgId: msg.message_id,
75
81
  });
76
82
  if (!ok) return { autosteered: false };
77
83
 
@@ -52,18 +52,24 @@ function createSlashCommands({
52
52
  } = ctx;
53
53
  const botAllowsCommands = !!config.bot?.allowConfigCommands;
54
54
 
55
- // /context
55
+ // /context — route through pm.getContextUsage(sessionKey) so the
56
+ // call works for both SDK and tmux backends (the latter computes
57
+ // from JSONL message.usage). Pre-0.10.0-P0.2 this reached into
58
+ // entry.query.getContextUsage directly, which silently said "No
59
+ // active session yet" on tmux even when the chat was alive.
56
60
  if (botAllowsCommands && text === '/context') {
57
- const entry = pm.get(sessionKey);
58
- const q = entry?.query;
59
- if (!q || typeof q.getContextUsage !== 'function') {
61
+ if (!pm.has(sessionKey)) {
60
62
  await sendReply('📚 No active session yet — send a message first, then /context.');
61
63
  return true;
62
64
  }
63
65
  try {
64
- const u = await q.getContextUsage();
66
+ const u = await pm.getContextUsage(sessionKey);
65
67
  await sendReply(formatContextReply(u));
66
68
  } catch (err) {
69
+ if (err?.code === 'UNSUPPORTED_OPERATION' || err?.code === 'NOT_IMPLEMENTED_YET') {
70
+ await sendReply('📚 Context info not available yet — send a message first, then /context.');
71
+ return true;
72
+ }
67
73
  logger.error?.(`[${label}] /context failed: ${err.message}`);
68
74
  await sendReply(`📚 Couldn't fetch context info: ${err.message}`);
69
75
  }
@@ -99,16 +105,20 @@ function createSlashCommands({
99
105
  resumed_session_id: savedSessionId,
100
106
  });
101
107
  }
102
- if (!entry?.inputController?.push) {
103
- await sendReply('🗜️ Session not ready for /compact (no input controller).');
108
+ if (!entry || typeof entry.fireUserMessage !== 'function') {
109
+ await sendReply('🗜️ Session not ready for /compact.');
104
110
  return true;
105
111
  }
106
112
  try {
107
- entry.inputController.push({
108
- type: 'user',
109
- message: { role: 'user', content: text },
110
- parent_tool_use_id: null,
111
- });
113
+ // 0.10.0 P0.3 fix: route through Process.fireUserMessage so
114
+ // SDK (push to inputController) and tmux (paste to TUI) both
115
+ // handle the slash command. Pre-0.10.0-P0.3 reached into
116
+ // entry.inputController.push directly — broken on tmux.
117
+ const ok = entry.fireUserMessage(text);
118
+ if (!ok) {
119
+ await sendReply('🗜️ Session not ready for /compact.');
120
+ return true;
121
+ }
112
122
  logEvent('compact-command', {
113
123
  chat_id: chatId, thread_id: threadIdStr, session_key: sessionKey,
114
124
  text_len: text.length,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Anthropic Claude API price rates per million tokens.
3
+ *
4
+ * Used by TmuxProcess to compute `cost_usd` per turn, since the
5
+ * per-session JSON log only carries token counts, not cost. SDK
6
+ * backend doesn't need this — Anthropic's claude-agent-sdk reports
7
+ * `total_cost_usd` directly in the result event.
8
+ *
9
+ * **Update reminder:** these rates can change. Last verified
10
+ * 2026-05-15 against https://www.anthropic.com/pricing. When rates
11
+ * shift, update the table here and bump the comment date.
12
+ *
13
+ * If a model isn't in the table, the `default` rates apply (Sonnet
14
+ * 4.6 today). Adding a new model is a 5-line PR.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ // Per-million-token rates ($ USD). Cache-read and cache-creation
20
+ // follow Anthropic's prompt-caching pricing — read is ~10% of normal
21
+ // input; 1-hour creation is ~125% of normal input.
22
+ const MODEL_COSTS = {
23
+ // Claude Sonnet 4.6
24
+ 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.30, cacheCreation: 3.75 },
25
+ // Claude Haiku 4.5 (date-suffixed model names map here via the
26
+ // `.replace(/-\d{8}$/, '')` strip in computeCostUsd; no need for
27
+ // a duplicate entry per snapshot).
28
+ 'claude-haiku-4-5': { input: 0.80, output: 4, cacheRead: 0.08, cacheCreation: 1 },
29
+ // Claude Opus 4.7 (1M context)
30
+ 'claude-opus-4-7': { input: 15, output: 75, cacheRead: 1.50, cacheCreation: 18.75 },
31
+ // Default fallback — Sonnet rates (safest mid-tier estimate).
32
+ default: { input: 3, output: 15, cacheRead: 0.30, cacheCreation: 3.75 },
33
+ };
34
+
35
+ /**
36
+ * Compute USD cost from a token-usage snapshot.
37
+ *
38
+ * @param {object} usage — { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens }
39
+ * @param {string|null} model — e.g. 'claude-haiku-4-5-20251001'
40
+ * @returns {number} cost in USD; 0 if usage is missing
41
+ */
42
+ function computeCostUsd(usage, model) {
43
+ if (!usage) return 0;
44
+ // Try exact match first; then prefix match (strip date suffix like -20251001).
45
+ let rate = MODEL_COSTS[model];
46
+ if (!rate && typeof model === 'string') {
47
+ const stripped = model.replace(/-\d{8}$/, '');
48
+ rate = MODEL_COSTS[stripped];
49
+ }
50
+ if (!rate) rate = MODEL_COSTS.default;
51
+ const M = 1_000_000;
52
+ return (
53
+ (usage.inputTokens || 0) * rate.input / M
54
+ + (usage.outputTokens || 0) * rate.output / M
55
+ + (usage.cacheReadTokens || 0) * rate.cacheRead / M
56
+ + (usage.cacheCreationTokens || 0) * rate.cacheCreation / M
57
+ );
58
+ }
59
+
60
+ module.exports = { computeCostUsd, MODEL_COSTS };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Process factory — chooses + constructs the right Process subclass
3
+ * per session, based on chat / topic / bot config.
4
+ *
5
+ * Backends:
6
+ * - 'sdk' → SdkProcess (default; long-lived SDK Query)
7
+ * - 'tmux' → TmuxProcess (claude TUI hosted in a tmux session)
8
+ *
9
+ * Backend selection precedence:
10
+ * topicConfig.pm > chatConfig.pm > config.bot.pm > 'sdk'
11
+ *
12
+ * The tmux backend requires a `tmuxRunner` + `botName` to be passed
13
+ * into createProcessFactory. If a tmux-routed chat is encountered
14
+ * without those wired, we log a loud warning and fall back to SDK so
15
+ * the daemon stays up (R2-F7 — never silent-fail config).
16
+ *
17
+ * @see docs/0.10.0-process-manager-abstraction-plan.md §6.4
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const { SdkProcess } = require('./sdk-process');
23
+ const { TmuxProcess } = require('./tmux-process');
24
+
25
+ /**
26
+ * @param {object} opts
27
+ * @param {object} opts.config — runtime config object
28
+ * @param {Function} opts.spawnFn — buildSdkOptions (SDK backend only)
29
+ * @param {object} [opts.db] — for SdkProcess._logEvent + clearSessionId
30
+ * @param {object} [opts.logger]
31
+ * @param {number} [opts.queueCap]
32
+ * @param {number} [opts.queryCloseTimeoutMs]
33
+ * @param {object} [opts.tmuxRunner] — required when ANY chat routes to 'tmux'
34
+ * @param {string} [opts.botName] — required when ANY chat routes to 'tmux'
35
+ * @param {object} [opts.pollScheduler] — shared PollScheduler instance.
36
+ * When provided, all TmuxProcess instances share ONE setInterval for
37
+ * their polling loops (one timer regardless of how many in-flight
38
+ * tmux chats). Falls back to per-instance setTimeout when omitted.
39
+ * @returns {Function} processFactory(sessionKey, ctx) → Process
40
+ */
41
+ function createProcessFactory({
42
+ config,
43
+ spawnFn,
44
+ db = null,
45
+ logger = console,
46
+ queueCap,
47
+ queryCloseTimeoutMs,
48
+ tmuxRunner = null,
49
+ botName = null,
50
+ pollScheduler = null,
51
+ } = {}) {
52
+ if (typeof spawnFn !== 'function') {
53
+ throw new TypeError('createProcessFactory: spawnFn required');
54
+ }
55
+
56
+ return function processFactory(sessionKey, ctx) {
57
+ const chatId = ctx?.chatId ?? null;
58
+ const threadId = ctx?.threadId ?? null;
59
+ const label = ctx?.label || sessionKey;
60
+
61
+ const choice = pickBackend({ config, chatId, threadId });
62
+
63
+ if (choice === 'tmux') {
64
+ if (!tmuxRunner || !botName) {
65
+ logger.warn?.(
66
+ `[${label}] config requests pm:'tmux' but tmuxRunner/botName not wired; ` +
67
+ `falling back to SdkProcess. Pass {tmuxRunner, botName} to createProcessFactory.`,
68
+ );
69
+ } else {
70
+ return new TmuxProcess({
71
+ sessionKey, chatId, threadId, label,
72
+ runner: tmuxRunner,
73
+ botName,
74
+ logger,
75
+ pollScheduler,
76
+ });
77
+ }
78
+ }
79
+
80
+ return new SdkProcess({
81
+ sessionKey, chatId, threadId, label,
82
+ spawnFn,
83
+ db,
84
+ logger,
85
+ queueCap,
86
+ queryCloseTimeoutMs,
87
+ });
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Per-chat / per-topic backend choice. Phase 1 always returned 'sdk'.
93
+ * Phase 2 honors topicConfig.pm / chatConfig.pm / config.bot.pm.
94
+ */
95
+ function pickBackend({ config, chatId, threadId }) {
96
+ if (!chatId) return 'sdk';
97
+ const chatCfg = config?.chats?.[chatId];
98
+ const topicCfg = threadId && chatCfg?.topics?.[threadId];
99
+ return topicCfg?.pm || chatCfg?.pm || config?.bot?.pm || 'sdk';
100
+ }
101
+
102
+ module.exports = { createProcessFactory, pickBackend };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Abstract Process — one running Claude session, regardless of backend.
3
+ *
4
+ * Subclasses ship per backend:
5
+ * - SdkProcess (lib/process/sdk-process.js) — long-lived
6
+ * @anthropic-ai/claude-agent-sdk Query
7
+ * - TmuxProcess (lib/process/tmux-process.js) — claude TUI hosted
8
+ * inside a tmux session (Phase 2)
9
+ *
10
+ * State machine: spawned → ready → (turn-in-flight | idle) ↔ closed.
11
+ *
12
+ * Public surface mirrors what polygram's handleMessage, slash-commands,
13
+ * autosteer, edit-correction etc. already call on the current SDK pm.
14
+ * Callers don't branch on subclass.
15
+ *
16
+ * Optional methods come in two flavors per the v3 audit:
17
+ * - Async ones MAY throw UnsupportedOperationError. Callers `await` +
18
+ * try/catch around them.
19
+ * - Sync HOT-PATH ones (drainQueue, injectUserMessage) return a
20
+ * sentinel value, NEVER throw. Per R1-F1: autosteer's call site
21
+ * has no try/catch — a throw would crash the message handler.
22
+ *
23
+ * Weighted LRU: each Process advertises its `cost`. The pm evicts
24
+ * to keep Σ cost ≤ budget rather than count ≤ cap. SDK Process cost=1,
25
+ * TmuxProcess cost=3 (per Phase 0 F-spike-2: tmux ~545MB vs SDK ~50MB
26
+ * per session).
27
+ *
28
+ * Phase 0 spike findings — `docs/0.10.0-phase0-spike-findings.md`.
29
+ * Spec — `docs/0.10.0-process-manager-abstraction-plan.md`.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const EventEmitter = require('events');
35
+
36
+ class UnsupportedOperationError extends Error {
37
+ constructor(method, backend) {
38
+ super(`Operation ${method} not supported by ${backend} backend`);
39
+ this.name = 'UnsupportedOperationError';
40
+ this.code = 'UNSUPPORTED_OPERATION';
41
+ this.method = method;
42
+ this.backend = backend;
43
+ }
44
+ }
45
+
46
+ class Process extends EventEmitter {
47
+ /**
48
+ * @param {object} opts
49
+ * @param {string} opts.sessionKey polygram session key
50
+ * @param {string|null} opts.chatId
51
+ * @param {string|null} opts.threadId
52
+ * @param {string} opts.label human-readable for logs
53
+ */
54
+ constructor({ sessionKey, chatId, threadId, label } = {}) {
55
+ super();
56
+ if (typeof sessionKey !== 'string' || sessionKey.length === 0) {
57
+ throw new TypeError('Process: sessionKey (string) required');
58
+ }
59
+ // Identity — immutable after construction
60
+ this.sessionKey = sessionKey;
61
+ this.chatId = chatId == null ? null : String(chatId);
62
+ this.threadId = threadId == null ? null : String(threadId);
63
+ this.label = label || `${this.chatId || ''}${this.threadId ? '/' + this.threadId : ''}` || sessionKey;
64
+ // backend identifier — subclass overrides
65
+ this.backend = 'abstract';
66
+
67
+ // Mutable state
68
+ this.closed = false;
69
+ this.inFlight = false;
70
+ this.pendingQueue = [];
71
+ this.claudeSessionId = null;
72
+ }
73
+
74
+ /**
75
+ * Cost weight for LRU eviction (per Phase 0 F-spike-2).
76
+ * Subclass overrides. Defaults to 1 (SDK-equivalent).
77
+ */
78
+ get cost() {
79
+ return 1;
80
+ }
81
+
82
+ // ─── REQUIRED methods — subclass MUST override ─────────────────────
83
+
84
+ /**
85
+ * Cold-spawn this process. Wire up internals; mark ready when
86
+ * the underlying claude session is responsive.
87
+ *
88
+ * @param {object} opts — backend-specific. Typically includes:
89
+ * existingSessionId — for --resume continuity
90
+ * model, effort, cwd, chatConfig, botName — spawn params
91
+ */
92
+ async start(_opts) {
93
+ throw new Error(`${this.constructor.name}.start() must be overridden`);
94
+ }
95
+
96
+ /**
97
+ * Send a user turn. Resolves with a PmSendResult on completion.
98
+ *
99
+ * @param {string} prompt
100
+ * @param {object} [opts]
101
+ * @returns {Promise<PmSendResult>}
102
+ */
103
+ async send(_prompt, _opts) {
104
+ throw new Error(`${this.constructor.name}.send() must be overridden`);
105
+ }
106
+
107
+ /**
108
+ * Close cleanly. Returns when fully torn down.
109
+ * Idempotent.
110
+ *
111
+ * @param {string} [reason]
112
+ */
113
+ async kill(_reason) {
114
+ throw new Error(`${this.constructor.name}.kill() must be overridden`);
115
+ }
116
+
117
+ // ─── OPTIONAL async methods — caller awaits + try/catch ────────────
118
+
119
+ async interrupt() {
120
+ throw new UnsupportedOperationError('interrupt', this.backend);
121
+ }
122
+ async setModel(_model) {
123
+ throw new UnsupportedOperationError('setModel', this.backend);
124
+ }
125
+ async applyFlagSettings(_settings) {
126
+ throw new UnsupportedOperationError('applyFlagSettings', this.backend);
127
+ }
128
+ async setPermissionMode(_mode) {
129
+ throw new UnsupportedOperationError('setPermissionMode', this.backend);
130
+ }
131
+ async resetSession(_opts) {
132
+ throw new UnsupportedOperationError('resetSession', this.backend);
133
+ }
134
+ async getContextUsage() {
135
+ throw new UnsupportedOperationError('getContextUsage', this.backend);
136
+ }
137
+
138
+ // ─── OPTIONAL sync HOT-PATH methods — never throw (R1-F1) ──────────
139
+
140
+ /**
141
+ * Reject all pending turns with the supplied error code.
142
+ * Used by /stop, daemon shutdown, /new.
143
+ *
144
+ * @param {string} [_code='INTERRUPTED']
145
+ * @returns {number} count of pendings drained
146
+ */
147
+ drainQueue(_code = 'INTERRUPTED') {
148
+ return 0;
149
+ }
150
+
151
+ /**
152
+ * Inject a user message into the in-flight turn (autosteer +
153
+ * edit-correction). Returns false if the backend can't inject
154
+ * right now (e.g. no live turn) — caller falls through to normal
155
+ * pm.send queue path.
156
+ *
157
+ * @returns {boolean}
158
+ */
159
+ injectUserMessage(_opts) {
160
+ return false;
161
+ }
162
+
163
+ /**
164
+ * Push priority='now' style steer (rare; legacy of OpenClaw shape).
165
+ * Hot-path-safe.
166
+ *
167
+ * @returns {boolean}
168
+ */
169
+ steer(_text, _opts) {
170
+ return false;
171
+ }
172
+
173
+ /**
174
+ * Fire-and-forget user-message injection regardless of inFlight
175
+ * state. Used by polygram's slash-command paths (/compact, /reload
176
+ * etc) where we want to send a user-shaped message into the
177
+ * underlying claude session BUT NOT wait for the turn to complete.
178
+ *
179
+ * Differs from `injectUserMessage` (which is for mid-stream fold and
180
+ * requires inFlight on tmux) and `send` (which blocks until turn
181
+ * completion). Default returns false; subclasses override.
182
+ *
183
+ * @returns {boolean} true if message was queued/pasted
184
+ */
185
+ fireUserMessage(_text) {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ module.exports = {
191
+ Process,
192
+ UnsupportedOperationError,
193
+ };