polygram 0.8.0-rc.13 → 0.8.0-rc.15

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.8.0-rc.13",
4
+ "version": "0.8.0-rc.15",
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",
@@ -69,6 +69,68 @@ function stripFrontmatter(content) {
69
69
  return content.slice(end + 5);
70
70
  }
71
71
 
72
+ // Recursively expand Claude Code @<file> import directives. A line
73
+ // starting with `@<path>` is replaced with the file's contents
74
+ // (frontmatter stripped, imports recursively expanded). Paths
75
+ // resolve relative to the importing file's directory FIRST, then
76
+ // fall back to cwd. Cycle detection via visited Set.
77
+ //
78
+ // rc.15: pre-rc.15 the literal "@_shumabit-base.md" reached the
79
+ // model verbatim because polygram's loader didn't process imports.
80
+ // Symptom: agent appeared loaded but the system prompt was
81
+ // effectively empty (just an unresolved import directive).
82
+ function expandImports(content, importingFile, cwd, visited, logger) {
83
+ if (typeof content !== 'string' || !content) return content;
84
+ const lines = content.split('\n');
85
+ const out = [];
86
+ for (const line of lines) {
87
+ const m = /^@(\S+)\s*$/.exec(line);
88
+ if (!m) {
89
+ out.push(line);
90
+ continue;
91
+ }
92
+ const ref = m[1];
93
+ const importingDir = path.dirname(importingFile);
94
+ // Resolution order: relative to importing file's dir; relative
95
+ // to cwd; absolute path as-is.
96
+ const candidates = [];
97
+ if (path.isAbsolute(ref)) {
98
+ candidates.push(ref);
99
+ } else {
100
+ candidates.push(path.join(importingDir, ref));
101
+ if (cwd) candidates.push(path.join(cwd, ref));
102
+ }
103
+ let resolved = null;
104
+ for (const c of candidates) {
105
+ if (fs.existsSync(c)) { resolved = c; break; }
106
+ }
107
+ if (!resolved) {
108
+ logger?.warn?.(`[agent-loader] @-import not found: ${ref} (in ${importingFile})`);
109
+ out.push(line);
110
+ continue;
111
+ }
112
+ if (visited.has(resolved)) {
113
+ logger?.warn?.(`[agent-loader] @-import cycle: ${resolved}`);
114
+ continue;
115
+ }
116
+ visited.add(resolved);
117
+ let imported = '';
118
+ try {
119
+ imported = fs.readFileSync(resolved, 'utf8');
120
+ } catch (err) {
121
+ logger?.error?.(`[agent-loader] reading @-import ${resolved}: ${err.message}`);
122
+ out.push(line);
123
+ continue;
124
+ }
125
+ // Strip frontmatter from imported file (same convention as
126
+ // top-level agent file) and recursively expand its imports.
127
+ imported = stripFrontmatter(imported);
128
+ imported = expandImports(imported, resolved, cwd, visited, logger);
129
+ out.push(imported);
130
+ }
131
+ return out.join('\n');
132
+ }
133
+
72
134
  // Parse a tiny subset of YAML frontmatter (key: value lines).
