instar 1.1.4 → 1.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.1.4",
3
+ "version": "1.2.2",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -105,6 +105,7 @@
105
105
  "moltbridge": "^0.1.6",
106
106
  "picocolors": "^1.0.0",
107
107
  "proper-lockfile": "^4.1.2",
108
+ "telegram": "^2.26.22",
108
109
  "ws": "^8.19.0",
109
110
  "zod": "^4.3.6"
110
111
  },
@@ -0,0 +1,224 @@
1
+ /**
2
+ * TelegramHistorian — gramjs-based MTProto backfill for instar topic-memory.
3
+ *
4
+ * Built 2026-05-20 in response to topic 10873 incident: Echo's
5
+ * topic-memory.db was truncated during a Node 22→25 native-module cascade,
6
+ * losing the local mirror of multiple Telegram topics. Telegram's Bot API
7
+ * is forward-only — bots cannot fetch chat history. A user-account MTProto
8
+ * session can. This module is the gramjs half of the recovery path.
9
+ *
10
+ * Authority posture (read carefully before extending):
11
+ * - This module authenticates AS the operator's Telegram account. It is
12
+ * not a bot. It can read everything the operator's account can read,
13
+ * including DMs and private groups. That power requires discipline.
14
+ * - Permitted operations: iterMessages (read), getDialogs (read),
15
+ * getMessages (read). No send/edit/delete is ever issued from here.
16
+ * - The session string is the long-lived credential. It must live behind
17
+ * mode-0600 file permissions, outside any git-tracked path, and be
18
+ * migrated into a real secret store as soon as one is available.
19
+ * - Auth requires interactive SMS code entry by the operator. The
20
+ * onCode/onPassword callbacks here are the only interactive surface.
21
+ *
22
+ * Forum topics: in a Telegram forum supergroup, each topic is identified
23
+ * by the message id of its topic-header message. Replies and posts in a
24
+ * topic carry reply_to.forum_topic = true and reply_to.reply_to_top_id =
25
+ * <topic_id>. gramjs surfaces this via the `replyTo` option to
26
+ * iterMessages — a value matching the topic id filters to that topic.
27
+ */
28
+
29
+ import { TelegramClient } from 'telegram';
30
+ import { StringSession } from 'telegram/sessions/index.js';
31
+ import { Api } from 'telegram';
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+
35
+ /**
36
+ * @typedef {Object} HistorianCredentials
37
+ * @property {number} apiId
38
+ * @property {string} apiHash
39
+ * @property {string} sessionFilePath Absolute path to the session string file
40
+ */
41
+
42
+ /**
43
+ * @typedef {Object} AuthPrompts
44
+ * @property {() => Promise<string>} onPhoneNumber Required if no saved session
45
+ * @property {() => Promise<string>} onCode Required if no saved session
46
+ * @property {() => Promise<string>} [onPassword] Optional 2FA password
47
+ * @property {(error: Error) => void} [onError]
48
+ */
49
+
50
+ const CONNECTION_RETRIES = 5;
51
+
52
+ export class TelegramHistorian {
53
+ /**
54
+ * @param {HistorianCredentials} creds
55
+ * @param {{ verbose?: boolean }} [opts]
56
+ */
57
+ constructor(creds, opts = {}) {
58
+ this.creds = creds;
59
+ this.verbose = opts.verbose ?? false;
60
+ this.client = null;
61
+ }
62
+
63
+ _loadSavedSession() {
64
+ if (!this.creds.sessionFilePath) return '';
65
+ if (!fs.existsSync(this.creds.sessionFilePath)) return '';
66
+ return fs.readFileSync(this.creds.sessionFilePath, 'utf8').trim();
67
+ }
68
+
69
+ _saveSession(sessionStr) {
70
+ if (!this.creds.sessionFilePath) return;
71
+ fs.mkdirSync(path.dirname(this.creds.sessionFilePath), { recursive: true, mode: 0o700 });
72
+ fs.writeFileSync(this.creds.sessionFilePath, sessionStr, { mode: 0o600 });
73
+ }
74
+
75
+ /**
76
+ * Connect and authenticate. If a saved session string exists at
77
+ * creds.sessionFilePath, reuses it (no SMS needed). Otherwise drives
78
+ * the interactive auth flow through the provided callbacks.
79
+ *
80
+ * @param {AuthPrompts} prompts
81
+ */
82
+ async connect(prompts) {
83
+ const saved = this._loadSavedSession();
84
+ const session = new StringSession(saved);
85
+ this.client = new TelegramClient(session, this.creds.apiId, this.creds.apiHash, {
86
+ connectionRetries: CONNECTION_RETRIES,
87
+ });
88
+
89
+ await this.client.start({
90
+ phoneNumber: prompts.onPhoneNumber,
91
+ phoneCode: prompts.onCode,
92
+ password: prompts.onPassword ?? (async () => {
93
+ throw new Error('2FA password required but no onPassword callback provided');
94
+ }),
95
+ onError: prompts.onError ?? ((err) => {
96
+ // eslint-disable-next-line no-console
97
+ console.error('[TelegramHistorian] auth error:', err.message);
98
+ }),
99
+ });
100
+
101
+ const sessionStr = this.client.session.save();
102
+ if (sessionStr && sessionStr !== saved) {
103
+ this._saveSession(sessionStr);
104
+ if (this.verbose) {
105
+ // eslint-disable-next-line no-console
106
+ console.error('[TelegramHistorian] session saved to', this.creds.sessionFilePath);
107
+ }
108
+ }
109
+ }
110
+
111
+ async disconnect() {
112
+ if (this.client) {
113
+ await this.client.disconnect();
114
+ this.client = null;
115
+ }
116
+ }
117
+
118
+ _ensureConnected() {
119
+ if (!this.client) throw new Error('TelegramHistorian: not connected — call connect() first');
120
+ }
121
+
122
+ /**
123
+ * Resolve a supergroup reference (numeric id like -1003742343280, or
124
+ * @username) to a gramjs entity.
125
+ * @param {string | number | bigint} chatRef
126
+ */
127
+ async resolveChat(chatRef) {
128
+ this._ensureConnected();
129
+ return await this.client.getEntity(chatRef);
130
+ }
131
+
132
+ /**
133
+ * List forum topics in a supergroup. Returns each topic's id, title, and
134
+ * top_message id.
135
+ * @param {string | number | bigint} chatRef
136
+ * @returns {Promise<Array<{ id: number, title: string, topMessageId: number, closed: boolean }>>}
137
+ */
138
+ async listForumTopics(chatRef) {
139
+ this._ensureConnected();
140
+ const entity = await this.resolveChat(chatRef);
141
+ const result = await this.client.invoke(
142
+ new Api.channels.GetForumTopics({
143
+ channel: entity,
144
+ offsetDate: 0,
145
+ offsetId: 0,
146
+ offsetTopic: 0,
147
+ limit: 100,
148
+ }),
149
+ );
150
+ const topics = [];
151
+ for (const t of result.topics) {
152
+ // ForumTopic vs ForumTopicDeleted
153
+ if (t.className === 'ForumTopic') {
154
+ topics.push({
155
+ id: Number(t.id),
156
+ title: t.title,
157
+ topMessageId: Number(t.topMessage ?? t.id),
158
+ closed: Boolean(t.closed),
159
+ });
160
+ }
161
+ }
162
+ return topics;
163
+ }
164
+
165
+ /**
166
+ * Iterate all messages in a single forum topic, oldest-first. Yields
167
+ * one BackfillMessage at a time. Handles FloodWait via gramjs's
168
+ * built-in retry. Caller is responsible for importing.
169
+ *
170
+ * @param {string | number | bigint} chatRef
171
+ * @param {number} topicId forum topic id
172
+ * @param {{ minMessageId?: number, batchSize?: number }} [opts]
173
+ */
174
+ async *iterTopicMessages(chatRef, topicId, opts = {}) {
175
+ this._ensureConnected();
176
+ const entity = await this.resolveChat(chatRef);
177
+ const batchSize = opts.batchSize ?? 100;
178
+ const minMessageId = opts.minMessageId ?? 0;
179
+
180
+ const iter = this.client.iterMessages(entity, {
181
+ replyTo: topicId,
182
+ limit: undefined,
183
+ reverse: true,
184
+ minId: minMessageId,
185
+ filter: undefined,
186
+ });
187
+
188
+ let pageCount = 0;
189
+ let yielded = 0;
190
+ for await (const msg of iter) {
191
+ if (!msg) continue;
192
+ const text = typeof msg.message === 'string' ? msg.message : '';
193
+ const sender = msg.sender ?? null;
194
+ const fromUser = msg.out === true ? 1 : 0;
195
+ const ts = new Date(Number(msg.date) * 1000).toISOString();
196
+
197
+ yield {
198
+ messageId: Number(msg.id),
199
+ topicId,
200
+ text,
201
+ timestamp: ts,
202
+ fromUser,
203
+ senderName: sender?.firstName
204
+ ? [sender.firstName, sender.lastName].filter(Boolean).join(' ')
205
+ : null,
206
+ senderUsername: sender?.username ?? null,
207
+ telegramUserId: sender?.id != null ? Number(sender.id) : null,
208
+ userId: null,
209
+ sessionName: null,
210
+ privacyScope: 'private',
211
+ };
212
+ yielded++;
213
+ if (yielded % batchSize === 0) {
214
+ pageCount++;
215
+ if (this.verbose) {
216
+ // eslint-disable-next-line no-console
217
+ console.error(
218
+ `[TelegramHistorian] topic ${topicId}: page ${pageCount} (${yielded} so far)`,
219
+ );
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * topic-memory-importer — idempotent import of Telegram messages into
3
+ * .instar/topic-memory.db.
4
+ *
5
+ * Built 2026-05-20 after the local topic-memory.db was truncated to 4
6
+ * messages during the Node 22→25 better-sqlite3 cascade recovery (incident
7
+ * recorded in topic 10873). Bots cannot fetch Telegram history; only a
8
+ * user-account MTProto session can. This module is the storage half of
9
+ * that backfill path — gramjs fetches, this writes.
10
+ *
11
+ * The schema (read 2026-05-20 from a live agent home):
12
+ *
13
+ * messages(id PK auto, message_id, topic_id, text, from_user, timestamp,
14
+ * session_name, sender_name, sender_username, telegram_user_id,
15
+ * user_id, privacy_scope, UNIQUE(message_id, topic_id))
16
+ *
17
+ * The UNIQUE(message_id, topic_id) constraint is the idempotency primitive.
18
+ * `INSERT OR IGNORE` is the only sanctioned write path here — re-running
19
+ * the importer against the same input is a no-op.
20
+ *
21
+ * Concurrency: the instar server (echo's own process) holds topic-memory.db
22
+ * open under WAL mode. SQLite serializes writes across processes via WAL,
23
+ * so this importer can write while the server is reading without
24
+ * corruption. The FTS triggers maintain the FTS index automatically.
25
+ */
26
+
27
+ import Database from 'better-sqlite3';
28
+
29
+ /**
30
+ * @typedef {Object} BackfillMessage
31
+ * @property {number} messageId Telegram message id (per chat)
32
+ * @property {number} topicId Forum topic id (= message id of the
33
+ * topic-header message in the supergroup)
34
+ * @property {string} text Message text. Empty string for
35
+ * service messages / media-only.
36
+ * @property {string} timestamp ISO 8601 UTC
37
+ * @property {number} [fromUser] 1 if message was sent by the user
38
+ * (justin), 0 if from any agent
39
+ * @property {string} [senderName] Display name
40
+ * @property {string} [senderUsername] @handle, no leading @
41
+ * @property {number} [telegramUserId] Numeric Telegram user id
42
+ * @property {string} [userId] instar user id, if known
43
+ * @property {string} [sessionName] instar session name, if known
44
+ * @property {string} [privacyScope] Defaults to 'private'
45
+ */
46
+
47
+ /**
48
+ * @typedef {Object} ImportResult
49
+ * @property {number} inserted
50
+ * @property {number} skipped
51
+ * @property {Set<number>} touchedTopics
52
+ */
53
+
54
+ export class TopicMemoryImporter {
55
+ /**
56
+ * @param {string} dbPath absolute path to topic-memory.db
57
+ * @param {{ readonly?: boolean, journalMode?: 'wal' | 'delete' }} [opts]
58
+ */
59
+ constructor(dbPath, opts = {}) {
60
+ this.db = new Database(dbPath, { readonly: opts.readonly ?? false });
61
+ this.db.pragma(`journal_mode = ${opts.journalMode ?? 'wal'}`);
62
+ this.db.pragma('busy_timeout = 5000');
63
+
64
+ this._insert = this.db.prepare(`
65
+ INSERT OR IGNORE INTO messages (
66
+ message_id, topic_id, text, from_user, timestamp,
67
+ session_name, sender_name, sender_username, telegram_user_id,
68
+ user_id, privacy_scope
69
+ ) VALUES (
70
+ @messageId, @topicId, @text, @fromUser, @timestamp,
71
+ @sessionName, @senderName, @senderUsername, @telegramUserId,
72
+ @userId, @privacyScope
73
+ )
74
+ `);
75
+
76
+ this._countByTopic = this.db.prepare(
77
+ `SELECT topic_id, COUNT(*) AS n FROM messages GROUP BY topic_id`,
78
+ );
79
+ }
80
+
81
+ close() {
82
+ this.db.close();
83
+ }
84
+
85
+ /**
86
+ * Import a batch of messages. All-or-nothing per call (wrapped in a
87
+ * transaction). Safe to retry — the UNIQUE(message_id, topic_id)
88
+ * constraint plus INSERT OR IGNORE means duplicates are silently dropped.
89
+ *
90
+ * @param {BackfillMessage[]} messages
91
+ * @returns {ImportResult}
92
+ */
93
+ importBatch(messages) {
94
+ const touched = new Set();
95
+ let inserted = 0;
96
+ let skipped = 0;
97
+ const tx = this.db.transaction((batch) => {
98
+ for (const m of batch) {
99
+ const row = this._insert.run({
100
+ messageId: m.messageId,
101
+ topicId: m.topicId,
102
+ text: m.text ?? '',
103
+ fromUser: m.fromUser ?? 0,
104
+ timestamp: m.timestamp,
105
+ sessionName: m.sessionName ?? null,
106
+ senderName: m.senderName ?? null,
107
+ senderUsername: m.senderUsername ?? null,
108
+ telegramUserId: m.telegramUserId ?? null,
109
+ userId: m.userId ?? null,
110
+ privacyScope: m.privacyScope ?? 'private',
111
+ });
112
+ if (row.changes === 1) inserted++;
113
+ else skipped++;
114
+ touched.add(m.topicId);
115
+ }
116
+ });
117
+ tx(messages);
118
+ return { inserted, skipped, touchedTopics: touched };
119
+ }
120
+
121
+ /**
122
+ * Current message counts keyed by topic_id.
123
+ * @returns {Map<number, number>}
124
+ */
125
+ countsByTopic() {
126
+ const m = new Map();
127
+ for (const row of this._countByTopic.all()) {
128
+ m.set(row.topic_id, row.n);
129
+ }
130
+ return m;
131
+ }
132
+ }