ray-finance 0.2.3 → 0.2.5

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 (110) hide show
  1. package/README.md +7 -1
  2. package/dist/ai/agent.d.ts +10 -2
  3. package/dist/ai/agent.js +42 -4
  4. package/dist/ai/audit.d.ts +1 -1
  5. package/dist/ai/insights.d.ts +1 -1
  6. package/dist/ai/insights.js +1 -1
  7. package/dist/ai/memory.d.ts +1 -1
  8. package/dist/ai/system-prompt.d.ts +1 -1
  9. package/dist/ai/system-prompt.js +8 -6
  10. package/dist/ai/tools.d.ts +1 -1
  11. package/dist/ai/tools.js +8 -3
  12. package/dist/alerts/index.d.ts +1 -1
  13. package/dist/alerts/index.js +16 -27
  14. package/dist/cli/chat.js +70 -16
  15. package/dist/cli/commands.d.ts +1 -0
  16. package/dist/cli/commands.js +38 -4
  17. package/dist/cli/completions.d.ts +1 -0
  18. package/dist/cli/completions.js +174 -0
  19. package/dist/cli/doctor.d.ts +1 -0
  20. package/dist/cli/doctor.js +171 -0
  21. package/dist/cli/format.d.ts +15 -0
  22. package/dist/cli/format.js +57 -0
  23. package/dist/cli/index.js +34 -0
  24. package/dist/cli/setup.js +63 -16
  25. package/dist/cli/updater.d.ts +3 -0
  26. package/dist/cli/updater.js +107 -0
  27. package/dist/daily-sync.d.ts +6 -2
  28. package/dist/daily-sync.js +30 -6
  29. package/dist/db/connection.d.ts +1 -1
  30. package/dist/db/connection.js +6 -6
  31. package/dist/db/schema.d.ts +1 -1
  32. package/dist/db/schema.js +36 -5
  33. package/dist/plaid/link.js +1 -0
  34. package/dist/plaid/sync.d.ts +8 -1
  35. package/dist/plaid/sync.js +65 -0
  36. package/dist/queries/index.d.ts +1 -1
  37. package/dist/queries/index.js +1 -1
  38. package/dist/scoring/index.d.ts +1 -1
  39. package/dist/server.js +23 -2
  40. package/package.json +5 -2
  41. package/.claude/settings.local.json +0 -18
  42. package/.env.example +0 -13
  43. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -19
  44. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -9
  45. package/.github/PULL_REQUEST_TEMPLATE.md +0 -5
  46. package/.github/ray-logo.png +0 -0
  47. package/.github/workflows/ci.yml +0 -22
  48. package/CHANGELOG.md +0 -16
  49. package/CODE_OF_CONDUCT.md +0 -31
  50. package/CONTRIBUTING.md +0 -41
  51. package/Dockerfile +0 -8
  52. package/SECURITY.md +0 -36
  53. package/docker-compose.yml +0 -9
  54. package/site/next-env.d.ts +0 -6
  55. package/site/next.config.ts +0 -7
  56. package/site/package-lock.json +0 -1704
  57. package/site/package.json +0 -25
  58. package/site/postcss.config.mjs +0 -7
  59. package/site/public/favicon.png +0 -0
  60. package/site/public/ray-logo-dark.png +0 -0
  61. package/site/public/ray-logo-light.png +0 -0
  62. package/site/public/ray-og.jpg +0 -0
  63. package/site/public/robots.txt +0 -4
  64. package/site/public/sitemap.xml +0 -8
  65. package/site/src/app/copy-command.tsx +0 -29
  66. package/site/src/app/globals.css +0 -87
  67. package/site/src/app/layout.tsx +0 -65
  68. package/site/src/app/page.tsx +0 -841
  69. package/site/src/app/pii-scramble.tsx +0 -190
  70. package/site/src/app/reveal.tsx +0 -29
  71. package/site/tsconfig.json +0 -21
  72. package/src/ai/agent.ts +0 -118
  73. package/src/ai/audit.ts +0 -11
  74. package/src/ai/context.ts +0 -94
  75. package/src/ai/insights.ts +0 -496
  76. package/src/ai/memory.ts +0 -21
  77. package/src/ai/redactor.test.ts +0 -63
  78. package/src/ai/redactor.ts +0 -114
  79. package/src/ai/system-prompt.ts +0 -90
  80. package/src/ai/tools.ts +0 -720
  81. package/src/alerts/index.ts +0 -123
  82. package/src/cli/backup.ts +0 -126
  83. package/src/cli/chat.ts +0 -219
  84. package/src/cli/commands.ts +0 -240
  85. package/src/cli/format.ts +0 -180
  86. package/src/cli/index.ts +0 -203
  87. package/src/cli/scheduler.ts +0 -116
  88. package/src/cli/setup.ts +0 -194
  89. package/src/config.ts +0 -81
  90. package/src/daily-sync.test.ts +0 -150
  91. package/src/daily-sync.ts +0 -170
  92. package/src/db/connection.ts +0 -49
  93. package/src/db/encryption.test.ts +0 -86
  94. package/src/db/encryption.ts +0 -39
  95. package/src/db/helpers.ts +0 -47
  96. package/src/db/schema.test.ts +0 -53
  97. package/src/db/schema.ts +0 -202
  98. package/src/index.ts +0 -3
  99. package/src/plaid/client.ts +0 -25
  100. package/src/plaid/link.ts +0 -25
  101. package/src/plaid/sync.ts +0 -219
  102. package/src/public/favicon.png +0 -0
  103. package/src/public/link.html +0 -184
  104. package/src/public/ray-logo-dark.png +0 -0
  105. package/src/queries/index.test.ts +0 -397
  106. package/src/queries/index.ts +0 -586
  107. package/src/scoring/index.ts +0 -468
  108. package/src/server.ts +0 -198
  109. package/tsconfig.json +0 -16
  110. package/vitest.config.ts +0 -7