73
135
  function parseFrontmatter(content) {
74
136
  if (typeof content !== 'string' || !content.startsWith('---\n')) return {};
@@ -125,11 +187,14 @@ function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger =
125
187
 
126
188
  if (loc.kind === 'file') {
127
189
  // Claude Code single-file format. Read whole file, parse and
128
- // strip frontmatter, body becomes systemPrompt.
190
+ // strip frontmatter, body becomes systemPrompt. Then expand
191
+ // any @<file> import directives recursively (rc.15).
129
192
  try {
130
193
  const raw = fs.readFileSync(loc.path, 'utf8');
131
194
  frontmatter = parseFrontmatter(raw);
132
- systemPrompt = stripFrontmatter(raw);
195
+ const stripped = stripFrontmatter(raw);
196
+ const visited = new Set([loc.path]);
197
+ systemPrompt = expandImports(stripped, loc.path, cwd, visited, logger);
133
198
  } catch (err) {
134
199
  logger.error?.('[agent-loader] reading ' + loc.path + ': ' + err.message);
135
200
  }
@@ -139,7 +204,11 @@ function loadAgent(agentName, { homeDir = process.env.HOME, cwd = null, logger =
139
204
  const p = path.join(loc.dir, fname);
140
205
  if (fs.existsSync(p)) {
141
206
  try {
142
- systemPrompt = fs.readFileSync(p, 'utf8');
207
+ const raw = fs.readFileSync(p, 'utf8');
208
+ // Expand @-imports for directory-layout agents too —
209
+ // their content might also reference shared base files.
210
+ const visited = new Set([p]);
211
+ systemPrompt = expandImports(raw, p, cwd, visited, logger);
143
212
  agentPath = p;
144
213
  break;
145
214
  } catch (err) {
@@ -250,5 +319,6 @@ module.exports = {
250
319
  _resolveAgentLocation: resolveAgentLocation,
251
320
  _stripFrontmatter: stripFrontmatter,
252
321
  _parseFrontmatter: parseFrontmatter,
322
+ _expandImports: expandImports,
253
323
  _cache: cache,
254
324
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.13",
3
+ "version": "0.8.0-rc.15",
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
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -707,6 +707,83 @@ let pm = null; // ProcessManager, created in main()
707
707
  // the assistant was mid-tool-use).
708
708
  const autosteerBuffer = createAutosteerBuffer();
709
709
 
710
+ // 0.8.0-rc.14: track msg_ids that received the AUTOSTEERED ✍ ack, per
711
+ // session, so we can clear those reactions when the in-flight turn
712
+ // finishes. Pre-rc.14 the ✍ persisted forever because each autosteer
713
+ // invocation runs in its OWN handleMessage scope (own reactor), and
714
+ // the TRIGGER message's reactor.clear() at turn-end couldn't reach
715
+ // across to other messages. Without this map, users see ✍ stuck on
716
+ // every follow-up and don't know whether the bot incorporated them.
717
+ const autosteeredMsgRefs = new Map(); // sessionKey → [{chatId, msgId}]
718
+
719
+ async function clearAutosteeredReactions(sessionKey) {
720
+ const list = autosteeredMsgRefs.get(sessionKey);
721
+ if (!list || list.length === 0) return;
722
+ autosteeredMsgRefs.delete(sessionKey);
723
+ if (!bot) return;
724
+ for (const { chatId: cid, msgId } of list) {
725
+ try {
726
+ await tg(bot, 'setMessageReaction', {
727
+ chat_id: cid, message_id: msgId, reaction: [],
728
+ }, { source: 'autosteer-clear', botName: BOT_NAME });
729
+ } catch (err) {
730
+ // Ack-clear failures are silent — the ✍ stays on screen
731
+ // but doesn't block the in-flight turn's reply UX.
732
+ }
733
+ }
734
+ }
735
+
736
+ // 0.8.0-rc.14: tool-less-turn drain. PostToolBatch hook only fires
737
+ // on tool boundaries — when a Query produces a turn that uses ZERO
738
+ // tools (just a text answer), the autosteerBuffer never gets
739
+ // drained and any user follow-ups buffered during that turn
740
+ // disappear silently into the next tool-using turn (or never, if
741
+ // the chat is purely conversational).
742
+ //
743
+ // Workaround: at every success exit in handleMessage, check if
744
+ // the buffer still has items and dispatch them as a synthetic
745
+ // next turn via pm.send. The bot replies to the drained content
746
+ // in a fresh turn — UX-wise the user sees TWO replies (one to
747
+ // the trigger message, one to "B + C") which is the same as if
748
+ // they'd sent the messages without autosteer. Better than losing.
749
+ async function drainStaleAutosteerBuffer(sessionKey, chatId, threadId) {
750
+ const stale = autosteerBuffer.drain(sessionKey);
751
+ if (stale.length === 0) return;
752
+ const followUpPrompt = stale.join('\n\n');
753
+ logEvent('autosteer-stale-drain', {
754
+ chat_id: chatId,
755
+ session_key: sessionKey,
756
+ message_count: stale.length,
757
+ text_len: followUpPrompt.length,
758
+ });
759
+ // Dispatch as a fresh pm.send via setImmediate so we don't
760
+ // block the current handleMessage's success-path return. No
761
+ // streamer / reactor — the synthetic turn gets a plain bubble
762
+ // reply (no streaming preview, no progress reactions). User
763
+ // already saw their ✍ ack on the original follow-up; this
764
+ // turn's existence is the substantive response.
765
+ setImmediate(async () => {
766
+ try {
767
+ const chatConfig = config.chats[chatId];
768
+ if (!chatConfig) return;
769
+ const result = await sendToProcess(sessionKey, followUpPrompt, {
770
+ streamer: null, reactor: null, sourceMsgId: null,
771
+ });
772
+ if (result?.text && bot) {
773
+ await tg(bot, 'sendMessage', {
774
+ chat_id: chatId,
775
+ text: result.text,
776
+ ...(threadId ? { message_thread_id: threadId } : {}),
777
+ }, { source: 'autosteer-stale-reply', botName: BOT_NAME }).catch((err) => {
778
+ console.error(`[${BOT_NAME}] autosteer-stale-reply send: ${err.message}`);
779
+ });
780
+ }
781
+ } catch (err) {
782
+ console.error(`[${BOT_NAME}] autosteer-stale-drain dispatch: ${err.message}`);
783
+ }
784
+ });
785
+ }
786
+
710
787
  function spawnClaude(sessionKey, ctx) {
711
788
  const { chatConfig, existingSessionId, label, chatId } = ctx;
712
789
  // 0.7.3: Claude Code's Chrome-extension integration (browser
@@ -2464,6 +2541,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2464
2541
  if (entry?.inFlight) {
2465
2542
  const ok = autosteerBuffer.append(sessionKey, prompt);
2466
2543
  if (ok) {
2544
+ // Track this msg_id so the in-flight turn's success / abort
2545
+ // / error path can clear the ✍ reaction at turn-end.
2546
+ const refs = autosteeredMsgRefs.get(sessionKey) || [];
2547
+ refs.push({ chatId, msgId: msg.message_id });
2548
+ autosteeredMsgRefs.set(sessionKey, refs);
2467
2549
  logEvent('autosteer', {
2468
2550
  chat_id: chatId, msg_id: msg.message_id,
2469
2551
  text_len: prompt?.length ?? 0,
@@ -2567,6 +2649,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2567
2649
  // every answered message is chat noise (plus triggers reaction
2568
2650
  // notifications for other group members).
2569
2651
  reactor.clear().catch(() => {});
2652
+ // 0.8.0-rc.14: also clear ✍ reactions on every follow-up
2653
+ // message that was autosteered into THIS turn — they live in
2654
+ // separate handleMessage scopes whose reactors are already GC'd.
2655
+ clearAutosteeredReactions(sessionKey).catch(() => {});
2656
+ // rc.14: tool-less-turn drain. PostToolBatch hook fires only
2657
+ // on tool boundaries; if this turn produced ZERO tools, the
2658
+ // hook never fired and the autosteer buffer still has the
2659
+ // user's follow-ups. Dispatch them as a synthetic next turn
2660
+ // so the bot at least addresses them (better than losing).
2661
+ drainStaleAutosteerBuffer(sessionKey, chatId, threadId).catch(() => {});
2570
2662
 
2571
2663
  // 0.8.0 Phase 2 step 4: 85%-context-full live hint. After a
2572
2664
  // successful turn, peek at SDK's getContextUsage(); if past
@@ -2623,6 +2715,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2623
2715
  && (result.metrics?.numAssistantMessages ?? 0) > 0;
2624
2716
  if (toolOnlyTurn) {
2625
2717
  await reactor.clear().catch(() => {});
2718
+ clearAutosteeredReactions(sessionKey).catch(() => {});
2719
+ // Tool-only turns DID fire PostToolBatch — buffer was drained
2720
+ // — but autosteers received AFTER the last tool-result still
2721
+ // wouldn't be merged. Defensive drain here too.
2722
+ drainStaleAutosteerBuffer(sessionKey, chatId, threadId).catch(() => {});
2626
2723
  logEvent('tool-only-completion', {
2627
2724
  chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
2628
2725
  num_tool_uses: result.metrics?.numToolUses,
@@ -2793,6 +2890,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2793
2890
  // the visible reaction. We DON'T set 🤯/😨 (those are for
2794
2891
  // unexpected errors); the user just wants their stop honored.
2795
2892
  await reactor.clear().catch(() => {});
2893
+ // rc.14: clear ✍ on autosteered followups too (per-msg
2894
+ // reactors are already GC'd in their own handleMessage scopes).
2895
+ await clearAutosteeredReactions(sessionKey).catch(() => {});
2796
2896
  } else {
2797
2897
  await streamer.finalize('', { errorSuffix: 'stream interrupted' }).catch(() => {});
2798
2898
  if (/wall-clock ceiling|idle with no Claude activity/i.test(err?.message || '')) {
@@ -3007,6 +3107,9 @@ function createBot(token) {
3007
3107
  // (stale steer leak across abort boundary, which is what the
3008
3108
  // user just asked us not to do).
3009
3109
  autosteerBuffer.clear(sessionKey);
3110
+ // rc.14: also clear ✍ reactions on already-autosteered
3111
+ // messages from this aborted turn — they're now dead context.
3112
+ clearAutosteeredReactions(sessionKey).catch(() => {});
3010
3113
  logEvent('abort-requested', {
3011
3114
  chat_id: chatId, user_id: msg.from?.id || null,
3012
3115
  had_active: hadActive,