memex-mvp 0.10.2 → 0.10.6

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/HELP.md CHANGED
@@ -331,6 +331,9 @@ memex telegram allow "Family" # auto-import на следу
331
331
  memex telegram block "*bank*" # никогда не индексировать (glob)
332
332
  memex telegram mode auto # pick (default) / auto / manual
333
333
  memex telegram notifications on --show-titles # macOS notif когда детектится новый export
334
+ memex telegram notifications target claude-cli # клик по баннеру → откроет Claude Code CLI
335
+ memex telegram open-pending # открыть pending в лучшем доступном клиенте
336
+ memex telegram open-pending --in terminal # forcefully в Terminal с командой
334
337
  memex telegram scan # one-shot ре-скан Downloads
335
338
  memex telegram status # all decisions
336
339
 
package/README.md CHANGED
@@ -206,7 +206,7 @@ Terminal equivalents: `memex telegram check / pending / import 1 3 5 / skip 2 /
206
206
 
207
207
  1. **In the AI agent (active session)** — `memex_search` / `memex_recent` / `memex_overview` tool responses include a `telegram_pending` field with chat names. Agent surfaces it as a natural aside.
208
208
  2. **In the terminal** — any `memex` CLI command appends a 💡 tip line when pending > 0. Throttled to once per 6h.
209
- 3. **macOS native notification** (opt-in) — daemon fires a banner when a new export is staged. `memex telegram notifications on` to enable. Default OFF for lock-screen privacy; add `--show-titles` if you want chat names in the banner.
209
+ 3. **macOS native notification** (opt-in) — daemon fires a banner when a new export is staged. `memex telegram notifications on` to enable. Default OFF for lock-screen privacy; add `--show-titles` if you want chat names in the banner. **v0.10.4+ clickable:** if `terminal-notifier` is installed (`brew install terminal-notifier`), clicking the banner opens — in priority order — Claude Code CLI in a fresh Terminal (Brian Chesky moment via SessionStart hook), Claude Desktop, or Terminal with `memex telegram pending` queued. Override priority via `memex telegram notifications target <auto|claude-cli|claude-desktop|terminal|none>`.
210
210
  4. **Brian Chesky hook (next Claude Code session)** — `memex context` injection includes a "🆕 N exports awaiting review" block with chat names. Claude leads with the question before you type anything.
211
211
 
212
212
  ---
package/ingest.js CHANGED
@@ -1386,27 +1386,42 @@ function scheduleTelegramStaging(srcPath) {
1386
1386
  const dest = stageExport(srcPath, { moveOrCopy: 'move' });
1387
1387
  log(`+ telegram-export staged → pending/: ${basename(dest)}`);
1388
1388
 
1389
- // Channel C: macOS native notification. Default OFF, opt-in via
1390
- // `memex telegram notifications on [--show-titles]`. Dedup by path
1391
- // hash so re-stages of the same export don't re-notify.
1389
+ // Channel C: macOS native notification (v0.10.4+ clickable).
1390
+ // Default OFF, opt-in via `memex telegram notifications on`. Dedup
1391
+ // by path hash so re-stages of same export don't re-notify.
1392
+ //
1393
+ // Click behavior priority (auto): Claude Code CLI → Claude Desktop
1394
+ // → Terminal. terminal-notifier required for click-through; without
1395
+ // it banner is shown via osascript (informative text, no click).
1392
1396
  try {
1393
1397
  const notify = await import('./lib/telegram-notify.js');
1398
+ const clickLib = await import('./lib/notify-click-action.js');
1394
1399
  const state = notify.loadNotifyState();
1395
1400
  if (state.notifications.enabled && !notify.notifShownFor(state, dest)) {
1396
- // Look up preview to know the chat name (only if user opted in)
1397
1401
  const list = listPending();
1398
1402
  const justStaged = list.find((e) => e.path === dest) || {};
1399
1403
  const totalPending = list.length;
1400
1404
  const showTitles = !!state.notifications.show_titles;
1401
- const title = `memex · ${totalPending} new Telegram chat${totalPending === 1 ? '' : 's'}`;
1402
- const body = showTitles && justStaged.chat_title
1403
- ? `"${justStaged.chat_title}" ${justStaged.message_count?.toLocaleString?.() || '?'} msgs. Review with: memex telegram pending`
1404
- : `Review with: memex telegram pending`;
1405
- const fired = notify.fireMacosNotification(title, body, { subtitle: 'Ready to review' });
1406
- if (fired) {
1405
+ const env = clickLib.detectEnvironment();
1406
+ const target = clickLib.pickTarget(state.notifications.click_target || 'auto', env);
1407
+ const clickable = !!env.terminal_notifier && target !== 'none';
1408
+ const cta = clickLib.bannerCallToAction(target, clickable);
1409
+
1410
+ const title = 'memex';
1411
+ const subtitle = `${totalPending} new Telegram chat${totalPending === 1 ? '' : 's'} ready to review`;
1412
+ const message = showTitles && justStaged.chat_title
1413
+ ? `"${justStaged.chat_title}" — ${(justStaged.message_count || 0).toLocaleString()} msgs · ${cta}`
1414
+ : `${cta}`;
1415
+
1416
+ const r = clickLib.fireClickableNotification({
1417
+ title, subtitle, message,
1418
+ target: state.notifications.click_target || 'auto',
1419
+ env,
1420
+ });
1421
+ if (r.backend !== 'noop') {
1407
1422
  notify.markNotifShown(state, [dest]);
1408
1423
  notify.saveNotifyState(state);
1409
- log(` notif fired (macOS, ${showTitles ? 'with' : 'no'} title)`);
1424
+ log(` notif fired (${r.backend}, target=${r.target}, titles=${showTitles ? 'yes' : 'no'})`);
1410
1425
  }
1411
1426
  }
1412
1427
  } catch (e) {
package/lib/cli/index.js CHANGED
@@ -1099,8 +1099,12 @@ async function cmdTelegram(args) {
1099
1099
  console.error(' mode [pick|auto|manual] Get or set capture mode (default: pick)');
1100
1100
  console.error(' status Show counts: allowed/skipped/blocked + recent imports');
1101
1101
  console.error(' scan One-shot rescan of ~/Downloads/Telegram Desktop/');
1102
- console.error(' notifications <on|off|status> [--show-titles]');
1102
+ console.error(' notifications <on|off|status|target> [--show-titles]');
1103
1103
  console.error(' macOS native notification on new export detect (default OFF)');
1104
+ console.error(' target <auto|claude-cli|claude-desktop|terminal|none>');
1105
+ console.error(' — what clicking the banner does (default auto)');
1106
+ console.error(' open-pending [--in <claude|claude-desktop|terminal>]');
1107
+ console.error(' Open pending list in best available client (Claude CLI > Desktop > Terminal)');
1104
1108
  console.error('');
1105
1109
  console.error(' --json Machine-readable output (works on most subcommands)');
1106
1110
  process.exit(sub ? 0 : 2);
@@ -1125,6 +1129,7 @@ async function cmdTelegram(args) {
1125
1129
  case 'status': return tgCmdStatus(opts, decisions, pending);
1126
1130
  case 'scan': return tgCmdScan(opts, discovery, pending);
1127
1131
  case 'notifications': return tgCmdNotifications(positionals, opts, args);
1132
+ case 'open-pending': return tgCmdOpenPending(positionals, opts, args);
1128
1133
  default:
1129
1134
  console.error(`Unknown 'memex telegram' subcommand: ${sub}`);
1130
1135
  console.error(`Run 'memex telegram --help' for usage.`);
@@ -1506,30 +1511,68 @@ async function tgCmdNotifications(positionals, opts, args) {
1506
1511
  const showTitlesFlag = args.includes('--show-titles');
1507
1512
  const noTitlesFlag = args.includes('--no-titles') || args.includes('--hide-titles');
1508
1513
  const notify = await import('../telegram-notify.js');
1514
+ const click = await import('../notify-click-action.js');
1509
1515
  const state = notify.loadNotifyState();
1510
1516
 
1511
1517
  if (!action || action === 'status') {
1512
- if (opts.json) { console.log(JSON.stringify(state.notifications)); return; }
1518
+ const env = click.detectEnvironment(true);
1519
+ const effectiveTarget = click.pickTarget(state.notifications.click_target, env);
1520
+ if (opts.json) {
1521
+ console.log(JSON.stringify({
1522
+ ...state.notifications,
1523
+ backend: env.terminal_notifier ? 'terminal-notifier' : 'osascript',
1524
+ effective_target: effectiveTarget,
1525
+ environment: {
1526
+ terminal_notifier_installed: !!env.terminal_notifier,
1527
+ claude_cli_installed: !!env.claude_cli,
1528
+ claude_desktop_installed: !!env.claude_desktop,
1529
+ },
1530
+ }, null, 2));
1531
+ return;
1532
+ }
1513
1533
  console.log(`Telegram notifications: ${state.notifications.enabled ? c.green('ON') : c.dim('OFF')}`);
1514
- console.log(` show_titles: ${state.notifications.show_titles ? c.green('yes') : c.dim('no (privacy: just count)')}`);
1534
+ console.log(` show_titles: ${state.notifications.show_titles ? c.green('yes') : c.dim('no (privacy: just count)')}`);
1535
+ console.log(` click target: ${c.cyan(state.notifications.click_target)} → ${click.targetLabel(effectiveTarget)}`);
1536
+ console.log(` backend: ${env.terminal_notifier ? c.green('terminal-notifier') + c.dim(' (clickable)') : c.yellow('osascript') + c.dim(' (no click — install brew terminal-notifier for click)')}`);
1537
+ console.log('');
1538
+ console.log(` Environment:`);
1539
+ console.log(` terminal-notifier: ${env.terminal_notifier ? c.green('✓ ' + env.terminal_notifier) : c.dim('✗ not installed — brew install terminal-notifier')}`);
1540
+ console.log(` Claude Code CLI: ${env.claude_cli ? c.green('✓ ' + env.claude_cli) : c.dim('✗ not installed')}`);
1541
+ console.log(` Claude Desktop: ${env.claude_desktop ? c.green('✓ ' + env.claude_desktop) : c.dim('✗ not installed')}`);
1515
1542
  console.log('');
1516
1543
  if (!state.notifications.enabled) {
1517
1544
  console.log('Enable: memex telegram notifications on');
1518
- console.log(' Optional: --show-titles to include chat names in the notification banner');
1519
- console.log(' ⚠ Without --show-titles, banner shows just "N new chat(s) ready to review".');
1520
- console.log(' With --show-titles, banner includes chat names — visible on lock screen.');
1545
+ console.log(' --show-titles include chat names in banner (default off — privacy on lock screen)');
1521
1546
  }
1547
+ console.log('Override click target: memex telegram notifications target <auto|claude-cli|claude-desktop|terminal|none>');
1522
1548
  return;
1523
1549
  }
1524
1550
 
1525
1551
  if (action === 'on' || action === 'enable') {
1526
1552
  notify.setNotificationsEnabled(state, true, showTitlesFlag ? true : (noTitlesFlag ? false : null));
1527
1553
  notify.saveNotifyState(state);
1528
- if (opts.json) { console.log(JSON.stringify(state.notifications)); return; }
1554
+ const env = click.detectEnvironment(true);
1555
+ const effectiveTarget = click.pickTarget(state.notifications.click_target, env);
1556
+ if (opts.json) {
1557
+ console.log(JSON.stringify({
1558
+ ...state.notifications,
1559
+ backend: env.terminal_notifier ? 'terminal-notifier' : 'osascript',
1560
+ effective_target: effectiveTarget,
1561
+ }));
1562
+ return;
1563
+ }
1529
1564
  console.log(`${c.green('✓')} Telegram notifications: ON`);
1530
- console.log(` show_titles: ${state.notifications.show_titles ? 'yes' : 'no (privacy)'}`);
1565
+ console.log(` show_titles: ${state.notifications.show_titles ? 'yes' : 'no (privacy)'}`);
1566
+ console.log(` click target: ${effectiveTarget} (${click.targetLabel(effectiveTarget)})`);
1531
1567
  console.log('');
1532
- console.log(c.dim('On first export, macOS may ask to grant notification permission.'));
1568
+ if (!env.terminal_notifier) {
1569
+ console.log(c.yellow(' ℹ Banner will not be clickable — install brew terminal-notifier for click-through.'));
1570
+ console.log(c.dim(' Without it: banner text is self-contained ("Run: memex telegram pending").'));
1571
+ } else if (effectiveTarget === 'claude-cli') {
1572
+ console.log(c.dim(' ℹ First click → macOS will ask permission to control Terminal.'));
1573
+ console.log(c.dim(' Allow it — that\'s how memex opens a new Terminal tab with Claude Code.'));
1574
+ }
1575
+ console.log(c.dim(' On first export, macOS may also ask to grant notification permission.'));
1533
1576
  return;
1534
1577
  }
1535
1578
  if (action === 'off' || action === 'disable') {
@@ -1539,10 +1582,62 @@ async function tgCmdNotifications(positionals, opts, args) {
1539
1582
  console.log(`${c.green('✓')} Telegram notifications: OFF`);
1540
1583
  return;
1541
1584
  }
1542
- console.error(`Unknown action: ${action}. Use: on | off | status`);
1585
+ if (action === 'target') {
1586
+ const newTarget = positionals[1];
1587
+ if (!newTarget) {
1588
+ console.error('Usage: memex telegram notifications target <auto|claude-cli|claude-desktop|terminal|none>');
1589
+ process.exit(2);
1590
+ }
1591
+ try {
1592
+ notify.setClickTarget(state, newTarget);
1593
+ notify.saveNotifyState(state);
1594
+ } catch (e) {
1595
+ console.error(`✗ ${e.message}`);
1596
+ process.exit(2);
1597
+ }
1598
+ const env = click.detectEnvironment(true);
1599
+ const effectiveTarget = click.pickTarget(newTarget, env);
1600
+ if (opts.json) { console.log(JSON.stringify({ click_target: newTarget, effective_target: effectiveTarget })); return; }
1601
+ console.log(`${c.green('✓')} click target: ${newTarget} → ${click.targetLabel(effectiveTarget)}`);
1602
+ if (newTarget !== effectiveTarget) {
1603
+ console.log(c.dim(` (your preference "${newTarget}" is not installed; falling back to "${effectiveTarget}")`));
1604
+ }
1605
+ return;
1606
+ }
1607
+ console.error(`Unknown action: ${action}. Use: on | off | status | target`);
1543
1608
  process.exit(2);
1544
1609
  }
1545
1610
 
1611
+ async function tgCmdOpenPending(positionals, opts, args) {
1612
+ const click = await import('../notify-click-action.js');
1613
+ // --in <X> flag override, else 'auto'
1614
+ let preference = 'auto';
1615
+ const inIdx = args.indexOf('--in');
1616
+ if (inIdx >= 0 && args[inIdx + 1]) {
1617
+ preference = args[inIdx + 1];
1618
+ // Accept short aliases for ergonomics
1619
+ if (preference === 'claude') preference = 'claude-cli';
1620
+ if (preference === 'term') preference = 'terminal';
1621
+ if (preference === 'desktop') preference = 'claude-desktop';
1622
+ }
1623
+ const env = click.detectEnvironment(true);
1624
+ const target = click.pickTarget(preference, env);
1625
+ if (target === 'none') {
1626
+ console.error(`No click target available. Use --in <claude|claude-desktop|terminal>.`);
1627
+ process.exit(1);
1628
+ }
1629
+ if (opts.json) {
1630
+ console.log(JSON.stringify({ preference, effective_target: target, label: click.targetLabel(target) }));
1631
+ } else {
1632
+ console.log(`${c.cyan('▶')} Opening pending in: ${click.targetLabel(target)}`);
1633
+ }
1634
+ const r = click.executeClickAction(preference, env);
1635
+ if (!r.ran && !opts.json) {
1636
+ console.error(`✗ Failed: ${r.reason || 'unknown'}`);
1637
+ process.exit(1);
1638
+ }
1639
+ }
1640
+
1546
1641
  function tgCmdScan(opts, discovery, pending) {
1547
1642
  const paths = discovery.defaultDownloadsPaths();
1548
1643
  const found = discovery.discoverExports(paths);
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Notification click-action picker for Telegram capture (v0.10.4+).
3
+ *
4
+ * macOS `osascript display notification` banners are NOT clickable — clicking
5
+ * them opens the parent app (Script Editor), which is confusing. To get a real
6
+ * click-action we use third-party `terminal-notifier` (brew install) which has
7
+ * `-execute "<shell command>"` support.
8
+ *
9
+ * Click target priority (auto-detect):
10
+ * 1. Claude Code CLI installed → open Terminal, launch `claude`
11
+ * → SessionStart hook (v0.8+) fires → agent leads with pending banner.
12
+ * This is the "Brian Chesky moment" — the wow case.
13
+ * 2. Claude Desktop installed (no CLI) → `open -a Claude`
14
+ * MCP is connected, but no auto-context. User has to ask.
15
+ * 3. Neither → open Terminal with `memex telegram pending` queued.
16
+ *
17
+ * User can override via `memex telegram notifications target <X>`:
18
+ * auto · claude-cli · claude-desktop · terminal · none
19
+ *
20
+ * This module is shell-out heavy; everything runs detached so we never
21
+ * block the daemon's chokidar event loop.
22
+ */
23
+
24
+ import { existsSync, accessSync, constants } from 'node:fs';
25
+ import { homedir, platform } from 'node:os';
26
+ import { join, delimiter } from 'node:path';
27
+ import { spawn } from 'node:child_process';
28
+
29
+ // ------------------------- Binary detection -------------------------
30
+
31
+ /**
32
+ * Is a binary available on PATH? Returns the absolute path or null.
33
+ * We do this manually (vs `which`) so it's fast + cross-platform.
34
+ */
35
+ export function findBin(name) {
36
+ const pathDirs = (process.env.PATH || '').split(delimiter).filter(Boolean);
37
+ // Also include common shell-rc-installed dirs that GUI daemons miss
38
+ const extras = [
39
+ join(homedir(), '.npm-global/bin'),
40
+ '/opt/homebrew/bin',
41
+ '/usr/local/bin',
42
+ '/usr/bin',
43
+ ];
44
+ for (const dir of [...pathDirs, ...extras]) {
45
+ const full = join(dir, name);
46
+ try {
47
+ accessSync(full, constants.X_OK);
48
+ return full;
49
+ } catch (_) { /* not here */ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Detect the user's notification + click-action environment.
56
+ *
57
+ * Returns an object describing what's available and what we'd pick if
58
+ * `target === 'auto'`. Cached briefly — detection is cheap but we don't
59
+ * want to fs.exists() on every fired notification.
60
+ */
61
+ let _detectCache = null;
62
+ let _detectCacheAt = 0;
63
+ const DETECT_CACHE_MS = 30_000;
64
+
65
+ export function detectEnvironment(force = false) {
66
+ if (!force && _detectCache && (Date.now() - _detectCacheAt) < DETECT_CACHE_MS) {
67
+ return _detectCache;
68
+ }
69
+ const env = {
70
+ platform: platform(),
71
+ terminal_notifier: findBin('terminal-notifier'),
72
+ claude_cli: findBin('claude'),
73
+ claude_desktop: detectClaudeDesktop(),
74
+ memex_bin: findBin('memex'),
75
+ };
76
+ _detectCache = env;
77
+ _detectCacheAt = Date.now();
78
+ return env;
79
+ }
80
+
81
+ function detectClaudeDesktop() {
82
+ if (platform() !== 'darwin') return null;
83
+ const candidates = [
84
+ '/Applications/Claude.app',
85
+ join(homedir(), 'Applications/Claude.app'),
86
+ ];
87
+ for (const c of candidates) if (existsSync(c)) return c;
88
+ return null;
89
+ }
90
+
91
+ // ------------------------- Target selection -------------------------
92
+
93
+ /**
94
+ * Decide which click-action target to use given the user's preference
95
+ * and detected environment.
96
+ *
97
+ * Returns one of: 'claude-cli', 'claude-desktop', 'terminal', 'none'.
98
+ *
99
+ * preference = 'auto' → priority: cli > desktop > terminal
100
+ * preference = 'claude-cli' → use if installed, else fall through to auto
101
+ * preference = 'claude-desktop' → use if installed, else fall through to auto
102
+ * preference = 'terminal' → always Terminal (user opted out of Claude)
103
+ * preference = 'none' → no click action
104
+ */
105
+ export function pickTarget(preference, env = detectEnvironment()) {
106
+ if (preference === 'none') return 'none';
107
+ if (preference === 'terminal') return 'terminal';
108
+ if (preference === 'claude-cli' && env.claude_cli) return 'claude-cli';
109
+ if (preference === 'claude-desktop' && env.claude_desktop) return 'claude-desktop';
110
+ // auto (or explicit-but-not-installed) — fall through priority
111
+ if (env.claude_cli) return 'claude-cli';
112
+ if (env.claude_desktop) return 'claude-desktop';
113
+ return 'terminal';
114
+ }
115
+
116
+ /**
117
+ * Human-readable label for the chosen target — shown in `notifications status`
118
+ * and used as the banner-text "call to action".
119
+ */
120
+ export function targetLabel(target) {
121
+ switch (target) {
122
+ case 'claude-cli': return 'Claude Code CLI (Brian Chesky moment)';
123
+ case 'claude-desktop': return 'Claude Desktop';
124
+ case 'terminal': return 'Terminal with `memex telegram pending`';
125
+ case 'none': return 'no click action';
126
+ default: return target;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * The call-to-action shown in the banner body. Depends on:
132
+ * • target (claude-cli / claude-desktop / terminal / none)
133
+ * • clickable (is terminal-notifier installed so the banner is actually clickable?)
134
+ *
135
+ * When NOT clickable, we drop the "Click to ..." phrasing and instead
136
+ * show the literal shell command so users without terminal-notifier
137
+ * still know exactly what to do.
138
+ */
139
+ export function bannerCallToAction(target, clickable = true) {
140
+ if (!clickable) {
141
+ // No click possible — show concrete action user must take manually
142
+ if (target === 'claude-cli') return 'Run: claude (or memex telegram pending)';
143
+ if (target === 'claude-desktop') return 'Open Claude Desktop, ask "what\'s pending in memex?"';
144
+ return 'Run: memex telegram pending';
145
+ }
146
+ switch (target) {
147
+ case 'claude-cli': return 'Click to launch Claude';
148
+ case 'claude-desktop': return 'Click to open Claude Desktop';
149
+ case 'terminal': return 'Click to open Terminal';
150
+ case 'none': return 'Run: memex telegram pending';
151
+ default: return 'memex telegram pending';
152
+ }
153
+ }
154
+
155
+ // ------------------------- Build click-action shell command -------------------------
156
+
157
+ /**
158
+ * Compose the shell command that `terminal-notifier -execute` will run when
159
+ * the user clicks the banner.
160
+ *
161
+ * Returns null if target = 'none' (banner has no click action).
162
+ *
163
+ * Notes:
164
+ * • Each target is wrapped in `osascript` to invoke macOS' Terminal app,
165
+ * so the user lands in an interactive shell session (not a daemon-spawned
166
+ * headless process).
167
+ * • Quoting: we shell-escape the inner double quotes for AppleScript's
168
+ * `do script` parameter.
169
+ */
170
+ export function buildClickCommand(target, env = detectEnvironment()) {
171
+ if (target === 'none') return null;
172
+
173
+ if (target === 'claude-cli') {
174
+ // Open a fresh Terminal window, launch `claude` from $HOME so the
175
+ // SessionStart hook injects pending Telegram exports into the
176
+ // first message. The hook fires regardless of cwd; pending is always
177
+ // surfaced when count > 0.
178
+ const cliPath = env.claude_cli || 'claude';
179
+ return `osascript -e 'tell application "Terminal" to activate' ` +
180
+ `-e 'tell application "Terminal" to do script "cd ~ && ${escapeApple(cliPath)}"'`;
181
+ }
182
+
183
+ if (target === 'claude-desktop') {
184
+ const appPath = env.claude_desktop || '/Applications/Claude.app';
185
+ return `open ${shellQuote(appPath)}`;
186
+ }
187
+
188
+ if (target === 'terminal') {
189
+ // Open Terminal and run `memex telegram pending` so user sees the list
190
+ const memexBin = env.memex_bin || 'memex';
191
+ return `osascript -e 'tell application "Terminal" to activate' ` +
192
+ `-e 'tell application "Terminal" to do script "${escapeApple(memexBin)} telegram pending"'`;
193
+ }
194
+
195
+ return null;
196
+ }
197
+
198
+ // AppleScript "do script" takes a string — we need to escape backslash + dquote
199
+ function escapeApple(s) {
200
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
201
+ }
202
+
203
+ // Conservative shell quote — used for `open <path>` where path may contain spaces
204
+ function shellQuote(s) {
205
+ return `'${String(s).replace(/'/g, "'\\''")}'`;
206
+ }
207
+
208
+ // ------------------------- Fire the notification -------------------------
209
+
210
+ /**
211
+ * Fire a clickable notification via terminal-notifier (preferred) or fall
212
+ * back to plain osascript (no click).
213
+ *
214
+ * opts = {
215
+ * title, subtitle, message,
216
+ * target, // 'auto' | 'claude-cli' | 'claude-desktop' | 'terminal' | 'none'
217
+ * env, // optional override of detected env (for tests)
218
+ * dryRun, // if true → compute backend+target+command but DON'T spawn.
219
+ * // Used by unit tests so `npm test` doesn't spam real
220
+ * // macOS notifications. Also honors env MEMEX_NO_FIRE=1.
221
+ * }
222
+ *
223
+ * Returns { backend: 'terminal-notifier' | 'osascript' | 'noop',
224
+ * target, click_command }
225
+ */
226
+ export function fireClickableNotification(opts = {}) {
227
+ const env = opts.env || detectEnvironment();
228
+ if (env.platform !== 'darwin') return { backend: 'noop', target: 'none', click_command: null };
229
+
230
+ const target = pickTarget(opts.target || 'auto', env);
231
+ const click = buildClickCommand(target, env);
232
+ const dryRun = opts.dryRun === true || process.env.MEMEX_NO_FIRE === '1';
233
+
234
+ const title = opts.title || 'memex';
235
+ const subtitle = opts.subtitle || '';
236
+ const message = opts.message || '';
237
+
238
+ if (env.terminal_notifier && click) {
239
+ if (dryRun) return { backend: 'terminal-notifier', target, click_command: click };
240
+ const args = [
241
+ '-title', title,
242
+ '-message', message,
243
+ '-execute', click,
244
+ ];
245
+ if (subtitle) { args.push('-subtitle'); args.push(subtitle); }
246
+ args.push('-sound', 'Pop');
247
+ args.push('-sender', 'com.apple.Terminal');
248
+ try {
249
+ spawn(env.terminal_notifier, args, { detached: true, stdio: 'ignore' }).unref();
250
+ return { backend: 'terminal-notifier', target, click_command: click };
251
+ } catch (_) { /* fall through to osascript */ }
252
+ }
253
+
254
+ // Plain osascript fallback — banner is not clickable but text is informative
255
+ if (dryRun) return { backend: 'osascript', target: 'none', click_command: null };
256
+ const esc = (s) => String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
257
+ const sub = subtitle ? ` subtitle "${esc(subtitle)}"` : '';
258
+ const script = `display notification "${esc(message)}" with title "${esc(title)}"${sub} sound name "Pop"`;
259
+ try {
260
+ spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
261
+ return { backend: 'osascript', target: 'none', click_command: null };
262
+ } catch (_) {
263
+ return { backend: 'noop', target: 'none', click_command: null };
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Run the click-action directly (for `memex telegram open-pending` CLI).
269
+ * Same target-resolution logic as the notification, just invoked from CLI.
270
+ */
271
+ export function executeClickAction(preference = 'auto', env = detectEnvironment()) {
272
+ const target = pickTarget(preference, env);
273
+ const cmd = buildClickCommand(target, env);
274
+ if (!cmd) return { ran: false, target, reason: 'no-action' };
275
+ try {
276
+ spawn('sh', ['-c', cmd], { detached: true, stdio: 'ignore' }).unref();
277
+ return { ran: true, target, command: cmd };
278
+ } catch (e) {
279
+ return { ran: false, target, reason: e.message };
280
+ }
281
+ }
@@ -119,9 +119,21 @@ export function allowChat(state, title, now = new Date()) {
119
119
  return state;
120
120
  }
121
121
 
122
+ /**
123
+ * Skip a chat title — record a per-chat decision so future re-exports
124
+ * of this chat are auto-skipped.
125
+ *
126
+ * IMPORTANT: if the chat is ALREADY in allowed_chats, this is a no-op
127
+ * on the decisions state. The user has previously committed to indexing
128
+ * this chat; "skip" in this case means "throw away THIS export file"
129
+ * (the file removal is the caller's job), NOT "block this chat forever".
130
+ * If the user truly wants to stop indexing a previously-allowed chat,
131
+ * they should use `memex telegram remove <title>` (purges from memex.db
132
+ * AND marks as skipped).
133
+ */
122
134
  export function skipChat(state, title, now = new Date()) {
135
+ if (isAllowed(state, title)) return state; // preserve prior import decision
123
136
  if (isSkipped(state, title)) return state;
124
- state.allowed_chats = state.allowed_chats.filter((c) => norm(c.title) !== norm(title));
125
137
  state.skipped_chats.push({ title, skipped_at: now.toISOString() });
126
138
  return state;
127
139
  }
@@ -33,6 +33,7 @@ import {
33
33
  writeFileSync,
34
34
  renameSync,
35
35
  mkdirSync,
36
+ statSync,
36
37
  } from 'node:fs';
37
38
  import { createHash } from 'node:crypto';
38
39
  import { join, dirname } from 'node:path';
@@ -49,9 +50,20 @@ const DEFAULT_STATE = () => ({
49
50
  notifications: {
50
51
  enabled: false, // privacy-first: opt-in for macOS notification
51
52
  show_titles: false, // even when on, don't leak chat names by default
53
+ // v0.10.4+: which app to open when the user clicks the banner.
54
+ // 'auto' → priority: claude-cli > claude-desktop > terminal
55
+ // 'claude-cli' → force open Claude Code CLI in a new Terminal tab
56
+ // 'claude-desktop' → force open Claude Desktop GUI
57
+ // 'terminal' → force open Terminal with `memex telegram pending`
58
+ // 'none' → banner not clickable
59
+ // If terminal-notifier is not installed, click is impossible — banner
60
+ // text falls back to "Run: memex telegram pending".
61
+ click_target: 'auto',
52
62
  },
53
63
  });
54
64
 
65
+ export const VALID_CLICK_TARGETS = ['auto', 'claude-cli', 'claude-desktop', 'terminal', 'none'];
66
+
55
67
  export function loadNotifyState(path = STATE_PATH) {
56
68
  if (!existsSync(path)) return DEFAULT_STATE();
57
69
  try {
@@ -95,8 +107,26 @@ export function markCliTipShown(state, now = new Date()) {
95
107
 
96
108
  // ---------------------- Notification dedup ----------------------
97
109
 
110
+ /**
111
+ * Stable hash of a pending export for notification dedup.
112
+ *
113
+ * v0.10.5+: hash now incorporates the file's mtime in addition to path.
114
+ *
115
+ * Why: Telegram Desktop reuses the same folder name on same-day re-exports
116
+ * (e.g. ChatExport_2026-05-16). After memex imports & removes that folder
117
+ * from pending, a fresh export with the same date creates the same path
118
+ * again. Path-only hash collided → notification was incorrectly deduped
119
+ * as "already shown".
120
+ *
121
+ * Including mtime makes the hash content-aware: same path + different
122
+ * mtime → fresh hash → notification fires. If the path doesn't exist
123
+ * (file was deleted), we fall back to path-only — it's an edge case
124
+ * (notifShownFor check before fire, file should exist).
125
+ */
98
126
  export function notifIdFor(path) {
99
- return createHash('sha256').update(String(path)).digest('hex').slice(0, 16);
127
+ let mtimeKey = '';
128
+ try { mtimeKey = String(Math.floor(statSync(path).mtimeMs)); } catch (_) { /* path missing — fall back to path-only */ }
129
+ return createHash('sha256').update(String(path) + ':' + mtimeKey).digest('hex').slice(0, 16);
100
130
  }
101
131
 
102
132
  export function notifShownFor(state, path) {
@@ -148,6 +178,14 @@ export function setNotificationsEnabled(state, enabled, showTitles = null) {
148
178
  return state;
149
179
  }
150
180
 
181
+ export function setClickTarget(state, target) {
182
+ if (!VALID_CLICK_TARGETS.includes(target)) {
183
+ throw new Error(`Invalid click_target '${target}'. Valid: ${VALID_CLICK_TARGETS.join(', ')}`);
184
+ }
185
+ state.notifications.click_target = target;
186
+ return state;
187
+ }
188
+
151
189
  // ---------------------- Channel B formatter ----------------------
152
190
 
153
191
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memex-mvp",
3
- "version": "0.10.2",
3
+ "version": "0.10.6",
4
4
  "description": "Local-first MCP server for cross-agent AI memory. One SQLite + FTS5 corpus across Claude Code, Cowork, Cursor, Continue, Zed, Obsidian, and Telegram — passively captured, verbatim, searchable from any MCP-compatible client.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -26,7 +26,7 @@
26
26
  "sync": "node ingest.js",
27
27
  "ingest": "node ingest.js",
28
28
  "bot": "node bot/index.js",
29
- "test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.test.js && node test/store-document.test.js && node test/cli.test.js && node test/hook.test.js && node test/telegram-html.test.js && node test/telegram-decisions.test.js && node test/telegram-pending.test.js && node test/telegram-notify.test.js",
29
+ "test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.test.js && node test/store-document.test.js && node test/cli.test.js && node test/hook.test.js && node test/telegram-html.test.js && node test/telegram-decisions.test.js && node test/telegram-pending.test.js && node test/telegram-notify.test.js && node test/notify-click-action.test.js",
30
30
  "prepublishOnly": "npm test"
31
31
  },
32
32
  "engines": {