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/dist/commands/setup.d.ts +14 -0
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +65 -6
- package/dist/commands/setup.js.map +1 -1
- package/dist/server/SecretDrop.d.ts +56 -3
- package/dist/server/SecretDrop.d.ts.map +1 -1
- package/dist/server/SecretDrop.js +116 -6
- package/dist/server/SecretDrop.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +46 -5
- package/dist/server/routes.js.map +1 -1
- package/package.json +2 -1
- package/scripts/lib/telegram-historian.mjs +224 -0
- package/scripts/lib/topic-memory-importer.mjs +132 -0
- package/scripts/telegram-history-backfill.mjs +353 -0
- package/src/data/builtin-manifest.json +46 -46
- package/upgrades/1.1.4.md +131 -30
- package/upgrades/1.2.1.md +71 -0
- package/upgrades/1.2.2.md +62 -0
- package/upgrades/side-effects/feat-bareword-framework-prompt.md +66 -0
- package/upgrades/side-effects/secret-drop-hardening.md +64 -0
- package/upgrades/side-effects/telegram-history-backfill.md +131 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "instar",
|
|
3
|
-
"version": "1.
|
|
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
|
+
}
|