ray-finance 0.1.0 → 0.1.1

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,2 +1,10 @@
1
1
  import type Database from "libsql";
2
- export declare function handleMessage(db: Database.Database, userMessage: string): Promise<string>;
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
  }
@@ -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
  }
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,28 @@ 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
+ try {
148
+ await runDailySync(db);
149
+ }
150
+ catch { }
151
+ }, 15 * 60 * 1000); // every 15 minutes
152
+ bgSyncTimer.unref(); // don't prevent process exit
124
153
  }
125
154
  }
126
155
  const shutdown = () => {
156
+ if (bgSyncTimer)
157
+ clearInterval(bgSyncTimer);
127
158
  console.log(chalk.dim("\nGoodbye!"));
128
159
  process.exit(0);
129
160
  };
@@ -159,6 +190,7 @@ export async function startChat() {
159
190
  if (syncStr)
160
191
  parts.push(syncStr);
161
192
  parts.push(hints[hintIdx]);
193
+ parts.push("ctrl+c to exit");
162
194
  hintIdx = (hintIdx + 1) % hints.length;
163
195
  return parts.join(" · ");
164
196
  };
@@ -189,15 +221,24 @@ export async function startChat() {
189
221
  shutdown();
190
222
  break;
191
223
  }
192
- const spinner = ora({ text: "Thinking...", color: "cyan", discardStdin: false }).start();
224
+ const spinner = ora({ text: getThinkingText(), color: "cyan", discardStdin: false }).start();
225
+ const onProgress = ({ phase, toolName, toolCount, elapsedMs }) => {
226
+ if (phase === "tool" && toolName) {
227
+ const label = TOOL_LABELS[toolName] || toolName;
228
+ spinner.text = `${label}... ${chalk.dim(`(${toolCount} ${toolCount === 1 ? "tool" : "tools"}, ${formatDuration(elapsedMs)})`)}`;
229
+ }
230
+ else if (phase === "responding" && toolCount > 0) {
231
+ spinner.text = `Composing response... ${chalk.dim(`(${toolCount} tools, ${formatDuration(elapsedMs)})`)}`;
232
+ }
233
+ };
193
234
  try {
194
- const response = await handleMessage(db, trimmed);
235
+ const response = await handleMessage(db, trimmed, onProgress);
195
236
  spinner.stop();
196
237
  console.log(`\n${formatResponse(response)}\n`);
197
238
  }
198
239
  catch (err) {
199
240
  spinner.stop();
200
- console.error(chalk.red("Error: " + err.message));
241
+ console.error(formatError(err));
201
242
  }
202
243
  }
203
244
  }
