polygram 0.8.0 → 0.9.0-rc.2

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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/{agent-loader.js → agents/loader.js} +6 -8
  3. package/lib/{approvals.js → approvals/store.js} +28 -5
  4. package/lib/{approval-ui.js → approvals/ui.js} +1 -17
  5. package/lib/config.js +121 -0
  6. package/lib/{error-classify.js → error/classify.js} +25 -34
  7. package/lib/handlers/abort.js +89 -0
  8. package/lib/handlers/approvals.js +361 -0
  9. package/lib/handlers/autosteer.js +94 -0
  10. package/lib/handlers/config-callback.js +118 -0
  11. package/lib/handlers/config-ui.js +104 -0
  12. package/lib/handlers/dispatcher.js +263 -0
  13. package/lib/handlers/download.js +182 -0
  14. package/lib/handlers/extract-attachments.js +97 -0
  15. package/lib/handlers/ipc-send.js +80 -0
  16. package/lib/handlers/poll.js +140 -0
  17. package/lib/handlers/record-inbound.js +88 -0
  18. package/lib/handlers/slash-commands.js +319 -0
  19. package/lib/handlers/voice.js +107 -0
  20. package/lib/pm-interface.js +27 -29
  21. package/lib/sdk/build-options.js +177 -0
  22. package/lib/sdk/callbacks.js +213 -0
  23. package/lib/{process-manager-sdk.js → sdk/process-manager.js} +19 -31
  24. package/lib/{telegram.js → telegram/api.js} +2 -2
  25. package/lib/{telegram-prompt.js → telegram/display-hint.js} +0 -14
  26. package/lib/{stream-reply.js → telegram/streamer.js} +4 -4
  27. package/package.json +2 -3
  28. package/polygram.js +347 -2581
  29. package/scripts/doctor.js +1 -1
  30. package/scripts/ipc-smoke.js +1 -10
  31. package/bin/approval-hook.js +0 -113
  32. package/lib/approval-waiters.js +0 -201
  33. package/lib/pm-router.js +0 -201
  34. package/lib/process-manager.js +0 -806
  35. /package/lib/{auto-resume.js → db/auto-resume.js} +0 -0
  36. /package/lib/{inbox.js → db/inbox.js} +0 -0
  37. /package/lib/{pairings.js → db/pairings.js} +0 -0
  38. /package/lib/{replay-window.js → db/replay-window.js} +0 -0
  39. /package/lib/{sent-cache.js → db/sent-cache.js} +0 -0
  40. /package/lib/{sessions.js → db/sessions.js} +0 -0
  41. /package/lib/{net-errors.js → error/net.js} +0 -0
  42. /package/lib/{ipc-client.js → ipc/client.js} +0 -0
  43. /package/lib/{ipc-file-validator.js → ipc/file-validator.js} +0 -0
  44. /package/lib/{ipc-server.js → ipc/server.js} +0 -0
  45. /package/lib/{telegram-chunk.js → telegram/chunk.js} +0 -0
  46. /package/lib/{deliver.js → telegram/deliver.js} +0 -0
  47. /package/lib/{telegram-format.js → telegram/format.js} +0 -0
  48. /package/lib/{parse-response.js → telegram/parse.js} +0 -0
  49. /package/lib/{status-reactions.js → telegram/reactions.js} +0 -0
  50. /package/lib/{typing-indicator.js → telegram/typing.js} +0 -0
  51. /package/lib/{voice.js → telegram/voice.js} +0 -0
