polygram 0.38.2 → 0.38.4

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.
@@ -55,14 +55,21 @@ function createSlashCommands({
55
55
  saveConfig = () => {},
56
56
  botName,
57
57
  logEvent,
58
+ // Narrow async hook: retire the question state of a session whose process
59
+ // this command is retiring. A callback, so this module never learns the
60
+ // question store.
61
+ retireQuestionSession = null,
58
62
  logger = console,
59
63
  } = {}) {
60
64
 
61
65
  return async function dispatchSlashCommand(ctx) {
62
66
  const {
63
- text, sessionKey, chatId, threadIdStr, chatConfig,
67
+ text, msgId, sessionKey, chatId, threadIdStr, chatConfig,
64
68
  cmdUser, cmdUserId, label, sendReply,
65
69
  } = ctx;
70
+ // Commands are logged by their recognized verb, never by the typed line:
71
+ // the arguments are message content and telemetry stays content-free.
72
+ const commandVerb = () => String(text || '').trim().split(/\s+/)[0] || null;
66
73
  const botAllowsCommands = !!config.bot?.allowConfigCommands;
67
74
 
68
75
  // /context — route through pm.getContextUsage(sessionKey) so the
@@ -184,10 +191,11 @@ function createSlashCommands({
184
191
  logEvent('compact-command', {
185
192
  chat_id: chatId, thread_id: threadIdStr, session_key: sessionKey,
186
193
  text_len: text.length,
187
- // rc.65: store full text so boot-time orphan recovery can
188
- // silently re-push after a deploy interrupted compaction.
189
- text,
190
- user: cmdUser, user_id: cmdUserId,
194
+ // The id of the inbound message carrying the hint. Boot-time orphan
195
+ // recovery joins it back to the stored row to re-push silently; the
196
+ // hint itself is message content and never enters telemetry.
197
+ msg_id: msgId ?? null,
198
+ user_id: cmdUserId,
191
199
  });
192
200
  const hasHint = text.length > '/compact'.length + 1;
193
201
  await sendReply(hasHint ? '🗜️ Compacting with your hint…' : '🗜️ Compacting…');
@@ -236,10 +244,18 @@ function createSlashCommands({
236
244
  );
237
245
  return true;
238
246
  }
247
+ // The process that owned any open question is gone; retire its state.
248
+ // Ordered after the kill so a refused reload leaves the still-live
249
+ // interaction intact.
250
+ await retireQuestionSession?.(sessionKey);
251
+ } else {
252
+ // No process to retire, but an open question has nothing to deliver to
253
+ // either.
254
+ await retireQuestionSession?.(sessionKey);
239
255
  }
240
256
  logEvent('session-reload-command', {
241
- chat_id: chatId, command: text,
242
- user: cmdUser, user_id: cmdUserId,
257
+ chat_id: chatId, command: commandVerb(),
258
+ user_id: cmdUserId,
243
259
  });
244
260
  await sendReply(
245
261
  codex
@@ -273,6 +289,10 @@ function createSlashCommands({
273
289
  const codex = isCodexRuntimeView(runtimeView);
274
290
  let drained = 0;
275
291
  try {
292
+ // A fresh session retires the process that owns any open question.
293
+ // Deliberately before the reset: if the reset then fails, the safe
294
+ // outcome is still that nothing exact was kept.
295
+ await retireQuestionSession?.(sessionKey);
276
296
  const r = await pm.resetSession(sessionKey, { reason: text.slice(1) });
277
297
  drained = r?.drainedPendings ?? 0;
278
298
  } catch (err) {
@@ -286,8 +306,8 @@ function createSlashCommands({
286
306
  }
287
307
  contextHintShown.delete(sessionKey);
288
308
  logEvent('session-reset-command', {
289
- chat_id: chatId, command: text, drained_pendings: drained,
290
- user: cmdUser, user_id: cmdUserId,
309
+ chat_id: chatId, command: commandVerb(), drained_pendings: drained,
310
+ user_id: cmdUserId,
291
311
  });
292
312
  await sendReply(
293
313
  codex
@@ -431,7 +451,7 @@ function createSlashCommands({
431
451
  const auditRows = [{
432
452
  chat_id: chatId, thread_id: wThread, field: 'model',
433
453
  old_value: oldModel, new_value: newModel,
434
- user: cmdUser, user_id: cmdUserId, source: 'command',
454
+ user_id: cmdUserId, source: 'command',
435
455
  }];
436
456
  if (effortAdjustment) {
437
457
  auditRows.push({
@@ -594,7 +614,7 @@ function createSlashCommands({
594
614
  const auditRow = {
595
615
  chat_id: chatId, thread_id: wThread, field: 'effort',
596
616
  old_value: oldEffort, new_value: newEffort,
597
- user: cmdUser, user_id: cmdUserId, source: 'command',
617
+ user_id: cmdUserId, source: 'command',
598
618
  };
599
619
  if (codex) {
600
620
  try {
@@ -689,7 +709,9 @@ function createSlashCommands({
689
709
  });
690
710
  logEvent('pair-code-issued', {
691
711
  bot: botName, by: issuerId, scope: out.scope,
692
- chat_id: out.chat_id, note: out.note,
712
+ chat_id: out.chat_id,
713
+ // The note is operator-typed text; record only that one was given.
714
+ noticed: Boolean(out.note),
693
715
  });
694
716
  const ttlLabel = args.ttl || '10m';
695
717
  const chatLabel = out.chat_id ? `chat ${out.chat_id}` : 'the chat where it is redeemed';
@@ -765,4 +787,23 @@ function createSlashCommands({
765
787
  };
766
788
  }
767
789
 
768
- module.exports = { createSlashCommands };
790
+ /**
791
+ * Normalize a stored line into the exact `/compact [hint]` form, or return
792
+ * null when it is not a compact command at all.
793
+ *
794
+ * Boot-time recovery re-pushes a stored line into a live session, so the line
795
+ * has to be proven to BE a compact command first: an arbitrary message, or a
796
+ * different command, must never be fired at the agent as if the user had
797
+ * asked to compact. Telegram also delivers group commands with the bot's
798
+ * username attached (`/compact@bot hint`), which the agent's own parser does
799
+ * not accept — that suffix is stripped here.
800
+ */
801
+ function normalizeCompactCommand(text) {
802
+ if (typeof text !== 'string') return null;
803
+ const match = /^\/compact(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/.exec(text.trim());
804
+ if (!match) return null;
805
+ const hint = (match[1] || '').trim();
806
+ return hint ? `/compact ${hint}` : '/compact';
807
+ }
808
+
809
+ module.exports = { createSlashCommands, normalizeCompactCommand };
@@ -75,7 +75,7 @@ function createTranscribeVoiceAttachments({
75
75
  } catch (err) {
76
76
  logger.error?.(`[${label}] transcribe failed for ${a.name}: ${err.message}`);
77
77
  logEvent('voice-transcribe-failed', {
78
- chat_id: chatId, msg_id: msgId, name: a.name, error: err.message,
78
+ chat_id: chatId, msg_id: msgId, error_class: err.name || 'Error',
79
79
  });
80
80
  }
81
81
  }));
@@ -6,6 +6,7 @@ async function prepareCleanRetirement({
6
6
  awaitIngressSettlement,
7
7
  awaitHandlerSettlement,
8
8
  settlementTimeoutMs = 30_000,
9
+ continuationAuthorized = false,
9
10
  qualificationExpectation,
10
11
  } = {}) {
11
12
  if (typeof deliveryBarrier?.fenceAndDrain !== 'function'
@@ -21,9 +22,13 @@ async function prepareCleanRetirement({
21
22
  if (typeof awaitHandlerSettlement !== 'function') {
22
23
  throw new TypeError('clean shutdown requires handler settlement');
23
24
  }
25
+ if (typeof continuationAuthorized !== 'boolean') {
26
+ throw new TypeError('clean shutdown continuation authorization must be boolean');
27
+ }
24
28
 
25
29
  const deliveryDrain = captureSettlement(() => deliveryBarrier.fenceAndDrain());
26
30
  const processRetirement = captureSettlement(() => pm.retireForCleanRestart({
31
+ continuationAuthorized,
27
32
  getDeliveryEvidence: (sessionKey, sourceMsgId) => (
28
33
  deliveryBarrier.inspect(sessionKey, sourceMsgId)
29
34
  ),
@@ -16,6 +16,8 @@
16
16
 
17
17
  'use strict';
18
18
 
19
+ const { sanitizeForDurableWrite } = require('../secret-detect');
20
+
19
21
  const MAX_LABEL = 40; // Telegram button labels truncate ~64; keep well under
20
22
  const MAX_OTHER = 1000; // cap on the user's free-text answer entering claude's context
21
23
  const MAX_PREVIEW = 500;
@@ -168,6 +170,51 @@ function applyFreeText(state, text) {
168
170
  return { state: next, kind: 'advanced', receipt: other, done: isDone(next) };
169
171
  }
170
172
 
173
+ /**
174
+ * Whether a recorded answer carries a credential the durable boundary would
175
+ * mask. It lives beside the answer shape so a new field cannot be added
176
+ * without this seeing it. Every text-bearing part counts: the header copied
177
+ * from the agent's question, the selected option labels, and a typed value.
178
+ * The durable-write sanitizer decides, so its allowlist applies — "password:
179
+ * required" is ordinary vocabulary, not a secret.
180
+ */
181
+ function answerIsFlagged(answer) {
182
+ const parts = [
183
+ ...(answer?.header != null ? [answer.header] : []),
184
+ ...(answer?.selected || []),
185
+ ...(answer?.other != null ? [answer.other] : []),
186
+ ];
187
+ return parts.some((part) => sanitizeForDurableWrite(String(part)).changed);
188
+ }
189
+
190
+ /**
191
+ * The durable stand-in for an answer whose text is being held live. It carries
192
+ * no answer text at all — a placeholder string would be indistinguishable from
193
+ * something the user actually chose, and could be delivered as one. The header
194
+ * is sanitized because it is copied from the agent's question.
195
+ */
196
+ function markOmitted(answer) {
197
+ return {
198
+ header: sanitizeForDurableWrite(String(answer?.header ?? '')).text,
199
+ selected: [],
200
+ secret_omitted: true,
201
+ };
202
+ }
203
+
204
+ const isOmitted = (answer) => !!(answer && answer.secret_omitted);
205
+
206
+ /**
207
+ * Resolve the answers for delivery. Returns a discriminated result rather than
208
+ * throwing or substituting: a marker means the exact answer was held live and
209
+ * the holder is gone, and the caller must cancel instead of sending anything
210
+ * upstream. No value or marker is ever put in an error.
211
+ */
212
+ function resolveForDelivery(state) {
213
+ const answers = Array.isArray(state?.answers) ? state.answers : [];
214
+ if (answers.some(isOmitted)) return { ok: false, reason: 'live-answer-missing' };
215
+ return { ok: true, result: { answers } };
216
+ }
217
+
171
218
  /** Final tool result once the set is done. */
172
219
  function assemble(state) {
173
220
  return { answers: state.answers };
@@ -182,6 +229,10 @@ module.exports = {
182
229
  applyTap,
183
230
  applyFreeText,
184
231
  assemble,
232
+ answerIsFlagged,
233
+ markOmitted,
234
+ isOmitted,
235
+ resolveForDelivery,
185
236
  MAX_LABEL,
186
237
  MAX_OTHER,
187
238
  };
@@ -10,6 +10,39 @@
10
10
  'use strict';
11
11
 
12
12
  const { newToken, tokensEqual } = require('../approvals/store');
13
+ const { sanitizeDurableStructured, sanitizeDurableJsonText } = require('../secret-detect');
14
+
15
+ // Two copies of the agent's questions, both durable:
16
+ //
17
+ // questions_json — the audit copy of what the agent asked.
18
+ // state_json — the state machine, which re-reads its own `questions`
19
+ // copy on every tap to render the card and to match the
20
+ // option the user picked.
21
+ //
22
+ // Both are sanitized. The exact array lives only in the handler's live
23
+ // question context, for as long as the owning process does, and is hydrated
24
+ // back for each live interaction. A typed answer carrying a credential is held
25
+ // the same way: the row keeps a marker with no text, and a marked row that
26
+ // outlives its process is cancelled rather than replayed. Terminal masking of
27
+ // the whole retained state stays as the last line of defense for text no
28
+ // detector flagged.
29
+
30
+ // Both columns hold JSON, so they are sanitized as structure before
31
+ // serialization — masking a serialized document can splice across its
32
+ // delimiters, and an escaped quote hides a declared value from the detector.
33
+ const maskDurableJson = (value) => JSON.stringify(sanitizeDurableStructured(value));
34
+ const maskDurableJsonText = (json) => sanitizeDurableJsonText(json).text;
35
+
36
+ // `state.questions` is a second copy of the array `questions_json` holds, and
37
+ // the state machine reads it on every tap. Sanitizing only the column would
38
+ // leave the provider's exact question in the row, so the state's copy is
39
+ // sanitized on every write; the handler hydrates the exact array back for the
40
+ // live interaction.
41
+ const withSanitizedQuestions = (state) => {
42
+ const value = state && typeof state === 'object' ? state : {};
43
+ if (!Array.isArray(value.questions)) return value;
44
+ return { ...value, questions: sanitizeDurableStructured(value.questions) };
45
+ };
13
46
 
14
47
  // A question waits for the user — the turn no longer times out while an `ask` is open
15
48
  // (cli-process defers its ceilings during a question wait, docs/progress-is-not-turn-end-spec.md),
@@ -42,8 +75,19 @@ function createQuestionStore(rawDb, now = () => Date.now()) {
42
75
  UPDATE pending_questions SET from_id = @from_id
43
76
  WHERE id = @id AND from_id IS NULL AND status = 'pending'`);
44
77
  const resolveStmt = rawDb.prepare(`
45
- UPDATE pending_questions SET status = @status, answered_ts = @answered_ts
78
+ UPDATE pending_questions SET status = @status, answered_ts = @answered_ts,
79
+ state_json = @state_json
46
80
  WHERE id = @id AND status = 'pending'`);
81
+ const resolveTxn = rawDb.transaction((id, status) => {
82
+ const current = getByIdStmt.get(id);
83
+ if (!current || current.status !== 'pending') return 0;
84
+ return resolveStmt.run({
85
+ id,
86
+ status,
87
+ answered_ts: now(),
88
+ state_json: maskDurableJsonText(current.state_json ?? '{}'),
89
+ }).changes;
90
+ });
47
91
  const listTimedOutStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE status = 'pending' AND timeout_ts < ?`);
48
92
  const listOpenStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE bot_name = ? AND status = 'pending'`);
49
93
 
@@ -61,8 +105,8 @@ function createQuestionStore(rawDb, now = () => Date.now()) {
61
105
  turn_id,
62
106
  tool_call_id,
63
107
  callback_token: newToken(),
64
- questions_json: JSON.stringify(questions ?? []),
65
- state_json: JSON.stringify(state ?? {}),
108
+ questions_json: maskDurableJson(questions ?? []),
109
+ state_json: JSON.stringify(withSanitizedQuestions(state)),
66
110
  created_ts,
67
111
  timeout_ts: created_ts + timeoutMs,
68
112
  });
@@ -75,7 +119,11 @@ function createQuestionStore(rawDb, now = () => Date.now()) {
75
119
  setMessageIds(id, ids) { return setMsgIdsStmt.run(JSON.stringify(ids ?? []), id).changes; },
76
120
 
77
121
  updateState(id, state, awaitingOther = false) {
78
- return updateStateStmt.run({ id, state_json: JSON.stringify(state ?? {}), awaiting_other: awaitingOther ? 1 : 0 }).changes;
122
+ return updateStateStmt.run({
123
+ id,
124
+ state_json: JSON.stringify(withSanitizedQuestions(state)),
125
+ awaiting_other: awaitingOther ? 1 : 0,
126
+ }).changes;
79
127
  },
80
128
 
81
129
  /**
@@ -95,7 +143,15 @@ function createQuestionStore(rawDb, now = () => Date.now()) {
95
143
  if (!['answered', 'cancelled', 'timeout', 'expired'].includes(status)) {
96
144
  throw new Error(`bad status: ${status}`);
97
145
  }
98
- return resolveStmt.run({ id, status, answered_ts: now() }).changes;
146
+ // Terminal transition: the answer has already been handed to the agent
147
+ // and no path replays a non-pending row, so the retained state can be
148
+ // masked. The row stays for audit; the credential does not.
149
+ //
150
+ // Read and write are one transaction. Read-then-write outside one would
151
+ // let a tap's updateState land in between and be overwritten by the
152
+ // state this call had already read — the answer state silently rolling
153
+ // back at the moment the row is sealed.
154
+ return resolveTxn(id, status);
99
155
  },
100
156
 
101
157
  sweepTimedOut() { return listTimedOutStmt.all(now()); },
@@ -26,7 +26,13 @@ function transcriptPathFor(cwd, sessionId) {
26
26
  * @param {object} deps { db, pm, tg, bot, botName, logEvent, logger, buildForkImpl }
27
27
  * @returns {(req) => Promise<{ok:boolean,error?:string,droppedCount?:number}>}
28
28
  */
29
- function createRewindExecutor({ db, pm, tg, bot, botName = 'bot', logEvent = () => {}, logger = console, buildForkImpl = buildFork } = {}) {
29
+ function createRewindExecutor({
30
+ db, pm, tg, bot, botName = 'bot', logEvent = () => {}, logger = console,
31
+ buildForkImpl = buildFork,
32
+ // Narrow async hook: retire the question state of the session whose process
33
+ // is being replaced. A callback, so this module never learns the store.
34
+ retireQuestionSession = null,
35
+ } = {}) {
30
36
  // P3: delete the bot's own outbound messages sent after M (it can always delete its own).
31
37
  async function deleteBotMessagesAfter({ chatId, threadId, afterMsgId }) {
32
38
  let rows;
@@ -74,8 +80,12 @@ function createRewindExecutor({ db, pm, tg, bot, botName = 'bot', logEvent = ()
74
80
  catch (e) {
75
81
  warning = 'the previous session may still be running — send a new message to confirm the rewind took';
76
82
  logger.error?.(`[${botName}] rewind kill: ${e.message}`);
77
- logEvent('rewind-kill-failed', { session_key: req.sessionKey, error: e.message });
83
+ logEvent('rewind-kill-failed', { session_key: req.sessionKey, error_class: e.name || 'Error' });
78
84
  }
85
+ // The replaced process owned any open question. Retire its state whether or
86
+ // not the kill was clean: the rewound session cannot complete that ask, and
87
+ // an exact answer must not outlive the process it belonged to.
88
+ await retireQuestionSession?.(req.sessionKey);
79
89
 
80
90
  let droppedCount = 0;
81
91
  try { droppedCount = await deleteBotMessagesAfter({ chatId: req.chatId, threadId: req.threadId, afterMsgId: req.target.msg_id }); }
@@ -103,7 +103,7 @@ function createRewindHandler({ pm, tg, bot, botName = 'bot', executeRewind, logE
103
103
  logEvent('rewind-done', { session_key: req.sessionKey, target_msg_id: req.target.msg_id });
104
104
  } else {
105
105
  await ack(req.chatId, req.threadId, `↩️ couldn't rewind — ${(result && result.error) || 'unknown error'}`);
106
- logEvent('rewind-failed', { session_key: req.sessionKey, target_msg_id: req.target.msg_id, error: result && result.error });
106
+ logEvent('rewind-failed', { session_key: req.sessionKey, target_msg_id: req.target.msg_id, error_code: (result && result.error) ? 'rewind-failed' : null });
107
107
  }
108
108
  }
109
109
 
@@ -89,8 +89,8 @@ function createBuildSdkOptions({
89
89
  } catch (err) {
90
90
  logger.error?.(`[${label}] agent-loader: ${err.message}`);
91
91
  logEvent('agent-load-failed', {
92
- chat_id: chatId, agent: effectiveAgent, error: err.message,
93
- topic: threadId || null,
92
+ chat_id: chatId, agent: effectiveAgent, error_class: err.name || 'Error',
93
+ thread_id: threadId || null,
94
94
  });
95
95
  }
96
96
  }
@@ -618,16 +618,22 @@ function createSdkCallbacks({
618
618
  stop_hook_active: payload?.stopHookActive ?? null,
619
619
  received_at_ms: payload?.receivedAtMs ?? null,
620
620
  };
621
- // `parse-error` and `unknown` carry their raw body so soak
622
- // analysis can decide whether they indicate transport
623
- // corruption or schema drift. Truncate hard — these are
624
- // expected to be rare.
621
+ // `parse-error` and `unknown` used to carry their raw body so soak
622
+ // analysis could tell transport corruption from schema drift. The
623
+ // body is hook payload — tool inputs and assistant text — so what is
624
+ // recorded now is its shape: an error code where the parser gave one,
625
+ // and the two sizes. A body that is absent, empty or huge is still
626
+ // distinguishable; its contents are not persisted.
625
627
  if (payload?.type === 'parse-error' || payload?.type === 'unknown') {
626
628
  let rawStr;
627
629
  try { rawStr = JSON.stringify(payload.raw); }
628
630
  catch { rawStr = String(payload.raw); }
629
- detail.raw_truncated = (rawStr || '').slice(0, 512);
630
- detail.parse_error = payload?.error ?? null;
631
+ detail.raw_len = (rawStr || '').length;
632
+ const parseError = payload?.error;
633
+ detail.parse_error_code = parseError?.code || parseError?.name || null;
634
+ detail.parse_error_len = typeof parseError === 'string'
635
+ ? parseError.length
636
+ : (parseError?.message?.length ?? 0);
631
637
  }
632
638
  logEvent('hook-event', detail);
633
639
 
@@ -758,8 +764,7 @@ function createSdkCallbacks({
758
764
  chat_id: getChatIdFromKey(sessionKey),
759
765
  session_key: sessionKey,
760
766
  backend: payload?.backend ?? 'cli', // Finding 0.12-M3 (fires on the CLI hook tail)
761
- message: (payload?.message || '').slice(0, 200),
762
- path: payload?.path ?? null,
767
+ error_len: (payload?.message || '').length,
763
768
  claude_session_id: payload?.sessionId ?? null,
764
769
  });
765
770
  } catch (err) {
@@ -921,7 +926,7 @@ function createSdkCallbacks({
921
926
  chat_id: getChatIdFromKey(sessionKey),
922
927
  session_key: sessionKey,
923
928
  msg_id: msgId ?? null,
924
- error: (payload?.err || '').slice(0, 200),
929
+ error_len: (payload?.err || '').length,
925
930
  backend: payload?.backend || 'tmux',
926
931
  });
927
932
  if (bot && msgId != null) {