@getrift/rift 0.1.0-beta.12 → 0.1.0-beta.14

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 (89) hide show
  1. package/README.md +35 -9
  2. package/dist/src/cli/commands/doctor.d.ts +6 -0
  3. package/dist/src/cli/commands/doctor.d.ts.map +1 -0
  4. package/dist/src/cli/commands/doctor.js +183 -0
  5. package/dist/src/cli/commands/doctor.js.map +1 -0
  6. package/dist/src/cli/commands/menubar.d.ts +30 -0
  7. package/dist/src/cli/commands/menubar.d.ts.map +1 -0
  8. package/dist/src/cli/commands/menubar.js +180 -0
  9. package/dist/src/cli/commands/menubar.js.map +1 -0
  10. package/dist/src/cli/commands/onboard.d.ts +38 -0
  11. package/dist/src/cli/commands/onboard.d.ts.map +1 -1
  12. package/dist/src/cli/commands/onboard.js +203 -121
  13. package/dist/src/cli/commands/onboard.js.map +1 -1
  14. package/dist/src/cli/commands/status.d.ts +9 -7
  15. package/dist/src/cli/commands/status.d.ts.map +1 -1
  16. package/dist/src/cli/commands/status.js +29 -10
  17. package/dist/src/cli/commands/status.js.map +1 -1
  18. package/dist/src/cli/commands/update.d.ts +3 -0
  19. package/dist/src/cli/commands/update.d.ts.map +1 -1
  20. package/dist/src/cli/commands/update.js +19 -0
  21. package/dist/src/cli/commands/update.js.map +1 -1
  22. package/dist/src/cli/index.d.ts.map +1 -1
  23. package/dist/src/cli/index.js +4 -0
  24. package/dist/src/cli/index.js.map +1 -1
  25. package/dist/src/cli/postinstall-menubar.d.ts +22 -0
  26. package/dist/src/cli/postinstall-menubar.d.ts.map +1 -0
  27. package/dist/src/cli/postinstall-menubar.js +39 -0
  28. package/dist/src/cli/postinstall-menubar.js.map +1 -0
  29. package/dist/src/cli/status/friend-header.d.ts +8 -1
  30. package/dist/src/cli/status/friend-header.d.ts.map +1 -1
  31. package/dist/src/cli/status/friend-header.js +93 -12
  32. package/dist/src/cli/status/friend-header.js.map +1 -1
  33. package/dist/src/cli/ui.d.ts +47 -0
  34. package/dist/src/cli/ui.d.ts.map +1 -0
  35. package/dist/src/cli/ui.js +166 -0
  36. package/dist/src/cli/ui.js.map +1 -0
  37. package/dist/src/diagnostics/doctor.d.ts +106 -0
  38. package/dist/src/diagnostics/doctor.d.ts.map +1 -0
  39. package/dist/src/diagnostics/doctor.js +251 -0
  40. package/dist/src/diagnostics/doctor.js.map +1 -0
  41. package/dist/src/diagnostics/notify.d.ts +90 -0
  42. package/dist/src/diagnostics/notify.d.ts.map +1 -0
  43. package/dist/src/diagnostics/notify.js +177 -0
  44. package/dist/src/diagnostics/notify.js.map +1 -0
  45. package/dist/src/diagnostics/repair-prompt.d.ts +49 -0
  46. package/dist/src/diagnostics/repair-prompt.d.ts.map +1 -0
  47. package/dist/src/diagnostics/repair-prompt.js +198 -0
  48. package/dist/src/diagnostics/repair-prompt.js.map +1 -0
  49. package/dist/src/jobs/handlers/dedupe-conversations.d.ts +25 -2
  50. package/dist/src/jobs/handlers/dedupe-conversations.d.ts.map +1 -1
  51. package/dist/src/jobs/handlers/dedupe-conversations.js +48 -9
  52. package/dist/src/jobs/handlers/dedupe-conversations.js.map +1 -1
  53. package/dist/src/jobs/handlers/ingest.d.ts.map +1 -1
  54. package/dist/src/jobs/handlers/ingest.js +8 -2
  55. package/dist/src/jobs/handlers/ingest.js.map +1 -1
  56. package/dist/src/main.js +43 -4
  57. package/dist/src/main.js.map +1 -1
  58. package/dist/src/mcp/server.d.ts.map +1 -1
  59. package/dist/src/mcp/server.js +43 -3
  60. package/dist/src/mcp/server.js.map +1 -1
  61. package/dist/src/mcp/tools/context-pack.js +163 -25
  62. package/dist/src/mcp/tools/context-pack.js.map +1 -1
  63. package/dist/src/observability/onboarding-metric.d.ts +115 -0
  64. package/dist/src/observability/onboarding-metric.d.ts.map +1 -0
  65. package/dist/src/observability/onboarding-metric.js +344 -0
  66. package/dist/src/observability/onboarding-metric.js.map +1 -0
  67. package/dist/src/observability/version-check.d.ts +1 -0
  68. package/dist/src/observability/version-check.d.ts.map +1 -1
  69. package/dist/src/observability/version-check.js +2 -1
  70. package/dist/src/observability/version-check.js.map +1 -1
  71. package/dist/src/retrieval/context-pack.d.ts +37 -0
  72. package/dist/src/retrieval/context-pack.d.ts.map +1 -1
  73. package/dist/src/retrieval/context-pack.js +165 -1
  74. package/dist/src/retrieval/context-pack.js.map +1 -1
  75. package/dist/src/retrieval/current-truth.d.ts +326 -0
  76. package/dist/src/retrieval/current-truth.d.ts.map +1 -0
  77. package/dist/src/retrieval/current-truth.js +747 -0
  78. package/dist/src/retrieval/current-truth.js.map +1 -0
  79. package/dist/src/retrieval/git-state.d.ts +53 -0
  80. package/dist/src/retrieval/git-state.d.ts.map +1 -0
  81. package/dist/src/retrieval/git-state.js +174 -0
  82. package/dist/src/retrieval/git-state.js.map +1 -0
  83. package/dist/src/server/routes/friend-status.d.ts +63 -0
  84. package/dist/src/server/routes/friend-status.d.ts.map +1 -1
  85. package/dist/src/server/routes/friend-status.js +97 -0
  86. package/dist/src/server/routes/friend-status.js.map +1 -1
  87. package/operator/swiftbar/render-menu.py +444 -0
  88. package/operator/swiftbar/rift.10s.sh +147 -0
  89. package/package.json +4 -1
