polygram 0.8.0-rc.4 → 0.8.0-rc.6

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.4",
4
+ "version": "0.8.0-rc.6",
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",
@@ -97,7 +97,10 @@ const USER_MESSAGES = {
97
97
  missingToolInput: '⚠️ Session history looks corrupted. Try /new.',
98
98
  timeout: '⏳ I went quiet too long without finishing. Try resending or simplifying.',
99
99
  format: '⚠️ Invalid request format. Try rephrasing or /new.',
100
- transient5xx: '☁️ Anthropic is temporarily unavailable. Retrying once…',
100
+ // Used both for in-flight retry attempts AND for the post-retry-failed
101
+ // bubble-up message. Avoid promising "retrying once" since by the
102
+ // time the user reads it pm has already retried and given up.
103
+ transient5xx: '☁️ Server hiccup — please try again in a moment.',
101
104
  };
102
105
 
103
106
  // Auto-recovery actions for kinds where the session is irrecoverable
@@ -183,15 +186,16 @@ function classify(err) {
183
186
  }
184
187
 
185
188
  // SDKAssistantMessage.error is a short string code from a fixed
186
- // union — match those directly, not via regex.
189
+ // union — match those directly, not via regex. Result subtypes
190
+ // are checked LATER (after pattern matching) so a more-specific
191
+ // pattern in the message text (e.g. 'HTTP 401' inside an
192
+ // error_during_execution subtype) wins over the generic subtype
193
+ // mapping that defaults the entire error_during_execution class
194
+ // to transient.
187
195
  if (typeof err === 'string') {
188
196
  const sdkMessageError = matchSdkMessageError(err);
189
197
  if (sdkMessageError) return sdkMessageError;
190
198
  }
191
- if (err?.subtype && typeof err.subtype === 'string') {
192
- const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
193
- if (sdkResultSubtype) return sdkResultSubtype;
194
- }
195
199
 
196
200
  const msg = extractMessage(err);
197
201
  for (const [kind, re] of Object.entries(PATTERNS)) {
@@ -205,6 +209,20 @@ function classify(err) {
205
209
  }
206
210
  }
207
211
 
212
+ // After pattern matching: try SDK result subtypes. A bare string
213
+ // like 'error_during_execution' (no message context) lands here
214
+ // and gets the friendly transient5xx kind. Object inputs with a
215
+ // subtype field also land here when their message text didn't
216
+ // match a more specific pattern.
217
+ if (typeof err === 'string') {
218
+ const sdkResultSubtype = matchSdkResultSubtype(err);
219
+ if (sdkResultSubtype) return sdkResultSubtype;
220
+ }
221
+ if (err?.subtype && typeof err.subtype === 'string') {
222
+ const sdkResultSubtype = matchSdkResultSubtype(err.subtype);
223
+ if (sdkResultSubtype) return sdkResultSubtype;
224
+ }
225
+
208
226
  // Fall-through: surface a snippet of the raw error so users at
209
227
  // least know SOMETHING happened. Same shape as before, just
210
228
  // routed through the classifier so callers get a uniform return.
