@cachly-dev/mcp-server 0.10.118 → 0.10.120

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.
@@ -1,4 +1,23 @@
1
1
  #!/usr/bin/env node
2
+ interface DeviceFlowState {
3
+ deviceCode: string;
4
+ userCode: string;
5
+ verifyUrl: string;
6
+ pollInterval: number;
7
+ deadline: number;
8
+ polling: boolean;
9
+ }
10
+ /**
11
+ * Once the device code is issued, poll in the BACKGROUND on the flow's interval
12
+ * so browser sign-in self-completes. Without this, the flow only advanced when
13
+ * the user manually triggered ANOTHER tool call — the #1 onboarding drop-off
14
+ * (report: device flow 36→1 = 3%). Now the user signs in and their Brain
15
+ * activates on its own; their next natural request just works instead of
16
+ * showing "still waiting". Bounded by the flow deadline, timer unref'd so it
17
+ * never keeps the process alive, fully fail-safe (never throws). `poll` and
18
+ * `isCurrent` are injectable for testing.
19
+ */
20
+ export declare function startBackgroundDevicePoll(flow: DeviceFlowState, poll?: (f: DeviceFlowState) => Promise<'pending' | 'expired' | 'done'>, isCurrent?: (f: DeviceFlowState) => boolean, schedule?: (fn: () => void, ms: number) => void): void;
2
21
  import { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS, katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP } from './search.js';
3
22
  export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS, katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
4
23
  export declare const EDITOR_FILES: Record<string, string>;
package/dist/src/index.js CHANGED
@@ -524,6 +524,60 @@ async function pollDeviceFlow(flow) {
524
524
  return 'pending';
525
525
  }
526
526
  }
527
+ /**
528
+ * Mutex wrapper so the background poller (below) and a user-triggered tool call
529
+ * never poll the same device code concurrently — a double poll can race the
530
+ * one-time JWT→API-key exchange.
531
+ */
532
+ async function pollDeviceFlowGuarded(flow) {
533
+ if (flow.polling)
534
+ return 'pending'; // another poll owns this tick
535
+ flow.polling = true;
536
+ try {
537
+ return await pollDeviceFlow(flow);
538
+ }
539
+ finally {
540
+ flow.polling = false;
541
+ }
542
+ }
543
+ /**
544
+ * Once the device code is issued, poll in the BACKGROUND on the flow's interval
545
+ * so browser sign-in self-completes. Without this, the flow only advanced when
546
+ * the user manually triggered ANOTHER tool call — the #1 onboarding drop-off
547
+ * (report: device flow 36→1 = 3%). Now the user signs in and their Brain
548
+ * activates on its own; their next natural request just works instead of
549
+ * showing "still waiting". Bounded by the flow deadline, timer unref'd so it
550
+ * never keeps the process alive, fully fail-safe (never throws). `poll` and
551
+ * `isCurrent` are injectable for testing.
552
+ */
553
+ export function startBackgroundDevicePoll(flow, poll = pollDeviceFlowGuarded, isCurrent = (f) => _deviceFlow === f, schedule = defaultUnrefTimeout) {
554
+ const tick = async () => {
555
+ if (!isCurrent(flow))
556
+ return; // superseded or already completed → stop
557
+ let status = 'pending';
558
+ try {
559
+ status = await poll(flow);
560
+ }
561
+ catch {
562
+ /* keep polling — a transient network error must not abort sign-in */
563
+ }
564
+ if (status === 'done')
565
+ return; // poll already set JWT + persisted the key
566
+ if (status === 'expired') {
567
+ if (isCurrent(flow))
568
+ _deviceFlow = null;
569
+ return;
570
+ }
571
+ if (isCurrent(flow))
572
+ schedule(tick, flow.pollInterval);
573
+ };
574
+ schedule(tick, flow.pollInterval);
575
+ }
576
+ function defaultUnrefTimeout(fn, ms) {
577
+ const t = setTimeout(fn, ms);
578
+ if (typeof t.unref === 'function')
579
+ t.unref();
580
+ }
527
581
  // ── Embeddings (imported from embeddings.ts) ────────────────────────────────
