ray-finance 0.2.0

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 (128) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/.env.example +13 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +9 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +5 -0
  6. package/.github/workflows/ci.yml +21 -0
  7. package/CHANGELOG.md +16 -0
  8. package/CODE_OF_CONDUCT.md +31 -0
  9. package/CONTRIBUTING.md +41 -0
  10. package/Dockerfile +8 -0
  11. package/LICENSE +21 -0
  12. package/README.md +168 -0
  13. package/SECURITY.md +36 -0
  14. package/SPEC.md +374 -0
  15. package/dist/ai/agent.d.ts +2 -0
  16. package/dist/ai/agent.js +80 -0
  17. package/dist/ai/audit.d.ts +3 -0
  18. package/dist/ai/audit.js +6 -0
  19. package/dist/ai/context.d.ts +6 -0
  20. package/dist/ai/context.js +89 -0
  21. package/dist/ai/insights.d.ts +3 -0
  22. package/dist/ai/insights.js +378 -0
  23. package/dist/ai/memory.d.ts +14 -0
  24. package/dist/ai/memory.js +12 -0
  25. package/dist/ai/redactor.d.ts +2 -0
  26. package/dist/ai/redactor.js +92 -0
  27. package/dist/ai/system-prompt.d.ts +2 -0
  28. package/dist/ai/system-prompt.js +85 -0
  29. package/dist/ai/tools.d.ts +4 -0
  30. package/dist/ai/tools.js +695 -0
  31. package/dist/alerts/index.d.ts +11 -0
  32. package/dist/alerts/index.js +95 -0
  33. package/dist/auth/anthropic.d.ts +7 -0
  34. package/dist/auth/anthropic.js +85 -0
  35. package/dist/auth/pkce.d.ts +5 -0
  36. package/dist/auth/pkce.js +10 -0
  37. package/dist/auth/store.d.ts +12 -0
  38. package/dist/auth/store.js +51 -0
  39. package/dist/cli/backup.d.ts +2 -0
  40. package/dist/cli/backup.js +85 -0
  41. package/dist/cli/chat.d.ts +1 -0
  42. package/dist/cli/chat.js +97 -0
  43. package/dist/cli/commands.d.ts +13 -0
  44. package/dist/cli/commands.js +201 -0
  45. package/dist/cli/format.d.ts +12 -0
  46. package/dist/cli/format.js +119 -0
  47. package/dist/cli/index.d.ts +2 -0
  48. package/dist/cli/index.js +176 -0
  49. package/dist/cli/scheduler.d.ts +2 -0
  50. package/dist/cli/scheduler.js +114 -0
  51. package/dist/cli/setup.d.ts +1 -0
  52. package/dist/cli/setup.js +168 -0
  53. package/dist/config.d.ts +22 -0
  54. package/dist/config.js +60 -0
  55. package/dist/daily-sync.d.ts +7 -0
  56. package/dist/daily-sync.js +94 -0
  57. package/dist/db/connection.d.ts +5 -0
  58. package/dist/db/connection.js +37 -0
  59. package/dist/db/encryption.d.ts +3 -0
  60. package/dist/db/encryption.js +24 -0
  61. package/dist/db/helpers.d.ts +16 -0
  62. package/dist/db/helpers.js +45 -0
  63. package/dist/db/schema.d.ts +2 -0
  64. package/dist/db/schema.js +194 -0
  65. package/dist/index.d.ts +1 -0
  66. package/dist/index.js +1 -0
  67. package/dist/plaid/client.d.ts +2 -0
  68. package/dist/plaid/client.js +22 -0
  69. package/dist/plaid/link.d.ts +8 -0
  70. package/dist/plaid/link.js +23 -0
  71. package/dist/plaid/sync.d.ts +18 -0
  72. package/dist/plaid/sync.js +186 -0
  73. package/dist/public/link.html +161 -0
  74. package/dist/queries/index.d.ts +163 -0
  75. package/dist/queries/index.js +411 -0
  76. package/dist/scoring/index.d.ts +53 -0
  77. package/dist/scoring/index.js +375 -0
  78. package/dist/server.d.ts +7 -0
  79. package/dist/server.js +140 -0
  80. package/docker-compose.yml +9 -0
  81. package/package.json +55 -0
  82. package/site/next-env.d.ts +6 -0
  83. package/site/next.config.ts +7 -0
  84. package/site/package-lock.json +1661 -0
  85. package/site/package.json +24 -0
  86. package/site/postcss.config.mjs +7 -0
  87. package/site/public/favicon.png +0 -0
  88. package/site/public/ray-og.jpg +0 -0
  89. package/site/public/robots.txt +4 -0
  90. package/site/public/sitemap.xml +8 -0
  91. package/site/src/app/copy-command.tsx +30 -0
  92. package/site/src/app/globals.css +87 -0
  93. package/site/src/app/layout.tsx +64 -0
  94. package/site/src/app/page.tsx +841 -0
  95. package/site/src/app/pii-scramble.tsx +190 -0
  96. package/site/src/app/reveal.tsx +29 -0
  97. package/site/tsconfig.json +21 -0
  98. package/src/ai/agent.ts +106 -0
  99. package/src/ai/audit.ts +11 -0
  100. package/src/ai/context.ts +93 -0
  101. package/src/ai/insights.ts +474 -0
  102. package/src/ai/memory.ts +21 -0
  103. package/src/ai/redactor.ts +102 -0
  104. package/src/ai/system-prompt.ts +90 -0
  105. package/src/ai/tools.ts +716 -0
  106. package/src/alerts/index.ts +123 -0
  107. package/src/cli/backup.ts +113 -0
  108. package/src/cli/chat.ts +105 -0
  109. package/src/cli/commands.ts +240 -0
  110. package/src/cli/format.ts +149 -0
  111. package/src/cli/index.ts +193 -0
  112. package/src/cli/scheduler.ts +116 -0
  113. package/src/cli/setup.ts +189 -0
  114. package/src/config.ts +81 -0
  115. package/src/daily-sync.ts +155 -0
  116. package/src/db/connection.ts +38 -0
  117. package/src/db/encryption.ts +29 -0
  118. package/src/db/helpers.ts +47 -0
  119. package/src/db/schema.ts +196 -0
  120. package/src/index.ts +3 -0
  121. package/src/plaid/client.ts +25 -0
  122. package/src/plaid/link.ts +25 -0
  123. package/src/plaid/sync.ts +219 -0
  124. package/src/public/link.html +161 -0
  125. package/src/queries/index.ts +586 -0
  126. package/src/scoring/index.ts +468 -0
  127. package/src/server.ts +162 -0
  128. package/tsconfig.json +16 -0
