parallelclaw 1.0.0
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/CHANGELOG.md +204 -0
- package/HELP.md +600 -0
- package/LICENSE +21 -0
- package/MULTI_MACHINE.md +152 -0
- package/README.md +417 -0
- package/README.ru.md +740 -0
- package/SYNC.md +844 -0
- package/bot/README.md +173 -0
- package/bot/config.js +66 -0
- package/bot/inbox.js +153 -0
- package/bot/index.js +294 -0
- package/bot/nexara.js +61 -0
- package/bot/poll.js +304 -0
- package/bot/search.js +155 -0
- package/bot/telegram.js +96 -0
- package/ingest.js +2712 -0
- package/lib/cli/index.js +1987 -0
- package/lib/config.js +220 -0
- package/lib/db-init.js +158 -0
- package/lib/hook/install.js +268 -0
- package/lib/import-telegram.js +158 -0
- package/lib/ingest-file.js +779 -0
- package/lib/notify-click-action.js +281 -0
- package/lib/openclaw-channel.js +643 -0
- package/lib/parse-cursor.js +172 -0
- package/lib/parse-obsidian.js +256 -0
- package/lib/parse-telegram-html.js +384 -0
- package/lib/parse.js +175 -0
- package/lib/render-markdown.js +0 -0
- package/lib/store-doc/canonicalize.js +116 -0
- package/lib/store-doc/detect.js +209 -0
- package/lib/store-doc/extract-title.js +162 -0
- package/lib/sync/auth.js +80 -0
- package/lib/sync/cert.js +144 -0
- package/lib/sync/cli.js +906 -0
- package/lib/sync/client.js +138 -0
- package/lib/sync/config.js +130 -0
- package/lib/sync/pair.js +145 -0
- package/lib/sync/pull.js +158 -0
- package/lib/sync/push.js +305 -0
- package/lib/sync/replicate.js +335 -0
- package/lib/sync/server.js +224 -0
- package/lib/sync/service.js +726 -0
- package/lib/tasks.js +215 -0
- package/lib/telegram-decisions.js +165 -0
- package/lib/telegram-discovery.js +373 -0
- package/lib/telegram-notify.js +272 -0
- package/lib/telegram-pending.js +200 -0
- package/lib/web/index.js +265 -0
- package/lib/web/routes/conversation.js +193 -0
- package/lib/web/routes/conversations.js +180 -0
- package/lib/web/routes/dashboard.js +175 -0
- package/lib/web/routes/pending.js +277 -0
- package/lib/web/routes/settings.js +226 -0
- package/lib/web/static/style.css +393 -0
- package/lib/web/templates.js +234 -0
- package/package.json +84 -0
- package/server.js +3816 -0
- package/skills/install-memex/README.md +109 -0
- package/skills/install-memex/SKILL.md +342 -0
- package/skills/install-memex/examples.md +294 -0
- package/skills/install-memex-claw/SKILL.md +423 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram-Desktop discovery & preview.
|
|
3
|
+
*
|
|
4
|
+
* Three responsibilities:
|
|
5
|
+
* 1. detectTelegramDesktop() — is the app installed, where, what kind
|
|
6
|
+
* 2. detectFirstLogin() — when did the user log in (anti-abuse 24h window)
|
|
7
|
+
* 3. discoverExports(dirs) — scan likely Downloads paths for ChatExport_*
|
|
8
|
+
* 4. previewExport(path) — extract chat name, msg count, date range
|
|
9
|
+
* without doing a full ingest. Used by `memex telegram pending`.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is fast and read-only — safe to call from CLI, MCP, or daemon.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { homedir, platform } from 'node:os';
|
|
17
|
+
import { detectTelegramHtml, parseTelegramHtmlExport } from './parse-telegram-html.js';
|
|
18
|
+
|
|
19
|
+
// ------------------------- Desktop detection -------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect Telegram Desktop installation.
|
|
23
|
+
*
|
|
24
|
+
* Returns {
|
|
25
|
+
* installed: bool,
|
|
26
|
+
* path: string|null, // absolute path to .app / executable / install dir
|
|
27
|
+
* variant: 'direct'|'app_store'|'snap'|'apt'|'exe'|null,
|
|
28
|
+
* platform: 'darwin'|'linux'|'win32'|'other',
|
|
29
|
+
* notes: string[] // user-facing hints, e.g. App Store sandbox warning
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* On macOS we check /Applications/Telegram.app and /Applications/Telegram Desktop.app.
|
|
33
|
+
* The Mac App Store version is sandboxed and can have issues with the export
|
|
34
|
+
* folder — we flag that.
|
|
35
|
+
*/
|
|
36
|
+
export function detectTelegramDesktop() {
|
|
37
|
+
const plat = platform();
|
|
38
|
+
const out = { installed: false, path: null, variant: null, platform: plat, notes: [] };
|
|
39
|
+
|
|
40
|
+
if (plat === 'darwin') {
|
|
41
|
+
const candidates = [
|
|
42
|
+
'/Applications/Telegram.app',
|
|
43
|
+
'/Applications/Telegram Desktop.app',
|
|
44
|
+
join(homedir(), 'Applications/Telegram.app'),
|
|
45
|
+
];
|
|
46
|
+
for (const c of candidates) {
|
|
47
|
+
if (existsSync(c)) {
|
|
48
|
+
out.installed = true;
|
|
49
|
+
out.path = c;
|
|
50
|
+
// Try to tell App Store apart from direct download by bundle receipt path
|
|
51
|
+
const receiptPath = join(c, 'Contents/_MASReceipt/receipt');
|
|
52
|
+
out.variant = existsSync(receiptPath) ? 'app_store' : 'direct';
|
|
53
|
+
if (out.variant === 'app_store') {
|
|
54
|
+
out.notes.push(
|
|
55
|
+
"App Store Telegram has sandboxed file access. Chat exports usually work but may go to a Containers/ path instead of ~/Downloads/Telegram Desktop/. If memex can't see your exports, install the direct version from telegram.org/dl/macos."
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else if (plat === 'linux') {
|
|
62
|
+
// Common Linux install paths
|
|
63
|
+
const candidates = [
|
|
64
|
+
'/usr/bin/telegram-desktop',
|
|
65
|
+
'/usr/local/bin/telegram-desktop',
|
|
66
|
+
'/snap/bin/telegram-desktop',
|
|
67
|
+
join(homedir(), '.local/share/TelegramDesktop'),
|
|
68
|
+
'/var/lib/flatpak/exports/bin/org.telegram.desktop',
|
|
69
|
+
];
|
|
70
|
+
for (const c of candidates) {
|
|
71
|
+
if (existsSync(c)) {
|
|
72
|
+
out.installed = true;
|
|
73
|
+
out.path = c;
|
|
74
|
+
out.variant = c.includes('/snap/') ? 'snap' : c.includes('flatpak') ? 'flatpak' : 'apt';
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} else if (plat === 'win32') {
|
|
79
|
+
const candidates = [
|
|
80
|
+
join(homedir(), 'AppData/Roaming/Telegram Desktop/Telegram.exe'),
|
|
81
|
+
'C:/Program Files/Telegram Desktop/Telegram.exe',
|
|
82
|
+
];
|
|
83
|
+
for (const c of candidates) {
|
|
84
|
+
if (existsSync(c)) {
|
|
85
|
+
out.installed = true;
|
|
86
|
+
out.path = c;
|
|
87
|
+
out.variant = 'exe';
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ------------------------- Login age (24h delay) -------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Detect when the user logged in to Telegram Desktop, by looking at the
|
|
100
|
+
* tdata directory mtime. Telegram refuses to export chats for the first
|
|
101
|
+
* ~24 hours after a fresh login (anti-abuse window).
|
|
102
|
+
*
|
|
103
|
+
* Returns {
|
|
104
|
+
* logged_in: bool,
|
|
105
|
+
* first_login_at: ISO string | null,
|
|
106
|
+
* hours_since_login: number | null,
|
|
107
|
+
* export_allowed: bool // true iff > 24h elapsed
|
|
108
|
+
* }
|
|
109
|
+
*/
|
|
110
|
+
export function detectFirstLogin() {
|
|
111
|
+
const out = { logged_in: false, first_login_at: null, hours_since_login: null, export_allowed: false };
|
|
112
|
+
const plat = platform();
|
|
113
|
+
let tdataDir = null;
|
|
114
|
+
|
|
115
|
+
if (plat === 'darwin') {
|
|
116
|
+
tdataDir = join(homedir(), 'Library/Application Support/Telegram Desktop/tdata');
|
|
117
|
+
} else if (plat === 'linux') {
|
|
118
|
+
tdataDir = join(homedir(), '.local/share/TelegramDesktop/tdata');
|
|
119
|
+
} else if (plat === 'win32') {
|
|
120
|
+
tdataDir = join(homedir(), 'AppData/Roaming/Telegram Desktop/tdata');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!tdataDir || !existsSync(tdataDir)) return out;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// Look for 'key_datas' or 'shortcuts-default.json' or just the dir itself.
|
|
127
|
+
// The dir creation time on Telegram-init is our login proxy.
|
|
128
|
+
const candidates = ['key_datas', 'shortcuts-default.json', ''];
|
|
129
|
+
let oldestMs = null;
|
|
130
|
+
for (const c of candidates) {
|
|
131
|
+
const p = c ? join(tdataDir, c) : tdataDir;
|
|
132
|
+
if (existsSync(p)) {
|
|
133
|
+
const s = statSync(p);
|
|
134
|
+
// Prefer birthtime if it's sensible; otherwise mtime
|
|
135
|
+
const t = s.birthtimeMs && s.birthtimeMs > 0 ? s.birthtimeMs : s.mtimeMs;
|
|
136
|
+
if (oldestMs === null || t < oldestMs) oldestMs = t;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (oldestMs !== null) {
|
|
140
|
+
const ageMs = Date.now() - oldestMs;
|
|
141
|
+
out.logged_in = true;
|
|
142
|
+
out.first_login_at = new Date(oldestMs).toISOString();
|
|
143
|
+
out.hours_since_login = Math.floor(ageMs / 3600000);
|
|
144
|
+
out.export_allowed = ageMs > 24 * 3600000;
|
|
145
|
+
}
|
|
146
|
+
} catch (_) {
|
|
147
|
+
/* swallow */
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ------------------------- Export discovery -------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Default paths to scan for ChatExport_* folders / result.json files.
|
|
157
|
+
* Returns absolute paths that exist on disk (caller filters).
|
|
158
|
+
*/
|
|
159
|
+
export function defaultDownloadsPaths() {
|
|
160
|
+
const paths = [];
|
|
161
|
+
if (platform() === 'darwin') {
|
|
162
|
+
paths.push(join(homedir(), 'Downloads/Telegram Desktop'));
|
|
163
|
+
paths.push(join(homedir(), 'Downloads'));
|
|
164
|
+
paths.push(join(homedir(), 'Desktop'));
|
|
165
|
+
} else if (platform() === 'linux') {
|
|
166
|
+
paths.push(join(homedir(), 'Downloads/Telegram Desktop'));
|
|
167
|
+
paths.push(join(homedir(), 'Downloads'));
|
|
168
|
+
} else if (platform() === 'win32') {
|
|
169
|
+
paths.push(join(homedir(), 'Downloads/Telegram Desktop'));
|
|
170
|
+
paths.push(join(homedir(), 'Downloads'));
|
|
171
|
+
}
|
|
172
|
+
return paths.filter((p) => existsSync(p));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Walk the given directories looking for Telegram chat exports.
|
|
177
|
+
* A "candidate" is either a `ChatExport_*` directory (HTML or JSON export)
|
|
178
|
+
* or a bare `result.json` file (legacy single-file JSON dump).
|
|
179
|
+
*
|
|
180
|
+
* Returns an array of { path, kind: 'html-dir'|'json-file', modified_ts, size_bytes }.
|
|
181
|
+
* The caller decides whether to preview each, import, or skip.
|
|
182
|
+
*/
|
|
183
|
+
export function discoverExports(rootDirs) {
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const root of rootDirs) {
|
|
186
|
+
if (!existsSync(root)) continue;
|
|
187
|
+
let entries = [];
|
|
188
|
+
try { entries = readdirSync(root); } catch (_) { continue; }
|
|
189
|
+
for (const name of entries) {
|
|
190
|
+
const full = join(root, name);
|
|
191
|
+
let s;
|
|
192
|
+
try { s = statSync(full); } catch (_) { continue; }
|
|
193
|
+
if (s.isDirectory()) {
|
|
194
|
+
// ChatExport_2026-05-15 style
|
|
195
|
+
if (name.startsWith('ChatExport_') || name.toLowerCase().startsWith('chatexport_')) {
|
|
196
|
+
const det = detectTelegramHtml(full);
|
|
197
|
+
if (det.type === 'dir') {
|
|
198
|
+
out.push({ path: full, kind: 'html-dir', modified_ts: Math.floor(s.mtimeMs / 1000), size_bytes: dirSizeShallow(full) });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
// Maybe it's a JSON export inside the same folder name
|
|
202
|
+
const resultJson = join(full, 'result.json');
|
|
203
|
+
if (existsSync(resultJson)) {
|
|
204
|
+
out.push({ path: resultJson, kind: 'json-file', modified_ts: Math.floor(statSync(resultJson).mtimeMs / 1000), size_bytes: statSync(resultJson).size });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} else if (s.isFile() && name === 'result.json') {
|
|
208
|
+
// Bare result.json at the root of one of the scanned dirs
|
|
209
|
+
out.push({ path: full, kind: 'json-file', modified_ts: Math.floor(s.mtimeMs / 1000), size_bytes: s.size });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Newest first
|
|
214
|
+
out.sort((a, b) => b.modified_ts - a.modified_ts);
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function dirSizeShallow(dir) {
|
|
219
|
+
let total = 0;
|
|
220
|
+
try {
|
|
221
|
+
for (const name of readdirSync(dir)) {
|
|
222
|
+
try {
|
|
223
|
+
const s = statSync(join(dir, name));
|
|
224
|
+
if (s.isFile()) total += s.size;
|
|
225
|
+
} catch (_) { /* skip */ }
|
|
226
|
+
}
|
|
227
|
+
} catch (_) { /* skip */ }
|
|
228
|
+
return total;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ------------------------- Preview -------------------------
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Read enough of an export to produce a UI-facing preview, WITHOUT a full
|
|
235
|
+
* ingest. Returns:
|
|
236
|
+
* {
|
|
237
|
+
* path,
|
|
238
|
+
* kind: 'html-dir'|'json-file',
|
|
239
|
+
* chat_title: string,
|
|
240
|
+
* chat_type: 'personal_chat'|'private_group'|null,
|
|
241
|
+
* message_count: number,
|
|
242
|
+
* date_first: ISO string | null,
|
|
243
|
+
* date_last: ISO string | null,
|
|
244
|
+
* senders_sample: string[], // up to 6 distinct senders
|
|
245
|
+
* size_bytes: number
|
|
246
|
+
* }
|
|
247
|
+
*
|
|
248
|
+
* The preview is fast — for HTML we run the same parser but cap at the first
|
|
249
|
+
* `messages.html` file; for JSON we do a streaming-ish read of the first chat
|
|
250
|
+
* in the list and tally.
|
|
251
|
+
*/
|
|
252
|
+
export function previewExport(path) {
|
|
253
|
+
const out = {
|
|
254
|
+
path,
|
|
255
|
+
kind: null,
|
|
256
|
+
chat_title: null,
|
|
257
|
+
chat_type: null,
|
|
258
|
+
message_count: 0,
|
|
259
|
+
date_first: null,
|
|
260
|
+
date_last: null,
|
|
261
|
+
senders_sample: [],
|
|
262
|
+
size_bytes: 0,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (!existsSync(path)) return out;
|
|
266
|
+
const s = statSync(path);
|
|
267
|
+
out.size_bytes = s.isDirectory() ? dirSizeShallow(path) : s.size;
|
|
268
|
+
|
|
269
|
+
if (s.isDirectory()) {
|
|
270
|
+
// Telegram Desktop directories can contain EITHER messages.html (HTML
|
|
271
|
+
// export) OR a top-level *.json (JSON export — usually `result.json`
|
|
272
|
+
// but custom names like `kimi.json` also occur when user renamed).
|
|
273
|
+
// Try HTML first; fall back to the first JSON file we find inside.
|
|
274
|
+
out.kind = 'html-dir';
|
|
275
|
+
let parsedOk = false;
|
|
276
|
+
try {
|
|
277
|
+
const parsed = parseTelegramHtmlExport(path);
|
|
278
|
+
if (parsed && parsed.chats.list[0] && parsed.chats.list[0].messages.length > 0) {
|
|
279
|
+
const chat = parsed.chats.list[0];
|
|
280
|
+
out.chat_title = chat.name;
|
|
281
|
+
out.chat_type = chat.type;
|
|
282
|
+
out.message_count = chat.messages.length;
|
|
283
|
+
out.date_first = chat.messages[0].date || null;
|
|
284
|
+
out.date_last = chat.messages[chat.messages.length - 1].date || null;
|
|
285
|
+
const seen = new Set();
|
|
286
|
+
for (const m of chat.messages) {
|
|
287
|
+
if (m.from && m.from !== 'Unknown' && !seen.has(m.from)) {
|
|
288
|
+
seen.add(m.from);
|
|
289
|
+
out.senders_sample.push(m.from);
|
|
290
|
+
if (out.senders_sample.length >= 6) break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
parsedOk = true;
|
|
294
|
+
}
|
|
295
|
+
} catch (_) { /* swallow — try JSON next */ }
|
|
296
|
+
|
|
297
|
+
// JSON fallback — look for any *.json directly in the dir
|
|
298
|
+
if (!parsedOk) {
|
|
299
|
+
try {
|
|
300
|
+
const entries = readdirSync(path);
|
|
301
|
+
// Prefer result.json; otherwise first *.json
|
|
302
|
+
const jsonName = entries.find((n) => n === 'result.json')
|
|
303
|
+
|| entries.find((n) => n.endsWith('.json'));
|
|
304
|
+
if (jsonName) {
|
|
305
|
+
const jsonPath = join(path, jsonName);
|
|
306
|
+
out.kind = 'json-in-dir';
|
|
307
|
+
out.inner_json_path = jsonPath;
|
|
308
|
+
const data = JSON.parse(readFileSync(jsonPath, 'utf-8'));
|
|
309
|
+
let chat = null;
|
|
310
|
+
if (data && data.chats && Array.isArray(data.chats.list) && data.chats.list[0]) {
|
|
311
|
+
chat = data.chats.list[0];
|
|
312
|
+
} else if (data && Array.isArray(data.messages)) {
|
|
313
|
+
chat = data;
|
|
314
|
+
}
|
|
315
|
+
if (chat) {
|
|
316
|
+
out.chat_title = chat.name || 'Telegram chat';
|
|
317
|
+
out.chat_type = chat.type || null;
|
|
318
|
+
const msgs = Array.isArray(chat.messages) ? chat.messages : [];
|
|
319
|
+
out.message_count = msgs.length;
|
|
320
|
+
if (msgs.length > 0) {
|
|
321
|
+
out.date_first = msgs[0].date || null;
|
|
322
|
+
out.date_last = msgs[msgs.length - 1].date || null;
|
|
323
|
+
}
|
|
324
|
+
const seen = new Set();
|
|
325
|
+
for (const m of msgs) {
|
|
326
|
+
const from = m.from || m.actor;
|
|
327
|
+
if (from && !seen.has(from)) {
|
|
328
|
+
seen.add(from);
|
|
329
|
+
out.senders_sample.push(from);
|
|
330
|
+
if (out.senders_sample.length >= 6) break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} catch (_) { /* swallow */ }
|
|
336
|
+
}
|
|
337
|
+
} else if (s.isFile() && path.endsWith('.json')) {
|
|
338
|
+
out.kind = 'json-file';
|
|
339
|
+
try {
|
|
340
|
+
// Streaming-ish: just JSON.parse the whole file; result.json size is typically
|
|
341
|
+
// a few MB, fine for preview. Telegram puts the chat list as the first key.
|
|
342
|
+
const text = readFileSync(path, 'utf-8');
|
|
343
|
+
const data = JSON.parse(text);
|
|
344
|
+
// result.json has two shapes: { chats: { list: [...] }, ... } or { name, type, messages: [...] }
|
|
345
|
+
let chat = null;
|
|
346
|
+
if (data && data.chats && Array.isArray(data.chats.list) && data.chats.list[0]) {
|
|
347
|
+
chat = data.chats.list[0];
|
|
348
|
+
} else if (data && Array.isArray(data.messages)) {
|
|
349
|
+
chat = data;
|
|
350
|
+
}
|
|
351
|
+
if (chat) {
|
|
352
|
+
out.chat_title = chat.name || 'Telegram chat';
|
|
353
|
+
out.chat_type = chat.type || null;
|
|
354
|
+
const msgs = Array.isArray(chat.messages) ? chat.messages : [];
|
|
355
|
+
out.message_count = msgs.length;
|
|
356
|
+
if (msgs.length > 0) {
|
|
357
|
+
out.date_first = msgs[0].date || null;
|
|
358
|
+
out.date_last = msgs[msgs.length - 1].date || null;
|
|
359
|
+
}
|
|
360
|
+
const seen = new Set();
|
|
361
|
+
for (const m of msgs) {
|
|
362
|
+
const from = m.from || m.actor;
|
|
363
|
+
if (from && !seen.has(from)) {
|
|
364
|
+
seen.add(from);
|
|
365
|
+
out.senders_sample.push(from);
|
|
366
|
+
if (out.senders_sample.length >= 6) break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch (_) { /* swallow */ }
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-channel notification + dedup state for the Telegram capture flow.
|
|
3
|
+
*
|
|
4
|
+
* State file: ~/.memex/.tg-tip-state.json — small JSON the daemon and CLI
|
|
5
|
+
* both read/write to coordinate WHEN a message was last shown to the user.
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* version: 1,
|
|
9
|
+
* cli_tip_last_shown_at: ISO-8601, // throttle CLI tips to once/6h
|
|
10
|
+
* notif_shown_for_ids: ["sha256(path)", …], // skip macOS notif we already fired
|
|
11
|
+
* notifications: { enabled: false, show_titles: false }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Public surface:
|
|
15
|
+
* • loadNotifyState() → state object (fresh on every call)
|
|
16
|
+
* • saveNotifyState(state) → atomic write
|
|
17
|
+
* • cliTipDue(state, cooldownHours=6) → bool — should CLI tip render?
|
|
18
|
+
* • markCliTipShown(state) → record now() in state
|
|
19
|
+
* • notifShownFor(state, path) → bool — already sent macOS notif?
|
|
20
|
+
* • markNotifShown(state, paths[]) → record ids
|
|
21
|
+
* • fireMacosNotification(title, body) → osascript shell-out (best-effort)
|
|
22
|
+
* • setNotificationsEnabled(state, enabled, showTitles?)
|
|
23
|
+
* • formatTelegramTip(entries, opts) → markdown string (channel B)
|
|
24
|
+
*
|
|
25
|
+
* All of this is platform-tolerant — on Linux/Windows we don't fire native
|
|
26
|
+
* notifications (osascript is macOS-only). The CLI tip + agent injection
|
|
27
|
+
* still work everywhere.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
existsSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
renameSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
statSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
39
|
+
import { join, dirname } from 'node:path';
|
|
40
|
+
import { homedir, platform } from 'node:os';
|
|
41
|
+
import { spawn } from 'node:child_process';
|
|
42
|
+
|
|
43
|
+
const HOME = homedir();
|
|
44
|
+
export const STATE_PATH = join(HOME, '.memex', '.tg-tip-state.json');
|
|
45
|
+
|
|
46
|
+
const DEFAULT_STATE = () => ({
|
|
47
|
+
version: 1,
|
|
48
|
+
cli_tip_last_shown_at: null,
|
|
49
|
+
notif_shown_for_ids: [],
|
|
50
|
+
// v0.10.10: dashboard discovery throttle. Three-strike pattern so the tip
|
|
51
|
+
// appears on a few different terminal sessions in the first days after
|
|
52
|
+
// install, then quiets down. Becomes permanently silent once the user
|
|
53
|
+
// actually opens the dashboard at least once.
|
|
54
|
+
dashboard_tip_shown_count: 0,
|
|
55
|
+
dashboard_tip_last_shown_at: null,
|
|
56
|
+
dashboard_ever_opened: false,
|
|
57
|
+
notifications: {
|
|
58
|
+
enabled: false, // privacy-first: opt-in for macOS notification
|
|
59
|
+
show_titles: false, // even when on, don't leak chat names by default
|
|
60
|
+
// v0.10.4+: which app to open when the user clicks the banner.
|
|
61
|
+
// 'auto' → priority: claude-cli > claude-desktop > terminal
|
|
62
|
+
// 'claude-cli' → force open Claude Code CLI in a new Terminal tab
|
|
63
|
+
// 'claude-desktop' → force open Claude Desktop GUI
|
|
64
|
+
// 'terminal' → force open Terminal with `memex telegram pending`
|
|
65
|
+
// 'none' → banner not clickable
|
|
66
|
+
// If terminal-notifier is not installed, click is impossible — banner
|
|
67
|
+
// text falls back to "Run: memex telegram pending".
|
|
68
|
+
click_target: 'auto',
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const VALID_CLICK_TARGETS = ['auto', 'claude-cli', 'claude-desktop', 'terminal', 'none'];
|
|
73
|
+
|
|
74
|
+
export function loadNotifyState(path = STATE_PATH) {
|
|
75
|
+
if (!existsSync(path)) return DEFAULT_STATE();
|
|
76
|
+
try {
|
|
77
|
+
const raw = readFileSync(path, 'utf-8');
|
|
78
|
+
const parsed = JSON.parse(raw);
|
|
79
|
+
return {
|
|
80
|
+
...DEFAULT_STATE(),
|
|
81
|
+
...parsed,
|
|
82
|
+
notifications: { ...DEFAULT_STATE().notifications, ...(parsed.notifications || {}) },
|
|
83
|
+
notif_shown_for_ids: Array.isArray(parsed.notif_shown_for_ids)
|
|
84
|
+
? parsed.notif_shown_for_ids
|
|
85
|
+
: [],
|
|
86
|
+
};
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return DEFAULT_STATE();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function saveNotifyState(state, path = STATE_PATH) {
|
|
93
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
94
|
+
const tmp = path + '.tmp';
|
|
95
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
96
|
+
renameSync(tmp, path);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------- CLI tip throttle ----------------------
|
|
100
|
+
|
|
101
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
102
|
+
|
|
103
|
+
export function cliTipDue(state, cooldownHours = 6) {
|
|
104
|
+
if (!state.cli_tip_last_shown_at) return true;
|
|
105
|
+
const last = Date.parse(state.cli_tip_last_shown_at);
|
|
106
|
+
if (isNaN(last)) return true;
|
|
107
|
+
return Date.now() - last >= cooldownHours * ONE_HOUR_MS;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function markCliTipShown(state, now = new Date()) {
|
|
111
|
+
state.cli_tip_last_shown_at = now.toISOString();
|
|
112
|
+
return state;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------- Dashboard discovery tip (v0.10.10) ----------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether the "try memex web" tip should fire on the next CLI command.
|
|
119
|
+
*
|
|
120
|
+
* - Hard-stop once the user has actually run `memex web` (any duration)
|
|
121
|
+
* - Cap at maxShows (default 3) total reveals
|
|
122
|
+
* - Cooldown cooldownHours (default 12) between reveals so it doesn't
|
|
123
|
+
* stack with the TG-pending tip on the same minute
|
|
124
|
+
*/
|
|
125
|
+
export function dashboardTipDue(state, opts = {}) {
|
|
126
|
+
const { maxShows = 3, cooldownHours = 12 } = opts;
|
|
127
|
+
if (state.dashboard_ever_opened) return false;
|
|
128
|
+
if ((state.dashboard_tip_shown_count || 0) >= maxShows) return false;
|
|
129
|
+
if (!state.dashboard_tip_last_shown_at) return true;
|
|
130
|
+
const last = Date.parse(state.dashboard_tip_last_shown_at);
|
|
131
|
+
if (isNaN(last)) return true;
|
|
132
|
+
return Date.now() - last >= cooldownHours * ONE_HOUR_MS;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function markDashboardTipShown(state, now = new Date()) {
|
|
136
|
+
state.dashboard_tip_shown_count = (state.dashboard_tip_shown_count || 0) + 1;
|
|
137
|
+
state.dashboard_tip_last_shown_at = now.toISOString();
|
|
138
|
+
return state;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Permanently silence the dashboard tip — call this the first time the user
|
|
143
|
+
* actually runs `memex web`. They've discovered it; no need to keep nagging.
|
|
144
|
+
*/
|
|
145
|
+
export function markDashboardEverOpened(state) {
|
|
146
|
+
state.dashboard_ever_opened = true;
|
|
147
|
+
return state;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The tip text itself. Plain string (no ANSI) — the caller decides whether
|
|
152
|
+
* to dim it. Returns null if there is genuinely nothing to say (defensive).
|
|
153
|
+
*/
|
|
154
|
+
export function formatDashboardTip() {
|
|
155
|
+
return '💡 New: try `memex web --open` — browse your memory in a browser (read-only, localhost only).';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------- Notification dedup ----------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Stable hash of a pending export for notification dedup.
|
|
162
|
+
*
|
|
163
|
+
* v0.10.5+: hash now incorporates the file's mtime in addition to path.
|
|
164
|
+
*
|
|
165
|
+
* Why: Telegram Desktop reuses the same folder name on same-day re-exports
|
|
166
|
+
* (e.g. ChatExport_2026-05-16). After memex imports & removes that folder
|
|
167
|
+
* from pending, a fresh export with the same date creates the same path
|
|
168
|
+
* again. Path-only hash collided → notification was incorrectly deduped
|
|
169
|
+
* as "already shown".
|
|
170
|
+
*
|
|
171
|
+
* Including mtime makes the hash content-aware: same path + different
|
|
172
|
+
* mtime → fresh hash → notification fires. If the path doesn't exist
|
|
173
|
+
* (file was deleted), we fall back to path-only — it's an edge case
|
|
174
|
+
* (notifShownFor check before fire, file should exist).
|
|
175
|
+
*/
|
|
176
|
+
export function notifIdFor(path) {
|
|
177
|
+
let mtimeKey = '';
|
|
178
|
+
try { mtimeKey = String(Math.floor(statSync(path).mtimeMs)); } catch (_) { /* path missing — fall back to path-only */ }
|
|
179
|
+
return createHash('sha256').update(String(path) + ':' + mtimeKey).digest('hex').slice(0, 16);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function notifShownFor(state, path) {
|
|
183
|
+
return state.notif_shown_for_ids.includes(notifIdFor(path));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function markNotifShown(state, paths) {
|
|
187
|
+
const ids = (Array.isArray(paths) ? paths : [paths]).map(notifIdFor);
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
if (!state.notif_shown_for_ids.includes(id)) state.notif_shown_for_ids.push(id);
|
|
190
|
+
}
|
|
191
|
+
// Cap memory — keep last 200
|
|
192
|
+
if (state.notif_shown_for_ids.length > 200) {
|
|
193
|
+
state.notif_shown_for_ids = state.notif_shown_for_ids.slice(-200);
|
|
194
|
+
}
|
|
195
|
+
return state;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------- macOS native notification ----------------------
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Fire a native macOS notification via osascript. Best-effort — silently
|
|
202
|
+
* no-ops on Linux/Windows. On macOS we may hit the user's notification-
|
|
203
|
+
* permission gate; we don't care to handle that synchronously.
|
|
204
|
+
*
|
|
205
|
+
* Returns true if we tried (macOS), false if we skipped (other platforms).
|
|
206
|
+
*/
|
|
207
|
+
export function fireMacosNotification(title, body, opts = {}) {
|
|
208
|
+
if (platform() !== 'darwin') return false;
|
|
209
|
+
// Escape double quotes for AppleScript string literals
|
|
210
|
+
const esc = (s) => String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
211
|
+
const subtitle = opts.subtitle ? ` subtitle "${esc(opts.subtitle)}"` : '';
|
|
212
|
+
const sound = opts.silent ? '' : ` sound name "Pop"`;
|
|
213
|
+
const script = `display notification "${esc(body)}" with title "${esc(title)}"${subtitle}${sound}`;
|
|
214
|
+
try {
|
|
215
|
+
// Non-blocking — fire and forget
|
|
216
|
+
spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
|
|
217
|
+
return true;
|
|
218
|
+
} catch (_) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------- Mutators for notifications config ----------------------
|
|
224
|
+
|
|
225
|
+
export function setNotificationsEnabled(state, enabled, showTitles = null) {
|
|
226
|
+
state.notifications.enabled = !!enabled;
|
|
227
|
+
if (showTitles !== null) state.notifications.show_titles = !!showTitles;
|
|
228
|
+
return state;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function setClickTarget(state, target) {
|
|
232
|
+
if (!VALID_CLICK_TARGETS.includes(target)) {
|
|
233
|
+
throw new Error(`Invalid click_target '${target}'. Valid: ${VALID_CLICK_TARGETS.join(', ')}`);
|
|
234
|
+
}
|
|
235
|
+
state.notifications.click_target = target;
|
|
236
|
+
return state;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------- Channel B formatter ----------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Render the CLI tip block (printed at the end of any non-telegram
|
|
243
|
+
* memex CLI command, suppressed if pending=0 or recently shown).
|
|
244
|
+
*
|
|
245
|
+
* 💡 3 Telegram export(s) ready to review:
|
|
246
|
+
* • Family (1,876 msgs)
|
|
247
|
+
* • Work team (3,221 msgs)
|
|
248
|
+
* • … and 1 more
|
|
249
|
+
* Run: memex telegram pending
|
|
250
|
+
*
|
|
251
|
+
* Always shows count + up to 3 chat titles + "and N more" tail. Honors
|
|
252
|
+
* `show_titles=false` setting by hiding titles entirely (just count).
|
|
253
|
+
*/
|
|
254
|
+
export function formatTelegramTip(entries, opts = {}) {
|
|
255
|
+
if (!entries || entries.length === 0) return '';
|
|
256
|
+
const showTitles = opts.showTitles !== false;
|
|
257
|
+
const count = entries.length;
|
|
258
|
+
const lines = [];
|
|
259
|
+
lines.push('');
|
|
260
|
+
lines.push(`💡 ${count} Telegram export${count === 1 ? '' : 's'} ready to review:`);
|
|
261
|
+
if (showTitles) {
|
|
262
|
+
const preview = entries.slice(0, 3);
|
|
263
|
+
for (const e of preview) {
|
|
264
|
+
const t = e.chat_title || '(untitled)';
|
|
265
|
+
const n = e.message_count ? `${e.message_count.toLocaleString()} msgs` : '?';
|
|
266
|
+
lines.push(` • ${t} (${n})`);
|
|
267
|
+
}
|
|
268
|
+
if (entries.length > 3) lines.push(` • … and ${entries.length - 3} more`);
|
|
269
|
+
}
|
|
270
|
+
lines.push(' Run: memex telegram pending');
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|