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.
Files changed (62) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/HELP.md +600 -0
  3. package/LICENSE +21 -0
  4. package/MULTI_MACHINE.md +152 -0
  5. package/README.md +417 -0
  6. package/README.ru.md +740 -0
  7. package/SYNC.md +844 -0
  8. package/bot/README.md +173 -0
  9. package/bot/config.js +66 -0
  10. package/bot/inbox.js +153 -0
  11. package/bot/index.js +294 -0
  12. package/bot/nexara.js +61 -0
  13. package/bot/poll.js +304 -0
  14. package/bot/search.js +155 -0
  15. package/bot/telegram.js +96 -0
  16. package/ingest.js +2712 -0
  17. package/lib/cli/index.js +1987 -0
  18. package/lib/config.js +220 -0
  19. package/lib/db-init.js +158 -0
  20. package/lib/hook/install.js +268 -0
  21. package/lib/import-telegram.js +158 -0
  22. package/lib/ingest-file.js +779 -0
  23. package/lib/notify-click-action.js +281 -0
  24. package/lib/openclaw-channel.js +643 -0
  25. package/lib/parse-cursor.js +172 -0
  26. package/lib/parse-obsidian.js +256 -0
  27. package/lib/parse-telegram-html.js +384 -0
  28. package/lib/parse.js +175 -0
  29. package/lib/render-markdown.js +0 -0
  30. package/lib/store-doc/canonicalize.js +116 -0
  31. package/lib/store-doc/detect.js +209 -0
  32. package/lib/store-doc/extract-title.js +162 -0
  33. package/lib/sync/auth.js +80 -0
  34. package/lib/sync/cert.js +144 -0
  35. package/lib/sync/cli.js +906 -0
  36. package/lib/sync/client.js +138 -0
  37. package/lib/sync/config.js +130 -0
  38. package/lib/sync/pair.js +145 -0
  39. package/lib/sync/pull.js +158 -0
  40. package/lib/sync/push.js +305 -0
  41. package/lib/sync/replicate.js +335 -0
  42. package/lib/sync/server.js +224 -0
  43. package/lib/sync/service.js +726 -0
  44. package/lib/tasks.js +215 -0
  45. package/lib/telegram-decisions.js +165 -0
  46. package/lib/telegram-discovery.js +373 -0
  47. package/lib/telegram-notify.js +272 -0
  48. package/lib/telegram-pending.js +200 -0
  49. package/lib/web/index.js +265 -0
  50. package/lib/web/routes/conversation.js +193 -0
  51. package/lib/web/routes/conversations.js +180 -0
  52. package/lib/web/routes/dashboard.js +175 -0
  53. package/lib/web/routes/pending.js +277 -0
  54. package/lib/web/routes/settings.js +226 -0
  55. package/lib/web/static/style.css +393 -0
  56. package/lib/web/templates.js +234 -0
  57. package/package.json +84 -0
  58. package/server.js +3816 -0
  59. package/skills/install-memex/README.md +109 -0
  60. package/skills/install-memex/SKILL.md +342 -0
  61. package/skills/install-memex/examples.md +294 -0
  62. 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
+ }