polygram 0.8.0-rc.20 → 0.8.0-rc.22

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.20",
4
+ "version": "0.8.0-rc.22",
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",
@@ -0,0 +1,160 @@
1
+ /**
2
+ * SessionStart hook factory: preloads recent chat history into a
3
+ * fresh SDK Query so the agent has context on day-zero.
4
+ *
5
+ * Why: when polygram spawns a brand-new Query for a chat (daemon
6
+ * boot, /new, /reset), the SDK has no transcript — the model
7
+ * starts blank even though the chat has been running for weeks.
8
+ * The user has to re-explain context every time. This hook injects
9
+ * the last N polygram-stored messages into the new session's
10
+ * `additionalContext`, plus a hint that the agent can query the
11
+ * history skill for older messages it didn't get preloaded.
12
+ *
13
+ * Fires only when SessionStart's `source` is 'startup' or 'clear'
14
+ * (genuinely fresh sessions). Skips on 'resume' (SDK is restoring
15
+ * the prior transcript) and 'compact' (SDK just compacted; history
16
+ * is already in the post-compact summary).
17
+ *
18
+ * Reuses lib/history.js's `recent()` helper — same DB query the
19
+ * polygram history skill exposes via CLI, so the agent's skill
20
+ * invocations and our preload return consistent shapes.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const history = require('./history');
26
+
27
+ const DEFAULT_PRELOAD_LIMIT = 15;
28
+ const DEFAULT_PRELOAD_SINCE = '7d';
29
+
30
+ /**
31
+ * Format a single message row as a transcript line.
32
+ *
33
+ * [2026-04-30 09:15] Ivan Shumkov: hello
34
+ * [2026-04-30 09:16] bot: hey
35
+ *
36
+ * Schema notes: messages table uses `direction` = 'in'|'out',
37
+ * `user` for the sender display name (inbound) or bot identity
38
+ * (outbound). reply_to_id is on the row directly. Attachment and
39
+ * voice flags live on the attachments table via JOIN — not
40
+ * surfaced here in the preload (operator-curated history docs are
41
+ * the place for that level of detail).
42
+ */
43
+ function formatRow(row) {
44
+ const ts = new Date(row.ts).toISOString().replace('T', ' ').slice(0, 16);
45
+ const who = row.direction === 'in'
46
+ ? (row.user || row.user_id || 'user')
47
+ : (row.user || row.bot_name || 'bot');
48
+ const prefix = row.reply_to_id ? `[reply→#${row.reply_to_id}] ` : '';
49
+ const text = (row.text || '').replace(/\s+/g, ' ').slice(0, 600);
50
+ return `[${ts}] ${who}: ${prefix}${text}`;
51
+ }
52
+
53
+ /**
54
+ * Build the SessionStart hook callback.
55
+ *
56
+ * @param {object} opts
57
+ * @param {object} opts.db polygram db wrapper (has .raw better-sqlite3 instance)
58
+ * @param {string} opts.chatId the chat being spawned
59
+ * @param {string|null} [opts.threadId]
60
+ * @param {string[]} [opts.allowedChatIds] scope-narrowing safety; defaults to [chatId]
61
+ * @param {number} [opts.limit] max messages to preload (default 15)
62
+ * @param {string} [opts.since] cutoff window (default '7d')
63
+ * @param {(kind: string, detail: object) => void} [opts.logEvent]
64
+ * @param {object} [opts.logger]
65
+ *
66
+ * @returns {async (input) => Promise<HookJSONOutput>}
67
+ */
68
+ function makeSessionStartHook({
69
+ db,
70
+ chatId,
71
+ threadId = null,
72
+ allowedChatIds = null,
73
+ limit = DEFAULT_PRELOAD_LIMIT,
74
+ since = DEFAULT_PRELOAD_SINCE,
75
+ logEvent = null,
76
+ logger = console,
77
+ } = {}) {
78
+ if (!db || !db.raw) throw new TypeError('db (with .raw better-sqlite3) required');
79
+ if (!chatId) throw new TypeError('chatId required');
80
+
81
+ return async (input) => {
82
+ try {
83
+ // Skip on resume / compact — transcript already has history.
84
+ if (input?.source === 'resume' || input?.source === 'compact') {
85
+ return { continue: true };
86
+ }
87
+
88
+ const scope = allowedChatIds || [String(chatId)];
89
+ let rows;
90
+ try {
91
+ // history.recent() expects the polygram db wrapper (it
92
+ // calls db.raw.prepare internally), not the raw bsqlite3.
93
+ rows = history.recent(db, {
94
+ chatId: String(chatId),
95
+ threadId: threadId ?? null,
96
+ limit,
97
+ since,
98
+ includeOutbound: true,
99
+ allowedChatIds: scope,
100
+ }) || [];
101
+ } catch (err) {
102
+ logger?.error?.(`[history-preload] recent() failed: ${err?.message || err}`);
103
+ return { continue: true };
104
+ }
105
+
106
+ if (rows.length === 0) {
107
+ return { continue: true };
108
+ }
109
+
110
+ // history.recent() returns rows in chronological order
111
+ // already (it does `ORDER BY ts DESC LIMIT N` then `.reverse()`
112
+ // internally — see lib/history.js:69).
113
+ const lines = rows.map(formatRow).join('\n');
114
+
115
+ const additionalContext = [
116
+ `<polygram-history chat_id="${chatId}"${threadId ? ` thread_id="${threadId}"` : ''} preloaded="${rows.length}" since="${since}">`,
117
+ lines,
118
+ `</polygram-history>`,
119
+ '',
120
+ '— More history available via `node skills/history/scripts/query.js`:',
121
+ ' recent <chat_id> [thread_id] --limit N (older than the preload window)',
122
+ ' around --chat <id> --msg-id N (context window around a message)',
123
+ ' search <term> [chat_id] (FTS5 across full transcript)',
124
+ ' by-user <name> [chat_id] [thread_id]',
125
+ ' Bot scope is auto-resolved from cwd; no admin flag needed.',
126
+ ].join('\n');
127
+
128
+ if (typeof logEvent === 'function') {
129
+ try {
130
+ logEvent('history-preloaded', {
131
+ chat_id: chatId,
132
+ session_source: input?.source ?? 'startup',
133
+ row_count: rows.length,
134
+ text_len: additionalContext.length,
135
+ });
136
+ } catch { /* swallow logger errors */ }
137
+ }
138
+
139
+ return {
140
+ continue: true,
141
+ hookSpecificOutput: {
142
+ hookEventName: 'SessionStart',
143
+ additionalContext,
144
+ },
145
+ };
146
+ } catch (err) {
147
+ logger?.error?.(`[history-preload] hook error: ${err?.message || err}`);
148
+ // Never throw out of a hook.
149
+ return { continue: true };
150
+ }
151
+ };
152
+ }
153
+
154
+ module.exports = {
155
+ makeSessionStartHook,
156
+ // Internals for tests
157
+ _formatRow: formatRow,
158
+ DEFAULT_PRELOAD_LIMIT,
159
+ DEFAULT_PRELOAD_SINCE,
160
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.20",
3
+ "version": "0.8.0-rc.22",
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
@@ -40,6 +40,7 @@ const {
40
40
  formatToolInputForCard,
41
41
  approvalCardText,
42
42
  } = require('./lib/approval-ui');
43
+ const { makeSessionStartHook } = require('./lib/history-preload');
43
44
  const agentLoader = require('./lib/agent-loader');
44
45
  const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
45
46
  const { createSender } = require('./lib/telegram');
@@ -929,6 +930,21 @@ function buildSdkOptions(sessionKey, ctx) {
929
930
  logger: console,
930
931
  });