@@ -252,8 +270,15 @@ function matchSdkMessageError(s) {
252
270
 
253
271
  // SDKResultMessage.subtype values (sdk.d.ts:3121). Most are
254
272
  // terminal-error indicators that don't have a clean pattern equivalent.
273
+ //
274
+ // `error_during_execution` is the SDK's catch-all for "something went
275
+ // wrong mid-turn" — could be a transient stream/network blip OR a
276
+ // systemic model issue. We treat it as transient (1 retry is cheap;
277
+ // if it's systemic the second attempt fails fast). Pre-rc.5 this was
278
+ // mapped to 'unknown' which fell through to the default "Hit a snag:
279
+ // error_during_execution" template — leaking the SDK enum to users.
255
280
  const SDK_RESULT_SUBTYPE_MAP = {
256
- error_during_execution: 'unknown',
281
+ error_during_execution: 'transient5xx',
257
282
  error_max_turns: 'format',
258
283
  error_max_budget_usd: 'billing',
259
284
  error_max_structured_output_retries: 'format',
@@ -265,8 +290,12 @@ function matchSdkResultSubtype(s) {
265
290
  return {
266
291
  kind,
267
292
  userMessage: USER_MESSAGES[kind] ?? null,
268
- isTransient: false, // result subtypes don't auto-retry; the
269
- // turn already burned its budget.
293
+ // Derive transience from the kind so error_during_execution →
294
+ // transient5xx isTransient=true, matching the pattern-match
295
+ // branch's behaviour. pm guards retry with firstAssistantSeen=
296
+ // false, which prevents budget waste when the turn already had
297
+ // billable assistant output.
298
+ isTransient: kind === 'transient5xx' || kind === 'rateLimit',
270
299
  autoRecover: AUTO_RECOVER[kind] ?? null,
271
300
  };
272
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.4",
3
+ "version": "0.8.0-rc.6",
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
@@ -1725,14 +1725,18 @@ async function handleConfigCallback(ctx) {
1725
1725
  const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
1726
1726
  const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
1727
1727
  const reason = setting === 'model' ? 'model-change' : 'effort-change';
1728
+ // Feature-detect on the routed pm for this specific session, not on
1729
+ // the router itself (the router exposes every method as a forwarding
1730
+ // shim so `typeof pm.X` is always 'function').
1731
+ const pmForCb = pm.pickFor(callbackSessionKey);
1728
1732
  let respawn;
1729
- if (typeof pm.requestRespawn === 'function') {
1730
- respawn = pm.requestRespawn(callbackSessionKey, reason);
1731
- } else if (setting === 'effort' && typeof pm.applyFlagSettings === 'function') {
1732
- const ok = await pm.applyFlagSettings(callbackSessionKey, { effortLevel: value });
1733
+ if (typeof pmForCb.requestRespawn === 'function') {
1734
+ respawn = pmForCb.requestRespawn(callbackSessionKey, reason);
1735
+ } else if (setting === 'effort' && typeof pmForCb.applyFlagSettings === 'function') {
1736
+ const ok = await pmForCb.applyFlagSettings(callbackSessionKey, { effortLevel: value });
1733
1737
  respawn = { killed: ok };
1734
- } else if (setting === 'model' && typeof pm.setModel === 'function') {
1735
- const ok = await pm.setModel(callbackSessionKey, value);
1738
+ } else if (setting === 'model' && typeof pmForCb.setModel === 'function') {
1739
+ const ok = await pmForCb.setModel(callbackSessionKey, value);
1736
1740
  respawn = { killed: ok };
1737
1741
  } else {
1738
1742
  respawn = { killed: false };
@@ -1891,8 +1895,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1891
1895
  // usage report. Only meaningful under SDK pm (CLI pm has no
1892
1896
  // getContextUsage equivalent); CLI path replies with a hint.
1893
1897
  if (botAllowsCommands && text === '/context') {
1894
- if (!USE_SDK) {
1895
- await sendReply('📚 /context requires the SDK pm (set POLYGRAM_USE_SDK=1 to enable).');
1898
+ if (!pm.isSdkFor(sessionKey)) {
1899
+ await sendReply('📚 /context requires the SDK pm. This chat is on the CLI pm path.');
1896
1900
  return;
1897
1901
  }
1898
1902
  const entry = pm.get(sessionKey);
@@ -1937,9 +1941,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1937
1941
  }
1938
1942
  if (botAllowsCommands && (text === '/new' || text === '/reset')) {
1939
1943
  let drained = 0;
1940
- if (typeof pm.resetSession === 'function') {
1944
+ const target = pm.pickFor(sessionKey);
1945
+ if (typeof target.resetSession === 'function') {
1941
1946
  try {
1942
- const r = await pm.resetSession(sessionKey, { reason: text.slice(1) });
1947
+ const r = await target.resetSession(sessionKey, { reason: text.slice(1) });
1943
1948
  drained = r?.drainedPendings ?? 0;
1944
1949
  } catch (err) {
1945
1950
  console.error(`[${label}] resetSession ${text}: ${err.message}`);
@@ -1970,15 +1975,16 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1970
1975
  if (botAllowsCommands && text.startsWith('/steer ')) {
1971
1976
  const steerText = text.slice(7).trim();
1972
1977
  if (!steerText) { await sendReply('Usage: /steer <text>'); return; }
1973
- if (!USE_SDK || typeof pm.steer !== 'function') {
1974
- await sendReply('🛞 /steer requires the SDK pm (set POLYGRAM_USE_SDK=1 to enable).');
1978
+ const target = pm.pickFor(sessionKey);
1979
+ if (typeof target.steer !== 'function') {
1980
+ await sendReply('🛞 /steer requires the SDK pm. This chat is on the CLI pm path.');
1975
1981
  return;
1976
1982
  }
1977
1983
  if (!pm.has(sessionKey)) {
1978
1984
  await sendReply('🛞 No active session — /steer only works mid-turn. Send a message first, then /steer while it\'s thinking.');
1979
1985
  return;
1980
1986
  }
1981
- const ok = pm.steer(sessionKey, steerText);
1987
+ const ok = target.steer(sessionKey, steerText);
1982
1988
  if (ok) {
1983
1989
  logEvent('steer-command', {
1984
1990
  chat_id: chatId, text_len: steerText.length,
@@ -2006,16 +2012,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2006
2012
  // applyFlagSettings — no respawn needed, change takes effect for
2007
2013
  // the rest of the in-flight turn AND all future ones.
2008
2014
  const applyConfigChange = async (reason, setting, value) => {
2009
- if (typeof pm.requestRespawn === 'function') {
2010
- const res = pm.requestRespawn(sessionKey, reason);
2015
+ const target = pm.pickFor(sessionKey);
2016
+ if (typeof target.requestRespawn === 'function') {
2017
+ const res = target.requestRespawn(sessionKey, reason);
2011
2018
  return { queued: res.queued, anyActive: !res.killed };
2012
2019
  }
2013
- if (setting === 'effort' && typeof pm.applyFlagSettings === 'function') {
2014
- const ok = await pm.applyFlagSettings(sessionKey, { effortLevel: value });
2020
+ if (setting === 'effort' && typeof target.applyFlagSettings === 'function') {
2021
+ const ok = await target.applyFlagSettings(sessionKey, { effortLevel: value });
2015
2022
  return { queued: 0, anyActive: !ok };
2016
2023
  }
2017
- if (setting === 'model' && typeof pm.setModel === 'function') {
2018
- const ok = await pm.setModel(sessionKey, value);
2024
+ if (setting === 'model' && typeof target.setModel === 'function') {
2025
+ const ok = await target.setModel(sessionKey, value);
2019
2026
  return { queued: 0, anyActive: !ok };
2020
2027
  }
2021
2028
  return { queued: 0, anyActive: false };
@@ -2411,11 +2418,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2411
2418
  const chatAutosteer = chatConfig.autosteer != null
2412
2419
  ? chatConfig.autosteer
2413
2420
  : config.bot?.autosteer;
2414
- const autosteerEnabled = USE_SDK && chatAutosteer !== false;
2415
- if (autosteerEnabled && typeof pm.steer === 'function' && pm.has(sessionKey)) {
2421
+ const autosteerTarget = pm.pickFor(sessionKey);
2422
+ const autosteerEnabled = chatAutosteer !== false
2423
+ && typeof autosteerTarget.steer === 'function';
2424
+ if (autosteerEnabled && pm.has(sessionKey)) {
2416
2425
  const entry = pm.get(sessionKey);
2417
2426
  if (entry?.inFlight) {
2418
- const ok = pm.steer(sessionKey, prompt);
2427
+ const ok = autosteerTarget.steer(sessionKey, prompt);
2419
2428
  if (ok) {
2420
2429
  // Quiet ack — no chat-bubble reply, just a reaction so the
2421
2430
  // user sees their message was incorporated. The in-flight
@@ -2493,8 +2502,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2493
2502
  // Only fires when pm.resetSession is available (SDK pm
2494
2503
  // path); CLI pm doesn't have the method.
2495
2504
  const cls = classifyError(result.error);
2496
- if (cls.autoRecover === 'reset_session' && typeof pm.resetSession === 'function') {
2497
- pm.resetSession(sessionKey, { reason: cls.kind })
2505
+ const recoverTarget = pm.pickFor(sessionKey);
2506
+ if (cls.autoRecover === 'reset_session' && typeof recoverTarget.resetSession === 'function') {
2507
+ recoverTarget.resetSession(sessionKey, { reason: cls.kind })
2498
2508
  .catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
2499
2509
  logEvent('auto-recover', {
2500
2510
  chat_id: chatId, kind: cls.kind, action: 'reset_session',
@@ -2523,7 +2533,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2523
2533
  // SDK pm only — CLI pm has no equivalent (no Query object,
2524
2534
  // no getContextUsage). Per-bot opt-out via
2525
2535
  // config.bot.contextHint = false.
2526
- if (USE_SDK && config.bot?.contextHint !== false) {
2536
+ if (pm.isSdkFor(sessionKey) && config.bot?.contextHint !== false) {
2527
2537
  const entry = pm.get(sessionKey);
2528
2538
  const q = entry?.query;
2529
2539
  if (q && typeof q.getContextUsage === 'function') {
@@ -2903,14 +2913,15 @@ function createBot(token) {
2903
2913
  // sessionKey is the chat itself, so killing one session is
2904
2914
  // the same as killing the chat — behavior unchanged for the
2905
2915
  // common case.
2906
- if (USE_SDK && typeof pm.interrupt === 'function') {
2907
- await pm.interrupt(sessionKey).catch((err) =>
2916
+ const stopTarget = pm.pickFor(sessionKey);
2917
+ if (typeof stopTarget.interrupt === 'function') {
2918
+ await stopTarget.interrupt(sessionKey).catch((err) =>
2908
2919
  console.error(`[${BOT_NAME}] interrupt failed: ${err.message}`));
2909
- if (typeof pm.drainQueue === 'function') {
2910
- pm.drainQueue(sessionKey, 'INTERRUPTED');
2920
+ if (typeof stopTarget.drainQueue === 'function') {
2921
+ stopTarget.drainQueue(sessionKey, 'INTERRUPTED');
2911
2922
  }
2912
2923
  } else {
2913
- await pm.kill(sessionKey).catch((err) =>
2924
+ await stopTarget.kill(sessionKey).catch((err) =>
2914
2925
  console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
2915
2926
  }
2916
2927
  logEvent('abort-requested', {
@@ -3351,17 +3362,37 @@ async function main() {
3351
3362
  });
3352
3363
 
3353
3364
  const cap = config.maxWarmProcesses || DEFAULT_MAX_WARM_PROCS;
3354
- // 0.8.0 Phase 3: pick pm implementation via env flag. Default
3355
- // (POLYGRAM_USE_SDK unset) keeps the CLI-based pm same as 0.7.x.
3356
- // Set POLYGRAM_USE_SDK=1 to switch to the SDK-backed pm.
3357
- // Phase 5 soak: enable on umi-assistant first, watch for
3358
- // regressions, then enable on shumabit.
3359
- const PMClass = USE_SDK ? ProcessManagerSdk : ProcessManager;
3360
- const spawnFn = USE_SDK ? buildSdkOptions : spawnClaude;
3361
- console.log(`[polygram] using ${USE_SDK ? 'SDK' : 'CLI'} ProcessManager`);
3362
- pm = new PMClass({
3365
+
3366
+ // 0.8.0-rc.6: per-chat pm selection. Three modes:
3367
+ // 1. POLYGRAM_USE_SDK=1 with no POLYGRAM_SDK_CHATS list → all chats SDK
3368
+ // 2. POLYGRAM_SDK_CHATS=id1,id2,... → those chats
3369
+ // use SDK; everyone else uses CLI (both pms live in the daemon)
3370
+ // 3. neither set → all chats CLI
3371
+ // The per-chat mode lets us soak SDK pm against real traffic in one
3372
+ // chat (Ivan's DM) while keeping partner-facing chats on the
3373
+ // battle-tested CLI path. When both pms run, killChat /shutdown
3374
+ // broadcast to both; everything else routes per-sessionKey via
3375
+ // pickPmFor() based on the chat's set membership.
3376
+ const sdkChatIdSet = new Set(
3377
+ String(process.env.POLYGRAM_SDK_CHATS || '')
3378
+ .split(',').map((s) => s.trim()).filter(Boolean)
3379
+ );
3380
+ const sdkAllChats = USE_SDK && sdkChatIdSet.size === 0;
3381
+ const sdkSomeChats = sdkChatIdSet.size > 0;
3382
+ const sdkActive = sdkAllChats || sdkSomeChats;
3383
+
3384
+ function pickPmKindFor(sessionKey) {
3385
+ if (sdkAllChats) return 'sdk';
3386
+ if (!sdkSomeChats) return 'cli';
3387
+ const chatId = String(getChatIdFromKey(sessionKey) ?? '');
3388
+ return sdkChatIdSet.has(chatId) ? 'sdk' : 'cli';
3389
+ }
3390
+
3391
+ // Shared callbacks: identical instance passed to both pms so a
3392
+ // chat's lifecycle events look the same regardless of which pm
3393
+ // is handling it.
3394
+ const pmOpts = {
3363
3395
  cap,
3364
- spawnFn,
3365
3396
  db,
3366
3397
  logger: console,
3367
3398
  onInit: (sessionKey, event, entry) => {
@@ -3476,7 +3507,104 @@ async function main() {
3476
3507
  ...(threadId && { message_thread_id: threadId }),
3477
3508
  }, { source: 'respawn-confirm', botName: BOT_NAME }).catch(() => {});
3478
3509
  },
3479
- });
3510
+ };
3511
+
3512
+ // Instantiate the actual pm(s). When sdkActive is false we still
3513
+ // build a CLI pm; SDK pm is null. When sdkActive is true we always
3514
+ // build BOTH so chats outside the SDK list still get the CLI path.
3515
+ const cliPm = new ProcessManager({ ...pmOpts, spawnFn: spawnClaude });
3516
+ const sdkPm = sdkActive
3517
+ ? new ProcessManagerSdk({ ...pmOpts, spawnFn: buildSdkOptions })
3518
+ : null;
3519
+
3520
+ // Routing pm: same surface as a single pm, but per-method routing
3521
+ // through pickPmKindFor(sessionKey). Methods that don't take a
3522
+ // sessionKey (killChat by chatId, shutdown) broadcast to both.
3523
+ // For optional methods (steer / setModel / applyFlagSettings /
3524
+ // requestRespawn / drainQueue / interrupt / resetSession) we
3525
+ // forward when the routed pm has the method and return a
3526
+ // sentinel otherwise — so feature-detection at the call site
3527
+ // still works via `typeof pm.pickFor(sessionKey).X === 'function'`.
3528
+ pm = (() => {
3529
+ function routedPm(sessionKey) {
3530
+ return pickPmKindFor(sessionKey) === 'sdk' && sdkPm ? sdkPm : cliPm;
3531
+ }
3532
+ const router = {
3533
+ pickFor: routedPm,
3534
+ isSdkFor(sessionKey) {
3535
+ return pickPmKindFor(sessionKey) === 'sdk' && !!sdkPm;
3536
+ },
3537
+ has(sessionKey) { return routedPm(sessionKey).has(sessionKey); },
3538
+ get(sessionKey) { return routedPm(sessionKey).get(sessionKey); },
3539
+ getOrSpawn(sessionKey, ctx) { return routedPm(sessionKey).getOrSpawn(sessionKey, ctx); },
3540
+ send(sessionKey, prompt, opts) { return routedPm(sessionKey).send(sessionKey, prompt, opts); },
3541
+ kill(sessionKey) { return routedPm(sessionKey).kill(sessionKey); },
3542
+ async killChat(chatId) {
3543
+ const tasks = [cliPm.killChat(chatId)];
3544
+ if (sdkPm) tasks.push(sdkPm.killChat(chatId));
3545
+ await Promise.all(tasks);
3546
+ },
3547
+ async shutdown() {
3548
+ const tasks = [cliPm.shutdown()];
3549
+ if (sdkPm) tasks.push(sdkPm.shutdown());
3550
+ await Promise.all(tasks);
3551
+ },
3552
+ // Optional methods. The router returns a function — but the
3553
+ // function returns a sentinel if the routed pm doesn't have
3554
+ // the method. Sites that want feature-detection should use
3555
+ // `pm.pickFor(sessionKey)` and check `typeof X === 'function'`
3556
+ // there instead of probing `pm.X` directly.
3557
+ steer(sessionKey, ...args) {
3558
+ const target = routedPm(sessionKey);
3559
+ return typeof target.steer === 'function' ? target.steer(sessionKey, ...args) : false;
3560
+ },
3561
+ resetSession(sessionKey, opts) {
3562
+ const target = routedPm(sessionKey);
3563
+ return typeof target.resetSession === 'function'
3564
+ ? target.resetSession(sessionKey, opts)
3565
+ : Promise.resolve({ closed: false, drainedPendings: 0 });
3566
+ },
3567
+ applyFlagSettings(sessionKey, settings) {
3568
+ const target = routedPm(sessionKey);
3569
+ return typeof target.applyFlagSettings === 'function'
3570
+ ? target.applyFlagSettings(sessionKey, settings)
3571
+ : Promise.resolve(false);
3572
+ },
3573
+ setModel(sessionKey, model) {
3574
+ const target = routedPm(sessionKey);
3575
+ return typeof target.setModel === 'function'
3576
+ ? target.setModel(sessionKey, model)
3577
+ : Promise.resolve(false);
3578
+ },
3579
+ requestRespawn(sessionKey, reason) {
3580
+ const target = routedPm(sessionKey);
3581
+ return typeof target.requestRespawn === 'function'
3582
+ ? target.requestRespawn(sessionKey, reason)
3583
+ : { killed: false, queued: 0 };
3584
+ },
3585
+ drainQueue(sessionKey, errCode) {
3586
+ const target = routedPm(sessionKey);
3587
+ return typeof target.drainQueue === 'function'
3588
+ ? target.drainQueue(sessionKey, errCode)
3589
+ : 0;
3590
+ },
3591
+ interrupt(sessionKey) {
3592
+ const target = routedPm(sessionKey);
3593
+ return typeof target.interrupt === 'function'
3594
+ ? target.interrupt(sessionKey)
3595
+ : Promise.resolve();
3596
+ },
3597
+ };
3598
+ return router;
3599
+ })();
3600
+
3601
+ if (sdkAllChats) {
3602
+ console.log('[polygram] using SDK ProcessManager (all chats)');
3603
+ } else if (sdkSomeChats) {
3604
+ console.log(`[polygram] router active: SDK pm for chats {${Array.from(sdkChatIdSet).join(',')}}, CLI pm for everyone else`);
3605
+ } else {
3606
+ console.log('[polygram] using CLI ProcessManager');
3607
+ }
3480
3608
 
3481
3609
  console.log(`polygram (LRU cap=${cap}, SQLite source of truth)`);
3482
3610
  console.log(`Chats: ${Object.entries(config.chats).map(([id, c]) => `${c.name} (${c.model}/${c.effort})`).join(', ')}`);