528
582
  import { setEmbedJwt } from './embeddings.js';
529
583
  // ── Search Engine (imported from search.ts) ─────────────────────────────────
@@ -622,7 +676,14 @@ async function getConnection(instance_id) {
622
676
  connectTimeout: 8000,
623
677
  retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
624
678
  });
625
- const evict = () => pool.delete(instance_id);
679
+ // Drop the client from the pool AND close its socket on error/end, so a
680
+ // mid-session Redis drop doesn't leak sockets across reconnects. disconnect()
681
+ // is idempotent and safe to call from these handlers. The next tool call
682
+ // rebuilds the connection (self-healing).
683
+ const evict = () => {
684
+ pool.delete(instance_id);
685
+ client.disconnect();
686
+ };
626
687
  client.on('error', evict);
627
688
  client.on('end', evict);
628
689
  try {
@@ -697,6 +758,8 @@ import { installClsPostCommitHook, CLS_HOOK_VERSION } from './cls-hook.js';
697
758
  import { installAmbientHooks, AMBIENT_HOOK_VERSION } from './ambient-hooks.js';
698
759
  import { runAmbient, truncateToTokens, parseHookPayload, stopObservation } from './ambient-cli.js';
699
760
  import { appendLedgerEntry, readLedger, defaultLedgerPath } from './ambient-ledger.js';
761
+ import { detectEditor as detectEditorImpl } from './editor.js';
762
+ import { milestoneSent, markMilestoneSent } from './funnel-milestones.js';
700
763
  import { netBalance, shouldBackoff } from './ambient-recall.js';
701
764
  import { handleShareTool } from './handlers/share.js';
702
765
  import { handleVizTool } from './handlers/viz.js';
@@ -707,12 +770,10 @@ import { TOOLS } from './tools.js';
707
770
  let _telemetryPingSent = false;
708
771
  // Fires once per process on the first successful Brain tool call — key activation metric
709
772
  let _firstCallSuccessSent = false;
773
+ // Host detection lives in editor.ts (pure + unit-tested). Kept as a thin
774
+ // wrapper so the many call sites here stay unchanged.
710
775
  function detectEditor() {
711
- return process.env.CURSOR_TRACE_ID ? 'cursor'
712
- : process.env.WINDSURF_SESSION_ID ? 'windsurf'
713
- : process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
714
- : process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
715
- : 'unknown';
776
+ return detectEditorImpl();
716
777
  }
717
778
  /** Derive an anonymous, non-reversible fingerprint from the JWT sub claim. */
718
779
  function _jwtUserFingerprint(jwt) {
@@ -852,7 +913,7 @@ async function handleTool(name, args) {
852
913
  // 1st call: start device flow, return code + URL
853
914
  // 2nd+ calls: poll for token; once authenticated, proceed transparently
854
915
  if (_deviceFlow) {
855
- const result = await pollDeviceFlow(_deviceFlow);
916
+ const result = await pollDeviceFlowGuarded(_deviceFlow);
856
917
  if (result === 'done') {
857
918
  // Auth complete — re-enter handleTool with now-valid JWT
858
919
  return handleTool(name, args);
@@ -862,14 +923,14 @@ async function handleTool(name, args) {
862
923
  _deviceFlow = null;
863
924
  return '⌛ **Authentication timed out.** Please call any tool again to restart the sign-in flow.';
864
925
  }
865
- // Still pending
926
+ // Still pending — the background poller will complete sign-in on its own.
866
927
  return [
867
- '⏳ **Waiting for authentication...**',
928
+ '⏳ **Signing you in…**',
868
929
  '',
869
- `Sign in at: **${_deviceFlow.verifyUrl}**`,
870
- `Enter code: **${_deviceFlow.userCode}**`,
930
+ `Finish in your browser: **${_deviceFlow.verifyUrl}**`,
931
+ `Code: **${_deviceFlow.userCode}**`,
871
932
  '',
872
- 'Once you complete sign-in in your browser, call this tool again and it will proceed automatically.',
933
+ 'This completes automatically once you sign in — no need to come back here.',
873
934
  ].join('\n');
874
935
  }
875
936
  // No pending flow — start a new one
@@ -879,6 +940,9 @@ async function handleTool(name, args) {
879
940
  sendFunnelEvent('device_flow_started', { tool: name });
880
941
  // Try to open the browser automatically — fire-and-forget, never block
881
942
  openInBrowser(flow.verifyUrl);
943
+ // Poll in the background so sign-in self-completes — the user no longer
944
+ // has to trigger another tool call to drive the flow (the #1 drop-off).
945
+ startBackgroundDevicePoll(flow);
882
946
  return [
883
947
  '🧠 **cachly AI Brain — sign in to activate** (browser opening...)',
884
948
  '',
@@ -886,7 +950,8 @@ async function handleTool(name, args) {
886
950
  '',
887
951
  `Code: **${flow.userCode}** (pre-filled if browser opened automatically)`,
888
952
  '',
889
- 'After sign-in: call **any tool again** — your Brain activates instantly.',
953
+ 'That’s it — your Brain activates automatically the moment you finish',
954
+ 'signing in. Just keep working; your next request arrives brain-powered.',
890
955
  '',
891
956
  '✨ Free forever · No credit card · 126 MCP tools · GDPR · EU servers',
892
957
  ].join('\n');
@@ -923,7 +988,12 @@ async function handleTool(name, args) {
923
988
  if (brainResult !== null) {
924
989
  if (!_firstCallSuccessSent && JWT) {
925
990
  _firstCallSuccessSent = true;
926
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
991
+ // Persistent guard: ambient hooks spawn a fresh process per prompt, so the
992
+ // in-memory flag alone re-sent "first call" on every hook invocation.
993
+ if (!milestoneSent('first_call_success')) {
994
+ markMilestoneSent('first_call_success');
995
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
996
+ }
927
997
  }
928
998
  // Per-tool telemetry — fires after every successful brain tool call.
929
999
  // api_key carries the cky_live_... token so the backend can resolve tenant + increment counters.
@@ -1026,7 +1096,12 @@ async function handleTool(name, args) {
1026
1096
  if (contextResult !== null) {
1027
1097
  if (!_firstCallSuccessSent && JWT) {
1028
1098
  _firstCallSuccessSent = true;
1029
- sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
1099
+ // Persistent guard: ambient hooks spawn a fresh process per prompt, so the
1100
+ // in-memory flag alone re-sent "first call" on every hook invocation.
1101
+ if (!milestoneSent('first_call_success')) {
1102
+ markMilestoneSent('first_call_success');
1103
+ sendFunnelEvent('first_call_success', { tool: name, instance_id: args.instance_id ?? _defaultInstanceId ?? '' });
1104
+ }
1030
1105
  }
1031
1106
  return contextResult;
1032
1107
  }
@@ -1332,6 +1407,20 @@ process.on('unhandledRejection', (reason) => {
1332
1407
  // Write to stderr only (stdout is the JSON-RPC channel)
1333
1408
  process.stderr.write(`[cachly-mcp] unhandledRejection: ${msg}\n`);
1334
1409
  });
1410
+ // Symmetric net for synchronous/async *exceptions* thrown outside a try/catch
1411
+ // (a timer or stream callback, the background device poller, ioredis internals).
1412
+ // Without this the process exits hard and the editor reports "Connection is
1413
+ // closed" mid-session. Log to stderr and stay alive — never exit here.
1414
+ process.on('uncaughtException', (err) => {
1415
+ process.stderr.write(`[cachly-mcp] uncaughtException: ${err?.stack ?? String(err)}\n`);
1416
+ });
1417
+ // The single most common stdio-MCP killer: the editor closes the read side of
1418
+ // our stdout pipe (window closed, server restart, client reconnect) and the
1419
+ // next JSON-RPC write throws EPIPE — an async stream error that would otherwise
1420
+ // crash the process. Swallow stream errors on both stdio ends so a client that
1421
+ // closes its pipe can never take the server down with it.
1422
+ process.stdout.on('error', () => { });
1423
+ process.stdin.on('error', () => { });
1335
1424
  // ── CLI helpers ───────────────────────────────────────────────────────────────
1336
1425
  export const EDITOR_FILES = {
1337
1426
  claude: '.mcp.json',