@@ -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() {
@@ -1,3 +1,18 @@
1
+ export declare const colors: {
2
+ money: {
3
+ positive: (t: string) => string;
4
+ negative: (t: string) => string;
5
+ };
6
+ accent: (t: string) => string;
7
+ muted: (t: string) => string;
8
+ error: (t: string) => string;
9
+ success: (t: string) => string;
10
+ warning: (t: string) => string;
11
+ info: (t: string) => string;
12
+ };
13
+ export declare function formatDuration(ms: number): string;
14
+ export declare function formatCount(n: number): string;
15
+ export declare function formatError(error: any, context?: string): string;
1
16
  export declare function formatMoney(n: number): string;
2
17
  export declare function formatMoneyColored(n: number, invert?: boolean): string;
3
18
  export declare function progressBar(pct: number, width?: number): string;
@@ -1,4 +1,61 @@
1
1
  import chalk from "chalk";
2
+ // ─── Color Palette ─── //
3
+ export const colors = {
4
+ money: {
5
+ positive: (t) => chalk.green(t),
6
+ negative: (t) => chalk.red(t),
7
+ },
8
+ accent: (t) => chalk.cyan(t),
9
+ muted: (t) => chalk.dim(t),
10
+ error: (t) => chalk.red(t),
11
+ success: (t) => chalk.green(t),
12
+ warning: (t) => chalk.yellow(t),
13
+ info: (t) => chalk.blue(t),
14
+ };
15
+ // ─── Number & Duration Formatting ─── //
16
+ export function formatDuration(ms) {
17
+ const s = Math.floor(ms / 1000);
18
+ if (s < 1)
19
+ return "< 1s";
20
+ if (s < 60)
21
+ return `${s}s`;
22
+ const m = Math.floor(s / 60);
23
+ const rem = s % 60;
24
+ if (m < 60)
25
+ return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
26
+ const h = Math.floor(m / 60);
27
+ return `${h}h ${m % 60}m`;
28
+ }
29
+ export function formatCount(n) {
30
+ return n.toLocaleString();
31
+ }
32
+ // ─── Error Display ─── //
33
+ const ERROR_MAP = {
34
+ "401": { msg: "Invalid API key.", action: "Run `ray setup` to reconfigure your credentials." },
35
+ "403": { msg: "API key rejected.", action: "Run `ray billing` to check status, or `ray setup` to reconfigure." },
36
+ "429": { msg: "Rate limited.", action: "Wait a moment and try again." },
37
+ network: { msg: "Could not reach the server.", action: "Check your internet connection." },
38
+ plaid: { msg: "Bank connection issue.", action: "Run `ray link` to reconnect." },
39
+ decrypt: { msg: "Could not decrypt your data.", action: "Check your encryption key in `ray setup`." },
40
+ };
41
+ export function formatError(error, context) {
42
+ let key = "unknown";
43
+ if (error.status)
44
+ key = String(error.status);
45
+ else if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT")
46
+ key = "network";
47
+ else if (error.message?.includes("Plaid") || error.message?.includes("plaid"))
48
+ key = "plaid";
49
+ else if (error.message?.includes("decrypt"))
50
+ key = "decrypt";
51
+ const mapped = ERROR_MAP[key];
52
+ if (mapped) {
53
+ return `${chalk.red("✗")} ${mapped.msg} ${chalk.dim(mapped.action)}`;
54
+ }
55
+ const safeMsg = error.message || "Something went wrong.";
56
+ return `${chalk.red("✗")} ${context ? context + ": " : ""}${safeMsg}`;
57
+ }
58
+ // ─── Money Formatting ─── //
2
59
  export function formatMoney(n) {
3
60
  const abs = Math.abs(n);
4
61
  const formatted = "$" + abs.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
package/dist/cli/setup.js CHANGED
@@ -3,6 +3,9 @@ import { existsSync, mkdirSync } from "fs";
3
3
  import { dirname } from "path";
4
4
  import { config, saveConfig, isConfigured, RAY_PROXY_BASE } from "../config.js";
5
5
  import { heading, dim } from "./format.js";
6
+ function stepHeader(current, total) {
7
+ return chalk.dim(`Step ${current}/${total}`);
8
+ }
6
9
  export async function runSetup() {
7
10
  const inquirer = (await import("inquirer")).default;
8
11
  console.log(`\n${heading("Ray Finance Setup")}\n`);
@@ -27,12 +30,14 @@ export async function runSetup() {
27
30
  }]);
28
31
  let canLink = false;
29
32
  if (setupMode === "managed") {
33
+ console.log(stepHeader(1, 3));
30
34
  const { userName } = await inquirer.prompt([{
31
35
  type: "input",
32
36
  name: "userName",
33
37
  message: "Your name:",
34
38
  default: config.userName !== "User" ? config.userName : undefined,
35
39
  }]);
40
+ console.log(stepHeader(2, 3));
36
41
  const { hasKey } = await inquirer.prompt([{
37
42
  type: "list",
38
43
  name: "hasKey",
@@ -88,6 +93,7 @@ export async function runSetup() {
88
93
  canLink = true;
89
94
  }
90
95
  else {
96
+ console.log(stepHeader(1, 4));
91
97
  const answers = await inquirer.prompt([
92
98
  {
93
99
  type: "input",
@@ -157,18 +163,47 @@ export async function runSetup() {
157
163
  const { createContextTemplate } = await import("../ai/context.js");
158
164
  createContextTemplate(config.userName);
159
165
  console.log(`\n${chalk.green("✓")} Config saved`);
160
- // Link first account immediately — this is the critical path
166
+ console.log(`${chalk.green("✓")} Database initialized`);
167
+ console.log(`${chalk.green("✓")} Ready to go\n`);
168
+ // Ask to link first account
161
169
  if (canLink) {
162
- console.log();
163
- const { runLink } = await import("./commands.js");
164
- await runLink();
165
- // Auto-schedule daily sync at 6am after first successful link
166
- if (!config.syncSchedule) {
167
- saveConfig({ syncSchedule: "06:00" });
168
- const { installSyncSchedule } = await import("./scheduler.js");
169
- installSyncSchedule("06:00");
170
- console.log(`${chalk.green("✓")} Daily sync scheduled at 6:00 AM`);
170
+ console.log(stepHeader(setupMode === "managed" ? 3 : 4, setupMode === "managed" ? 3 : 4));
171
+ const { wantLink } = await inquirer.prompt([{
172
+ type: "confirm",
173
+ name: "wantLink",
174
+ message: "Link your first bank account now?",
175
+ default: true,
176
+ }]);
177
+ if (wantLink) {
178
+ const { runLink } = await import("./commands.js");
179
+ await runLink();
180
+ // Sync immediately after linking
181
+ const ora = (await import("ora")).default;
182
+ const spinner = ora("Syncing your transactions...").start();
183
+ try {
184
+ const { getDb } = await import("../db/connection.js");
185
+ const { runDailySync } = await import("../daily-sync.js");
186
+ await runDailySync(getDb());
187
+ spinner.succeed("Transactions synced!");
188
+ console.log(chalk.dim(" Note: some banks take a few hours to deliver all transactions."));
189
+ console.log(chalk.dim(" Ray will re-sync in the background to pick up anything missing.\n"));
190
+ }
191
+ catch (err) {
192
+ spinner.fail(`Sync failed: ${err.message}`);
193
+ }
194
+ // Auto-schedule daily sync at 6am
195
+ if (!config.syncSchedule) {
196
+ saveConfig({ syncSchedule: "06:00" });
197
+ const { installSyncSchedule } = await import("./scheduler.js");
198
+ installSyncSchedule("06:00");
199
+ console.log(`${chalk.green("✓")} Daily sync scheduled at 6:00 AM`);
200
+ }
201
+ // Go straight into chat
202
+ console.log();
203
+ const { startChat } = await import("./chat.js");
204
+ await startChat();
205
+ return;
171
206
  }
172
207
  }
173
- console.log(`\n${chalk.green("✓")} Setup complete! Run ${chalk.bold("ray")} to start chatting.\n`);
208
+ console.log(`Run ${chalk.bold("ray")} to start chatting.\n`);
174
209
  }
@@ -1,7 +1,11 @@
1
1
  import type BetterSqlite3 from "libsql";
2
2
  type Database = BetterSqlite3.Database;
3
+ export interface SyncResult {
4
+ transactionsAdded: number;
5
+ institutionsSynced: number;
6
+ }
3
7
  /** Run the daily sync for a single database */
4
- export declare function runDailySync(db: Database): Promise<void>;
8
+ export declare function runDailySync(db: Database): Promise<SyncResult>;
5
9
  /** Run daily sync (cron / CLI entry point) */
6
10
  export declare function runDailySyncAll(): Promise<void>;
7
11
  export {};
@@ -9,8 +9,10 @@ export async function runDailySync(db) {
9
9
  .all();
10
10
  if (institutions.length === 0) {
11
11
  console.log("No linked institutions.");
12
- return;
12
+ return { transactionsAdded: 0, institutionsSynced: 0 };
13
13
  }
14
+ let totalAdded = 0;
15
+ let instSynced = 0;
14
16
  for (const inst of institutions) {
15
17
  if (inst.access_token === "manual") {
16
18
  console.log(`Skipping ${inst.name} (manual entry)`);
@@ -32,12 +34,14 @@ export async function runDailySync(db) {
32
34
  const products = JSON.parse(inst.products);
33
35
  console.log(`Syncing: ${inst.name} (${products.join(", ")})`);
34
36
  try {
37
+ instSynced++;
35
38
  // Always sync balances
36
39
  const accountCount = await syncBalances(db, accessToken);
37
40
  console.log(` Accounts: ${accountCount}`);
38
41
  // Sync transactions if available
39
42
  if (products.includes("transactions")) {
40
43
  const txResult = await syncTransactions(db, inst.item_id, accessToken, inst.cursor);
44
+ totalAdded += txResult.added;
41
45
  console.log(` Transactions: +${txResult.added} ~${txResult.modified} -${txResult.removed}`);
42
46
  }
43
47
  // Sync investments if available
@@ -100,6 +104,7 @@ export async function runDailySync(db) {
100
104
  console.log(`Auto-recategorized ${totalRecat} transaction(s).`);
101
105
  }
102
106
  console.log("Sync complete.");
107
+ return { transactionsAdded: totalAdded, institutionsSynced: instSynced };
103
108
  }
104
109
  /** Run daily sync (cron / CLI entry point) */
105
110
  export async function runDailySyncAll() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ray-finance",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local-first CLI that turns your bank data into a personal AI financial advisor",
5
5
  "type": "module",
6
6
  "license": "MIT",