@@ -38,10 +38,20 @@ import { runAutoCapture, resolveCodexCaptureDeps, } from "../../capture/auto-cap
38
38
  import { CodexCliTriageProvider } from "../../capture/codex-cli-triage-provider.js";
39
39
  import { discoverClaudeCodeSessions } from "../../ingestion/parsers/claude-code-jsonl.js";
40
40
  import { discoverCodexSessions } from "../../ingestion/parsers/codex-jsonl.js";
41
+ import { sniffInboxSource } from "../../ingestion/inbox-core/source-sniffer.js";
41
42
  import { writeHookConfig } from "../hooks-writers/index.js";
42
43
  import { HooksParseFailedError } from "../hooks-writers/index.js";
43
44
  import { pollJob } from "../job-poller.js";
44
45
  import { isJobFailure } from "../output.js";
46
+ import * as ui from "../ui.js";
47
+ /**
48
+ * Resolve a `--no-<flag>` to a single boolean ("yes, skip this thing").
49
+ * Commander populates the affirmative key with `false`; programmatic
50
+ * callers may pass the `no*` key with `true`. Either form wins.
51
+ */
52
+ export function isOff(opts, affirmative, negative) {
53
+ return opts[affirmative] === false || opts[negative] === true;
54
+ }
45
55
  const SUPPORTED_INGEST_EXTENSIONS = new Set([".json", ".zip"]);
46
56
  const DEFAULT_DATA_DIR = path.join(os.homedir(), "Library", "Application Support", "Rift", "data");
47
57
  // Inline import is intentionally narrow until Slice 3 lands the real
