polygram 0.12.6 → 0.12.8
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.
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
const DAY_MS = 86_400_000;
|
|
26
|
+
// A few minutes of grace so rows written ~now (between Date.now() capture and the
|
|
27
|
+
// COUNT) don't read as "future". Genuine backward-clock detection is fraction-based.
|
|
28
|
+
const CLOCK_SKEW_TOLERANCE_MS = 60_000;
|
|
26
29
|
|
|
27
30
|
const DEFAULT_POLICY = {
|
|
28
31
|
enabled: true,
|
|
@@ -65,6 +68,25 @@ function resolveRetentionPolicy(config) {
|
|
|
65
68
|
|
|
66
69
|
/** Fail loud on a misconfigured policy. Called at load and defensively per-run. */
|
|
67
70
|
function validatePolicy(policy) {
|
|
71
|
+
// Numeric sanity. An unvalidated batchSize<=0 makes batchedDelete spin forever
|
|
72
|
+
// (DELETE LIMIT 0 deletes 0, `0 < 0` is false) and wedges the synchronous
|
|
73
|
+
// daemon — the "a config typo must never take down the bot" promise depends on
|
|
74
|
+
// catching it here, before the loop.
|
|
75
|
+
if (!Number.isInteger(policy.batchSize) || policy.batchSize < 1) {
|
|
76
|
+
throw new Error(`events_retention: batchSize must be a positive integer (got ${policy.batchSize})`);
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isInteger(policy.maxPerKind) || policy.maxPerKind < 1) {
|
|
79
|
+
throw new Error(`events_retention: maxPerKind must be a positive integer (got ${policy.maxPerKind})`);
|
|
80
|
+
}
|
|
81
|
+
if (!(typeof policy.maxDeleteFraction === 'number' && policy.maxDeleteFraction > 0 && policy.maxDeleteFraction <= 1)) {
|
|
82
|
+
throw new Error(`events_retention: maxDeleteFraction must be in (0,1] (got ${policy.maxDeleteFraction})`);
|
|
83
|
+
}
|
|
84
|
+
for (const f of ['diagnosticDays', 'defaultDays']) {
|
|
85
|
+
if (!(typeof policy[f] === 'number' && policy[f] > 0)) {
|
|
86
|
+
throw new Error(`events_retention: ${f} must be a positive number (got ${policy[f]})`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
const diag = policy.diagnosticKinds || [];
|
|
69
91
|
const keep = policy.keepForeverKinds || [];
|
|
70
92
|
for (const k of [...diag, ...keep]) {
|
|
@@ -98,7 +120,10 @@ function batchedDelete(rawDb, sql, params, batchSize) {
|
|
|
98
120
|
for (;;) {
|
|
99
121
|
const r = stmt.run(...params, batchSize);
|
|
100
122
|
deleted += r.changes;
|
|
101
|
-
|
|
123
|
+
// `=== 0` is the belt-and-suspenders against a pathological LIMIT (e.g. a
|
|
124
|
+
// negative LIMIT = unlimited, which would otherwise loop on the empty set);
|
|
125
|
+
// validatePolicy already rejects batchSize<1, so this is defense-in-depth.
|
|
126
|
+
if (r.changes < batchSize || r.changes === 0) break;
|
|
102
127
|
}
|
|
103
128
|
return deleted;
|
|
104
129
|
}
|
|
@@ -117,37 +142,57 @@ function pruneEvents(rawDb, now, policy) {
|
|
|
117
142
|
const diagSet = new Set(policy.diagnosticKinds);
|
|
118
143
|
const keepSet = new Set(policy.keepForeverKinds);
|
|
119
144
|
|
|
120
|
-
const
|
|
121
|
-
const totalBefore = before.c;
|
|
145
|
+
const totalBefore = rawDb.prepare('SELECT count(*) c FROM events').get().c;
|
|
122
146
|
if (totalBefore === 0) {
|
|
123
|
-
return
|
|
147
|
+
return policy.dryRun
|
|
148
|
+
? { dryRun: true, preview: { default: 0, diagnostic: 0, cap: 0, total: 0 }, before: 0 }
|
|
149
|
+
: { deleted: { default: 0, diagnostic: 0, cap: 0, total: 0 }, before: 0, after: 0 };
|
|
124
150
|
}
|
|
125
151
|
|
|
126
|
-
// Clock-
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
152
|
+
// Clock-suspect guard. A genuine backward clock makes a LARGE fraction of rows
|
|
153
|
+
// read as "future" relative to `now` — pruning under it would delete recent
|
|
154
|
+
// data. But a SINGLE skewed/imported future row must NOT disable pruning, so
|
|
155
|
+
// this is fraction-based, not max(ts)-based (review finding #3: one outlier
|
|
156
|
+
// poisoning MAX(ts) silently stopped all pruning).
|
|
157
|
+
const future = rawDb.prepare('SELECT count(*) c FROM events WHERE ts > ?').get(now + CLOCK_SKEW_TOLERANCE_MS).c;
|
|
158
|
+
if (future > 0 && future / totalBefore > policy.maxDeleteFraction) {
|
|
159
|
+
return { skipped: true, reason: `clock-suspect (${future}/${totalBefore} rows future-dated > ${policy.maxDeleteFraction})` };
|
|
130
160
|
}
|
|
131
161
|
|
|
132
162
|
const diagCut = now - policy.diagnosticDays * DAY_MS;
|
|
133
163
|
const defCut = now - policy.defaultDays * DAY_MS;
|
|
134
164
|
|
|
135
|
-
// Default-bucket predicate: old AND not diagnostic
|
|
136
|
-
// Explicit ?-placeholders — better-sqlite3 does NOT expand
|
|
137
|
-
// param, and `NOT IN (…, NULL)` is a 3-valued-logic trap
|
|
138
|
-
// already guarantees no NULL members.
|
|
165
|
+
// Default-bucket predicate (for the actual bulk delete): old AND not diagnostic
|
|
166
|
+
// AND not keep-forever. Explicit ?-placeholders — better-sqlite3 does NOT expand
|
|
167
|
+
// a JS array from one param, and `NOT IN (…, NULL)` is a 3-valued-logic trap;
|
|
168
|
+
// validatePolicy already guarantees no NULL members.
|
|
139
169
|
const excluded = [...diagSet, ...keepSet];
|
|
140
170
|
const ph = excluded.map(() => '?').join(',');
|
|
141
171
|
const defWhere = `ts < ?${excluded.length ? ` AND kind NOT IN (${ph})` : ''}`;
|
|
142
172
|
|
|
143
173
|
// ---- estimate (drives dryRun + the mass-delete guard) ----
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
174
|
+
// Per-kind so the cap estimate counts SURVIVORS after the time-delete, not raw
|
|
175
|
+
// counts. Otherwise a kind that is both old AND over-cap is counted twice
|
|
176
|
+
// (time bucket + cap bucket), inflating estTotal past the real delete count and
|
|
177
|
+
// tripping the mass-delete guard against the exact high-volume prune it exists
|
|
178
|
+
// for — self-defeating (review finding #1). Each row is counted at most once.
|
|
179
|
+
const cntOlder = rawDb.prepare('SELECT count(*) c FROM events WHERE kind = ? AND ts < ?');
|
|
148
180
|
const kinds = rawDb.prepare('SELECT kind, count(*) c FROM events GROUP BY kind').all();
|
|
181
|
+
let estDefault = 0;
|
|
182
|
+
let estDiag = 0;
|
|
149
183
|
let estCap = 0;
|
|
150
|
-
for (const { c } of kinds)
|
|
184
|
+
for (const { kind, c } of kinds) {
|
|
185
|
+
let timeDel = 0;
|
|
186
|
+
if (keepSet.has(kind)) {
|
|
187
|
+
timeDel = 0; // keep-forever: no time delete
|
|
188
|
+
} else if (diagSet.has(kind)) {
|
|
189
|
+
timeDel = cntOlder.get(kind, diagCut).c; estDiag += timeDel;
|
|
190
|
+
} else {
|
|
191
|
+
timeDel = cntOlder.get(kind, defCut).c; estDefault += timeDel;
|
|
192
|
+
}
|
|
193
|
+
const survivors = c - timeDel;
|
|
194
|
+
if (survivors > policy.maxPerKind) estCap += survivors - policy.maxPerKind;
|
|
195
|
+
}
|
|
151
196
|
const estTotal = estDefault + estDiag + estCap;
|
|
152
197
|
|
|
153
198
|
// dryRun returns the preview regardless of the mass-delete guard (you want to
|
|
@@ -79,6 +79,17 @@ function createGateInbound({
|
|
|
79
79
|
&& pairings.hasLivePairing({ bot_name: botName, user_id: msg.from.id, chat_id: chatId })) {
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
|
+
// The operator owns the bot — their abort is never a bystander abort, so it
|
|
83
|
+
// outranks the @mention/reply requirement even in a group. Without this, the
|
|
84
|
+
// operator's bare "stop" in a group is silently abort-identity-blocked (prod:
|
|
85
|
+
// chat -1003369922517, 2026-06-15). Same operator predicate /rewind uses
|
|
86
|
+
// below: operatorUserId, else adminChatId ONLY when it's a user id — a
|
|
87
|
+
// negative/group adminChatId never equals a positive sender id, so it grants
|
|
88
|
+
// no bypass (fail-safe). Narrow: only the operator, not every group member.
|
|
89
|
+
const opId = config.bot?.operatorUserId;
|
|
90
|
+
const adminChatId = config.bot?.adminChatId;
|
|
91
|
+
const operatorUid = opId != null ? Number(opId) : (adminChatId != null ? Number(adminChatId) : null);
|
|
92
|
+
if (operatorUid != null && msg.from?.id != null && Number(msg.from.id) === operatorUid) return true;
|
|
82
93
|
return false;
|
|
83
94
|
}
|
|
84
95
|
|
package/lib/history-preload.js
CHANGED
|
@@ -57,6 +57,19 @@ function formatRow(row) {
|
|
|
57
57
|
return `[${ts}] ${who}: ${prefix}${text}`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The `<polygram-history …>` opening tag. All string attribute values are
|
|
62
|
+
* xmlEscaped — defense-in-depth, matching lib/prompt.js's convention. Today
|
|
63
|
+
* chat_id/thread_id are Telegram integers and since is a constant ('7d'), so
|
|
64
|
+
* this isn't currently exploitable, but the #10 body-escape fix shouldn't leave
|
|
65
|
+
* the attribute axis as the one unescaped breakout sink. `count` is rows.length
|
|
66
|
+
* (a number) — no escape needed.
|
|
67
|
+
*/
|
|
68
|
+
function openTag(chatId, threadId, count, since) {
|
|
69
|
+
const t = threadId ? ` thread_id="${xmlEscape(threadId)}"` : '';
|
|
70
|
+
return `<polygram-history chat_id="${xmlEscape(chatId)}"${t} preloaded="${count}" since="${xmlEscape(since)}">`;
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
/**
|
|
61
74
|
* Build the SessionStart hook callback.
|
|
62
75
|
*
|
|
@@ -122,7 +135,7 @@ function makeSessionStartHook({
|
|
|
122
135
|
const lines = rows.map(formatRow).join('\n');
|
|
123
136
|
|
|
124
137
|
const additionalContext = [
|
|
125
|
-
|
|
138
|
+
openTag(chatId, threadId, rows.length, since),
|
|
126
139
|
lines,
|
|
127
140
|
`</polygram-history>`,
|
|
128
141
|
'',
|
|
@@ -208,9 +221,8 @@ function buildHistoryBlock({
|
|
|
208
221
|
}
|
|
209
222
|
if (rows.length === 0) return '';
|
|
210
223
|
const lines = rows.map(formatRow).join('\n');
|
|
211
|
-
const attrs = `chat_id="${chatId}"${threadId ? ` thread_id="${threadId}"` : ''} preloaded="${rows.length}" since="${since}"`;
|
|
212
224
|
return [
|
|
213
|
-
|
|
225
|
+
openTag(chatId, threadId, rows.length, since),
|
|
214
226
|
lines,
|
|
215
227
|
`</polygram-history>`,
|
|
216
228
|
'',
|
|
@@ -228,6 +240,7 @@ module.exports = {
|
|
|
228
240
|
buildHistoryBlock,
|
|
229
241
|
// Internals for tests
|
|
230
242
|
_formatRow: formatRow,
|
|
243
|
+
_openTag: openTag,
|
|
231
244
|
DEFAULT_PRELOAD_LIMIT,
|
|
232
245
|
DEFAULT_PRELOAD_SINCE,
|
|
233
246
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.8",
|
|
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": {
|
|
@@ -21,7 +21,14 @@ const fs = require('fs');
|
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const Database = require('better-sqlite3');
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// The history helpers live at the PACKAGE ROOT lib/ (one source of truth, shared
|
|
25
|
+
// with the daemon's history-preload) — NOT a skill-local copy. The old
|
|
26
|
+
// `../lib/history` pointed at skills/history/lib/history.js, which has never
|
|
27
|
+
// existed in the repo or the published package, so EVERY `query.js` invocation
|
|
28
|
+
// crashed with MODULE_NOT_FOUND (the history skill was entirely non-functional;
|
|
29
|
+
// found in the 0.12.5–0.12.7 ultra-review). `../../../lib` = <pkg-root>/lib in
|
|
30
|
+
// both the repo and the npm install (which ships skills/ + lib/ as siblings).
|
|
31
|
+
const history = require('../../../lib/history');
|
|
25
32
|
|
|
26
33
|
const POLYGRAM_DIR = process.env.POLYGRAM_DIR || path.resolve(__dirname, '../../../../polygram');
|
|
27
34
|
const CONFIG_PATH = process.env.POLYGRAM_CONFIG || path.join(POLYGRAM_DIR, 'config.json');
|
|
@@ -281,4 +288,8 @@ function main() {
|
|
|
281
288
|
}
|
|
282
289
|
}
|
|
283
290
|
|
|
284
|
-
|
|
291
|
+
// Exported for tests (the #4 scope-derivation security boundary needs a
|
|
292
|
+
// regression pin). Only auto-run when invoked as the CLI, not when require()'d.
|
|
293
|
+
module.exports = { deriveBotScope, resolveDbPaths, loadConfig };
|
|
294
|
+
|
|
295
|
+
if (require.main === module) main();
|