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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/history-preload.js +160 -0
- package/package.json +1 -1
- package/polygram.js +79 -2
|
@@ -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
|
+
"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.
|
|
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
|
|
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
|