@@ -1,19 +1,23 @@
1
1
  /**
2
2
  * Canonical Pm interface (JSDoc typedef).
3
3
  *
4
- * Both `lib/process-manager.js` (CLI pm) and `lib/process-manager-sdk.js`
5
- * (SDK pm) implement this. `lib/pm-router.js`'s `createPmRouter()`
6
- * forwards calls to one or the other based on per-chat policy.
4
+ * Implemented by `lib/sdk/process-manager.js` (the only pm impl
5
+ * post-0.9.0). `lib/sdk/router.js`'s `createPmRouter()` wraps the
6
+ * pm and is currently an identity passthrough kept as a forward-
7
+ * compat seam for future alternate pm impls (a pi-agent-core
8
+ * adapter, a synthetic test pm). When that lands, the router
9
+ * becomes the per-chat dispatch layer again; this interface stays
10
+ * the contract.
7
11
  *
8
- * Optional methods are marked `?` the router exposes them too but
9
- * returns documented sentinels when the routed pm doesn't implement
10
- * them. Sites that need to feature-detect should call
11
- * `pm.pickFor(sessionKey)` and probe `typeof X === 'function'` on
12
- * the returned pm instance, not on the router.
12
+ * Optional methods are marked `?`. SDK pm currently exposes ALL
13
+ * of them, so post-0.9.0 polygram.js can call them directly
14
+ * without `typeof === 'function'` guards. Future alternate impls
15
+ * may opt out of an optional method; if they do, callers will
16
+ * need to feature-detect at the call site.
13
17
  *
14
18
  * @typedef {object} PmEntry
15
- * The shape pm.get(sessionKey) returns. Different pms decorate it
16
- * with their own internal fields; only the documented fields below
19
+ * The shape pm.get(sessionKey) returns. The pm impl decorates it
20
+ * with its own internal fields; only the documented fields below
17
21
  * are part of the public contract.
18
22
  * @property {string} sessionKey
19
23
  * @property {string|null} chatId
@@ -45,16 +49,15 @@
45
49
  *
46
50
  * @typedef {object} PmSpawnContext
47
51
  * What polygram passes to spawnFn(sessionKey, ctx). Internal to
48
- * each pm but documented here so callers know what's available.
52
+ * the pm but documented here so callers know what's available.
49
53
  * @property {string|null} chatId
50
54
  * @property {string|null} threadId
51
55
  * @property {string} label
52
56
  *
53
57
  * @typedef {object} Pm
54
- * The unified ProcessManager interface. Both CLI and SDK pm
55
- * implement these; the router forwards.
58
+ * The unified ProcessManager interface.
56
59
  *
57
- * Required (every pm has these):
60
+ * Required:
58
61
  * @property {(sessionKey: string) => boolean} has
59
62
  * @property {(sessionKey: string) => PmEntry|null} get
60
63
  * @property {(sessionKey: string, ctx: PmSpawnContext) => Promise<PmEntry>} getOrSpawn
@@ -65,30 +68,25 @@
65
68
  * @property {() => Promise<void>} shutdown
66
69
  * — graceful daemon-wide drain + close.
67
70
  *
68
- * Optional (only one of the two pms implements these — feature-detect):
71
+ * Optional (SDK pm exposes all of these; future alt impls may not):
69
72
  * @property {((sessionKey: string, text: string, opts?: object) => boolean)=} steer
70
- * — SDK pm only (rc.9 priority='now' direct push, opt-in shouldQuery).
73
+ * — priority='now' direct push, opt-in shouldQuery.
71
74
  * @property {((sessionKey: string, opts: {content: string, priority?: 'now'|'next'|'later', shouldQuery?: boolean, parent_tool_use_id?: string|null}) => boolean)=} injectUserMessage
72
- * — SDK pm only (rc.42 native autosteer / queue via SDKUserMessage
73
- * priority hint). Returns false on CLI pm (no inputController
74
- * surface) or when sessionKey not found.
75
+ * — native autosteer / queue via SDKUserMessage priority hint.
76
+ * Returns false when sessionKey not found.
75
77
  * @property {((sessionKey: string, model: string) => Promise<boolean>)=} setModel
76
- * — SDK pm only (Query.setModel live).
78
+ * — Query.setModel live (no respawn).
77
79
  * @property {((sessionKey: string, settings: {effortLevel?: string}) => Promise<boolean>)=} applyFlagSettings
78
- * — SDK pm only (Query.applyFlagSettings live).
80
+ * — Query.applyFlagSettings live (no respawn).
79
81
  * @property {((sessionKey: string, mode: string) => Promise<boolean>)=} setPermissionMode
80
- * — SDK pm only.
81
- * @property {((sessionKey: string, reason?: string) => {killed: boolean, queued: number})=} requestRespawn
82
- * — CLI pm only (drain pending queue then kill; respawn on next send).
83
82
  * @property {((sessionKey: string, errCode: string) => number)=} drainQueue
84
- * — SDK pm only (reject all queued pendings with errCode).
83
+ * — reject all queued pendings with errCode.
85
84
  * @property {((sessionKey: string) => Promise<void>)=} interrupt
86
- * — SDK pm only (Query.interrupt non-destructive).
85
+ * — Query.interrupt (non-destructive — preserves session for resume).
87
86
  * @property {((sessionKey: string, opts?: {reason?: string}) => Promise<{closed: boolean, drainedPendings: number}>)=} resetSession
88
- * — SDK pm only (close Query + clear sessionId from DB).
87
+ * — close Query + clear sessionId from DB.
89
88
  *
90
- * Lifecycle introspection (for tests / debugging — not required
91
- * to be present, but both current pms expose them):
89
+ * Lifecycle introspection (for tests / debugging):
92
90
  * @property {() => string[]=} keys — sessionKey list
93
91
  * @property {() => number=} size — number of live sessions
94
92
  */
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Factory for the SDK pm's spawn `Options` builder.
3
+ *
4
+ * polygram.js wires this at boot via createBuildSdkOptions(deps);
5
+ * the returned function is what gets passed to ProcessManagerSdk
6
+ * as `spawnFn`. Per-call, it composes the SdkOptions object the SDK
7
+ * needs: model + effort + cwd + permissionMode +
8
+ * canUseTool wiring + agent overlay + per-topic overrides + env
9
+ * shadow + appended display hint.
10
+ *
11
+ * Why a factory instead of a top-level function: buildSdkOptions
12
+ * needs polygram-runtime context (config, botName, childHome,
13
+ * makeCanUseTool, logEvent, agentLoader). Passing them via
14
+ * factory closure keeps the per-call signature `(sessionKey, ctx)`
15
+ * — the shape pm-sdk's spawnFn contract requires.
16
+ *
17
+ * Per v4 plan §6.5.7 — explicit env enumeration (Options.env is
18
+ * SHADOW per Phase 0 gate 33), bypassPermissions +
19
+ * allowDangerouslySkipPermissions both set for forward-compat,
20
+ * agent-loader composes per-chat agent into systemPrompt + skills +
21
+ * mcpServers, optional resume sessionId for continuity.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const agentLoader = require('../agents/loader');
27
+ const { getTopicConfig } = require('../session-key');
28
+ const { appendDisplayHint } = require('../telegram/display-hint');
29
+
30
+ // Env: SHADOW semantics — must enumerate every var the spawned
31
+ // worker is allowed to see. Anything else is dropped.
32
+ const CHILD_ENV_ALLOWLIST = new Set([
33
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'COLORTERM',
34
+ 'TMPDIR', 'TMP', 'TEMP', 'TZ', 'LANG', 'PWD', 'SHLVL',
35
+ ]);
36
+ const CHILD_ENV_PREFIXES = ['LC_', 'NODE_', 'CLAUDE_', 'ANTHROPIC_'];
37
+
38
+ function filterEnv(src) {
39
+ const out = {};
40
+ for (const [k, v] of Object.entries(src)) {
41
+ if (CHILD_ENV_ALLOWLIST.has(k) || CHILD_ENV_PREFIXES.some((p) => k.startsWith(p))) {
42
+ out[k] = v;
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * @param {object} deps
50
+ * @param {object} deps.config — runtime config object (config.bot, config.chats, config.defaults)
51
+ * @param {string} deps.botName — current bot's name
52
+ * @param {string} deps.childHome — HOME passed to spawned child
53
+ * @param {(sessionKey: string) => Function} deps.makeCanUseTool — closure that builds canUseTool callbacks
54
+ * @param {(kind: string, detail: object) => void} deps.logEvent — bound to db
55
+ * @param {object} [deps.logger=console]
56
+ * @param {object} [deps.processEnv=process.env] — overridable for tests
57
+ * @returns {(sessionKey: string, ctx: object) => object} spawnFn
58
+ */
59
+ function createBuildSdkOptions({
60
+ config,
61
+ botName,
62
+ childHome,
63
+ makeCanUseTool,
64
+ logEvent,
65
+ logger = console,
66
+ processEnv = process.env,
67
+ } = {}) {
68
+
69
+ return function buildSdkOptions(sessionKey, ctx) {
70
+ const { chatConfig, existingSessionId, label, chatId, threadId } = ctx;
71
+
72
+ // rc.48: per-topic config overrides. Per-topic agent / cwd /
73
+ // permissionMode take precedence over chat-level config when
74
+ // isolateTopics is true (each topic has its own SDK Query).
75
+ const topicConfig = getTopicConfig(chatConfig, threadId);
76
+ const effectiveAgent = topicConfig.agent || chatConfig.agent;
77
+
78
+ // Per-chat agent: load + compose. Failure is non-fatal — chat
79
+ // falls back to defaults; the failure is logged for ops.
80
+ let agentBundle = null;
81
+ if (effectiveAgent) {
82
+ try {
83
+ agentBundle = agentLoader.loadAgent(effectiveAgent, {
84
+ homeDir: childHome,
85
+ cwd: topicConfig.cwd || chatConfig.cwd,
86
+ logger,
87
+ });
88
+ } catch (err) {
89
+ logger.error?.(`[${label}] agent-loader: ${err.message}`);
90
+ logEvent('agent-load-failed', {
91
+ chat_id: chatId, agent: effectiveAgent, error: err.message,
92
+ topic: threadId || null,
93
+ });
94
+ }
95
+ }
96
+
97
+ const effectiveModel = topicConfig.model || chatConfig.model;
98
+ const effectiveEffort = topicConfig.effort || chatConfig.effort;
99
+ const agentSuffix = effectiveAgent && effectiveAgent !== chatConfig.agent
100
+ ? ` agent=${effectiveAgent}` : '';
101
+ logger.log?.(`[${label}] Spawning SDK Query (${effectiveModel}/${effectiveEffort}${agentSuffix})`);
102
+
103
+ // Env scrub: SHADOW. Pass the bot's per-call additions on top.
104
+ const botConfig = config.bot || {};
105
+ const childEnv = filterEnv(processEnv);
106
+ childEnv.HOME = childHome;
107
+ childEnv.CLAUDE_CHANNEL_BOT = botName;
108
+ // 0.9.0: gated behind explicit opt-in. Pre-cleanup, the IPC secret
109
+ // was unconditionally exported so the deleted bin/approval-hook.js
110
+ // could authenticate; with the hook gone, the only IPC consumers
111
+ // are external scripts (cron-driven sends) running in their own
112
+ // processes with their own access to /tmp/polygram-<bot>.secret.
113
+ if (botConfig.exposeIpcSecretToChildren && processEnv.POLYGRAM_IPC_SECRET) {
114
+ childEnv.POLYGRAM_IPC_SECRET = processEnv.POLYGRAM_IPC_SECRET;
115
+ }
116
+ if (botConfig.needsToken) {
117
+ childEnv.TELEGRAM_BOT_TOKEN = botConfig.token || '';
118
+ }
119
+
120
+ // canUseTool: in-process approval flow. Wire up only when
121
+ // approvals.gatedTools is configured for this bot — otherwise
122
+ // leave canUseTool unset and rely on bypassPermissions.
123
+ const apprCfg = config.bot?.approvals;
124
+ const useCanUseTool = apprCfg && apprCfg.adminChatId
125
+ && Array.isArray(apprCfg.gatedTools) && apprCfg.gatedTools.length > 0;
126
+
127
+ const baseOpts = {
128
+ model: chatConfig.model || config.defaults.model,
129
+ effort: chatConfig.effort || config.defaults.effort,
130
+ cwd: chatConfig.cwd,
131
+ env: childEnv,
132
+ permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
133
+ allowDangerouslySkipPermissions: !useCanUseTool,
134
+ ...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
135
+ hooks: {},
136
+ executable: 'node',
137
+ ...(existingSessionId && { resume: existingSessionId }),
138
+ ...(processEnv.POLYGRAM_CLAUDE_BIN && {
139
+ pathToClaudeCodeExecutable: processEnv.POLYGRAM_CLAUDE_BIN,
140
+ }),
141
+ };
142
+
143
+ // agent-loader precedence: topicConfig > chatConfig > agent > defaults.
144
+ const composed = agentLoader.composeSdkOptions(
145
+ {
146
+ model: chatConfig.model,
147
+ effort: chatConfig.effort,
148
+ cwd: chatConfig.cwd,
149
+ ...(chatConfig.thinking && { thinking: chatConfig.thinking }),
150
+ },
151
+ agentBundle,
152
+ baseOpts,
153
+ topicConfig,
154
+ );
155
+
156
+ // rc.48: keep permissionMode + allowDangerouslySkipPermissions
157
+ // consistent. If a topic flipped permissionMode away from
158
+ // 'bypassPermissions', also disable the skip flag.
159
+ if (composed.permissionMode && composed.permissionMode !== 'bypassPermissions') {
160
+ composed.allowDangerouslySkipPermissions = false;
161
+ }
162
+
163
+ // Append polygram's display constraints to the systemPrompt —
164
+ // infrastructure-layer hint, not agent business logic.
165
+ composed.systemPrompt = appendDisplayHint(composed.systemPrompt);
166
+ return composed;
167
+ };
168
+ }
169
+
170
+ module.exports = {
171
+ createBuildSdkOptions,
172
+ // Exposed for tests + adjacent extractions that need the same env
173
+ // discipline (e.g. lib/sdk/callbacks.js when it ships).
174
+ filterEnv,
175
+ CHILD_ENV_ALLOWLIST,
176
+ CHILD_ENV_PREFIXES,
177
+ };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Factory for the SDK pm's lifecycle callbacks.
3
+ *
4
+ * polygram.js wires this at boot via createSdkCallbacks(deps); the
5
+ * returned object is spread into ProcessManagerSdk's constructor as
6
+ * `{ onInit, onClose, onStreamChunk, onToolUse,
7
+ * onAssistantMessageStart, onAutonomousAssistantMessage,
8
+ * onCompactBoundary }`.
9
+ *
10
+ * Each callback is a thin glue layer: pm-sdk emits a typed event,
11
+ * polygram's callback decides what to persist (db / events) and
12
+ * what to surface to the user (telegram).
13
+ *
14
+ * Why factory: callbacks need polygram-runtime context (db, config,
15
+ * bot, BOT_NAME, tg, logEvent, dbWrite, classifyToolName, announce,
16
+ * shouldAnnounce, contextHintShown, extractAssistantText, getChatIdFromKey,
17
+ * getThreadIdFromKey). Closing over them at boot keeps each callback's
18
+ * runtime signature compatible with pm-sdk's contract.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ function createSdkCallbacks({
24
+ db,
25
+ dbWrite,
26
+ config,
27
+ bot,
28
+ botName,
29
+ tg,
30
+ logEvent,
31
+ classifyToolName,
32
+ announce,
33
+ shouldAnnounce,
34
+ contextHintShown,
35
+ extractAssistantText,
36
+ getChatIdFromKey,
37
+ getThreadIdFromKey,
38
+ logger = console,
39
+ } = {}) {
40
+ return {
41
+ onInit: (sessionKey, event, entry) => {
42
+ dbWrite(() => db.upsertSession({
43
+ session_key: sessionKey,
44
+ chat_id: entry.chatId,
45
+ thread_id: entry.threadId,
46
+ claude_session_id: event.session_id,
47
+ agent: config.chats[entry.chatId]?.agent || null,
48
+ cwd: config.chats[entry.chatId]?.cwd || null,
49
+ model: config.chats[entry.chatId]?.model || null,
50
+ effort: config.chats[entry.chatId]?.effort || null,
51
+ }), `upsert session ${sessionKey}`);
52
+ },
53
+
54
+ onClose: (sessionKey, code, entry) => {
55
+ logger.log?.(`[${entry.label}] Process exited (code ${code})`);
56
+ logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code });
57
+ },
58
+
59
+ onStreamChunk: (sessionKey, partial, entry) => {
60
+ // Route to the head pending's per-turn streamer. In the
61
+ // concurrent-pending model, only the HEAD is the turn Claude
62
+ // is actively emitting events for.
63
+ const head = entry.pendingQueue?.[0];
64
+ const s = head?.context?.streamer;
65
+ if (s) s.onChunk(partial).catch(() => {});
66
+ // Heartbeat the reactor so long text generation doesn't trip
67
+ // the 10s STALL → 🥱 / 30s TIMEOUT → 😨 promotion.
68
+ const r = head?.context?.reactor;
69
+ if (r && typeof r.heartbeat === 'function') r.heartbeat();
70
+ },
71
+
72
+ onToolUse: (sessionKey, toolName, entry) => {
73
+ const head = entry.pendingQueue?.[0];
74
+ const r = head?.context?.reactor;
75
+ if (r) r.setState(classifyToolName(toolName));
76
+ // Subagent announce: when Claude uses Task to spawn a subagent,
77
+ // post a brief informational message. Per-chat 30s debounce
78
+ // prevents announce-storms in tool-heavy turns.
79
+ const chatCfg = config.chats[entry.chatId] || {};
80
+ const optOut = chatCfg.announceSubagents != null
81
+ ? chatCfg.announceSubagents === false
82
+ : config.bot?.announceSubagents === false;
83
+ if (toolName === 'Task' && !optOut) {
84
+ if (shouldAnnounce(entry.chatId)) {
85
+ announce({
86
+ send: (b, method, params, m) => tg(b, method, params, m),
87
+ bot, chatId: entry.chatId,
88
+ threadId: head?.context?.threadId ?? null,
89
+ text: '🤖 Spawning subagent…',
90
+ meta: { botName, source: 'subagent-announce' },
91
+ logger: { error: (m) => logger.error?.(`[${entry.label}] ${m}`) },
92
+ });
93
+ }
94
+ }
95
+ },
96
+
97
+ // Each new top-level assistant message gets its own bubble.
98
+ // When Claude emits text, then tool_use, then more text in a NEW
99
+ // assistant message, the previous bubble's content stays visible
100
+ // as a "thinking out loud" intermediate; the new message starts
101
+ // fresh below.
102
+ onAssistantMessageStart: (sessionKey, entry) => {
103
+ const head = entry.pendingQueue?.[0];
104
+ const s = head?.context?.streamer;
105
+ if (s) s.forceNewMessage();
106
+ // Heartbeat at every assistant-message boundary too. A long
107
+ // thinking phase (effort=high, 30+s before first chunk) doesn't
108
+ // fire onStreamChunk; without this, the freeze timer could
109
+ // expire while the model is "still thinking but about to speak".
110
+ const r = head?.context?.reactor;
111
+ if (r && typeof r.heartbeat === 'function') r.heartbeat();
112
+ },
113
+
114
+ // rc.47: autonomous wakeup forwarding. Fires when an SDK
115
+ // assistant message arrives with no head pending — typical
116
+ // ScheduleWakeup case where the agent self-fires without an
117
+ // inbound user message. Best-effort send: failures are logged
118
+ // but don't propagate.
119
+ onAutonomousAssistantMessage: (sessionKey, msg /* , entry */) => {
120
+ try {
121
+ const text = extractAssistantText(msg);
122
+ if (!text) return;
123
+ const chatId = getChatIdFromKey(sessionKey);
124
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
125
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
126
+ if (!bot) {
127
+ logger.error?.(`[${botName}] autonomous wakeup: bot not ready, dropping ${text.length} chars`);
128
+ return;
129
+ }
130
+ const params = {
131
+ chat_id: chatId,
132
+ text,
133
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
134
+ };
135
+ // Don't await — keep the pm-sdk event loop unblocked.
136
+ tg(bot, 'sendMessage', params,
137
+ { source: 'autonomous-wakeup', botName }).catch((err) => {
138
+ logger.error?.(`[${botName}] autonomous wakeup send failed: ${err.message}`);
139
+ });
140
+ logEvent('autonomous-wakeup-message', {
141
+ chat_id: chatId,
142
+ session_key: sessionKey,
143
+ thread_id: threadIdRaw,
144
+ text_len: text.length,
145
+ });
146
+ } catch (err) {
147
+ logger.error?.(`[${botName}] autonomous wakeup handler: ${err.message}`);
148
+ }
149
+ },
150
+
151
+ // SDK auto-compaction observability. Fires when SDK emits
152
+ // SDKCompactBoundaryMessage. Surfaces a quiet system status note
153
+ // to the chat so the user knows the bot is busy reorganising
154
+ // context. ON by default; set per-chat or per-bot
155
+ // `announceCompact: false` to silence.
156
+ onCompactBoundary: async (sessionKey, msg, entry) => {
157
+ // Clear the contextHint once-per-cycle gate. After compaction,
158
+ // context drops below threshold; if it climbs back up the next
159
+ // cycle should fire a fresh hint.
160
+ contextHintShown.delete(sessionKey);
161
+
162
+ const chatCfg = config.chats[entry.chatId] || {};
163
+ const optOut = chatCfg.announceCompact != null
164
+ ? chatCfg.announceCompact === false
165
+ : config.bot?.announceCompact === false;
166
+ if (optOut) return;
167
+ const threadId = entry.threadId || undefined;
168
+
169
+ // Word the message based on what actually happened. Pre-rc.62
170
+ // every event read as "💭 Catching up…" — but compact_boundary
171
+ // fires AFTER compaction completes, leaving users confused
172
+ // when nothing followed. Now: distinguish manual vs auto and
173
+ // surface the compression ratio.
174
+ const meta = msg?.compact_metadata || {};
175
+ const trigger = meta.trigger; // 'manual' | 'auto'
176
+ const preTokens = meta.pre_tokens;
177
+ const postTokens = meta.post_tokens;
178
+ const durationMs = meta.duration_ms;
179
+ const fmtTok = (n) => {
180
+ if (n == null) return null;
181
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
182
+ return String(n);
183
+ };
184
+ const ratio = (preTokens && postTokens)
185
+ ? `${fmtTok(preTokens)} → ${fmtTok(postTokens)}` : null;
186
+ const duration = durationMs ? `${(durationMs / 1000).toFixed(1)}s` : null;
187
+ const stats = [ratio, duration].filter(Boolean).join(', ');
188
+
189
+ let text;
190
+ if (trigger === 'manual') {
191
+ text = stats
192
+ ? `✅ Compacted (${stats}). Ready for your next message.`
193
+ : `✅ Compacted. Ready for your next message.`;
194
+ } else {
195
+ text = stats
196
+ ? `💭 Auto-compacted (${stats}). Continuing…`
197
+ : `💭 Auto-compacted. Continuing…`;
198
+ }
199
+
200
+ try {
201
+ await tg(bot, 'sendMessage', {
202
+ chat_id: entry.chatId,
203
+ text,
204
+ ...(threadId ? { message_thread_id: threadId } : {}),
205
+ }, { source: 'compact-boundary', botName });
206
+ } catch (err) {
207
+ logger.error?.(`[${entry.label}] compact-boundary post: ${err.message}`);
208
+ }
209
+ },
210
+ };
211
+ }
212
+
213
+ module.exports = { createSdkCallbacks };
@@ -1,34 +1,29 @@
1
1
  /**
2
- * SDK-backed ProcessManager — `@anthropic-ai/claude-agent-sdk` Query
3
- * objects in place of `child_process.spawn('claude', ...)` and
4
- * stream-json line parsing.
2
+ * ProcessManager — `@anthropic-ai/claude-agent-sdk` Query objects.
5
3
  *
6
- * Public API matches `lib/process-manager.js` (the CLI version) so
7
- * polygram.js can swap implementations via env flag (POLYGRAM_USE_SDK=1).
8
- * Phase 4 deletes the CLI version after Phase 5 soak proves the SDK
9
- * version stable.
10
- *
11
- * Per v4 plan §6.5.7 (buildSdkOptions), §6.6 (ship-breaker
12
- * mitigations), Phase 0 spike findings (docs/0.8.0-phase0-findings.md).
4
+ * The canonical pm impl post-0.9.0. Pre-0.9.0 polygram ran a dual-pm
5
+ * router (CLI subprocess pm + this SDK pm) behind env flags; the CLI
6
+ * variant was deleted with the rest of the migration safety net once
7
+ * both bots had soaked on SDK pm. See `lib/pm-interface.js` for the
8
+ * canonical contract and `docs/0.8.0-sdk-migration-plan.md` for the
9
+ * migration history.
13
10
  *
14
11
  * Architecture:
15
- * - One Query per active sessionKey, held for the chat lifetime
16
- * (Phase 0 gate 1 PASS — long-lived input AsyncIterable works).
12
+ * - One Query per active sessionKey, held for the chat lifetime.
17
13
  * - inputController is the writable end of an
18
14
  * AsyncIterable<SDKUserMessage>; pm.send() pushes user messages
19
15
  * onto it; the SDK's streamInput() consumes from the other end.
20
16
  * - iteratePromise is the for-await loop over the Query's
21
17
  * AsyncGenerator output. Wrapped in try/catch (D7 commitment).
22
18
  * - pendingQueue maps N user messages → N SDKResultMessage events
23
- * in FIFO order (same as CLI version's stream-json model).
24
- * - LRU eviction across the procs Map (cap = DEFAULT_CAP) — same
25
- * behaviour as CLI version, with Query.close() instead of
26
- * proc.kill().
19
+ * in FIFO order.
20
+ * - LRU eviction across the procs Map (cap = DEFAULT_CAP) via
21
+ * Query.close().
27
22
  *
28
- * Decisions encoded:
23
+ * Decisions encoded (v4 plan):
29
24
  * D1 streaming: subscribe to SDKAssistantMessage (cumulative)
30
25
  * D2 long-lived Query per chat
31
- * D3 /effort via applyFlagSettings DELETE requestRespawn
26
+ * D3 /effort via applyFlagSettings (no respawn)
32
27
  * D5 Options.env SHADOW — buildSdkOptions enumerates everything
33
28
  * D6 Query.close() is fast — 100ms shutdown timeout safe
34
29
  * D7 killChat Promise.allSettled with 5s per-Query timeout
@@ -39,7 +34,7 @@
39
34
  'use strict';
40
35
 
41
36
  const { query } = require('@anthropic-ai/claude-agent-sdk');
42
- const { isTransientHttpError } = require('./error-classify');
37
+ const { isTransientHttpError } = require('../error/classify');
43
38
 
44
39
  const DEFAULT_CAP = 10;
45
40
  const DEFAULT_QUEUE_CAP = 50;
@@ -169,14 +164,11 @@ function makeInputController({ queueCap = DEFAULT_QUEUE_CAP } = {}) {
169
164
  // ─── ProcessManager ────────────────────────────────────────────────
170
165
 
171
166
  /**
172
- * @anthropic-ai/claude-agent-sdk-backed ProcessManager. Implements
173
- * the canonical Pm interface (`lib/pm-interface.js`). Optional
174
- * methods exposed: `steer`, `setModel`, `applyFlagSettings`,
175
- * `setPermissionMode`, `drainQueue`, `interrupt`, `resetSession`.
176
- *
177
- * Optional methods NOT implemented (CLI pm has this): `requestRespawn`.
178
- * For mid-session config changes use `applyFlagSettings` (effort)
179
- * or `setModel`.
167
+ * @anthropic-ai/claude-agent-sdk-backed ProcessManager. The canonical
168
+ * pm impl post-0.9.0. Implements the Pm interface
169
+ * (`lib/pm-interface.js`); optional methods exposed: `steer`,
170
+ * `setModel`, `applyFlagSettings`, `setPermissionMode`, `drainQueue`,
171
+ * `interrupt`, `resetSession`.
180
172
  *
181
173
  * @implements {import('./pm-interface.js').Pm}
182
174
  */