@@ -0,0 +1,716 @@
1
+ import type Database from "better-sqlite3-multiple-ciphers";
2
+ import type { Tool } from "@anthropic-ai/sdk/resources/messages.js";
3
+ import {
4
+ getNetWorth, getAccountBalances, getTransactionsFiltered,
5
+ getRecentSpending, getBudgetStatuses, getGoals,
6
+ getIncome, searchTransactions, getCashFlow, forecastBalance,
7
+ getPortfolio, getInvestmentPerformance, getDebts,
8
+ compareSpending, getNetWorthTrend,
9
+ formatMoney, categoryLabel,
10
+ } from "../queries/index.js";
11
+ import { getLatestScore, getMonthlySavings } from "../scoring/index.js";
12
+ import { generateAlerts } from "../alerts/index.js";
13
+ import { saveMemory, getMemories } from "./memory.js";
14
+ import { readContext, writeContext, replaceContextSection } from "./context.js";
15
+ import { simulatePayoff } from "../db/helpers.js";
16
+
17
+ export const toolDefinitions: Tool[] = [
18
+ // --- Existing tools ---
19
+ {
20
+ name: "get_net_worth",
21
+ description: "Get current net worth with breakdown of assets, liabilities, investments, cash, and home equity",
22
+ input_schema: { type: "object" as const, properties: {}, required: [] },
23
+ },
24
+ {
25
+ name: "get_accounts",
26
+ description: "List all linked bank accounts with current balances",
27
+ input_schema: { type: "object" as const, properties: {}, required: [] },
28
+ },
29
+ {
30
+ name: "get_transactions",
31
+ description: "Search transactions with optional filters. Returns matching transactions.",
32
+ input_schema: {
33
+ type: "object" as const,
34
+ properties: {
35
+ start_date: { type: "string", description: "Start date (YYYY-MM-DD). Defaults to 30 days ago." },
36
+ end_date: { type: "string", description: "End date (YYYY-MM-DD). Defaults to today." },
37
+ category: { type: "string", description: "Filter by category (e.g. FOOD_AND_DRINK, GENERAL_MERCHANDISE)" },
38
+ merchant: { type: "string", description: "Filter by merchant name (partial match)" },
39
+ min_amount: { type: "number", description: "Minimum transaction amount" },
40
+ max_amount: { type: "number", description: "Maximum transaction amount" },
41
+ limit: { type: "number", description: "Max results to return (default 20)" },
42
+ },
43
+ required: [],
44
+ },
45
+ },
46
+ {
47
+ name: "get_spending_summary",
48
+ description: "Get spending breakdown by category for a time period",
49
+ input_schema: {
50
+ type: "object" as const,
51
+ properties: {
52
+ period: { type: "string", description: "Period: this_month, last_month, last_30, last_90, or YYYY-MM-DD:YYYY-MM-DD", default: "this_month" },
53
+ },
54
+ required: [],
55
+ },
56
+ },
57
+ {
58
+ name: "get_budgets",
59
+ description: "Get all budget categories with current month spending vs limits",
60
+ input_schema: { type: "object" as const, properties: {}, required: [] },
61
+ },
62
+ {
63
+ name: "set_budget",
64
+ description: "Create or update a monthly budget for a spending category",
65
+ input_schema: {
66
+ type: "object" as const,
67
+ properties: {
68
+ category: { type: "string", description: "Plaid category (e.g. FOOD_AND_DRINK, GENERAL_MERCHANDISE, ENTERTAINMENT)" },
69
+ monthly_limit: { type: "number", description: "Monthly budget limit in dollars" },
70
+ },
71
+ required: ["category", "monthly_limit"],
72
+ },
73
+ },
74
+ {
75
+ name: "get_goals",
76
+ description: "Get financial goals with progress tracking",
77
+ input_schema: { type: "object" as const, properties: {}, required: [] },
78
+ },
79
+ {
80
+ name: "set_goal",
81
+ description: "Create or update a financial goal",
82
+ input_schema: {
83
+ type: "object" as const,
84
+ properties: {
85
+ name: { type: "string", description: "Goal name" },
86
+ target_amount: { type: "number", description: "Target amount in dollars" },
87
+ current_amount: { type: "number", description: "Current amount saved (optional)" },
88
+ target_date: { type: "string", description: "Target date (YYYY-MM-DD, optional)" },
89
+ },
90
+ required: ["name", "target_amount"],
91
+ },
92
+ },
93
+ {
94
+ name: "get_score",
95
+ description: "Get the latest daily financial behavior score (0-100) and streaks",
96
+ input_schema: { type: "object" as const, properties: {}, required: [] },
97
+ },
98
+ {
99
+ name: "get_recurring",
100
+ description: "List detected recurring transactions and bills",
101
+ input_schema: { type: "object" as const, properties: {}, required: [] },
102
+ },
103
+ {
104
+ name: "get_alerts",
105
+ description: "Get current financial alerts (large transactions, low balances, budget overruns)",
106
+ input_schema: { type: "object" as const, properties: {}, required: [] },
107
+ },
108
+ {
109
+ name: "save_memory",
110
+ description: "Save an important fact or preference to long-term memory. Use this when the user shares something worth remembering across conversations.",
111
+ input_schema: {
112
+ type: "object" as const,
113
+ properties: {
114
+ content: { type: "string", description: "The fact or preference to remember" },
115
+ category: { type: "string", description: "Category: general, preference, goal, life_event", default: "general" },
116
+ },
117
+ required: ["content"],
118
+ },
119
+ },
120
+ {
121
+ name: "get_memories",
122
+ description: "Retrieve all saved long-term memories",
123
+ input_schema: { type: "object" as const, properties: {}, required: [] },
124
+ },
125
+
126
+ // --- New: Income & Search ---
127
+ {
128
+ name: "get_income",
129
+ description: "Get income sources and amounts for a time period",
130
+ input_schema: {
131
+ type: "object" as const,
132
+ properties: {
133
+ start_date: { type: "string", description: "Start date (YYYY-MM-DD). Defaults to start of current month." },
134
+ end_date: { type: "string", description: "End date (YYYY-MM-DD). Defaults to today." },
135
+ },
136
+ required: [],
137
+ },
138
+ },
139
+ {
140
+ name: "search_transactions",
141
+ description: "Full-text search transactions by name, merchant, or category. Use this when the user asks to find specific transactions.",
142
+ input_schema: {
143
+ type: "object" as const,
144
+ properties: {
145
+ query: { type: "string", description: "Search query (matches name, merchant, or category)" },
146
+ limit: { type: "number", description: "Max results (default 30)" },
147
+ },
148
+ required: ["query"],
149
+ },
150
+ },
151
+ {
152
+ name: "categorize_transaction",
153
+ description: "Re-categorize a specific transaction",
154
+ input_schema: {
155
+ type: "object" as const,
156
+ properties: {
157
+ transaction_id: { type: "string", description: "The transaction ID to recategorize" },
158
+ category: { type: "string", description: "New category (e.g. FOOD_AND_DRINK, GENERAL_MERCHANDISE)" },
159
+ subcategory: { type: "string", description: "New subcategory (optional)" },
160
+ },
161
+ required: ["transaction_id", "category"],
162
+ },
163
+ },
164
+
165
+ // --- New: Analysis ---
166
+ {
167
+ name: "cash_flow",
168
+ description: "Analyze income vs expenses with savings rate and monthly breakdown",
169
+ input_schema: {
170
+ type: "object" as const,
171
+ properties: {
172
+ start_date: { type: "string", description: "Start date (YYYY-MM-DD). Defaults to 3 months ago." },
173
+ end_date: { type: "string", description: "End date (YYYY-MM-DD). Defaults to today." },
174
+ },
175
+ required: [],
176
+ },
177
+ },
178
+ {
179
+ name: "forecast_balance",
180
+ description: "Project account balance N months forward based on recent cash flow patterns",
181
+ input_schema: {
182
+ type: "object" as const,
183
+ properties: {
184
+ account_id: { type: "string", description: "Specific account ID (optional, defaults to all depository)" },
185
+ months: { type: "number", description: "Number of months to forecast (default 6)" },
186
+ },
187
+ required: [],
188
+ },
189
+ },
190
+ {
191
+ name: "compare_spending",
192
+ description: "Side-by-side spending comparison of two time periods, broken down by category",
193
+ input_schema: {
194
+ type: "object" as const,
195
+ properties: {
196
+ period1_start: { type: "string", description: "Period 1 start date (YYYY-MM-DD)" },
197
+ period1_end: { type: "string", description: "Period 1 end date (YYYY-MM-DD)" },
198
+ period2_start: { type: "string", description: "Period 2 start date (YYYY-MM-DD)" },
199
+ period2_end: { type: "string", description: "Period 2 end date (YYYY-MM-DD)" },
200
+ },
201
+ required: ["period1_start", "period1_end", "period2_start", "period2_end"],
202
+ },
203
+ },
204
+ {
205
+ name: "get_net_worth_trend",
206
+ description: "Get net worth history over time to see the trend",
207
+ input_schema: {
208
+ type: "object" as const,
209
+ properties: {
210
+ limit: { type: "number", description: "Number of data points (default 30)" },
211
+ },
212
+ required: [],
213
+ },
214
+ },
215
+ {
216
+ name: "get_monthly_savings",
217
+ description: "Compare this month's spending pace to the baseline month to see how much you're saving",
218
+ input_schema: { type: "object" as const, properties: {}, required: [] },
219
+ },
220
+
221
+ // --- New: Investments ---
222
+ {
223
+ name: "get_portfolio",
224
+ description: "Get investment holdings grouped by account with values, cost basis, and gain/loss",
225
+ input_schema: { type: "object" as const, properties: {}, required: [] },
226
+ },
227
+ {
228
+ name: "investment_performance",
229
+ description: "Get investment returns vs cost basis for each holding",
230
+ input_schema: { type: "object" as const, properties: {}, required: [] },
231
+ },
232
+
233
+ // --- New: Debts ---
234
+ {
235
+ name: "get_debts",
236
+ description: "List all debts with balances, interest rates, and minimum payments",
237
+ input_schema: { type: "object" as const, properties: {}, required: [] },
238
+ },
239
+ {
240
+ name: "calculate_debt_payoff",
241
+ description: "Simulate debt payoff with different strategies (minimum, avalanche, snowball) and optional extra payment",
242
+ input_schema: {
243
+ type: "object" as const,
244
+ properties: {
245
+ strategy: { type: "string", description: "Payoff strategy: minimum, avalanche (highest rate first), or snowball (lowest balance first)", default: "avalanche" },
246
+ extra_monthly: { type: "number", description: "Extra monthly payment beyond minimums (default 0)" },
247
+ },
248
+ required: [],
249
+ },
250
+ },
251
+
252
+ // --- New: Context ---
253
+ {
254
+ name: "update_context",
255
+ description: "Update the persistent financial context file. Use when circumstances change: new decisions, completed goals, changed balances, updated strategy, or important life events. Use 'section' param to replace a specific section cleanly.",
256
+ input_schema: {
257
+ type: "object" as const,
258
+ properties: {
259
+ updates: { type: "string", description: "Description of what changed and the new information to incorporate (used when no section specified)" },
260
+ section: { type: "string", description: "Section heading to replace (e.g. 'Family', 'Income', 'Goals', 'Strategy'). Replaces everything under that ## heading." },
261
+ content: { type: "string", description: "New content for the section (used with section param)" },
262
+ },
263
+ required: [],
264
+ },
265
+ },
266
+
267
+ // --- New: Data modification ---
268
+ {
269
+ name: "delete_budget",
270
+ description: "Remove a budget category",
271
+ input_schema: {
272
+ type: "object" as const,
273
+ properties: {
274
+ category: { type: "string", description: "Budget category to delete" },
275
+ },
276
+ required: ["category"],
277
+ },
278
+ },
279
+ {
280
+ name: "delete_goal",
281
+ description: "Remove a financial goal",
282
+ input_schema: {
283
+ type: "object" as const,
284
+ properties: {
285
+ name: { type: "string", description: "Goal name to delete" },
286
+ },
287
+ required: ["name"],
288
+ },
289
+ },
290
+ {
291
+ name: "update_goal_progress",
292
+ description: "Update the current amount on a financial goal",
293
+ input_schema: {
294
+ type: "object" as const,
295
+ properties: {
296
+ name: { type: "string", description: "Goal name" },
297
+ current_amount: { type: "number", description: "New current amount" },
298
+ },
299
+ required: ["name", "current_amount"],
300
+ },
301
+ },
302
+ {
303
+ name: "label_transaction",
304
+ description: "Add a note or label to a transaction for personal tracking",
305
+ input_schema: {
306
+ type: "object" as const,
307
+ properties: {
308
+ transaction_id: { type: "string", description: "Transaction ID" },
309
+ label: { type: "string", description: "Label text (optional)" },
310
+ note: { type: "string", description: "Note text (optional)" },
311
+ },
312
+ required: ["transaction_id"],
313
+ },
314
+ },
315
+ {
316
+ name: "add_recat_rule",
317
+ description: "Create an auto-recategorization rule for future syncs. Transactions matching the pattern will be automatically recategorized.",
318
+ input_schema: {
319
+ type: "object" as const,
320
+ properties: {
321
+ match_field: { type: "string", description: "Field to match: name, merchant_name" },
322
+ match_pattern: { type: "string", description: "Pattern to match (case-insensitive substring)" },
323
+ target_category: { type: "string", description: "Category to assign" },
324
+ target_subcategory: { type: "string", description: "Subcategory (optional)" },
325
+ label: { type: "string", description: "Label to apply (optional)" },
326
+ },
327
+ required: ["match_field", "match_pattern", "target_category"],
328
+ },
329
+ },
330
+ ];
331
+
332
+ export async function executeTool(db: Database.Database, toolName: string, toolInput: any): Promise<string> {
333
+ switch (toolName) {
334
+ case "get_net_worth": {
335
+ const nw = getNetWorth(db);
336
+ const change = nw.prev_net_worth !== null ? nw.net_worth - nw.prev_net_worth : null;
337
+ let result = `Net worth: ${formatMoney(nw.net_worth)}`;
338
+ if (change !== null) result += ` (${change >= 0 ? "+" : ""}${formatMoney(change)} from yesterday)`;
339
+ result += `\nAssets: ${formatMoney(nw.assets)} | Liabilities: ${formatMoney(nw.liabilities)}`;
340
+ result += `\nHome equity: ${formatMoney(nw.home_equity)} | Investments: ${formatMoney(nw.investments)} | Cash: ${formatMoney(nw.cash)}`;
341
+ if (nw.credit_debt > 0) result += `\nCredit card debt: ${formatMoney(nw.credit_debt)}`;
342
+ if (nw.mortgage > 0) result += `\nMortgage: ${formatMoney(nw.mortgage)}`;
343
+ return result;
344
+ }
345
+
346
+ case "get_accounts": {
347
+ const accounts = getAccountBalances(db);
348
+ if (accounts.length === 0) return "No accounts linked yet.";
349
+ return accounts.map(a => `${a.name} (${a.type}): ${a.type === "credit" ? "-" : ""}${formatMoney(a.balance)}`).join("\n");
350
+ }
351
+
352
+ case "get_transactions": {
353
+ const txns = getTransactionsFiltered(db, {
354
+ startDate: toolInput.start_date,
355
+ endDate: toolInput.end_date,
356
+ category: toolInput.category,
357
+ merchant: toolInput.merchant,
358
+ minAmount: toolInput.min_amount,
359
+ maxAmount: toolInput.max_amount,
360
+ limit: toolInput.limit,
361
+ });
362
+ if (txns.length === 0) return "No transactions found matching those filters.";
363
+ return txns.map(t => `${t.date} | ${t.name} | ${formatMoney(t.amount)} | ${categoryLabel(t.category)}`).join("\n");
364
+ }
365
+
366
+ case "get_spending_summary": {
367
+ const period = toolInput.period || "this_month";
368
+ const { resolvePeriod } = await import("../db/helpers.js");
369
+ const { start, end } = resolvePeriod(period);
370
+ const rows = db.prepare(
371
+ `SELECT category, SUM(amount) as total, COUNT(*) as count FROM transactions
372
+ WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
373
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')
374
+ GROUP BY category ORDER BY total DESC`
375
+ ).all(start, end) as { category: string; total: number; count: number }[];
376
+ if (rows.length === 0) return "No spending found for that period.";
377
+ const grandTotal = rows.reduce((s, r) => s + r.total, 0);
378
+ let result = `Spending ${start} to ${end}: ${formatMoney(grandTotal)} total\n\n`;
379
+ result += rows.map(r => `${categoryLabel(r.category)}: ${formatMoney(r.total)} (${r.count} transactions)`).join("\n");
380
+ return result;
381
+ }
382
+
383
+ case "get_budgets": {
384
+ const budgets = getBudgetStatuses(db);
385
+ if (budgets.length === 0) return "No budgets set up yet. Use set_budget to create one.";
386
+ const now = new Date();
387
+ const monthPct = Math.round((now.getDate() / new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()) * 100);
388
+ let result = `Budget status (${monthPct}% through the month):\n\n`;
389
+ result += budgets.map(b => {
390
+ const status = b.over_budget ? "OVER" : `${b.pct_used}%`;
391
+ return `${b.over_budget ? "!" : "•"} ${categoryLabel(b.category)}: ${formatMoney(b.spent)} / ${formatMoney(b.budget)} (${status})${b.over_budget ? ` — ${formatMoney(Math.abs(b.remaining))} over` : ""}`;
392
+ }).join("\n");
393
+ return result;
394
+ }
395
+
396
+ case "set_budget": {
397
+ db.prepare(
398
+ `INSERT INTO budgets (category, monthly_limit) VALUES (?, ?)
399
+ ON CONFLICT(category) DO UPDATE SET monthly_limit = excluded.monthly_limit`
400
+ ).run(toolInput.category, toolInput.monthly_limit);
401
+ return `Budget set: ${categoryLabel(toolInput.category)} at ${formatMoney(toolInput.monthly_limit)}/month`;
402
+ }
403
+
404
+ case "get_goals": {
405
+ const goals = getGoals(db);
406
+ if (goals.length === 0) return "No goals set up yet. Use set_goal to create one.";
407
+ return goals.map(g => {
408
+ let line = `${g.name}: ${formatMoney(g.current)} / ${formatMoney(g.target)} (${g.progress_pct}%)`;
409
+ if (g.target_date) line += ` — target: ${g.target_date}`;
410
+ if (g.monthly_needed > 0) line += ` — need ${formatMoney(g.monthly_needed)}/mo`;
411
+ return line;
412
+ }).join("\n");
413
+ }
414
+
415
+ case "set_goal": {
416
+ const existing = db.prepare(`SELECT id FROM goals WHERE name = ?`).get(toolInput.name) as any;
417
+ if (existing) {
418
+ const updates: string[] = [];
419
+ const params: any[] = [];
420
+ if (toolInput.target_amount !== undefined) { updates.push("target_amount = ?"); params.push(toolInput.target_amount); }
421
+ if (toolInput.current_amount !== undefined) { updates.push("current_amount = ?"); params.push(toolInput.current_amount); }
422
+ if (toolInput.target_date !== undefined) { updates.push("target_date = ?"); params.push(toolInput.target_date); }
423
+ params.push(existing.id);
424
+ db.prepare(`UPDATE goals SET ${updates.join(", ")} WHERE id = ?`).run(...params);
425
+ return `Goal "${toolInput.name}" updated.`;
426
+ } else {
427
+ db.prepare(
428
+ `INSERT INTO goals (name, target_amount, current_amount, target_date) VALUES (?, ?, ?, ?)`
429
+ ).run(toolInput.name, toolInput.target_amount, toolInput.current_amount || 0, toolInput.target_date || null);
430
+ return `Goal "${toolInput.name}" created: target ${formatMoney(toolInput.target_amount)}`;
431
+ }
432
+ }
433
+
434
+ case "get_score": {
435
+ const score = getLatestScore(db);
436
+ if (!score) return "No daily scores calculated yet. Scores are calculated during the daily sync.";
437
+ let result = `Daily score: ${score.score}/100 (${score.date})`;
438
+ result += `\nStreaks: ${score.no_restaurant_streak}d no restaurants | ${score.no_shopping_streak}d no shopping | ${score.on_pace_streak}d on pace`;
439
+ result += `\nYesterday: ${formatMoney(score.total_spend)} total spend, ${score.restaurant_count} restaurant visits, ${score.shopping_count} shopping purchases`;
440
+ if (score.zero_spend) result += "\nZero-spend day!";
441
+ return result;
442
+ }
443
+
444
+ case "get_recurring": {
445
+ const rows = db.prepare(
446
+ `SELECT merchant_name, avg_amount, frequency, last_date FROM recurring WHERE active = 1 ORDER BY avg_amount DESC`
447
+ ).all() as { merchant_name: string; avg_amount: number; frequency: string; last_date: string }[];
448
+ if (rows.length === 0) return "No recurring transactions detected yet.";
449
+ return rows.map(r => `${r.merchant_name}: ${formatMoney(r.avg_amount)} (${r.frequency}, last: ${r.last_date})`).join("\n");
450
+ }
451
+
452
+ case "get_alerts": {
453
+ const alerts = generateAlerts(db);
454
+ if (alerts.length === 0) return "No active alerts. Everything looks good!";
455
+ return alerts.map(a => `${a.severity === "critical" ? "\u{1F534}" : a.severity === "warning" ? "\u{1F7E1}" : "\u2139\uFE0F"} ${a.message}`).join("\n");
456
+ }
457
+
458
+ case "save_memory": {
459
+ saveMemory(db, toolInput.content, toolInput.category || "general");
460
+ return `Remembered: "${toolInput.content}"`;
461
+ }
462
+
463
+ case "get_memories": {
464
+ const memories = getMemories(db);
465
+ if (memories.length === 0) return "No memories saved yet.";
466
+ return memories.map(m => `[${m.category}] ${m.content} (saved ${m.created_at})`).join("\n");
467
+ }
468
+
469
+ // --- New: Income & Search ---
470
+
471
+ case "get_income": {
472
+ const now = new Date();
473
+ const start = toolInput.start_date || new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
474
+ const end = toolInput.end_date || now.toISOString().slice(0, 10);
475
+ const sources = getIncome(db, start, end);
476
+ if (sources.length === 0) return `No income found from ${start} to ${end}.`;
477
+ const total = sources.reduce((s, r) => s + r.total, 0);
478
+ let result = `Income ${start} to ${end}: ${formatMoney(total)} total\n\n`;
479
+ result += sources.map(s => `${s.source}: ${formatMoney(s.total)} (${s.count} deposits)`).join("\n");
480
+ return result;
481
+ }
482
+
483
+ case "search_transactions": {
484
+ const results = searchTransactions(db, toolInput.query, toolInput.limit);
485
+ if (results.length === 0) return `No transactions found matching "${toolInput.query}".`;
486
+ return results.map(t => `${t.date} | ${t.name}${t.merchant_name ? ` (${t.merchant_name})` : ""} | ${formatMoney(t.amount)} | ${categoryLabel(t.category)}`).join("\n");
487
+ }
488
+
489
+ case "categorize_transaction": {
490
+ const updates: string[] = ["category = ?"];
491
+ const params: any[] = [toolInput.category];
492
+ if (toolInput.subcategory) {
493
+ updates.push("subcategory = ?");
494
+ params.push(toolInput.subcategory);
495
+ }
496
+ params.push(toolInput.transaction_id);
497
+ const info = db.prepare(`UPDATE transactions SET ${updates.join(", ")} WHERE transaction_id = ?`).run(...params);
498
+ if (info.changes === 0) return `Transaction ${toolInput.transaction_id} not found.`;
499
+ return `Transaction recategorized to ${categoryLabel(toolInput.category)}.`;
500
+ }
501
+
502
+ // --- New: Analysis ---
503
+
504
+ case "cash_flow": {
505
+ const now = new Date();
506
+ const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
507
+ const start = toolInput.start_date || threeMonthsAgo.toISOString().slice(0, 10);
508
+ const end = toolInput.end_date || now.toISOString().slice(0, 10);
509
+ const cf = getCashFlow(db, start, end);
510
+ let result = `Cash Flow ${start} to ${end}:\n`;
511
+ result += `Income: ${formatMoney(cf.income)} | Expenses: ${formatMoney(cf.expenses)} | Net: ${formatMoney(cf.net)}`;
512
+ result += `\nSavings rate: ${cf.savingsRate}%`;
513
+ if (cf.monthly.length > 1) {
514
+ result += `\n\nMonthly breakdown:`;
515
+ for (const m of cf.monthly) {
516
+ result += `\n${m.month}: Income ${formatMoney(m.income)} | Expenses ${formatMoney(m.expenses)} | Net ${formatMoney(m.net)}`;
517
+ }
518
+ }
519
+ return result;
520
+ }
521
+
522
+ case "forecast_balance": {
523
+ const fc = forecastBalance(db, toolInput.account_id, toolInput.months || 6);
524
+ let result = `Current balance: ${formatMoney(fc.currentBalance)}`;
525
+ result += `\nAvg monthly inflow: ${formatMoney(fc.avgMonthlyInflow)} | Avg outflow: ${formatMoney(fc.avgMonthlyOutflow)}`;
526
+ result += `\n\nProjections:`;
527
+ for (const p of fc.projections) {
528
+ result += `\n${p.month}: ${formatMoney(p.projected)}`;
529
+ }
530
+ return result;
531
+ }
532
+
533
+ case "compare_spending": {
534
+ const cmp = compareSpending(db, toolInput.period1_start, toolInput.period1_end, toolInput.period2_start, toolInput.period2_end);
535
+ let result = `Period 1 (${toolInput.period1_start} to ${toolInput.period1_end}): ${formatMoney(cmp.period1Total)}`;
536
+ result += `\nPeriod 2 (${toolInput.period2_start} to ${toolInput.period2_end}): ${formatMoney(cmp.period2Total)}`;
537
+ result += `\nDifference: ${cmp.difference >= 0 ? "+" : ""}${formatMoney(cmp.difference)} (${cmp.pctChange >= 0 ? "+" : ""}${cmp.pctChange}%)`;
538
+ if (cmp.categories.length > 0) {
539
+ result += `\n\nBy category:`;
540
+ for (const c of cmp.categories) {
541
+ const arrow = c.diff > 0 ? "+" : "";
542
+ result += `\n${categoryLabel(c.category)}: ${formatMoney(c.period1)} → ${formatMoney(c.period2)} (${arrow}${formatMoney(c.diff)})`;
543
+ }
544
+ }
545
+ return result;
546
+ }
547
+
548
+ case "get_net_worth_trend": {
549
+ const trend = getNetWorthTrend(db, toolInput.limit || 30);
550
+ if (trend.length === 0) return "No net worth history available yet.";
551
+ let result = `Net worth trend (${trend.length} data points):\n`;
552
+ result += trend.map(t => `${t.date}: ${formatMoney(t.net_worth)} (assets: ${formatMoney(t.assets)}, liabilities: ${formatMoney(t.liabilities)})`).join("\n");
553
+ if (trend.length >= 2) {
554
+ const first = trend[0].net_worth;
555
+ const last = trend[trend.length - 1].net_worth;
556
+ const change = last - first;
557
+ result += `\n\nChange over period: ${change >= 0 ? "+" : ""}${formatMoney(change)}`;
558
+ }
559
+ return result;
560
+ }
561
+
562
+ case "get_monthly_savings": {
563
+ const savings = getMonthlySavings(db);
564
+ if (!savings.baselineMonth) return "Not enough data to compare savings yet. Need at least one full month of transaction history.";
565
+ let result = `Monthly savings vs ${savings.baselineMonth} baseline:`;
566
+ result += `\nBaseline pace (${savings.daysCompared} days): ${formatMoney(savings.baselinePace)}`;
567
+ result += `\nCurrent pace: ${formatMoney(savings.currentPace)}`;
568
+ result += `\n${savings.saved >= 0 ? "Saved" : "Over by"}: ${formatMoney(Math.abs(savings.saved))}`;
569
+ return result;
570
+ }
571
+
572
+ // --- New: Investments ---
573
+
574
+ case "get_portfolio": {
575
+ const port = getPortfolio(db);
576
+ if (port.holdings.length === 0) return "No investment holdings found.";
577
+ let result = `Portfolio: ${formatMoney(port.totalValue)} total value`;
578
+ result += ` | Cost basis: ${formatMoney(port.totalCostBasis)} | Gain/Loss: ${port.totalGainLoss >= 0 ? "+" : ""}${formatMoney(port.totalGainLoss)}`;
579
+ result += `\n\nHoldings:`;
580
+ for (const h of port.holdings) {
581
+ result += `\n${h.ticker || h.security} (${h.account}): ${formatMoney(h.value)} | ${h.quantity} shares | G/L: ${h.gainLoss >= 0 ? "+" : ""}${formatMoney(h.gainLoss)}`;
582
+ }
583
+ return result;
584
+ }
585
+
586
+ case "investment_performance": {
587
+ const perf = getInvestmentPerformance(db);
588
+ if (perf.holdings.length === 0) return "No investment holdings found.";
589
+ let result = `Total return: ${perf.totalReturn >= 0 ? "+" : ""}${formatMoney(perf.totalReturn)} (${perf.totalReturnPct >= 0 ? "+" : ""}${perf.totalReturnPct}%)`;
590
+ result += `\n\nBy holding:`;
591
+ for (const h of perf.holdings) {
592
+ result += `\n${h.ticker || h.security}: ${formatMoney(h.value)} (cost: ${formatMoney(h.costBasis)}, return: ${h.returnPct >= 0 ? "+" : ""}${h.returnPct}%)`;
593
+ }
594
+ return result;
595
+ }
596
+
597
+ // --- New: Debts ---
598
+
599
+ case "get_debts": {
600
+ const d = getDebts(db);
601
+ if (d.debts.length === 0) return "No debts found.";
602
+ let result = `Total debt: ${formatMoney(d.totalDebt)}\n`;
603
+ for (const debt of d.debts) {
604
+ result += `\n${debt.name}: ${formatMoney(debt.balance)}`;
605
+ if (debt.rate > 0) result += ` @ ${debt.rate}% APR`;
606
+ if (debt.minPayment > 0) result += ` | Min: ${formatMoney(debt.minPayment)}/mo`;
607
+ if (debt.nextDue) result += ` | Next due: ${debt.nextDue}`;
608
+ }
609
+ return result;
610
+ }
611
+
612
+ case "calculate_debt_payoff": {
613
+ const d = getDebts(db);
614
+ if (d.debts.length === 0) return "No debts found.";
615
+
616
+ const strategy = toolInput.strategy || "avalanche";
617
+ const extraMonthly = toolInput.extra_monthly || 0;
618
+
619
+ // Sort debts by strategy
620
+ const sorted = [...d.debts].filter(debt => debt.balance > 0);
621
+ if (strategy === "avalanche") sorted.sort((a, b) => b.rate - a.rate);
622
+ else if (strategy === "snowball") sorted.sort((a, b) => a.balance - b.balance);
623
+
624
+ let result = `Debt payoff simulation (${strategy} strategy, ${formatMoney(extraMonthly)} extra/mo):\n`;
625
+ result += `Total debt: ${formatMoney(d.totalDebt)}\n`;
626
+
627
+ let totalInterest = 0;
628
+ let maxMonths = 0;
629
+
630
+ for (const debt of sorted) {
631
+ if (debt.rate === 0 && debt.minPayment === 0) {
632
+ result += `\n${debt.name}: ${formatMoney(debt.balance)} — no rate/payment info available`;
633
+ continue;
634
+ }
635
+ const payment = Math.max(debt.minPayment, 50) + (sorted.indexOf(debt) === 0 ? extraMonthly : 0);
636
+ const sim = simulatePayoff(debt.balance, debt.rate, payment);
637
+ totalInterest += sim.totalInterest;
638
+ maxMonths = Math.max(maxMonths, sim.months);
639
+
640
+ const payoffDate = new Date();
641
+ payoffDate.setMonth(payoffDate.getMonth() + sim.months);
642
+ result += `\n${debt.name}: ${formatMoney(debt.balance)} @ ${debt.rate}%`;
643
+ result += ` → ${sim.months} months (${payoffDate.toISOString().slice(0, 7)})`;
644
+ result += ` | ${formatMoney(sim.totalInterest)} interest | ${formatMoney(payment)}/mo`;
645
+ }
646
+
647
+ result += `\n\nTotal interest: ${formatMoney(totalInterest)}`;
648
+ if (maxMonths > 0) {
649
+ const finalDate = new Date();
650
+ finalDate.setMonth(finalDate.getMonth() + maxMonths);
651
+ result += ` | Debt-free by: ${finalDate.toISOString().slice(0, 7)}`;
652
+ }
653
+ return result;
654
+ }
655
+
656
+ // --- New: Context ---
657
+
658
+ case "update_context": {
659
+ if (toolInput.section) {
660
+ const sectionContent = toolInput.content || toolInput.updates || "";
661
+ replaceContextSection(toolInput.section, sectionContent);
662
+ return `Context section "${toolInput.section}" updated.`;
663
+ }
664
+ // Fallback: append mode
665
+ const current = readContext();
666
+ const updates = toolInput.updates || toolInput.content || "";
667
+ const updated = current
668
+ ? `${current}\n\n---\n_Updated ${new Date().toISOString().slice(0, 10)}_: ${updates}`
669
+ : updates;
670
+ writeContext(updated);
671
+ return `Context updated: ${updates.slice(0, 100)}${updates.length > 100 ? "..." : ""}`;
672
+ }
673
+
674
+ // --- New: Data modification ---
675
+
676
+ case "delete_budget": {
677
+ const info = db.prepare(`DELETE FROM budgets WHERE category = ?`).run(toolInput.category);
678
+ if (info.changes === 0) return `No budget found for category "${toolInput.category}".`;
679
+ return `Budget for ${categoryLabel(toolInput.category)} deleted.`;
680
+ }
681
+
682
+ case "delete_goal": {
683
+ const info = db.prepare(`DELETE FROM goals WHERE name = ?`).run(toolInput.name);
684
+ if (info.changes === 0) return `No goal found with name "${toolInput.name}".`;
685
+ return `Goal "${toolInput.name}" deleted.`;
686
+ }
687
+
688
+ case "update_goal_progress": {
689
+ const info = db.prepare(`UPDATE goals SET current_amount = ? WHERE name = ?`).run(toolInput.current_amount, toolInput.name);
690
+ if (info.changes === 0) return `No goal found with name "${toolInput.name}".`;
691
+ return `Goal "${toolInput.name}" updated to ${formatMoney(toolInput.current_amount)}.`;
692
+ }
693
+
694
+ case "label_transaction": {
695
+ const updates: string[] = [];
696
+ const params: any[] = [];
697
+ if (toolInput.label) { updates.push("label = ?"); params.push(toolInput.label); }
698
+ if (toolInput.note) { updates.push("note = ?"); params.push(toolInput.note); }
699
+ if (updates.length === 0) return "Provide a label or note to add.";
700
+ params.push(toolInput.transaction_id);
701
+ const info = db.prepare(`UPDATE transactions SET ${updates.join(", ")} WHERE transaction_id = ?`).run(...params);
702
+ if (info.changes === 0) return `Transaction ${toolInput.transaction_id} not found.`;
703
+ return `Transaction labeled.`;
704
+ }
705
+
706
+ case "add_recat_rule": {
707
+ db.prepare(
708
+ `INSERT INTO recategorization_rules (match_field, match_pattern, target_category, target_subcategory, label) VALUES (?, ?, ?, ?, ?)`
709
+ ).run(toolInput.match_field, toolInput.match_pattern, toolInput.target_category, toolInput.target_subcategory || null, toolInput.label || null);
710
+ return `Recategorization rule added: ${toolInput.match_field} matching "${toolInput.match_pattern}" → ${categoryLabel(toolInput.target_category)}`;
711
+ }
712
+
713
+ default:
714
+ return `Unknown tool: ${toolName}`;
715
+ }
716
+ }