931
932
 
933
+ // 0.8.0-rc.21: SessionStart hook preloads recent polygram-DB
934
+ // history into a fresh Query (no resume). Without this, every
935
+ // /new or daemon-boot starts the agent blank — even though the
936
+ // chat has been running for weeks. Skips when source is
937
+ // 'resume' or 'compact' (transcript already populated).
938
+ const sessionStartHook = ctx?.chatId
939
+ ? makeSessionStartHook({
940
+ db,
941
+ chatId: ctx.chatId,
942
+ threadId: ctx.threadId ?? null,
943
+ logEvent,
944
+ logger: console,
945
+ })
946
+ : null;
947
+
932
948
  const baseOpts = {
933
949
  model: chatConfig.model || config.defaults.model,
934
950
  effort: chatConfig.effort || config.defaults.effort,
@@ -942,6 +958,9 @@ function buildSdkOptions(sessionKey, ctx) {
942
958
  ...(useCanUseTool && { canUseTool: makeCanUseTool(sessionKey) }),
943
959
  hooks: {
944
960
  PostToolBatch: [{ hooks: [postToolBatchHook] }],
961
+ ...(sessionStartHook && {
962
+ SessionStart: [{ hooks: [sessionStartHook] }],
963
+ }),
945
964
  },
946
965
  executable: 'node',
947
966
  ...(existingSessionId && { resume: existingSessionId }),
@@ -1968,6 +1987,51 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1968
1987
  }
1969
1988
  return;
1970
1989
  }