@@ -333,7 +325,6 @@ class ProcessManagerSdk {
333
325
  inFlight: false,
334
326
  lastUsedTs: Date.now(),
335
327
  iteratePromise: null,
336
- needsRespawn: null,
337
328
  };
338
329
 
339
330
  inputController.onDrop((dropped) => {
@@ -640,9 +631,6 @@ class ProcessManagerSdk {
640
631
  if (!entry || entry.closed) {
641
632
  return reject(new Error('No process for session'));
642
633
  }
643
- if (entry.needsRespawn) {
644
- return reject(new Error(`Session awaiting respawn (${entry.needsRespawn})`));
645
- }
646
634
 
647
635
  entry.lastUsedTs = Date.now();
648
636
 
@@ -26,8 +26,8 @@ const {
26
26
  isMessageNotModifiedError,
27
27
  isRateLimitError,
28
28
  getRetryAfterMs,
29
- } = require('./telegram-format');
30
- const { isSafeToRetry, redactBotToken } = require('./net-errors');
29
+ } = require('./format');
30
+ const { isSafeToRetry, redactBotToken } = require('../error/net');
31
31
 
32
32
  // Topic deletion race: a user can delete a forum topic while a turn is in
33
33
  // flight, turning a valid `message_thread_id` into a 404. Telegram's error
@@ -97,22 +97,8 @@ function appendDisplayHint(systemPromptOpt, hint = POLYGRAM_DISPLAY_HINT) {
97
97
  return systemPromptOpt;
98
98
  }
99
99
 
100
- /**
101
- * For the CLI pm (`claude -p ...`), the equivalent of an appended
102
- * system prompt is the `--append-system-prompt <text>` flag. This
103
- * helper returns the args the CLI pm should add to its argv.
104
- *
105
- * @param {string} [hint] — override (tests)
106
- * @returns {string[]} — argv tail, e.g. ['--append-system-prompt', '...']
107
- */
108
- function appendDisplayHintCliArgs(hint = POLYGRAM_DISPLAY_HINT) {
109
- if (!hint) return [];
110
- return ['--append-system-prompt', hint];
111
- }
112
-
113
100
  module.exports = {
114
101
  POLYGRAM_DISPLAY_HINT,
115
102
  TELEGRAM_TABLE_WIDTH_BUDGET,
116
103
  appendDisplayHint,
117
- appendDisplayHintCliArgs,
118
104
  };
@@ -211,10 +211,10 @@ function createStreamer({
211
211
  }
212
212
 
213
213
  // Reset bubble state so the next onChunk creates a NEW message.
214
- // Used by `onAssistantMessageStart` in process-manager.js when Claude
215
- // emits a new top-level assistant message mid-turn (post tool-result):
216
- // we want it in its own bubble below the previous one, not appended
217
- // via editMessageText to the original.
214
+ // Used by `onAssistantMessageStart` in lib/process-manager-sdk.js
215
+ // when Claude emits a new top-level assistant message mid-turn
216
+ // (post tool-result): we want it in its own bubble below the
217
+ // previous one, not appended via editMessageText to the original.
218
218
  //
219
219
  // rc.44: by default, the previous bubble is PRESERVED (not archived
220
220
  // for end-of-turn deletion). Intermediate text segments are
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0",
3
+ "version": "0.9.0-rc.2",
4
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",
5
+ "main": "lib/ipc/client.js",
6
6
  "bin": {
7
7
  "polygram": "polygram.js",
8
8
  "polygram-split-db": "scripts/split-db.js",
@@ -11,7 +11,6 @@
11
11
  },
12
12
  "files": [
13
13
  "polygram.js",
14
- "bin/",
15
14
  "lib/",
16
15
  "migrations/",
17
16
  "scripts/split-db.js",