@@ -60,6 +70,7 @@ export function makeOnboardCommand() {
60
70
  .option("--reconfigure-voyage", "Recovery flow: replace the Voyage key only", false)
61
71
  .option("--yes", "Accept all defaults (non-interactive)", false)
62
72
  .option("--skip-capture", "Skip the post-setup capture pass (test-only)", false)
73
+ .option("--no-codex-capture", "Skip the Codex CLI preflight + disable the capture pass for this run")
63
74
  .option("--with-claude-hook", "Install the Rift policy hook into Claude Code without prompting", false)
64
75
  .option("--no-claude-hook", "Skip the Claude Code policy-hook prompt entirely")
65
76
  .action(async (opts, cmd) => {
@@ -89,23 +100,23 @@ async function runOnboard(opts, globalOpts) {
89
100
  await reconfigureVoyageFlow(opts, globalOpts, rl);
90
101
  return;
91
102
  }
92
- say("");
93
- say("Rift — first-run setup");
94
- say("─────────────────────");
103
+ ui.banner();
95
104
  // Step 1 — legacy migration (idempotent).
96
105
  const dataDir = await ensureConfigAndDataDir(globalOpts.config, opts, rl);
97
- say("");
98
- say("Running legacy migration check…");
99
106
  const migration = await runLegacyMigration({ dataDir });
100
107
  if (migration.migratedCount > 0) {
101
- say(`Migrated ${migration.migratedCount} legacy artifact(s).`);
108
+ ui.step("ok", "Existing data", `carried over ${migration.migratedCount} item(s)`);
102
109
  }
103
110
  else {
104
- say("No legacy artifacts found.");
111
+ ui.step("ok", "Existing data", "nothing to carry over");
105
112
  }
113
+ // Privacy contract — shown BEFORE the first outbound choice (the
114
+ // Voyage key, whose snippets leave the machine for embedding). A beta
115
+ // user should understand what stays local and what leaves before they
116
+ // paste anything.
117
+ ui.step("info", "Privacy", "");
118
+ sayPrivacyContract();
106
119
  // Step 2 — Voyage key + validate + persist + kickstart + smoke.
107
- say("");
108
- say("Voyage API key");
109
120
  const last4 = await collectAndPersistVoyageKey(opts, globalOpts, rl);
110
121
  // Step 2b — sanitize + persist optional --voyage-label to config.json
111
122
  // (backup first). Invalid labels are dropped without echoing the raw
@@ -113,39 +124,53 @@ async function runOnboard(opts, globalOpts) {
113
124
  // can never leak through stdout or config.json.
114
125
  const safeLabel = applyVoyageLabel(opts.voyageLabel, globalOpts.config, say);
115
126
  // Step 3 — Codex CLI preflight.
116
- say("");
117
- say("Codex CLI preflight…");
118
- const codexOk = await codexPreflight();
119
- if (!codexOk) {
120
- say("Codex CLI is not authenticated. Run: codex login");
121
- say("(Auto-capture needs Codex CLI auth. Re-run rift onboard once codex login succeeds.)");
122
- throw new CliError("Codex CLI preflight failed.", "validation");
123
- }
124
- say("Codex CLI ready.");
127
+ //
128
+ // Codex CLI is only required by the auto-capture lane. Friends who
129
+ // use only Claude Desktop / Claude Code (and import via the inbox
130
+ // or `rift import`) should not be blocked by a missing/expired
131
+ // Codex auth. So a failed preflight is a warning, not fatal
132
+ // onboarding continues and the capture pass is skipped for this
133
+ // run. `--no-codex-capture` skips the preflight entirely.
134
+ let captureDisabled = false;
135
+ if (isOff(opts, "codexCapture", "noCodexCapture")) {
136
+ ui.step("skip", "Chat access", "skipped (--no-codex-capture) · auto-import off");
137
+ captureDisabled = true;
138
+ }
139
+ else {
140
+ const codexSpin = new ui.Spinner("Chat access").start();
141
+ const codexOk = await codexPreflight();
142
+ if (codexOk) {
143
+ codexSpin.succeed("Chat access", "ready · Codex CLI");
144
+ }
145
+ else {
146
+ codexSpin.fail("Chat access", "not ready · Codex CLI — auto-import off");
147
+ ui.note("To enable later: run `codex login`, then re-run `rift onboard`.");
148
+ captureDisabled = true;
149
+ }
150
+ }
125
151
  // Step 4 — discover sessions.
126
- say("");
127
152
  const claudeSessions = safeDiscover(() => discoverClaudeCodeSessions(path.join(os.homedir(), ".claude")));
128
153
  const codexSessions = safeDiscover(() => discoverCodexSessions());
129
- say(`Found ${claudeSessions} Claude Code session(s) and ${codexSessions} Codex CLI session(s).`);
130
- // Step 5 — privacy + feedback opt-in.
131
- say("");
132
- say("Privacy");
133
- sayPrivacyContract();
134
- const feedback = await collectFeedbackPreference(opts, rl, dataDir);
154
+ ui.step("ok", "Chat history", `${claudeSessions} Claude Code · ${codexSessions} Codex CLI`);
155
+ // Step 5 — feedback preference (privacy contract already shown above).
156
+ const feedback = await collectFeedbackPreference(opts, dataDir);
135
157
  if (feedback.enabled) {
136
- say(`Feedback relay enabled (installation_id: ${feedback.installation_id}).`);
158
+ ui.step("ok", "Feedback", `relay on (installation_id: ${feedback.installation_id})`);
137
159
  }
138
160
  else {
139
- say("Feedback relay off — feedback stays in local JSONL only.");
161
+ ui.step("ok", "Feedback", "relay off — stays in local JSONL");
140
162
  }
141
163
  // Step 5b — optional Claude Code policy hook.
142
- say("");
143
164
  await maybeInstallClaudeCodeHook(opts, rl);
144
165
  // Step 6 + 7 — watermark current sessions + run one capture pass.
145
166
  let captureSaved = 0;
146
- if (!opts.skipCapture) {
147
- say("");
148
- say("Running first capture pass (watermark + scan)…");
167
+ if (opts.skipCapture) {
168
+ ui.step("skip", "Chat import", "skipped (--skip-capture)");
169
+ }
170
+ else if (captureDisabled) {
171
+ ui.step("skip", "Chat import", "skipped (Codex CLI not ready)");
172
+ }
173
+ else {
149
174
  const captureResult = await runFirstCapturePass(globalOpts.config, dataDir);
150
175
  captureSaved = captureResult.saved;
151
176
  // Per A-1.1/C-1.3: a token-issuance failure during capture-pass
@@ -157,13 +182,12 @@ async function runOnboard(opts, globalOpts) {
157
182
  return;
158
183
  }
159
184
  }
160
- else {
161
- say("Skipping capture pass (--skip-capture).");
162
- }
163
185
  // Step 8 — optional export import.
164
- say("");
165
186
  let importSucceeded = false;
166
- if (!opts.noImportExport) {
187
+ if (isOff(opts, "importExport", "noImportExport")) {
188
+ ui.step("skip", "File import", "skipped (--no-import-export)");
189
+ }
190
+ else {
167
191
  const importPath = await collectImportPath(opts, rl);
168
192
  if (importPath) {
169
193
  const outcome = await runImport(importPath, globalOpts.config);
@@ -172,30 +196,34 @@ async function runOnboard(opts, globalOpts) {
172
196
  }
173
197
  }
174
198
  else {
175
- say("Skipping import. Run rift import <path> --source <name> later.");
199
+ ui.step("skip", "File import", "none — run `rift import <path> --source <name>` later");
176
200
  }
177
201
  }
178
- else {
179
- say("Skipping import (--no-import-export).");
180
- }
181
202
  // Step 9 — first-recall verification.
182
203
  // Onboarding declares "complete" only when (a) capture or import landed
183
204
  // user data this run, AND (b) a recall query against the daemon returns
184
205
  // at least one hit. Without (a), there is nothing to recall — calling
185
206
  // it "complete" because /search returned 0 rows would be misleading.
186
- say("");
187
207
  const ingestedAny = captureSaved > 0 || importSucceeded;
188
- say(`First-recall sanity check (capture saved ${captureSaved} · import ${importSucceeded ? "ok" : "none"})…`);
208
+ const recallSpin = new ui.Spinner("Search check").start();
189
209
  const recall = ingestedAny
190
210
  ? await firstRecallCheck(globalOpts.config)
191
211
  : { ok: false, reason: "no user data was captured or imported during onboarding" };
212
+ if (recall.ok && recall.hits > 0) {
213
+ recallSpin.succeed("Search check", `found ${recall.hits} result(s)`);
214
+ }
215
+ else if (recall.ok) {
216
+ // 0 hits is NOT success — decideOnboardOutcome treats it as incomplete.
217
+ recallSpin.warn("Search check", "no results yet — index is fresh, try a query shortly");
218
+ }
219
+ else {
220
+ recallSpin.fail("Search check", recall.reason);
221
+ }
192
222
  // Step 10 — next-action card.
193
- say("");
194
- say("─────────────────────");
195
- for (const line of decideOnboardOutcome({ ingestedAny, recall }))
196
- say(line);
197
- say(`Voyage: key valid (last 4 …${last4})${safeLabel ? ` · label ${safeLabel}` : ""}`);
198
- say("");
223
+ const card = decideOnboardOutcome({ ingestedAny, recall });
224
+ card.push(ui.pc.dim(`Voyage: key valid (last 4 …${last4})${safeLabel ? ` · label ${safeLabel}` : ""}`));
225
+ ui.box(card);
226
+ ui.line("");
199
227
  }
200
228
  finally {
201
229
  rl.close();
@@ -204,10 +232,11 @@ async function runOnboard(opts, globalOpts) {
204
232
  // ----- Step 1: config.json + data dir -----
205
233
  async function ensureConfigAndDataDir(configPath, opts, rl) {
206
234
  const absoluteConfig = path.resolve(configPath);
235
+ const shownConfig = absoluteConfig.replace(os.homedir(), "~");
207
236
  if (fs.existsSync(absoluteConfig)) {
208
237
  const config = loadConfig(absoluteConfig);
209
- say(`Using existing config: ${absoluteConfig}`);
210
- say(`Data dir: ${config.data_paths.data_dir}`);
238
+ ui.step("ok", "Settings", shownConfig);
239
+ ui.note(`data dir: ${config.data_paths.data_dir}`);
211
240
  return config.data_paths.data_dir;
212
241
  }
213
242
  const defaultDataDir = DEFAULT_DATA_DIR;
@@ -237,8 +266,8 @@ async function ensureConfigAndDataDir(configPath, opts, rl) {
237
266
  encoding: "utf8",
238
267
  mode: 0o644,
239
268
  });
240
- say(`Wrote default config: ${absoluteConfig}`);
241
- say(`Data dir: ${dataDir}`);
269
+ ui.step("ok", "Settings", `wrote default · ${shownConfig}`);
270
+ ui.note(`data dir: ${dataDir}`);
242
271
  // Trigger ensureDirectories.
243
272
  loadConfig(absoluteConfig);
244
273
  return dataDir;
@@ -311,17 +340,18 @@ export function persistVoyageLabel(configPath, label) {
311
340
  // ----- Step 2: Voyage key flow -----
312
341
  async function collectAndPersistVoyageKey(opts, globalOpts, rl) {
313
342
  const key = await collectVoyageKey(opts, rl);
314
- say("Validating with Voyage API…");
343
+ const validateSpin = new ui.Spinner("Search index").start();
315
344
  const validation = await validateVoyageKey({ apiKey: key });
316
345
  if (!validation.ok) {
346
+ validateSpin.fail("Search index", "validation failed");
317
347
  throw new CliError(`Voyage validation failed: ${validation.reason}`, "validation");
318
348
  }
319
- say(`Voyage key valid (last 4 …${validation.last4}).`);
349
+ validateSpin.succeed("Search index", `connected · Voyage · ····${validation.last4}`);
320
350
  const envPath = defaultRiftEnvPath();
321
351
  const writeResult = writeEnvFile({ filePath: envPath, key: "VOYAGE_API_KEY", value: key });
322
- say(writeResult.backedUp
323
- ? "Wrote ~/.rift.env (existing file backed up)."
324
- : "Wrote ~/.rift.env (mode 0600).");
352
+ ui.note(writeResult.backedUp
353
+ ? "wrote ~/.rift.env (existing file backed up)"
354
+ : "wrote ~/.rift.env (mode 0600)");
325
355
  // Refresh process.env so any subsequent in-process Voyage call sees it.
326
356
  loadRiftEnv({ filePath: envPath });
327
357
  const refresh = await daemonRefreshFlow(globalOpts);
@@ -342,6 +372,14 @@ async function collectVoyageKey(opts, rl) {
342
372
  if (opts.yes) {
343
373
  throw new CliError("--yes given but no Voyage key found (use --voyage-key or set VOYAGE_API_KEY).", "validation");
344
374
  }
375
+ // Explain the key BEFORE asking for it — a beta user should know what
376
+ // they are pasting and why, not be confronted with a bare prompt.
377
+ ui.detail([
378
+ "Search index key",
379
+ "• What: paste the Voyage key Clem sent you (it won't echo as you type).",
380
+ "• Why: Rift uses Voyage to make your archive searchable by meaning.",
381
+ "• Privacy: the key is stored locally and only sent to Voyage when Rift calls Voyage; never sent to Clem.",
382
+ ].join("\n"));
345
383
  const answer = (await ask(rl, "Paste your Voyage API key: ")).trim();
346
384
  if (answer.length === 0) {
347
385
  throw new CliError("Voyage key is required.", "validation");
@@ -359,35 +397,38 @@ async function collectVoyageKey(opts, rl) {
359
397
  async function daemonRefreshFlow(globalOpts) {
360
398
  const baseUrl = safeResolveBaseUrl(globalOpts.config);
361
399
  if (!baseUrl) {
362
- say("Daemon not configured yet — skipping kickstart. Bootstrap via install.sh, then re-run rift onboard.");
400
+ ui.step("skip", "Rift service", "not configured yet — bootstrap via install.sh, then re-run");
363
401
  return { ok: false, reason: "daemon not configured", kind: "not_configured" };
364
402
  }
403
+ const daemonSpin = new ui.Spinner("Rift service").start();
365
404
  const kick = await kickstartDaemon();
366
405
  if (kick.status === "agent_not_loaded") {
367
- say(kick.hint);
406
+ daemonSpin.fail("Rift service", "launchd agent not loaded");
407
+ ui.note(kick.hint);
368
408
  return { ok: false, reason: kick.hint ?? "agent not loaded", kind: "agent_not_loaded" };
369
409
  }
370
410
  if (kick.status === "failed") {
411
+ daemonSpin.fail("Rift service", "kickstart failed");
371
412
  return {
372
413
  ok: false,
373
414
  reason: kick.hint ?? "kickstart failed",
374
415
  kind: "kickstart_failed",
375
416
  };
376
417
  }
377
- say("Daemon kickstarted.");
378
418
  const health = await waitForHealth({ baseUrl });
379
419
  if (!health.ok) {
420
+ daemonSpin.fail("Rift service", "health check failed");
380
421
  return { ok: false, reason: health.reason, kind: "health_failed" };
381
422
  }
382
- say(`Daemon healthy (uptime ${health.uptimeSeconds}s).`);
383
423
  if (!health.voyageKeyPresent) {
424
+ daemonSpin.fail("Rift service", "voyage_key_present=false after kickstart");
384
425
  return {
385
426
  ok: false,
386
427
  reason: "Daemon /health reports voyage_key_present=false after kickstart.",
387
428
  kind: "smoke_failed",
388
429
  };
389
430
  }
390
- say("Cloud embedding live (daemon loaded VOYAGE_API_KEY).");
431
+ daemonSpin.succeed("Rift service", `healthy · daemon · uptime ${health.uptimeSeconds}s`);
391
432
  return { ok: true, kickstarted: true };
392
433
  }
393
434
  export function classifyTokenFailure(err) {
@@ -490,30 +531,18 @@ async function codexPreflight() {
490
531
  return false;
491
532
  }
492
533
  }
493
- async function collectFeedbackPreference(opts, rl, dataDir) {
534
+ async function collectFeedbackPreference(opts, dataDir) {
494
535
  let enabled = false;
495
536
  let url;
537
+ // Default onboarding keeps feedback fully local (JSONL). There is no
538
+ // bundled hosted relay endpoint, so we never ask a beta user to paste a
539
+ // "Relay URL:" — that field is operator-only infrastructure. The relay
540
+ // is reachable solely via the advanced, non-interactive
541
+ // `--enable-feedback-relay <url>` flag.
496
542
  if (opts.enableFeedbackRelay) {
497
543
  enabled = true;
498
544
  url = opts.enableFeedbackRelay;
499
545
  }
500
- else if (opts.noFeedbackRelay || opts.yes) {
501
- enabled = false;
502
- }
503
- else {
504
- const answer = (await ask(rl, "Opt into the Rift feedback relay? [y/N]: ", "n"))
505
- .trim()
506
- .toLowerCase();
507
- enabled = answer === "y" || answer === "yes";
508
- if (enabled) {
509
- url = (await ask(rl, "Relay URL: ")).trim();
510
- if (!url) {
511
- say("No URL supplied — keeping relay off.");
512
- enabled = false;
513
- url = undefined;
514
- }
515
- }
516
- }
517
546
  const cfg = enabled && url
518
547
  ? { enabled: true, url, installation_id: crypto.randomUUID() }
519
548
  : { enabled: false };
@@ -540,7 +569,7 @@ async function collectFeedbackPreference(opts, rl, dataDir) {
540
569
  * guardrail, not a correctness requirement.
541
570
  */
542
571
  async function maybeInstallClaudeCodeHook(opts, rl) {
543
- if (opts.noClaudeHook) {
572
+ if (isOff(opts, "claudeHook", "noClaudeHook")) {
544
573
  return;
545
574
  }
546
575
  const homeDir = os.homedir();
@@ -555,20 +584,22 @@ async function maybeInstallClaudeCodeHook(opts, rl) {
555
584
  }
556
585
  else if (opts.yes) {
557
586
  // Non-interactive default: skip. Operator must opt in with --with-claude-hook.
558
- say("Claude Code detected — skipping the policy-hook prompt (pass --with-claude-hook to install non-interactively).");
587
+ ui.step("skip", "Claude Code", "skipped (pass --with-claude-hook to install non-interactively)");
559
588
  return;
560
589
  }
561
590
  else {
562
- say("Claude Code detected.");
563
- say("Optional: install a PreToolUse policy hook so Rift retrieval starts with rift_context_pack");
564
- say("(prevents broad rift_search dumps from burning context). Disable any time with RIFT_POLICY_DISABLED=1.");
565
- const answer = (await ask(rl, "Install Claude Code policy hook? [y/N]: ", "n"))
591
+ ui.note([
592
+ "Claude Code detected.",
593
+ "Optional: install a PreToolUse policy hook so Rift retrieval starts with rift_context_pack",
594
+ "(prevents broad rift_search dumps from burning context). Disable any time with RIFT_POLICY_DISABLED=1.",
595
+ ].join("\n"));
596
+ const answer = (await ask(rl, " Install Claude Code policy hook? [y/N]: ", "n"))
566
597
  .trim()
567
598
  .toLowerCase();
568
599
  install = answer === "y" || answer === "yes";
569
600
  }
570
601
  if (!install) {
571
- say("Skipping Claude Code policy hook.");
602
+ ui.step("skip", "Claude Code", "not installed");
572
603
  return;
573
604
  }
574
605
  try {
@@ -578,25 +609,25 @@ async function maybeInstallClaudeCodeHook(opts, rl) {
578
609
  dryRun: false,
579
610
  });
580
611
  if (outcome.hookEntryAdded) {
581
- say("Installed Rift policy hook into Claude Code (PreToolUse entry added).");
612
+ ui.step("ok", "Claude Code", "connected · auto-recall hook added");
582
613
  }
583
614
  else if (outcome.hookEntryReplaced || outcome.scriptUpdated) {
584
- say("Updated Rift policy hook in Claude Code.");
615
+ ui.step("ok", "Claude Code", "updated");
585
616
  }
586
617
  else {
587
- say("Rift policy hook already up-to-date in Claude Code.");
618
+ ui.step("ok", "Claude Code", "already up-to-date");
588
619
  }
589
620
  if (outcome.backupId) {
590
- say(` Backup ID: ${outcome.backupId}`);
621
+ ui.note(`backup ID: ${outcome.backupId}`);
591
622
  }
592
- say(" (Restart Claude Code or open /hooks once for the new entry to load.)");
623
+ ui.note("(Restart Claude Code or open /hooks once for the new entry to load.)");
593
624
  }
594
625
  catch (err) {
595
626
  if (err instanceof HooksParseFailedError) {
596
- say(err.message);
627
+ ui.step("fail", "Claude Code", err.message);
597
628
  }
598
629
  else {
599
- say(`Claude Code hook install failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
630
+ ui.step("fail", "Claude Code", `install failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
600
631
  }
601
632
  }
602
633
  }
@@ -604,7 +635,7 @@ async function maybeInstallClaudeCodeHook(opts, rl) {
604
635
  export async function runFirstCapturePass(configPath, dataDir) {
605
636
  const baseUrl = safeResolveBaseUrl(configPath);
606
637
  if (!baseUrl) {
607
- say("Daemon not reachable — skipping capture pass.");
638
+ ui.step("skip", "Chat import", "daemon not reachable — skipped");
608
639
  return { saved: 0, reviewed: 0 };
609
640
  }
610
641
  // Per A-1.1/C-1.3: token failure here is fatal. The wizard cannot
@@ -642,6 +673,7 @@ export async function runFirstCapturePass(configPath, dataDir) {
642
673
  catch {
643
674
  codexCaptureDeps = {};
644
675
  }
676
+ const captureSpin = new ui.Spinner("Chat import").start();
645
677
  try {
646
678
  const report = await runAutoCapture({
647
679
  dataDir,
@@ -667,21 +699,43 @@ export async function runFirstCapturePass(configPath, dataDir) {
667
699
  }
668
700
  },
669
701
  });
670
- say(`Capture: discovered ${report.total_discovered}, new ${report.new_conversations}, saved ${report.saved}, review ${report.review}, errors ${report.errors}.`);
702
+ const base = `${report.saved} added · ${report.review} to review`;
703
+ if (report.errors > 0) {
704
+ captureSpin.warn("Chat import", `${base} · ${report.errors} error(s)`);
705
+ }
706
+ else {
707
+ captureSpin.succeed("Chat import", base);
708
+ }
671
709
  return { saved: report.saved, reviewed: report.review };
672
710
  }
673
711
  catch (err) {
674
- say(`Capture pass error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
712
+ captureSpin.fail("Chat import", `error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
675
713
  return { saved: 0, reviewed: 0 };
676
714
  }
677
715
  }
678
716
  // ----- Step 8: import -----
679
717
  async function collectImportPath(opts, rl) {
680
- if (opts.importExport)
718
+ // opts.importExport is `string | false | undefined` — Commander writes
719
+ // `false` when `--no-import-export` was passed, but the caller already
720
+ // short-circuits in that case. Only a non-empty string is a real path.
721
+ if (typeof opts.importExport === "string" && opts.importExport.length > 0) {
681
722
  return opts.importExport;
723
+ }
682
724
  if (opts.yes)
683
725
  return null;
684
- const answer = (await ask(rl, "Drop a path to a ChatGPT/Claude/Grok/Gemini export to import now (or `skip`): ", "skip")).trim();
726
+ // Explain what a path is, where to find the export, why it's optional,
727
+ // and that ChatGPT is the only inline source — BEFORE the prompt. A beta
728
+ // user who has never exported a chat archive shouldn't hit a bare
729
+ // "Drop a path" prompt. The source-sniffer below still fail-fasts a
730
+ // non-ChatGPT export, but the friend learns the boundary earlier here.
731
+ ui.detail([
732
+ "Import past chats (optional)",
733
+ "• What: the file path to a ChatGPT data export (a .zip or .json on your Mac), e.g. ~/Downloads/chatgpt-export.zip.",
734
+ "• Where: chatgpt.com → Settings → Data controls → Export data, then unzip nothing — just drop the downloaded file's path here.",
735
+ "• Why: this seeds Rift with your existing ChatGPT history so search works on day one; skipping is fine — new chats get captured automatically.",
736
+ "• Other tools: Claude/Grok/Gemini exports aren't imported here. Run them later with: rift import <path> --source claude_web|grok_web|gemini_web",
737
+ ].join("\n"));
738
+ const answer = (await ask(rl, "Path to a ChatGPT export to import now (or `skip`): ", "skip")).trim();
685
739
  if (!answer || answer.toLowerCase() === "skip")
686
740
  return null;
687
741
  return answer;
@@ -711,11 +765,20 @@ async function runImport(filePath, configPath) {
711
765
  }
712
766
  const client = createHttpClient({ baseUrl, token: tokenResult.token });
713
767
  const buf = fs.readFileSync(absolute);
768
+ // Fail-fast for non-ChatGPT exports: route them to `rift import` with
769
+ // the right `--source` flag instead of silently feeding them through
770
+ // the ChatGPT parser. Returns null on "looks like ChatGPT or unknown,"
771
+ // a concrete provider otherwise.
772
+ const sniffed = sniffInboxSource(path.basename(absolute), buf);
773
+ if (sniffed) {
774
+ say(`Detected a ${sniffed} export — the inline importer is ChatGPT-only.`);
775
+ say(`Run: rift import "${absolute}" --source ${sniffed}`);
776
+ return { kind: "skipped", reason: `non-chatgpt export detected: ${sniffed}` };
777
+ }
714
778
  const form = new FormData();
715
779
  form.append("source", ONBOARD_INLINE_IMPORT_SOURCE);
716
780
  form.append("file", new Blob([buf]), path.basename(absolute));
717
781
  say(`Importing ${path.basename(absolute)} as ${ONBOARD_INLINE_IMPORT_SOURCE}…`);
718
- say("(For Claude/Grok/Gemini exports, run: rift import <path> --source <name>)");
719
782
  const { data } = await client.postMultipart("/ingest", form);
720
783
  const resp = data;
721
784
  if (resp.duplicate) {
@@ -755,8 +818,10 @@ export function decideOnboardOutcome(input) {
755
818
  }
756
819
  if (recall.ok) {
757
820
  return [
758
- "Setup partially complete — capture+import succeeded but search returned 0 hits.",
759
- "Next: rift feedback --kind=broke --with-status \"first-recall returned 0\"",
821
+ "Setup ready — capture+import succeeded but the generic recall query returned 0 hits.",
822
+ "Indexing may still be in flight, or this query just didn't match your archive yet.",
823
+ "Try: rift search \"<a topic you remember discussing>\"",
824
+ "If repeated tries still return nothing, run: rift feedback --kind=broke --with-status",
760
825
  ];
761
826
  }
762
827
  return [
@@ -776,19 +841,35 @@ async function firstRecallCheck(configPath) {
776
841
  // sessions or imported chat exports without targeting any onboarding
777
842
  // marker. The smoke is non-indexing (see daemonRefreshFlow), so any
778
843
  // hit returned here is real user data, not a leftover probe.
779
- try {
780
- const { data } = await client.post("/search", {
781
- query: "recent conversation",
782
- scope: "all",
783
- top_k: 10,
784
- });
785
- const body = data;
786
- const hits = (body.results?.length ?? body.hits?.length ?? 0);
787
- return { ok: true, hits };
788
- }
789
- catch (err) {
790
- return { ok: false, reason: err instanceof Error ? err.message : String(err) };
844
+ //
845
+ // Small unusual imports (e.g. one short Claude project export) can
846
+ // return 0 hits for this generic query even when the data was
847
+ // indexed correctly. Retry briefly so the post-onboard nudge isn't a
848
+ // false alarm on slow embedders / sparse archives.
849
+ const RECALL_RETRY_DELAYS_MS = [0, 5_000];
850
+ let lastErr = null;
851
+ for (const delay of RECALL_RETRY_DELAYS_MS) {
852
+ if (delay > 0)
853
+ await new Promise((r) => setTimeout(r, delay));
854
+ try {
855
+ const { data } = await client.post("/search", {
856
+ query: "recent conversation",
857
+ scope: "all",
858
+ top_k: 10,
859
+ });
860
+ const body = data;
861
+ const hits = (body.results?.length ?? body.hits?.length ?? 0);
862
+ if (hits > 0)
863
+ return { ok: true, hits };
864
+ lastErr = null;
865
+ }
866
+ catch (err) {
867
+ lastErr = err instanceof Error ? err.message : String(err);
868
+ }
791
869
  }
870
+ if (lastErr)
871
+ return { ok: false, reason: lastErr };
872
+ return { ok: true, hits: 0 };
792
873
  }
793
874
  // ----- Slice 6: --reconfigure-voyage -----
794
875
  /**
@@ -893,13 +974,14 @@ function say(line) {
893
974
  process.stdout.write(line + "\n");
894
975
  }
895
976
  function sayPrivacyContract() {
896
- say([
897
- " • Conversation content stays local (LanceDB + raw transcripts).",
898
- " • Content snippets leave the machine for embedding only (Voyage AI).",
899
- " • The Voyage key sits in ~/.rift.env (mode 0600). Never logged, never sent to Clem.",
900
- " Feedback is stored locally as JSONL. Relay is opt-in: explicit notes only,",
901
- " plus daemon health booleans no paths, no content, no key bytes.",
902
- " Full contract: docs/feedback/PRIVACY.md",
977
+ ui.detail([
978
+ "• Conversation content stays local (LanceDB + raw transcripts).",
979
+ "• Content snippets leave the machine for embedding only (Voyage AI).",
980
+ "• The Voyage key sits in ~/.rift.env (mode 0600), is sent only to Voyage,",
981
+ " and is never logged or sent to Clem.",
982
+ " Feedback is stored locally as JSONL. Relay is opt-in: explicit notes only,",
983
+ " plus daemon health booleans — no paths, no content, no key bytes.",
984
+ "• Full contract: https://getrift.dev/privacy",
903
985
  ].join("\n"));
904
986
  }
905
987
  function safeDiscover(fn) {