1990
+ // 0.8.0-rc.22: /compact <preserve text> — manual SDK compaction with
1991
+ // user-supplied preservation instructions. The SDK's CLI binary
1992
+ // recognises "/compact" as a slash command via streamInput.push
1993
+ // (verified by scripts/spikes/compact-via-streaminput.mjs: PreCompact
1994
+ // hook fires with trigger:'manual', compact_boundary event lands).
1995
+ // We push the raw text "/compact <instructions>" through the SDK's
1996
+ // input controller; the SDK handles parsing + compaction internally.
1997
+ if (botAllowsCommands && text.startsWith('/compact')) {
1998
+ if (!pm.isSdkFor(sessionKey)) {
1999
+ await sendReply('🗜️ /compact requires the SDK pm. This chat is on the CLI pm path.');
2000
+ return;
2001
+ }
2002
+ if (!pm.has(sessionKey)) {
2003
+ await sendReply('🗜️ No active session — /compact only works once a turn has started.');
2004
+ return;
2005
+ }
2006
+ const entry = pm.get(sessionKey);
2007
+ if (!entry?.inputController?.push) {
2008
+ await sendReply('🗜️ Session not ready for /compact (no input controller).');
2009
+ return;
2010
+ }
2011
+ // Push the literal "/compact ..." text into the input stream.
2012
+ // The SDK parses leading "/" as a slash command and triggers
2013
+ // manual compaction; user's preserve instructions land in
2014
+ // PreCompactHookInput.custom_instructions.
2015
+ try {
2016
+ entry.inputController.push({
2017
+ type: 'user',
2018
+ message: { role: 'user', content: text },
2019
+ parent_tool_use_id: null,
2020
+ });
2021
+ logEvent('compact-command', {
2022
+ chat_id: chatId, text_len: text.length,
2023
+ user: cmdUser, user_id: cmdUserId,
2024
+ });
2025
+ const preserveBit = text.length > '/compact'.length
2026
+ ? ' with your preservation instructions'
2027
+ : '';
2028
+ await sendReply(`🗜️ Compacting${preserveBit}…`);
2029
+ } catch (err) {
2030
+ console.error(`[${label}] /compact push: ${err.message}`);
2031
+ await sendReply(`🗜️ Couldn't trigger compact: ${err.message}`);
2032
+ }
2033
+ return;
2034
+ }
1971
2035
  if (botAllowsCommands && (text === '/new' || text === '/reset')) {
1972
2036
  let drained = 0;
1973
2037
  const target = pm.pickFor(sessionKey);
@@ -2584,9 +2648,22 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2584
2648
  // context (and fired below the intended 85% threshold).
2585
2649
  const pct = usage?.percentage ?? 0;
2586
2650
  if (pct < 85) return;
2651
+ // rc.22: three-choice hint. The original "send /new"
2652
+ // message implied the only path forward was a hard
2653
+ // reset. Now offer all three options the user actually
2654
+ // has — start fresh, compact with their preserve
2655
+ // instructions, or keep going (auto-compact eventually
2656
+ // fires).
2657
+ const text = [
2658
+ `📚 Context window ${pct.toFixed(0)}% full. Three options:`,
2659
+ '',
2660
+ '• `/new` — start fresh; this conversation ends.',
2661
+ '• `/compact <preserve text>` — summarise older messages, keep what you specify.',
2662
+ '• Keep chatting — I\'ll auto-compact when needed; key context is preserved automatically.',
2663
+ ].join('\n');
2587
2664
  return tg(bot, 'sendMessage', {
2588
2665
  chat_id: chatId,
2589
- text: `📚 Context window ${pct.toFixed(0)}% full. Send /new to start fresh — older messages will start dropping soon.`,
2666
+ text,
2590
2667
  ...(threadId ? { message_thread_id: threadId } : {}),
2591
2668
  }, { source: 'context-full-hint', botName: BOT_NAME });
2592
2669
  }).catch((err) => {
@@ -2849,7 +2926,7 @@ function createBot(token) {
2849
2926
  // Cached once @botUsername is known — was recompiling per inbound msg.
2850
2927
  let mentionRe = null;
2851
2928
  // Hoisted admin-command matcher; was re-allocated per message.
2852
- const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context)(\s|$)/;
2929
+ const ADMIN_CMD_RE = /^\/(model|effort|config|pair-code|pairings|unpair|new|reset|context|compact)(\s|$)/;
2853
2930
  const PAIR_CLAIM_RE = /^\/pair\s+\S+/;
2854
2931
 
2855
2932
  // The filter in main() guarantees config.chats only contains chats owned