polygram 0.8.0-rc.50 → 0.8.0-rc.52

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.50",
4
+ "version": "0.8.0-rc.52",
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",
package/lib/db.js CHANGED
@@ -498,6 +498,27 @@ function wrap(db) {
498
498
  // Treating ambiguous states as "replied" costs us occasional missed
499
499
  // replies (recoverable: user resends) to prevent duplicates
500
500
  // (irrecoverable: user has to mentally dedupe two answers).
501
+ // rc.51: stricter dedupe than hasOutboundReplyTo for boot-replay.
502
+ // A `turn_metrics` row is only inserted when a turn definitively
503
+ // completes (onResult callback). If no row exists for this inbound
504
+ // msg_id, the turn never finished — even if intermediate ack-bubbles
505
+ // were already sent. The rc.50 incident's lost msg 12158 had a
506
+ // partial "I'll write a quick inline script..." outbound but no
507
+ // turn_metrics, and was being silently skipped by replay-dedupe.
508
+ //
509
+ // Caveat: a row whose `error` is set (transient/aborted/timeout)
510
+ // does NOT count as complete — the turn started but failed. Boot
511
+ // replay should redispatch within window so the user gets a real
512
+ // answer.
513
+ hasCompletedTurnFor({ chat_id, msg_id }) {
514
+ const row = db.prepare(`
515
+ SELECT 1 FROM turn_metrics
516
+ WHERE chat_id = ? AND msg_id = ? AND error IS NULL
517
+ LIMIT 1
518
+ `).get(String(chat_id), msg_id);
519
+ return !!row;
520
+ },
521
+
501
522
  hasOutboundReplyTo({ chat_id, msg_id }) {
502
523
  const row = db.prepare(`
503
524
  SELECT 1 FROM messages
@@ -151,8 +151,68 @@ function makeSessionStartHook({
151
151
  };
152
152
  }
153
153
 
154
+ /**
155
+ * rc.52: synchronous variant of the same preload, returning the
156
+ * `<polygram-history>` block as a string ready to prepend to the
157
+ * fresh-session user message. Replaces the SessionStart-hook path
158
+ * (the SDK's `Options.hooks.SessionStart` is a documented API that
159
+ * the runtime does not actually dispatch — verified by spike +
160
+ * SDK source grep, see rc.52 commit).
161
+ *
162
+ * Exclude the row corresponding to `excludeMsgId` from the preload
163
+ * — that's the user message we're about to send, no need to echo
164
+ * it back to itself in the history block.
165
+ *
166
+ * Returns '' (empty string) when there's nothing to inject — caller
167
+ * just skips the prepend.
168
+ */
169
+ function buildHistoryBlock({
170
+ db,
171
+ chatId,
172
+ threadId = null,
173
+ excludeMsgId = null,
174
+ limit = DEFAULT_PRELOAD_LIMIT,
175
+ since = DEFAULT_PRELOAD_SINCE,
176
+ logger = console,
177
+ } = {}) {
178
+ if (!db?.raw || !chatId) return '';
179
+ let rows;
180
+ try {
181
+ rows = history.recent(db, {
182
+ chatId: String(chatId),
183
+ threadId: threadId ?? null,
184
+ limit,
185
+ since,
186
+ includeOutbound: true,
187
+ allowedChatIds: [String(chatId)],
188
+ }) || [];
189
+ } catch (err) {
190
+ logger?.error?.(`[history-preload] recent() failed: ${err?.message || err}`);
191
+ return '';
192
+ }
193
+ if (excludeMsgId != null) {
194
+ rows = rows.filter((r) => String(r.msg_id) !== String(excludeMsgId));
195
+ }
196
+ if (rows.length === 0) return '';
197
+ const lines = rows.map(formatRow).join('\n');
198
+ const attrs = `chat_id="${chatId}"${threadId ? ` thread_id="${threadId}"` : ''} preloaded="${rows.length}" since="${since}"`;
199
+ return [
200
+ `<polygram-history ${attrs}>`,
201
+ lines,
202
+ `</polygram-history>`,
203
+ '',
204
+ '— More history available via `node skills/history/scripts/query.js`:',
205
+ ' recent <chat_id> [thread_id] --limit N (older than the preload window)',
206
+ ' around --chat <id> --msg-id N (context window around a message)',
207
+ ' search <term> [chat_id] (FTS5 across full transcript)',
208
+ ' by-user <name> [chat_id] [thread_id]',
209
+ ' Bot scope is auto-resolved from cwd; no admin flag needed.',
210
+ ].join('\n');
211
+ }
212
+
154
213
  module.exports = {
155
214
  makeSessionStartHook,
215
+ buildHistoryBlock,
156
216
  // Internals for tests
157
217
  _formatRow: formatRow,
158
218
  DEFAULT_PRELOAD_LIMIT,
package/lib/prompt.js CHANGED
@@ -164,7 +164,7 @@ function buildVoiceTags(attachments) {
164
164
  * @param {Array} params.attachments - downloaded attachments
165
165
  * @param {Object} params.replyTo - input for buildReplyToBlock (optional)
166
166
  */
167
- function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], replyTo = null }) {
167
+ function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], replyTo = null, polygramHistory = '' }) {
168
168
  const chatId = msg.chat.id.toString();
169
169
  const msgId = msg.message_id.toString();
170
170
  const user = msg.from?.first_name || msg.from?.username || 'Unknown';
@@ -179,6 +179,15 @@ function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], r
179
179
  if (sessionCtx) {
180
180
  prompt += `<session-context>\n${sessionCtx}\n</session-context>\n\n`;
181
181
  }
182
+ // rc.52: fresh-session history preload. The caller (polygram.js)
183
+ // populates polygramHistory ONLY when this is the first message of a
184
+ // fresh Claude session (no --resume), built via
185
+ // history-preload.buildHistoryBlock. Empty string for resume-path
186
+ // turns. Replaces the SDK SessionStart hook which the SDK runtime
187
+ // doesn't actually dispatch (rc.52 finding).
188
+ if (polygramHistory) {
189
+ prompt += polygramHistory + '\n\n';
190
+ }
182
191
  prompt += `<polygram-info>${POLYGRAM_INFO}</polygram-info>\n\n`;
183
192
 
184
193
  const replyBlock = buildReplyToBlock(replyTo);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.50",
3
+ "version": "0.8.0-rc.52",
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
@@ -43,7 +43,7 @@ const {
43
43
  formatToolInputForCard,
44
44
  approvalCardText,
45
45
  } = require('./lib/approval-ui');
46
- const { makeSessionStartHook } = require('./lib/history-preload');
46
+ const { buildHistoryBlock } = require('./lib/history-preload');
47
47
  const { formatContextReply, maybeContextFullHint } = require('./lib/context-format');
48
48
  const { appendDisplayHint, appendDisplayHintCliArgs } = require('./lib/telegram-prompt');
49
49
  const { createAbortGrace } = require('./lib/abort-grace');
@@ -698,17 +698,53 @@ function resolveReplyTo(msg) {
698
698
  return { replyToId };
699
699
  }
700
700
 
701
- function formatPrompt(msg, sessionCtx, attachments = []) {
701
+ function formatPrompt(msg, sessionCtx, attachments = [], { sessionKey = null } = {}) {
702
702
  const chatId = msg.chat.id.toString();
703
703
  const threadId = msg.message_thread_id?.toString() || '';
704
704
  const chatConfig = config.chats[chatId];
705
705
  const topicName = threadId ? getTopicName(chatConfig, threadId) : '';
706
+
707
+ // rc.52: when the upcoming Query has no resume target (fresh
708
+ // session — daemon boot, /new, /reset, first-ever message in a
709
+ // chat/topic), prepend a `<polygram-history>` block so the fresh
710
+ // session has continuity instead of starting blank. Replaces the
711
+ // dead SessionStart hook (registered into `Options.hooks.SessionStart`
712
+ // since rc.21 but never fired — the SDK runtime doesn't dispatch
713
+ // user-defined hooks for that event, only CLI settings.json shell
714
+ // hooks).
715
+ let polygramHistory = '';
716
+ if (sessionKey && db) {
717
+ const existingSessionId = getClaudeSessionId(db, sessionKey);
718
+ if (!existingSessionId) {
719
+ try {
720
+ polygramHistory = buildHistoryBlock({
721
+ db,
722
+ chatId,
723
+ threadId: threadId || null,
724
+ excludeMsgId: msg.message_id,
725
+ logger: console,
726
+ });
727
+ if (polygramHistory) {
728
+ logEvent('history-preloaded', {
729
+ chat_id: chatId,
730
+ thread_id: threadId || null,
731
+ text_len: polygramHistory.length,
732
+ session_source: 'fresh',
733
+ });
734
+ }
735
+ } catch (err) {
736
+ console.error(`[history-preload] buildHistoryBlock failed: ${err?.message || err}`);
737
+ }
738
+ }
739
+ }
740
+
706
741
  return buildPrompt({
707
742
  msg,
708
743
  topicName,
709
744
  sessionCtx,
710
745
  attachments,
711
746
  replyTo: resolveReplyTo(msg),
747
+ polygramHistory,
712
748
  });
713
749
  }
714
750
 
@@ -903,21 +939,13 @@ function buildSdkOptions(sessionKey, ctx) {
903
939
  // turn-end path (handleMessage finally + success branches —
904
940
  // existing rc.38 cleanup).
905
941
 
906
- // 0.8.0-rc.21: SessionStart hook preloads recent polygram-DB
907
- // history into a fresh Query (no resume). Without this, every
908
- // /new or daemon-boot starts the agent blank even though the
909
- // chat has been running for weeks. Skips when source is
910
- // 'resume' or 'compact' (transcript already populated).
911
- const sessionStartHook = ctx?.chatId
912
- ? makeSessionStartHook({
913
- db,
914
- chatId: ctx.chatId,
915
- threadId: ctx.threadId ?? null,
916
- logEvent,
917
- logger: console,
918
- })
919
- : null;
920
-
942
+ // rc.52: dropped the SDK SessionStart hook registration. The hook
943
+ // never fired in production (verified: zero history-preloaded events
944
+ // across both production DBs since rc.21; SDK runtime grep showed
945
+ // exactly one occurrence of "SessionStart" in the type listing,
946
+ // not in dispatch logic). The history preload is now done inline at
947
+ // formatPrompt time when the upcoming Query has no resume target,
948
+ // via lib/history-preload.buildHistoryBlock. See formatPrompt above.
921
949
  const baseOpts = {
922
950
  model: chatConfig.model || config.defaults.model,
923
951
  effort: chatConfig.effort || config.defaults.effort,
@@ -929,11 +957,7 @@ function buildSdkOptions(sessionKey, ctx) {
929
957
  permissionMode: useCanUseTool ? 'default' : 'bypassPermissions',
930
958
  allowDangerouslySkipPermissions: !useCanUseTool,
931
959
  ...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
932
- hooks: {
933
- ...(sessionStartHook && {
934
- SessionStart: [{ hooks: [sessionStartHook] }],
935
- }),
936
- },
960
+ hooks: {},
937
961
  executable: 'node',
938
962
  ...(existingSessionId && { resume: existingSessionId }),
939
963
  ...(process.env.POLYGRAM_CLAUDE_BIN && {
@@ -2278,7 +2302,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2278
2302
  chatId, msgId: msg.message_id, label, botApi: bot, threadId,
2279
2303
  }) || { ackEmitted: false };
2280
2304
 
2281
- const prompt = formatPrompt(msg, sessionCtx, downloaded);
2305
+ const prompt = formatPrompt(msg, sessionCtx, downloaded, { sessionKey });
2282
2306
  const stopTyping = startTyping({
2283
2307
  bot, chatId, threadId,
2284
2308
  logger: { error: (m) => console.error(`[${label}] ${m}`) },
@@ -3977,8 +4001,19 @@ async function main() {
3977
4001
  let replayed = 0;
3978
4002
  let skipped = 0;
3979
4003
  for (const row of candidates) {
3980
- if (db.hasOutboundReplyTo({ chat_id: row.chat_id, msg_id: row.msg_id })) {
3981
- // Already replied — just mark so we don't look at it again.
4004
+ // rc.51: dedupe on turn_metrics (definitive turn completion),
4005
+ // NOT just on hasOutboundReplyTo. The latter trips on
4006
+ // intermediate ack-bubbles (e.g. "Catching up on history…",
4007
+ // "I'll write a quick inline script…") and silently skips the
4008
+ // replay even when the actual answer never arrived. The rc.50
4009
+ // EIO-orphan incident lost Ivan DM msg 12158 this way: an ack
4010
+ // bubble was sent at 13:20:36, the turn was killed mid-flight,
4011
+ // boot-replay saw the ack and assumed "answered."
4012
+ //
4013
+ // turn_metrics is only inserted by the SDK pm's onResult
4014
+ // callback, which fires only when the turn definitively
4015
+ // completes. No row → no completion → re-dispatch.
4016
+ if (db.hasCompletedTurnFor({ chat_id: row.chat_id, msg_id: row.msg_id })) {
3982
4017
  db.setInboundHandlerStatus({
3983
4018
  chat_id: row.chat_id, msg_id: row.msg_id, status: 'replied',
3984
4019
  });