package/README.md CHANGED
@@ -59,10 +59,16 @@ Open Ray and it shows your net worth, spending vs last month, budget pacing, and
59
59
  - **Scheduled daily sync** — Automatic bank sync via launchd (macOS) or cron (Linux)
60
60
  - **Export/import** — Back up and restore your financial data
61
61
 
62
+ ## Install
63
+
64
+ ```bash
65
+ npm install -g ray-finance
66
+ ```
67
+
62
68
  ## Quick Start
63
69
 
64
70
  ```bash
65
- npx ray-finance setup
71
+ ray setup
66
72
  ```
67
73
 
68
74
  The setup wizard offers two modes:
@@ -1,2 +1,10 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
2
- export declare function handleMessage(db: Database.Database, userMessage: string): Promise<string>;
1
+ import type Database from "libsql";
2
+ /** Human-readable labels for tool calls shown in the spinner */
3
+ export declare const TOOL_LABELS: Record<string, string>;
4
+ export type ProgressCallback = (event: {
5
+ phase: "tool" | "responding";
6
+ toolName?: string;
7
+ toolCount: number;
8
+ elapsedMs: number;
9
+ }) => void;
10
+ export declare function handleMessage(db: Database.Database, userMessage: string, onProgress?: ProgressCallback): Promise<string>;
package/dist/ai/agent.js CHANGED
@@ -11,7 +11,23 @@ const anthropic = new Anthropic(useManaged()
11
11
  function supportsThinking(model) {
12
12
  return /sonnet-4|opus-4/i.test(model);
13
13
  }
14
- export async function handleMessage(db, userMessage) {
14
+ /** Human-readable labels for tool calls shown in the spinner */
15
+ export const TOOL_LABELS = {
16
+ get_net_worth: "Checking net worth",
17
+ get_accounts: "Reviewing accounts",
18
+ get_transactions: "Looking at transactions",
19
+ get_spending_summary: "Analyzing spending",
20
+ get_budgets: "Reviewing budgets",
21
+ set_budget: "Setting budget",
22
+ get_goals: "Checking goals",
23
+ set_goal: "Setting goal",
24
+ get_score: "Calculating score",
25
+ get_recurring: "Finding recurring charges",
26
+ get_alerts: "Checking alerts",
27
+ save_memory: "Remembering that",
28
+ update_context: "Updating your profile",
29
+ };
30
+ export async function handleMessage(db, userMessage, onProgress) {
15
31
  // Save incoming message
16
32
  saveMessage(db, "user", userMessage);
17
33
  // Load conversation context, truncated to fit token budget
@@ -56,6 +72,8 @@ export async function handleMessage(db, userMessage) {
56
72
  // Initial API call
57
73
  let response = await anthropic.messages.create(apiParams);
58
74
  // Agentic tool loop
75
+ const startTime = Date.now();
76
+ let toolCount = 0;
59
77
  while (response.stop_reason === "tool_use") {
60
78
  // Filter out thinking blocks before adding to messages
61
79
  const assistantContent = response.content.filter((b) => b.type !== "thinking");
@@ -63,6 +81,13 @@ export async function handleMessage(db, userMessage) {
63
81
  const toolResults = [];
64
82
  for (const block of assistantContent) {
65
83
  if (block.type === "tool_use") {
84
+ toolCount++;
85
+ onProgress?.({
86
+ phase: "tool",
87
+ toolName: block.name,
88
+ toolCount,
89
+ elapsedMs: Date.now() - startTime,
90
+ });
66
91
  const result = await executeTool(db, block.name, block.input);
67
92
  logToolCall(db, block.name, block.input, result, response.usage?.output_tokens);
68
93
  toolResults.push({
@@ -73,6 +98,11 @@ export async function handleMessage(db, userMessage) {
73
98
  }
74
99
  }
75
100
  messages.push({ role: "user", content: toolResults });
101
+ onProgress?.({
102
+ phase: "responding",
103
+ toolCount,
104
+ elapsedMs: Date.now() - startTime,
105
+ });
76
106
  response = await anthropic.messages.create(apiParams);
77
107
  }
78
108
  // Extract text response (filter out thinking blocks), restore PII for display
@@ -83,11 +113,19 @@ export async function handleMessage(db, userMessage) {
83
113
  return responseText || "I looked into that but couldn't formulate a response. Could you try rephrasing?";
84
114
  }
85
115
  catch (error) {
86
- // Log full error internally but don't expose details to user
116
+ if (error.status === 403) {
117
+ return "Your API key was rejected. This usually means your subscription is inactive. Run `ray billing` to check your payment status, or `ray setup` to reconfigure.";
118
+ }
119
+ if (error.status === 401) {
120
+ return "Invalid API key. Run `ray setup` to reconfigure your credentials.";
121
+ }
122
+ if (error.status === 429) {
123
+ return "Rate limited. Wait a moment and try again.";
124
+ }
87
125
  const safeMessage = error.status
88
126
  ? `API error (${error.status})`
89
- : "internal error";
90
- console.error("AI agent error:", safeMessage);
127
+ : error.message || "internal error";
128
+ console.error("AI error:", safeMessage);
91
129
  return "Sorry, I had trouble processing that. Could you try again?";
92
130
  }
93
131
  }
@@ -1,3 +1,3 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
1
+ import type Database from "libsql";
2
2
  export declare function logToolCall(db: Database.Database, toolName: string, inputParams: any, resultSummary: string, tokensUsed?: number): void;
3
3
  export declare function getAuditLog(db: Database.Database, limit?: number): any[];
@@ -1,3 +1,3 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
1
+ import type Database from "libsql";
2
2
  export declare function computeInsights(db: Database.Database): string;
3
3
  export declare function cliBriefing(db: Database.Database): string | null;
@@ -53,7 +53,7 @@ function buildSnapshot(db) {
53
53
  // Account balances — cap at 5
54
54
  const accounts = getAccountBalances(db).slice(0, 5);
55
55
  if (accounts.length > 0) {
56
- lines.push(accounts.map(a => `${a.name} (${a.type}): ${a.type === "credit" ? "-" : ""}${formatMoney(a.balance)}`).join(" | "));
56
+ lines.push(accounts.map(a => `${a.name} (${a.type}): ${["credit", "loan"].includes(a.type) ? "-" : ""}${formatMoney(a.balance)}`).join(" | "));
57
57
  }
58
58
  // Debt summary
59
59
  const debts = getDebts(db);
@@ -1,4 +1,4 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
1
+ import type Database from "libsql";
2
2
  export declare function getConversationHistory(db: Database.Database, limit?: number): {
3
3
  role: string;
4
4
  content: string;
@@ -1,2 +1,2 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
1
+ import type Database from "libsql";
2
2
  export declare function buildSystemPrompt(db: Database.Database): string;
@@ -38,6 +38,7 @@ Today is ${dateStr}.
38
38
  - When the user shares something worth remembering (a preference, life event, financial goal context), use save_memory.
39
39
  - When circumstances change (new decisions, completed goals, changed balances, updated strategy), use update_context to persist the change.
40
40
  - For date-based queries, figure out the right date range from context (e.g., "this month" = first of current month to today).
41
+ - If you notice transactions suggesting unlinked accounts (e.g., mortgage payments, car loans, investment transfers) that aren't in the linked accounts, mention it once and suggest \`ray link\`. If the user says they don't have that account, save it to context.
41
42
 
42
43
  ## Privacy
43
44
  - Never reveal account numbers, routing numbers, or Plaid access tokens.
@@ -49,18 +50,19 @@ ${name} just connected their financial accounts and needs help setting up their
49
50
  Instructions:
50
51
  1. Start by calling these tools to review their synced data: get_accounts, get_transactions (last 30 days), get_spending_summary, get_debts
51
52
  2. Present a concise summary of what you found — accounts, recent spending patterns, any debts
52
- 3. Then ask about gaps ONE TOPIC AT A TIME (not all at once). Topics to cover:
53
+ 3. Check for missing account types: if you see mortgage payments but no mortgage account, car payments but no auto loan, investment contributions but no investment account, etc. — ask if they'd like to link those too (they can run \`ray link\`). If they say they don't have one, save that to context so you don't ask again.
54
+ 4. Then ask about gaps ONE TOPIC AT A TIME (not all at once). Topics to cover:
53
55
  - Family situation (partner, dependents)
54
56
  - Income details (salary, side income, frequency)
55
57
  - Financial goals (short-term and long-term)
56
58
  - Current challenges or concerns
57
59
  - Budget targets or spending limits they want
58
60
  - Any upcoming life changes (job change, move, baby, etc.)
59
- 4. After each answer, call update_context with the "section" param to save that section of their context
60
- 5. Also use save_memory for notable individual facts
61
- 6. Keep it to 1-2 questions per turn — be conversational, not interrogative
62
- 7. After gathering enough info, write a Strategy section summarizing priorities and next steps
63
- 8. If the user says "skip" or changes topic, gracefully stop onboarding and help with whatever they need
61
+ 5. After each answer, call update_context with the "section" param to save that section of their context
62
+ 6. Also use save_memory for notable individual facts
63
+ 7. Keep it to 1-2 questions per turn — be conversational, not interrogative
64
+ 8. After gathering enough info, write a Strategy section summarizing priorities and next steps
65
+ 9. If the user says "skip" or changes topic, gracefully stop onboarding and help with whatever they need
64
66
 
65
67
  This onboarding block will automatically disappear once the context file is filled in.`;
66
68
  }
@@ -1,4 +1,4 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
1
+ import type Database from "libsql";
2
2
  import type { Tool } from "@anthropic-ai/sdk/resources/messages.js";
3
3
  export declare const toolDefinitions: Tool[];
4
4
  export declare function executeTool(db: Database.Database, toolName: string, toolInput: any): Promise<string>;
package/dist/ai/tools.js CHANGED
@@ -332,7 +332,7 @@ export async function executeTool(db, toolName, toolInput) {
332
332
  const accounts = getAccountBalances(db);
333
333
  if (accounts.length === 0)
334
334
  return "No accounts linked yet.";
335
- return accounts.map(a => `${a.name} (${a.type}): ${a.type === "credit" ? "-" : ""}${formatMoney(a.balance)}`).join("\n");
335
+ return accounts.map(a => `${a.name} (${a.type}): ${["credit", "loan"].includes(a.type) ? "-" : ""}${formatMoney(a.balance)}`).join("\n");
336
336
  }
337
337
  case "get_transactions": {
338
338
  const txns = getTransactionsFiltered(db, {
@@ -432,10 +432,15 @@ export async function executeTool(db, toolName, toolInput) {
432
432
  return result;
433
433
  }
434
434
  case "get_recurring": {
435
- const rows = db.prepare(`SELECT merchant_name, avg_amount, frequency, last_date FROM recurring WHERE active = 1 ORDER BY avg_amount DESC`).all();
435
+ const rows = db.prepare(`SELECT merchant_name, description, avg_amount, last_amount, frequency, last_date, stream_type
436
+ FROM recurring WHERE is_active = 1 ORDER BY stream_type, avg_amount DESC`).all();
436
437
  if (rows.length === 0)
437
438
  return "No recurring transactions detected yet.";
438
- return rows.map(r => `${r.merchant_name}: ${formatMoney(r.avg_amount)} (${r.frequency}, last: ${r.last_date})`).join("\n");
439
+ return rows.map(r => {
440
+ const name = r.merchant_name || r.description;
441
+ const arrow = r.stream_type === "inflow" ? "+" : "-";
442
+ return `${arrow} ${name}: ${formatMoney(Math.abs(r.avg_amount))} (${r.frequency.toLowerCase()}, last: ${r.last_date})`;
443
+ }).join("\n");
439
444
  }
440
445
  case "get_alerts": {
441
446
  const alerts = generateAlerts(db);
@@ -1,4 +1,4 @@
1
- import type BetterSqlite3 from "better-sqlite3-multiple-ciphers";
1
+ import type BetterSqlite3 from "libsql";
2
2
  type Database = BetterSqlite3.Database;
3
3
  export interface Alert {
4
4
  type: string;
@@ -59,36 +59,25 @@ export function generateAlerts(db) {
59
59
  });
60
60
  }
61
61
  }
62
- // Subscription price changes (compare last 2 occurrences of recurring merchants)
62
+ // Subscription price changes uses Plaid's recurring transaction streams
63
63
  const recurring = db
64
- .prepare(`SELECT merchant_name, amount, date FROM transactions
65
- WHERE merchant_name IN (
66
- SELECT merchant_name FROM transactions
67
- WHERE merchant_name IS NOT NULL AND amount > 0
68
- GROUP BY merchant_name HAVING COUNT(*) >= 2
69
- )
70
- AND amount > 0 AND pending = 0
71
- ORDER BY merchant_name, date DESC`)
64
+ .prepare(`SELECT merchant_name, description, avg_amount, last_amount, frequency, status
65
+ FROM recurring
66
+ WHERE is_active = 1 AND stream_type = 'outflow' AND status = 'MATURE'
67
+ AND last_amount IS NOT NULL AND avg_amount IS NOT NULL`)
72
68
  .all();
73
- const byMerchant = {};
74
69
  for (const r of recurring) {
75
- if (!byMerchant[r.merchant_name])
76
- byMerchant[r.merchant_name] = [];
77
- if (byMerchant[r.merchant_name].length < 2) {
78
- byMerchant[r.merchant_name].push({ amount: r.amount, date: r.date });
79
- }
80
- }
81
- for (const [merchant, charges] of Object.entries(byMerchant)) {
82
- if (charges.length === 2) {
83
- const diff = Math.abs(charges[0].amount - charges[1].amount);
84
- if (diff > 0.5 && diff / charges[1].amount > 0.05) {
85
- alerts.push({
86
- type: "price_change",
87
- severity: "info",
88
- message: `Price change: ${merchant} went from $${charges[1].amount} to $${charges[0].amount}`,
89
- data: { merchant, previous: charges[1].amount, current: charges[0].amount },
90
- });
91
- }
70
+ if (r.avg_amount === 0)
71
+ continue;
72
+ const diff = Math.abs(r.last_amount - r.avg_amount);
73
+ if (diff > 0.5 && diff / Math.abs(r.avg_amount) > 0.05) {
74
+ const name = r.merchant_name || r.description;
75
+ alerts.push({
76
+ type: "price_change",
77
+ severity: "info",
78
+ message: `Price change: ${name} went from $${Math.abs(r.avg_amount).toFixed(2)} to $${Math.abs(r.last_amount).toFixed(2)} (${r.frequency.toLowerCase()})`,
79
+ data: { merchant: name, previous: r.avg_amount, current: r.last_amount, frequency: r.frequency },
80
+ });
92
81
  }
93
82
  }
94
83
  return alerts;
package/dist/cli/chat.js CHANGED
@@ -1,22 +1,18 @@
1
1
  import chalk from "chalk";
2
- import { getDb } from "../db/connection.js";
3
- import { handleMessage } from "../ai/agent.js";
4
2
  import { config } from "../config.js";
5
- import { isContextEmpty } from "../ai/context.js";
6
- import { cliBriefing } from "../ai/insights.js";
7
- import { banner, formatResponse } from "./format.js";
3
+ import { banner, formatResponse, formatDuration, formatError } from "./format.js";
8
4
  /** Raw-mode line reader that renders content below the cursor while waiting for input */
9
5
  function rawReadLine(prompt, belowLines) {
10
6
  return new Promise((resolve) => {
11
7
  let buf = "";
12
8
  const out = process.stdout;
13
- // Render: prompt on current line, then content below, then restore cursor
9
+ // Render: prompt on current line, then content below, then move cursor back
14
10
  out.write(prompt);
15
11
  if (belowLines.length > 0) {
16
- // Save cursor, render below, restore
17
- out.write("\x1b[s");
18
12
  out.write("\n" + belowLines.join("\n"));
19
- out.write("\x1b[u");
13
+ // Move cursor back up to the prompt line and to the end of prompt text
14
+ out.write(`\x1b[${belowLines.length}A`);
15
+ out.write("\r" + prompt);
20
16
  }
21
17
  process.stdin.setRawMode(true);
22
18
  process.stdin.resume();
@@ -73,8 +69,25 @@ function rawReadLine(prompt, belowLines) {
73
69
  process.stdin.on("data", onData);
74
70
  });
75
71
  }
72
+ const THINKING_PHRASES = [
73
+ "Thinking...",
74
+ "Crunching numbers...",
75
+ "Reviewing your accounts...",
76
+ "Analyzing...",
77
+ "Looking into that...",
78
+ "Pulling up your data...",
79
+ "Checking the numbers...",
80
+ "On it...",
81
+ ];
82
+ function getThinkingText() {
83
+ return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)];
84
+ }
76
85
  export async function startChat() {
77
86
  const ora = (await import("ora")).default;
87
+ const { getDb } = await import("../db/connection.js");
88
+ const { handleMessage, TOOL_LABELS } = await import("../ai/agent.js");
89
+ const { isContextEmpty } = await import("../ai/context.js");
90
+ const { cliBriefing } = await import("../ai/insights.js");
78
91
  const db = getDb();
79
92
  // Show logo + briefing
80
93
  console.log("");
@@ -111,8 +124,8 @@ export async function startChat() {
111
124
  }
112
125
  // Auto-trigger onboarding for new users
113
126
  if (isContextEmpty()) {
114
- console.log(chalk.cyan("Welcome! Let me review your accounts and help set up your financial profile.\n"));
115
- const spinner = ora({ text: "Reviewing your accounts...", color: "cyan", discardStdin: false }).start();
127
+ console.log(chalk.yellowBright("Welcome! Let me review your accounts and help set up your financial profile.\n"));
128
+ const spinner = ora({ text: "Reviewing your accounts...", color: "yellow", discardStdin: false }).start();
116
129
  try {
117
130
  const response = await handleMessage(db, "I just connected my financial accounts. Help me set up my financial profile.");
118
131
  spinner.stop();
@@ -120,10 +133,35 @@ export async function startChat() {
120
133
  }
121
134
  catch (err) {
122
135
  spinner.stop();
123
- console.error(chalk.red("Error during onboarding: " + err.message));
136
+ console.error(formatError(err, "Onboarding error"));
137
+ }
138
+ }
139
+ // Background re-sync for recently linked accounts (Plaid backfill can take hours)
140
+ let bgSyncTimer = null;
141
+ const oldestAccount = db.prepare(`SELECT MIN(created_at) as ts FROM institutions`).get();
142
+ if (oldestAccount?.ts) {
143
+ const ageMs = Date.now() - new Date(oldestAccount.ts + "Z").getTime();
144
+ if (ageMs < 6 * 60 * 60 * 1000) { // linked within last 6 hours
145
+ const { runDailySync } = await import("../daily-sync.js");
146
+ bgSyncTimer = setInterval(async () => {
147
+ // Silence all output during background sync
148
+ const origWrite = process.stdout.write;
149
+ const origErr = process.stderr.write;
150
+ process.stdout.write = () => true;
151
+ process.stderr.write = () => true;
152
+ try {
153
+ await runDailySync(db);
154
+ }
155
+ catch { }
156
+ process.stdout.write = origWrite;
157
+ process.stderr.write = origErr;
158
+ }, 15 * 60 * 1000); // every 15 minutes
159
+ bgSyncTimer.unref(); // don't prevent process exit
124
160
  }
125
161
  }
126
162
  const shutdown = () => {
163
+ if (bgSyncTimer)
164
+ clearInterval(bgSyncTimer);
127
165
  console.log(chalk.dim("\nGoodbye!"));
128
166
  process.exit(0);
129
167
  };
@@ -159,6 +197,7 @@ export async function startChat() {
159
197
  if (syncStr)
160
198
  parts.push(syncStr);
161
199
  parts.push(hints[hintIdx]);
200
+ parts.push("ctrl+c to exit");
162
201
  hintIdx = (hintIdx + 1) % hints.length;
163
202
  return parts.join(" · ");
164
203
  };
@@ -173,8 +212,14 @@ export async function startChat() {
173
212
  console.log(rule);
174
213
  const input = await rawReadLine(chalk.dim("❯ "), [rule, footerText]);
175
214
  const trimmed = input.trim();
176
- if (!trimmed)
215
+ if (!trimmed) {
216
+ // Clear the prompt frame (top rule + prompt + bottom rule + footer)
217
+ process.stdout.write("\x1b[3A\r");
218
+ for (let i = 0; i < 4; i++)
219
+ process.stdout.write("\x1b[2K\x1b[1B");
220
+ process.stdout.write("\x1b[4A\r");
177
221
  continue;
222
+ }
178
223
  // Replace prompt frame with gray-background user message
179
224
  // Move up 4 lines (footer, bottom rule, prompt, top rule) and clear them
180
225
  process.stdout.write("\x1b[4A\r");
@@ -189,15 +234,24 @@ export async function startChat() {
189
234
  shutdown();
190
235
  break;
191
236
  }
192
- const spinner = ora({ text: "Thinking...", color: "cyan", discardStdin: false }).start();
237
+ const spinner = ora({ text: getThinkingText(), color: "cyan", discardStdin: false }).start();
238
+ const onProgress = ({ phase, toolName, toolCount, elapsedMs }) => {
239
+ if (phase === "tool" && toolName) {
240
+ const label = TOOL_LABELS[toolName] || toolName;
241
+ spinner.text = `${label}... ${chalk.dim(`(${toolCount} ${toolCount === 1 ? "tool" : "tools"}, ${formatDuration(elapsedMs)})`)}`;
242
+ }
243
+ else if (phase === "responding" && toolCount > 0) {
244
+ spinner.text = `Composing response... ${chalk.dim(`(${toolCount} tools, ${formatDuration(elapsedMs)})`)}`;
245
+ }
246
+ };
193
247
  try {
194
- const response = await handleMessage(db, trimmed);
248
+ const response = await handleMessage(db, trimmed, onProgress);
195
249
  spinner.stop();
196
250
  console.log(`\n${formatResponse(response)}\n`);
197
251
  }
198
252
  catch (err) {
199
253
  spinner.stop();
200
- console.error(chalk.red("Error: " + err.message));
254
+ console.error(formatError(err));
201
255
  }
202
256
  }
203
257
  }
@@ -1,5 +1,6 @@
1
1
  export declare function runSync(): Promise<void>;
2
2
  export declare function runLink(): Promise<void>;
3
+ export declare function showAccounts(): void;
3
4
  export declare function showStatus(): void;
4
5
  export declare function showTransactions(options?: {
5
6
  limit?: number;
@@ -5,17 +5,22 @@ import { getLatestScore, getAchievements, getMonthlySavings } from "../scoring/i
5
5
  import { generateAlerts } from "../alerts/index.js";
6
6
  import { runDailySync } from "../daily-sync.js";
7
7
  import { startLinkServer } from "../server.js";
8
- import { heading, progressBar, formatMoney, formatMoneyColored, dim } from "./format.js";
8
+ import { heading, progressBar, formatMoney, formatMoneyColored, dim, formatDuration, formatError } from "./format.js";
9
9
  export async function runSync() {
10
10
  const ora = (await import("ora")).default;
11
11
  const spinner = ora("Syncing transactions...").start();
12
+ const startTime = Date.now();
12
13
  try {
13
14
  const db = getDb();
14
- await runDailySync(db);
15
- spinner.succeed("Sync complete.");
15
+ const result = await runDailySync(db);
16
+ const elapsed = formatDuration(Date.now() - startTime);
17
+ const parts = [elapsed];
18
+ if (result.transactionsAdded > 0)
19
+ parts.push(`${result.transactionsAdded} new transactions`);
20
+ spinner.succeed(`Sync complete. ${chalk.dim(`(${parts.join(", ")})`)}`);
16
21
  }
17
22
  catch (err) {
18
- spinner.fail(`Sync failed: ${err.message}`);
23
+ spinner.fail(formatError(err, "Sync failed"));
19
24
  }
20
25
  }
21
26
  export async function runLink() {
@@ -31,6 +36,35 @@ export async function runLink() {
31
36
  stop();
32
37
  spinner.succeed("Bank account linked successfully!");
33
38
  }
39
+ export function showAccounts() {
40
+ const db = getDb();
41
+ const institutions = db.prepare(`SELECT i.name as institution, i.item_id, i.created_at,
42
+ a.name, a.type, a.subtype, a.mask, a.current_balance, a.currency
43
+ FROM institutions i
44
+ LEFT JOIN accounts a ON a.item_id = i.item_id AND a.hidden = 0
45
+ ORDER BY i.created_at, a.type, a.current_balance DESC`).all();
46
+ if (institutions.length === 0) {
47
+ console.log("\nNo accounts linked. Run 'ray link' to connect one.\n");
48
+ return;
49
+ }
50
+ console.log(`\n${heading("Linked Accounts")}\n`);
51
+ let currentInst = "";
52
+ for (const row of institutions) {
53
+ if (row.institution !== currentInst) {
54
+ currentInst = row.institution;
55
+ console.log(chalk.bold(currentInst));
56
+ }
57
+ if (!row.name) {
58
+ console.log(dim(" No accounts found"));
59
+ continue;
60
+ }
61
+ const mask = row.mask ? ` ••${row.mask}` : "";
62
+ const balance = row.current_balance != null ? rawFormatMoney(row.current_balance) : "—";
63
+ const label = row.subtype || row.type || "";
64
+ console.log(` ${row.name}${dim(mask)} ${dim(label)} ${balance}`);
65
+ }
66
+ console.log("");
67
+ }
34
68
  export function showStatus() {
35
69
  const db = getDb();
36
70
  const nw = getNetWorth(db);
@@ -0,0 +1 @@
1
+ export declare function installCompletions(): void;