polygram 0.37.2 → 0.38.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.37.2",
4
+ "version": "0.38.0",
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",
@@ -18,10 +18,10 @@
18
18
 
19
19
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
20
20
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
21
- const CODEX_MODEL_OPTIONS = new Set([
22
- 'gpt-5.6-sol',
23
- 'gpt-5.6-terra',
24
- 'gpt-5.6-luna',
21
+ const CODEX_MODEL_OPTIONS = Object.freeze([
22
+ Object.freeze({ model: 'gpt-5.6-sol', label: 'Sol' }),
23
+ Object.freeze({ model: 'gpt-5.6-terra', label: 'Terra' }),
24
+ Object.freeze({ model: 'gpt-5.6-luna', label: 'Luna' }),
25
25
  ]);
26
26
  // Which runtimes the card offers. The sdk backend stays fully supported in
27
27
  // code and config files — it is per-token and nobody picks it from a chat, so
@@ -63,20 +63,30 @@ function getCodexCatalogModels(runtimeView) {
63
63
  if (!isCodexRuntimeView(runtimeView) || !Array.isArray(runtimeView.models)) {
64
64
  return [];
65
65
  }
66
- const seen = new Set();
67
- return runtimeView.models.filter((entry) => {
68
- if (
69
- !entry
70
- || typeof entry.model !== 'string'
71
- || entry.model.length === 0
72
- || !CODEX_MODEL_OPTIONS.has(entry.model)
73
- || seen.has(entry.model)
74
- ) {
75
- return false;
66
+ const catalogByModel = new Map();
67
+ for (const entry of runtimeView.models) {
68
+ if (entry && typeof entry.model === 'string' && entry.model.length > 0) {
69
+ if (!catalogByModel.has(entry.model)) {
70
+ catalogByModel.set(entry.model, entry);
71
+ }
76
72
  }
77
- seen.add(entry.model);
78
- return true;
79
- });
73
+ }
74
+ return CODEX_MODEL_OPTIONS.flatMap(({ model }) => (
75
+ catalogByModel.has(model) ? [catalogByModel.get(model)] : []
76
+ ));
77
+ }
78
+
79
+ function codexModelLabel(model) {
80
+ return CODEX_MODEL_OPTIONS.find((entry) => entry.model === model)?.label
81
+ ?? model;
82
+ }
83
+
84
+ function hasCodexCatalogModels(runtimeView) {
85
+ return isCodexRuntimeView(runtimeView)
86
+ && Array.isArray(runtimeView.models)
87
+ && runtimeView.models.some(
88
+ (entry) => typeof entry?.model === 'string' && entry.model.length > 0,
89
+ );
80
90
  }
81
91
 
82
92
  function getCodexCatalogEfforts(runtimeView, model = runtimeView?.model) {
@@ -262,10 +272,15 @@ function buildConfigKeyboard(
262
272
  }
263
273
  if ((show === 'model' || show === 'all') && modelOptions.length > 0) {
264
274
  rows.push(modelOptions.map((entry) => {
265
- const label = (
275
+ let label = entry.model;
276
+ if (codex) {
277
+ label = codexModelLabel(entry.model);
278
+ } else if (
266
279
  typeof entry.displayName === 'string'
267
280
  && entry.displayName.length > 0
268
- ) ? entry.displayName : entry.model;
281
+ ) {
282
+ label = entry.displayName;
283
+ }
269
284
  return {
270
285
  text: entry.model === model ? `✓ ${label}` : label,
271
286
  callback_data: `cfg:model:${entry.model}`,
@@ -325,33 +340,30 @@ function createFormatConfigInfoText({
325
340
  ? codexModels.length > 0 ? [
326
341
  '',
327
342
  '**Models**',
328
- ...codexModels.map((entry) => {
329
- const displayName = (
330
- typeof entry.displayName === 'string'
331
- && entry.displayName.length > 0
332
- && entry.displayName !== entry.model
333
- ) ? ` — ${entry.displayName}` : '';
334
- return `• **${entry.model}**${displayName}`;
335
- }),
343
+ ...codexModels.map(
344
+ (entry) => `• **${codexModelLabel(entry.model)}** — ${entry.model}`,
345
+ ),
336
346
  ].join('\n') : [
337
347
  '',
338
348
  '**Models**',
339
- 'Models are unavailable until Codex preflight succeeds.',
349
+ hasCodexCatalogModels(runtimeView)
350
+ ? 'Sol, Terra, and Luna are unavailable for this account.'
351
+ : 'Models are unavailable until Codex preflight succeeds.',
340
352
  ].join('\n')
341
353
  : [
342
354
  '',
343
355
  '**Models**',
344
- '🧠 **opus** — deep analysis, code refactor, multi-source reconciliation. ~1.7× sonnet cost.',
345
- '🤖 **sonnet** — default. Most ops, code review, document summary.',
346
- '⚡ **haiku** — quick simple tasks, classification, lookup.',
356
+ '🧠 **opus** — deepest reasoning',
357
+ '🤖 **sonnet** — balanced default',
358
+ '⚡ **haiku** — fastest',
347
359
  ].join('\n');
348
360
 
349
361
  const effortHelp = codex
350
362
  ? codexEfforts.length > 0 ? [
351
363
  '',
352
- `**Effort** — authenticated options for ${codexModel}:`,
353
- ...codexEfforts.map((value) => `• **${value}**`),
354
- 'Model and effort changes apply to the next turn; an active turn is unchanged.',
364
+ `**Effort for ${codexModelLabel(codexModel)}**`,
365
+ codexEfforts.map((value) => `**${value}**`).join(' · '),
366
+ 'Changes apply on the next turn; an active turn is unchanged.',
355
367
  ].join('\n') : [
356
368
  '',
357
369
  '**Effort**',
@@ -359,11 +371,9 @@ function createFormatConfigInfoText({
359
371
  ].join('\n')
360
372
  : [
361
373
  '',
362
- '**Effort** — ceiling on how much Claude can think. Simple questions get fast replies; hard ones spend more tokens. Safe to set higher — Claude scales down automatically when it doesn\'t need to think.',
363
- '• **low** — fast replies, minimum reasoning. Casual chat, simple lookups.',
364
- '• **medium** — balanced default. Fits most use cases.',
365
- '• **high** — multi-step tasks. Audit, debug, multi-source analysis.',
366
- '• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
374
+ '**Effort**',
375
+ '**low** · **medium** · **high** · **xhigh** · **max**',
376
+ 'Higher values allow more reasoning when needed.',
367
377
  ].join('\n');
368
378
 
369
379
  // The card is its buttons. Everything the body used to state — model,
@@ -373,11 +383,10 @@ function createFormatConfigInfoText({
373
383
  // button to live on.
374
384
  let body = '⚙️ Settings';
375
385
  if (codex) {
376
- body += '\n\nNative Codex beta: command network and web search are disabled. '
377
- + 'Product MCP tools and interactive approvals are unavailable. '
378
- + 'Detached/background servers are unsupported and may survive hard runtime loss. '
379
- + 'Native goals are disabled for Polygram-managed Codex sessions until '
380
- + 'native goal support is implemented.';
386
+ body += '\n\n_Codex beta: no command network, web search, product MCP tools, '
387
+ + 'or interactive approvals. Detached/background servers are unsupported '
388
+ + 'and may survive hard runtime loss. '
389
+ + 'Native goals are currently disabled._';
381
390
  }
382
391
  if (show === 'model' || show === 'all') body += '\n' + modelHelp;
383
392
  if (show === 'effort' || show === 'all') body += '\n' + effortHelp;
@@ -90,6 +90,7 @@ const richSection = (inlineMedia) => [
90
90
  '- A numbered sequence (steps, ranked facts, top-N) → a real ordered list (`1.` at line start, continuation lines indented under their item), not bold pseudo-items like `**1. First thing.**` on their own paragraphs. A real list renders with native numbering and hanging indentation; the bold fake renders as flat paragraphs that merely start with a digit.',
91
91
  '- A list of items each with a yes/no/pending state (approve this? confirm that?) → a real task list (`- [ ] item`), not `• item` with an emoji glued on.',
92
92
  '- A comparison or key/value summary → a real markdown table.',
93
+ '- Rows and columns you are laying out yourself → a real markdown table, never hand-aligned columns inside a ``` fence. A fence is not a table: its columns are just whitespace you typed, so the client cannot re-lay-out them to fit and on a narrow screen the rows break mid-row and the alignment collapses into noise. Keep fences for code, logs, and command output you are quoting verbatim — including output that is already table-shaped, which you reproduce exactly rather than retype as a markdown table.',
93
94
  '- A quoted aside, caveat, or callout you want visually set apart → a real blockquote (`> like this`), not a bold prefix.',
94
95
  '- A visual break between distinct sections of a long reply → a real divider (`---` on its own line), the same way you already reach for it, but now it renders as a divider instead of literal dashes.',
95
96
  '- Verbose supporting detail (a diff, a log, raw output) you want available but not front-and-center → `<details><summary>Label</summary>...</details>`.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.37.2",
3
+ "version": "0.38.0",
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
@@ -482,6 +482,13 @@ let richEditMessageText;
482
482
  // classification, but it never throws: its caller holds the only copy of the
483
483
  // reply and owns the plain fallback.
484
484
  let richSendMessage;
485
+ // The rich half of the reply-tool delivery chain, hoisted so the interactive
486
+ // turn path can reuse the same instance rather than configure a second one.
487
+ // Two instances would be two capability latches, two styling verdicts and two
488
+ // sets of ceilings to keep in step; one is the only way they cannot drift.
489
+ // Null until main() wires it — callers fall back to plain, which is what a
490
+ // chat without rich gets anyway.
491
+ let makeRichDeliverText = null;
485
492
  let richMediaFileIdCache;
486
493
 
487
494
  // 0.15 secret redaction (agent-flagged path): the agent marks a secret it saw
@@ -1554,12 +1561,15 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1554
1561
  // path — same contract for "not opted in", "capability known
1555
1562
  // unsupported", and "content doesn't need it" (needsRichRendering
1556
1563
  // inside toTelegramRichBlocks handles the last one).
1557
- // Rich delivery is limited to this interactive streamer instance.
1558
- // A reply that never crosses minChars (stays idle → delivered via
1559
- // deliverReplies) and any non-streaming send path — autonomous-wakeup/
1564
+ // This callback covers the streamed bubble only. A turn that ends with
1565
+ // text instead of streaming it renders rich too, through the reply tool's
1566
+ // own factory reused at the non-streamed branch below — one gate, one
1567
+ // sender, one latch, so the same markdown cannot format two ways
1568
+ // depending on how the answer arrived.
1569
+ // Still plain: the non-streaming send paths — autonomous-wakeup/
1560
1570
  // auto-resume/autosteer via lib/telegram/process-agent-reply.js, cron/
1561
- // IPC sends — never render rich even when richText is on and the
1562
- // content qualifies. Those delivery paths intentionally remain plain.
1571
+ // IPC sends. Those render plain even when richText is on and the content
1572
+ // qualifies.
1563
1573
  // Re-read both controls for every flush so a config change or a
1564
1574
  // capability result applies to the turn already in progress.
1565
1575
  // Typed media resolution for rich replies. Same
@@ -2907,27 +2917,78 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2907
2917
  console.error(`[${label}] sendSticker failed: ${err.message}`);
2908
2918
  });
2909
2919
  } else if (parsed.text) {
2910
- // 0.7.0: use markdown-aware chunker + deliverReplies primitive.
2911
- // The old chunkText was newline/byte-only; chunkMarkdownText also
2912
- // respects code-fence boundaries (closes + reopens across chunks).
2913
- // Short replies never went live-rich, so image markdown from a
2914
- // rich-enabled chat degrades to caption text here too — never a
2915
- // raw local path in the bubble.
2916
- const shortText = resolveRichTextEnabled(config, chatId, threadId)
2917
- ? stripMediaMarkdown(parsed.text)
2918
- : parsed.text;
2919
- const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
2920
- const deliveryResult = await deliverReplies({
2921
- bot,
2922
- send: (b, method, params, m) => tg(b, method, params, m),
2923
- chatId,
2924
- threadId,
2925
- chunks,
2926
- replyToMessageId: msg.message_id,
2927
- meta: outMeta,
2928
- logger: { error: (m) => console.error(`[${label}] ${m}`) },
2929
- });
2930
- mediaContext.recordTextFailures(deliveryResult.failed.length);
2920
+ // A turn that ends with text instead of calling the reply tool lands
2921
+ // here. It is the same agent writing the same markdown as the reply
2922
+ // tool, so it renders through the same gate and the same sender —
2923
+ // otherwise a table is a table or a wall of pipes depending only on
2924
+ // which way the answer happened to arrive, which is not a distinction
2925
+ // the reader can see or act on.
2926
+ //
2927
+ // Text-only by construction: no allowedRoots, so media stays degraded
2928
+ // to caption text exactly as it was here before, and this path opens no
2929
+ // upload surface of its own.
2930
+ // Wrapped even though the factory is contracted not to throw: that
2931
+ // contract is convention, and its styling-retry ladder evaluates
2932
+ // blocksAreStyled/flattenStyledBlocks outside any try. A throw escaping
2933
+ // here would skip the plain arm and cost the whole reply, so this catch
2934
+ // is the difference between a flat answer and no answer.
2935
+ let richOut = null;
2936
+ try {
2937
+ richOut = makeRichDeliverText
2938
+ ? await makeRichDeliverText({ chatId, threadId })({
2939
+ text: parsed.text,
2940
+ replyToMessageId: msg.message_id,
2941
+ // Its own source, because rich-message-sent carries none and the
2942
+ // streamed rich open reports as bot-reply-stream-open-rich.
2943
+ // Without a distinct tag nothing downstream can tell this branch
2944
+ // from the other sites writing the bare streamer tag.
2945
+ meta: { ...outMeta, source: 'bot-reply-final-rich' },
2946
+ })
2947
+ : null;
2948
+ } catch (richErr) {
2949
+ // Named apart from the turn's own `err` on purpose: this is the
2950
+ // rich attempt failing, not the turn, and the completion tail is
2951
+ // located in tests by scanning for the turn handler's catch.
2952
+ console.error(`[${label}] rich delivery threw, falling back to plain: ${richErr.message}`);
2953
+ richOut = null;
2954
+ }
2955
+ if (richOut?.handled) {
2956
+ // One bubble, no chunks, nothing failed — but still report, because
2957
+ // this is the same incomplete-delivery gate the plain arm feeds and
2958
+ // it must hear from whichever arm ran.
2959
+ mediaContext.recordTextFailures(richOut.failed.length);
2960
+ } else {
2961
+ // 0.7.0: use markdown-aware chunker + deliverReplies primitive.
2962
+ // The old chunkText was newline/byte-only; chunkMarkdownText also
2963
+ // respects code-fence boundaries (closes + reopens across chunks).
2964
+ // Image markdown from a rich-enabled chat degrades to caption text
2965
+ // here too — never a raw local path in the bubble.
2966
+ //
2967
+ // The `??` arm is load-bearing: a chat with rich off declines with
2968
+ // no body at all, and chunking `undefined` would drop the reply
2969
+ // entirely for most of the fleet. Both arms strip through the same
2970
+ // call, so the delivered text is byte-identical either way.
2971
+ const shortText = richOut?.text ?? (resolveRichTextEnabled(config, chatId, threadId)
2972
+ ? stripMediaMarkdown(parsed.text)
2973
+ : parsed.text);
2974
+ const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
2975
+ const deliveryResult = await deliverReplies({
2976
+ bot,
2977
+ send: (b, method, params, m) => tg(b, method, params, m),
2978
+ chatId,
2979
+ threadId,
2980
+ chunks,
2981
+ replyToMessageId: msg.message_id,
2982
+ // Tagged like its rich sibling. Five call sites write the bare
2983
+ // 'bot-reply-stream' tag and three of them deliver reply bodies,
2984
+ // so a row carrying it says nothing about which one produced it.
2985
+ // Naming both arms makes this branch identifiable from the
2986
+ // messages table whichever way the reply went out.
2987
+ meta: { ...outMeta, source: 'bot-reply-final' },
2988
+ logger: { error: (m) => console.error(`[${label}] ${m}`) },
2989
+ });
2990
+ mediaContext.recordTextFailures(deliveryResult.failed.length);
2991
+ }
2931
2992
  }
2932
2993
 
2933
2994
  // A turn whose final output was a solo sticker/reaction skips the
@@ -4062,6 +4123,38 @@ async function main() {
4062
4123
  //
4063
4124
  // Neither feature may switch the other off, which a single strategy slot
4064
4125
  // could not express.
4126
+ // Built before the chain rather than inline in it, because the interactive
4127
+ // turn path reuses this exact instance for its non-streamed replies. See
4128
+ // the makeRichDeliverText declaration for why it must be one instance.
4129
+ // Injected rather than imported by the dispatcher: rich-media.js requires
4130
+ // the dispatcher module, so the reverse direction would be a require cycle.
4131
+ makeRichDeliverText = createRichDeliveryFactory({
4132
+ bot,
4133
+ sendRich: (args) => richSendMessage(args),
4134
+ isRichTextEnabled: (chatId, threadId) => resolveRichTextEnabled(config, chatId, threadId),
4135
+ getRichKnownUnsupported: () => richSendKnownUnsupported,
4136
+ // No config knob: the automatic flatten-and-retry below IS the control,
4137
+ // and it needs no operator to notice anything. A knob would add a state
4138
+ // where styling is off while the server accepts it, which nothing in
4139
+ // the system can detect or correct.
4140
+ isInlineStylingEnabled: () => !richInlineStylingUnsupported,
4141
+ onStylingRejected: () => richStylingLatch?.recordStylingRejection(),
4142
+ onStylingAccepted: () => richStylingLatch?.recordHealthyOutcome(),
4143
+ redactError: redactBotToken,
4144
+ // Media for one reply. The envelope itself (no URL media, the `files:`
4145
+ // fan-out ceiling, the shared stat) lives in rich-media.js so it is
4146
+ // pinned by behavior rather than by this literal — nothing executes
4147
+ // this file. `allowedRoots` arrives per call from the dispatcher, which
4148
+ // computed it once and validates `files:` against that very array, so a
4149
+ // path the reply tool may upload is exactly a path image syntax may
4150
+ // upload. The interactive path passes none, so it renders text-only.
4151
+ makeMediaWiring: makeReplyMediaWiring({
4152
+ config,
4153
+ fileIdCache: richMediaFileIdCache,
4154
+ logEvent,
4155
+ botName: BOT_NAME,
4156
+ }),
4157
+ });
4065
4158
  const makeDeliverText = composeDeliverTextFactories([
4066
4159
  createDeliverTextFactory({
4067
4160
  registry: streamerRegistry,
@@ -4079,35 +4172,7 @@ async function main() {
4079
4172
  resolveRichTextEnabled(config, chatId, threadId) ? stripMediaMarkdown(text) : text
4080
4173
  ),
4081
4174
  }),
4082
- // Injected rather than imported by the dispatcher: rich-media.js requires
4083
- // the dispatcher module, so the reverse direction would be a require cycle.
4084
- createRichDeliveryFactory({
4085
- bot,
4086
- sendRich: (args) => richSendMessage(args),
4087
- isRichTextEnabled: (chatId, threadId) => resolveRichTextEnabled(config, chatId, threadId),
4088
- getRichKnownUnsupported: () => richSendKnownUnsupported,
4089
- // No config knob: the automatic flatten-and-retry below IS the control,
4090
- // and it needs no operator to notice anything. A knob would add a state
4091
- // where styling is off while the server accepts it, which nothing in
4092
- // the system can detect or correct.
4093
- isInlineStylingEnabled: () => !richInlineStylingUnsupported,
4094
- onStylingRejected: () => richStylingLatch?.recordStylingRejection(),
4095
- onStylingAccepted: () => richStylingLatch?.recordHealthyOutcome(),
4096
- redactError: redactBotToken,
4097
- // Media for one reply. The envelope itself (no URL media, the `files:`
4098
- // fan-out ceiling, the shared stat) lives in rich-media.js so it is
4099
- // pinned by behavior rather than by this literal — nothing executes
4100
- // this file. `allowedRoots` arrives per call from the dispatcher, which
4101
- // computed it once and validates `files:` against that very array, so a
4102
- // path the reply tool may upload is exactly a path image syntax may
4103
- // upload.
4104
- makeMediaWiring: makeReplyMediaWiring({
4105
- config,
4106
- fileIdCache: richMediaFileIdCache,
4107
- logEvent,
4108
- botName: BOT_NAME,
4109
- }),
4110
- }),
4175
+ makeRichDeliverText,
4111
4176
  ]);
4112
4177
 
4113
4178
  // The edit tool's counterpart to the reply chain above. An agent that posts