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,378 @@
1
+ import chalk from "chalk";
2
+ import { getNetWorth, getAccountBalances, getDebts, getBudgetStatuses, getGoals, compareSpending, formatMoney, categoryLabel, } from "../queries/index.js";
3
+ import { getLatestScore } from "../scoring/index.js";
4
+ const MAX_CHARS = 6000;
5
+ export function computeInsights(db) {
6
+ // Fresh install guard
7
+ const txCount = db.prepare(`SELECT COUNT(*) as cnt FROM transactions`).get();
8
+ if (txCount.cnt === 0) {
9
+ return `## Current Financial Briefing\nAccounts connected — run \`ray sync\` to pull transactions. No financial data to analyze yet.`;
10
+ }
11
+ const sections = [];
12
+ // 1. Financial Snapshot (priority 1 — always kept)
13
+ sections.push({ priority: 1, text: buildSnapshot(db) });
14
+ // 2. Spending Intelligence (priority 2 — always kept)
15
+ sections.push({ priority: 2, text: buildSpending(db) });
16
+ // 3. Goal & Savings Pace (priority 4)
17
+ const goals = buildGoals(db);
18
+ if (goals)
19
+ sections.push({ priority: 4, text: goals });
20
+ // 4. Upcoming & Proactive (priority 5)
21
+ const upcoming = buildUpcoming(db);
22
+ if (upcoming)
23
+ sections.push({ priority: 5, text: upcoming });
24
+ // 5. Anomaly Detection (priority 6 — nice to have)
25
+ const anomalies = buildAnomalies(db);
26
+ if (anomalies)
27
+ sections.push({ priority: 6, text: anomalies });
28
+ // 6. Behavioral Score (priority 7 — nice to have)
29
+ const score = buildScore(db);
30
+ if (score)
31
+ sections.push({ priority: 7, text: score });
32
+ // Token budget: drop lowest-priority sections first if over budget
33
+ sections.sort((a, b) => a.priority - b.priority);
34
+ let combined = "";
35
+ const included = [];
36
+ for (const s of sections) {
37
+ if ((combined + s.text).length > MAX_CHARS && s.priority > 2)
38
+ break;
39
+ included.push(s.text);
40
+ combined = included.join("\n\n");
41
+ }
42
+ return `## Current Financial Briefing (auto-generated)\n\n${combined}`;
43
+ }
44
+ function buildSnapshot(db) {
45
+ const nw = getNetWorth(db);
46
+ const lines = [];
47
+ let nwLine = `Net worth: ${formatMoney(nw.net_worth)}`;
48
+ if (nw.prev_net_worth !== null) {
49
+ const change = nw.net_worth - nw.prev_net_worth;
50
+ nwLine += ` (${change >= 0 ? "+" : "-"}${formatMoney(Math.abs(change))} today)`;
51
+ }
52
+ lines.push(nwLine);
53
+ // Account balances — cap at 5
54
+ const accounts = getAccountBalances(db).slice(0, 5);
55
+ if (accounts.length > 0) {
56
+ lines.push(accounts.map(a => `${a.name} (${a.type}): ${a.type === "credit" ? "-" : ""}${formatMoney(a.balance)}`).join(" | "));
57
+ }
58
+ // Debt summary
59
+ const debts = getDebts(db);
60
+ if (debts.totalDebt > 0) {
61
+ const rates = debts.debts.filter(d => d.rate > 0);
62
+ let debtLine = `Total debt: ${formatMoney(debts.totalDebt)}`;
63
+ if (rates.length > 0) {
64
+ const weightedRate = rates.reduce((s, d) => s + d.rate * d.balance, 0) / rates.reduce((s, d) => s + d.balance, 0);
65
+ debtLine += ` (avg ${weightedRate.toFixed(1)}% APR)`;
66
+ }
67
+ lines.push(debtLine);
68
+ }
69
+ return lines.join("\n");
70
+ }
71
+ function buildSpending(db) {
72
+ const now = new Date();
73
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
74
+ const today = now.toISOString().slice(0, 10);
75
+ const dayOfMonth = now.getDate();
76
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
77
+ const daysLeft = daysInMonth - dayOfMonth;
78
+ // This month's total spending
79
+ const thisMonthSpend = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
80
+ WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
81
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`).get(monthStart.toISOString().slice(0, 10), today);
82
+ const lines = [];
83
+ let spendLine = `SPENDING: ${formatMoney(thisMonthSpend.total)} this month`;
84
+ // Compare to last month (same day-of-month)
85
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
86
+ const lastMonthSameDay = new Date(now.getFullYear(), now.getMonth() - 1, Math.min(dayOfMonth, new Date(now.getFullYear(), now.getMonth(), 0).getDate()));
87
+ // Only compare if we have >30 days of history
88
+ const oldestTx = db.prepare(`SELECT MIN(date) as d FROM transactions`).get();
89
+ const hasEnoughHistory = oldestTx.d && (new Date(today).getTime() - new Date(oldestTx.d).getTime()) > 30 * 24 * 60 * 60 * 1000;
90
+ if (hasEnoughHistory) {
91
+ const cmp = compareSpending(db, lastMonthStart.toISOString().slice(0, 10), lastMonthSameDay.toISOString().slice(0, 10), monthStart.toISOString().slice(0, 10), today);
92
+ if (cmp.period1Total > 0) {
93
+ const pctStr = cmp.pctChange >= 0 ? `${cmp.pctChange}% above` : `${Math.abs(cmp.pctChange)}% below`;
94
+ spendLine += ` (${pctStr} last month`;
95
+ // Top driver
96
+ const topDrivers = cmp.categories.filter(c => c.diff > 0).slice(0, 3);
97
+ if (topDrivers.length > 0 && cmp.pctChange > 0) {
98
+ spendLine += `, driven by ${topDrivers.map(d => `${categoryLabel(d.category)} +${formatMoney(d.diff)}`).join(", ")}`;
99
+ }
100
+ spendLine += ")";
101
+ }
102
+ }
103
+ lines.push(spendLine);
104
+ // Budget status
105
+ const budgets = getBudgetStatuses(db);
106
+ const alertBudgets = budgets.filter(b => b.pct_used >= 80).slice(0, 3);
107
+ for (const b of alertBudgets) {
108
+ if (b.over_budget) {
109
+ lines.push(`Budget OVER: ${categoryLabel(b.category)} — ${formatMoney(b.spent)} / ${formatMoney(b.budget)} (${formatMoney(Math.abs(b.remaining))} over)`);
110
+ }
111
+ else {
112
+ lines.push(`Budget alert: ${categoryLabel(b.category)} at ${b.pct_used}% of limit with ${daysLeft} days remaining`);
113
+ }
114
+ }
115
+ // Daily discretionary remaining
116
+ if (daysLeft > 0) {
117
+ const totalBudget = budgets.reduce((s, b) => s + b.budget, 0);
118
+ if (totalBudget > 0) {
119
+ const totalRemaining = budgets.reduce((s, b) => s + Math.max(0, b.remaining), 0);
120
+ lines.push(`Daily discretionary budget remaining: ${formatMoney(totalRemaining / daysLeft)}/day`);
121
+ }
122
+ }
123
+ return lines.join("\n");
124
+ }
125
+ function buildGoals(db) {
126
+ const goals = getGoals(db);
127
+ const active = goals.filter(g => g.progress_pct < 100);
128
+ if (active.length === 0)
129
+ return null;
130
+ const lines = active.slice(0, 3).map(g => {
131
+ let line = `${g.name}: ${formatMoney(g.current)} / ${formatMoney(g.target)} (${g.progress_pct}%)`;
132
+ if (g.target_date) {
133
+ line += ` — need ${formatMoney(g.monthly_needed)}/mo`;
134
+ }
135
+ return line;
136
+ });
137
+ return `GOALS: ${lines.join(" | ")}`;
138
+ }
139
+ function buildUpcoming(db) {
140
+ const parts = [];
141
+ // Recurring bills due in next 7 days
142
+ const now = new Date();
143
+ const todayDay = now.getDate();
144
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
145
+ const endDay = todayDay + 7;
146
+ let bills = [];
147
+ if (endDay <= daysInMonth) {
148
+ bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, endDay);
149
+ }
150
+ else {
151
+ // Wraparound: rest of this month + start of next
152
+ const thisMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, daysInMonth);
153
+ const nextMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ?`).all(endDay - daysInMonth);
154
+ bills = [...thisMonthBills, ...nextMonthBills];
155
+ }
156
+ // Also handle bills on day 31 in shorter months
157
+ if (daysInMonth < 31) {
158
+ const endOfMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month > ? AND day_of_month NOT IN (SELECT day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?)`).all(daysInMonth, todayDay + 1, Math.min(endDay, daysInMonth));
159
+ // These bills fall on the last day of the month
160
+ if (daysInMonth >= todayDay + 1 && daysInMonth <= endDay) {
161
+ bills.push(...endOfMonthBills);
162
+ }
163
+ }
164
+ if (bills.length > 0) {
165
+ const billStrs = bills.slice(0, 5).map(b => {
166
+ const daysUntil = b.day_of_month > todayDay
167
+ ? b.day_of_month - todayDay
168
+ : daysInMonth - todayDay + b.day_of_month;
169
+ return `${b.name} (${formatMoney(b.amount)}) due in ${daysUntil} days`;
170
+ });
171
+ parts.push(`UPCOMING: ${billStrs.join(", ")}`);
172
+ }
173
+ // Low balance warning
174
+ const avgMonthlyExpenses = db.prepare(`SELECT COALESCE(SUM(amount), 0) / 3.0 as avg FROM transactions
175
+ WHERE amount > 0 AND date > date('now', '-90 days') AND pending = 0
176
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN')`).get();
177
+ const lowAccounts = db.prepare(`SELECT name, current_balance FROM accounts WHERE type = 'depository' AND current_balance < ?`).all(avgMonthlyExpenses.avg);
178
+ for (const a of lowAccounts.slice(0, 2)) {
179
+ parts.push(`LOW BALANCE: ${a.name} at ${formatMoney(a.current_balance)} (below 1 month of avg expenses)`);
180
+ }
181
+ // Credit utilization
182
+ const creditCards = db.prepare(`SELECT name, current_balance, available_balance FROM accounts
183
+ WHERE type = 'credit' AND current_balance > 0 AND available_balance IS NOT NULL`).all();
184
+ for (const card of creditCards) {
185
+ const limit = card.current_balance + card.available_balance;
186
+ if (limit > 0) {
187
+ const utilization = card.current_balance / limit;
188
+ if (utilization > 0.3) {
189
+ parts.push(`CREDIT: ${card.name} at ${Math.round(utilization * 100)}% utilization (${formatMoney(card.current_balance)} / ${formatMoney(limit)} limit)`);
190
+ }
191
+ }
192
+ }
193
+ return parts.length > 0 ? parts.join("\n") : null;
194
+ }
195
+ function buildAnomalies(db) {
196
+ const parts = [];
197
+ // Large transactions in last 7 days (>$200)
198
+ const largeTx = db.prepare(`SELECT name, merchant_name, amount, date FROM transactions
199
+ WHERE amount > 200 AND date > date('now', '-7 days') AND pending = 0
200
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS', 'RENT_AND_UTILITIES')
201
+ ORDER BY amount DESC LIMIT 3`).all();
202
+ for (const tx of largeTx) {
203
+ parts.push(`Large charge: ${formatMoney(tx.amount)} at ${tx.merchant_name || tx.name} (${tx.date})`);
204
+ }
205
+ // Spending velocity (only after day 5)
206
+ const now = new Date();
207
+ const dayOfMonth = now.getDate();
208
+ if (dayOfMonth >= 5) {
209
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
210
+ const today = now.toISOString().slice(0, 10);
211
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
212
+ const thisMonth = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
213
+ WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
214
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`).get(monthStart, today);
215
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10);
216
+ const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10);
217
+ const lastMonth = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
218
+ WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
219
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`).get(lastMonthStart, lastMonthEnd);
220
+ if (lastMonth.total > 0) {
221
+ const projected = (thisMonth.total / dayOfMonth) * daysInMonth;
222
+ const velocity = projected / lastMonth.total;
223
+ if (velocity > 1.2) {
224
+ parts.push(`PACE ALERT: On track to spend ${formatMoney(projected)} this month (${Math.round((velocity - 1) * 100)}% above last month's ${formatMoney(lastMonth.total)})`);
225
+ }
226
+ }
227
+ }
228
+ return parts.length > 0 ? parts.join("\n") : null;
229
+ }
230
+ // ─── CLI Briefing (colored, for terminal display on launch) ─── //
231
+ export function cliBriefing(db) {
232
+ const txCount = db.prepare(`SELECT COUNT(*) as cnt FROM transactions`).get();
233
+ if (txCount.cnt === 0)
234
+ return null;
235
+ const now = new Date();
236
+ const dayOfMonth = now.getDate();
237
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
238
+ const daysLeft = daysInMonth - dayOfMonth;
239
+ const lines = [];
240
+ // Net worth headline
241
+ const nw = getNetWorth(db);
242
+ let nwLine = chalk.white(` ${fmtMoney(nw.net_worth)}`);
243
+ if (nw.prev_net_worth !== null) {
244
+ const change = nw.net_worth - nw.prev_net_worth;
245
+ nwLine += change >= 0
246
+ ? chalk.green(` +${fmtMoney(change)}`)
247
+ : chalk.red(` -${fmtMoney(Math.abs(change))}`);
248
+ }
249
+ lines.push(chalk.dim(" net worth") + nwLine);
250
+ lines.push("");
251
+ // Spending vs last month
252
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
253
+ const today = now.toISOString().slice(0, 10);
254
+ const thisMonthSpend = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
255
+ WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
256
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`).get(monthStart.toISOString().slice(0, 10), today);
257
+ const oldestTx = db.prepare(`SELECT MIN(date) as d FROM transactions`).get();
258
+ const hasHistory = oldestTx.d && (new Date(today).getTime() - new Date(oldestTx.d).getTime()) > 30 * 24 * 60 * 60 * 1000;
259
+ if (hasHistory) {
260
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
261
+ const lastMonthSameDay = new Date(now.getFullYear(), now.getMonth() - 1, Math.min(dayOfMonth, new Date(now.getFullYear(), now.getMonth(), 0).getDate()));
262
+ const cmp = compareSpending(db, lastMonthStart.toISOString().slice(0, 10), lastMonthSameDay.toISOString().slice(0, 10), monthStart.toISOString().slice(0, 10), today);
263
+ if (cmp.period1Total > 0) {
264
+ const diff = cmp.period2Total - cmp.period1Total;
265
+ const arrow = diff <= 0 ? chalk.green(`${fmtMoney(Math.abs(diff))} less`) : chalk.red(`${fmtMoney(diff)} more`);
266
+ lines.push(chalk.dim(" spending") + chalk.white(` ${fmtMoney(thisMonthSpend.total)} this month`) + chalk.dim(` · `) + arrow + chalk.dim(` than this point last month`));
267
+ // Top movers (up to 3, show both ups and downs)
268
+ const movers = cmp.categories
269
+ .filter(c => Math.abs(c.diff) > 10)
270
+ .sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff))
271
+ .slice(0, 4);
272
+ if (movers.length > 0) {
273
+ const moverStrs = movers.map(m => {
274
+ const label = categoryLabel(m.category);
275
+ const color = m.diff <= 0 ? chalk.green : chalk.red;
276
+ const sign = m.diff <= 0 ? "-" : "+";
277
+ return `${chalk.dim(label)} ${color(`${sign}${fmtMoney(Math.abs(m.diff))}`)}`;
278
+ });
279
+ lines.push(" " + moverStrs.join(chalk.dim(" · ")));
280
+ }
281
+ }
282
+ }
283
+ else {
284
+ lines.push(chalk.dim(" spending") + chalk.white(` ${fmtMoney(thisMonthSpend.total)} this month`) + chalk.dim(` · ${daysLeft} days left`));
285
+ }
286
+ // Budget alerts (only if something is hot)
287
+ const budgets = getBudgetStatuses(db);
288
+ const hot = budgets.filter(b => b.pct_used >= 90).slice(0, 2);
289
+ if (hot.length > 0) {
290
+ lines.push("");
291
+ for (const b of hot) {
292
+ const pct = Math.round(b.pct_used);
293
+ const color = b.over_budget ? chalk.red : chalk.yellow;
294
+ const bar = miniBar(b.pct_used);
295
+ lines.push(` ${bar} ${color(categoryLabel(b.category))} ${chalk.dim(`${pct}%`)}`);
296
+ }
297
+ }
298
+ // Goals (compact)
299
+ const goals = getGoals(db).filter(g => g.progress_pct < 100).slice(0, 2);
300
+ if (goals.length > 0) {
301
+ lines.push("");
302
+ for (const g of goals) {
303
+ const bar = miniBar(g.progress_pct);
304
+ const pace = g.target_date
305
+ ? chalk.dim(` · need ${fmtMoney(g.monthly_needed)}/mo`)
306
+ : "";
307
+ lines.push(` ${bar} ${chalk.white(g.name)} ${chalk.dim(`${fmtMoney(g.current)}/${fmtMoney(g.target)}`)}${pace}`);
308
+ }
309
+ }
310
+ // Upcoming bills
311
+ const todayDay = now.getDate();
312
+ const endDay = todayDay + 7;
313
+ let bills = [];
314
+ if (endDay <= daysInMonth) {
315
+ bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, endDay);
316
+ }
317
+ else {
318
+ const a = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, daysInMonth);
319
+ const b = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ?`).all(endDay - daysInMonth);
320
+ bills = [...a, ...b];
321
+ }
322
+ if (bills.length > 0) {
323
+ lines.push("");
324
+ const billStrs = bills.slice(0, 3).map(b => {
325
+ const daysUntil = b.day_of_month > todayDay ? b.day_of_month - todayDay : daysInMonth - todayDay + b.day_of_month;
326
+ return chalk.dim(`${b.name} ${fmtMoney(b.amount)}`) + chalk.dim(` in ${daysUntil}d`);
327
+ });
328
+ lines.push(` ${chalk.dim("upcoming")} ${billStrs.join(chalk.dim(" · "))}`);
329
+ }
330
+ // Score
331
+ const score = getLatestScore(db);
332
+ if (score) {
333
+ lines.push("");
334
+ const scoreColor = score.score >= 70 ? chalk.green : score.score >= 40 ? chalk.yellow : chalk.red;
335
+ let scoreLine = ` ${chalk.dim("score")} ${scoreColor(String(score.score))}${chalk.dim("/100")}`;
336
+ const streaks = [];
337
+ if (score.no_restaurant_streak > 0)
338
+ streaks.push(`${score.no_restaurant_streak}d no dining`);
339
+ if (score.on_pace_streak > 0)
340
+ streaks.push(`${score.on_pace_streak}d on pace`);
341
+ if (streaks.length > 0)
342
+ scoreLine += chalk.dim(` · ${streaks.join(" · ")}`);
343
+ lines.push(scoreLine);
344
+ }
345
+ return lines.join("\n");
346
+ }
347
+ function fmtMoney(n) {
348
+ return "$" + Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
349
+ }
350
+ function miniBar(pct) {
351
+ const width = 8;
352
+ const clamped = Math.max(0, Math.min(100, pct));
353
+ const filled = Math.round((clamped / 100) * width);
354
+ const empty = width - filled;
355
+ const color = pct > 100 ? chalk.red : pct > 80 ? chalk.yellow : chalk.green;
356
+ return color("▓".repeat(filled)) + chalk.dim("░".repeat(empty));
357
+ }
358
+ function buildScore(db) {
359
+ const score = getLatestScore(db);
360
+ if (!score)
361
+ return null;
362
+ let line = `SCORE: ${score.score}/100`;
363
+ const streaks = [];
364
+ if (score.no_restaurant_streak > 0)
365
+ streaks.push(`${score.no_restaurant_streak}-day no-restaurant streak`);
366
+ if (score.no_shopping_streak > 0)
367
+ streaks.push(`${score.no_shopping_streak}-day no-shopping streak`);
368
+ if (score.on_pace_streak > 0)
369
+ streaks.push(`${score.on_pace_streak}-day on-pace streak`);
370
+ if (streaks.length > 0)
371
+ line += ` — ${streaks.join(", ")}`;
372
+ // Recent achievements
373
+ const achievements = db.prepare(`SELECT name FROM achievements ORDER BY unlocked_at DESC LIMIT 3`).all();
374
+ if (achievements.length > 0) {
375
+ line += ` | Recent: ${achievements.map(a => a.name).join(", ")}`;
376
+ }
377
+ return line;
378
+ }
@@ -0,0 +1,14 @@
1
+ import type Database from "better-sqlite3-multiple-ciphers";
2
+ export declare function getConversationHistory(db: Database.Database, limit?: number): {
3
+ role: string;
4
+ content: string;
5
+ created_at: string;
6
+ }[];
7
+ export declare function saveMessage(db: Database.Database, role: "user" | "assistant", content: string): void;
8
+ export declare function getMemories(db: Database.Database): {
9
+ id: number;
10
+ content: string;
11
+ category: string;
12
+ created_at: string;
13
+ }[];
14
+ export declare function saveMemory(db: Database.Database, content: string, category?: string): void;
@@ -0,0 +1,12 @@
1
+ export function getConversationHistory(db, limit = 20) {
2
+ return db.prepare(`SELECT role, content, created_at FROM conversation_history ORDER BY id DESC LIMIT ?`).all(limit).reverse();
3
+ }
4
+ export function saveMessage(db, role, content) {
5
+ db.prepare(`INSERT INTO conversation_history (role, content) VALUES (?, ?)`).run(role, content);
6
+ }
7
+ export function getMemories(db) {
8
+ return db.prepare(`SELECT id, content, category, created_at FROM memories ORDER BY created_at DESC`).all();
9
+ }
10
+ export function saveMemory(db, content, category = "general") {
11
+ db.prepare(`INSERT INTO memories (content, category) VALUES (?, ?)`).run(content, category);
12
+ }
@@ -0,0 +1,2 @@
1
+ export declare function redact(text: string): string;
2
+ export declare function unredact(text: string): string;
@@ -0,0 +1,92 @@
1
+ import { config } from "../config.js";
2
+ import { readContext } from "./context.js";
3
+ /**
4
+ * Builds a list of PII terms to redact from outbound API calls.
5
+ * Extracts: user name, family/partner names, employer names from context.
6
+ */
7
+ function buildRedactions() {
8
+ const entries = [];
9
+ const seen = new Set();
10
+ function add(real, token) {
11
+ const trimmed = real.trim();
12
+ if (trimmed.length < 2 || seen.has(trimmed.toLowerCase()))
13
+ return;
14
+ seen.add(trimmed.toLowerCase());
15
+ entries.push({ real: trimmed, token });
16
+ }
17
+ // User's name (and first name if multi-word)
18
+ const userName = config.userName;
19
+ if (userName && userName !== "User") {
20
+ add(userName, "[USER]");
21
+ const parts = userName.split(/\s+/);
22
+ if (parts.length > 1) {
23
+ add(parts[0], "[USER_FIRST]");
24
+ add(parts[parts.length - 1], "[USER_LAST]");
25
+ }
26
+ }
27
+ // Parse context.md for family and employer names
28
+ const context = readContext();
29
+ if (context) {
30
+ // Extract names from ## Family section
31
+ const familyMatch = context.match(/## Family\n([\s\S]*?)(?=\n##|$)/);
32
+ if (familyMatch) {
33
+ const lines = familyMatch[1].split("\n").filter(l => l.trim().startsWith("-"));
34
+ for (const line of lines) {
35
+ const text = line.replace(/^-\s*/, "").trim();
36
+ // Skip the user's own name and placeholder lines
37
+ if (!text || text.startsWith("(") || text.toLowerCase() === userName.toLowerCase())
38
+ continue;
39
+ // Extract name — could be "Partner: Jane" or "Jane (wife)" or just "Jane Smith"
40
+ const nameMatch = text.match(/^(?:partner|spouse|wife|husband|child|kid|son|daughter|dependent)[:\s]+(.+)/i)
41
+ || text.match(/^([A-Z][a-z]+(?: [A-Z][a-z]+)*)/);
42
+ if (nameMatch) {
43
+ const name = nameMatch[1].replace(/\s*\(.*\)/, "").trim();
44
+ if (name && name.toLowerCase() !== userName.toLowerCase()) {
45
+ add(name, "[PARTNER]");
46
+ const nameParts = name.split(/\s+/);
47
+ if (nameParts.length > 1) {
48
+ add(nameParts[0], "[PARTNER_FIRST]");
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ // Extract employer from ## Income section
55
+ const incomeMatch = context.match(/## Income\n([\s\S]*?)(?=\n##|$)/);
56
+ if (incomeMatch) {
57
+ const lines = incomeMatch[1].split("\n").filter(l => l.trim().startsWith("-"));
58
+ for (const line of lines) {
59
+ const text = line.replace(/^-\s*/, "").trim();
60
+ if (!text || text.startsWith("("))
61
+ continue;
62
+ // Match patterns like "Employer: Acme Corp", "Salary from Acme Corp", "$85k/year from Acme Corp"
63
+ const employerMatch = text.match(/(?:employer|works? (?:at|for)|employed (?:at|by))[:\s]+([A-Z][\w\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/i)
64
+ || text.match(/\bfrom ([A-Z][A-Za-z\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/)
65
+ || text.match(/\bat ([A-Z][A-Za-z\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/);
66
+ if (employerMatch) {
67
+ add(employerMatch[1].trim(), "[EMPLOYER]");
68
+ }
69
+ }
70
+ }
71
+ }
72
+ // Sort longest first so "John Smith" is replaced before "John"
73
+ entries.sort((a, b) => b.real.length - a.real.length);
74
+ return entries;
75
+ }
76
+ export function redact(text) {
77
+ const redactions = buildRedactions();
78
+ let result = text;
79
+ for (const { real, token } of redactions) {
80
+ result = result.replaceAll(real, token);
81
+ }
82
+ return result;
83
+ }
84
+ export function unredact(text) {
85
+ const redactions = buildRedactions();
86
+ let result = text;
87
+ // Reverse: replace tokens with real values (shortest tokens last to avoid partial matches)
88
+ for (const { real, token } of redactions) {
89
+ result = result.replaceAll(token, real);
90
+ }
91
+ return result;
92
+ }
@@ -0,0 +1,2 @@
1
+ import type Database from "better-sqlite3-multiple-ciphers";
2
+ export declare function buildSystemPrompt(db: Database.Database): string;
@@ -0,0 +1,85 @@
1
+ import { config } from "../config.js";
2
+ import { getMemories } from "./memory.js";
3
+ import { readContext, isContextEmpty } from "./context.js";
4
+ import { computeInsights } from "./insights.js";
5
+ export function buildSystemPrompt(db) {
6
+ const memories = getMemories(db);
7
+ const now = new Date();
8
+ const dateStr = now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" });
9
+ const context = readContext();
10
+ const name = config.userName;
11
+ let prompt = `You are Ray, a personal financial advisor for ${name}. You have access to their real bank, investment, and debt data. You are thoughtful, precise, and candid about money — like a trusted friend who happens to be a financial expert.
12
+
13
+ Today is ${dateStr}.
14
+
15
+ ## Personality
16
+ - You are not a chatbot. You are a sharp, opinionated CFO who's been watching ${name}'s money all day. Talk like a person, not a customer service rep.
17
+ - Lead with the insight, not the data. Don't say "Here's the breakdown:" — say what the breakdown means. "You crushed dining this month — down $114. That's the biggest swing."
18
+ - Be specific with numbers — always cite actual balances, amounts, and percentages from the data.
19
+ - Have a point of view. Instead of "here are your options", say what you'd actually do and why. You can present alternatives, but lead with your recommendation.
20
+ - Be proactive: if you notice something concerning or interesting in the briefing data, bring it up even if not asked. A good CFO doesn't wait to be asked.
21
+ - Be concise. 2-4 sentences for simple questions. Don't pad responses with filler.
22
+ - Skip transitions and preamble. Don't start with "Great question!" or "Let me look into that." Just answer.
23
+ - Be warm but direct. Celebrate wins genuinely. Flag problems without sugarcoating.
24
+
25
+ ## Approach
26
+ 1. You already have a financial briefing with current data. Use it — don't re-fetch what you already know. Call tools for deeper dives or data not in the briefing.
27
+ 2. Connect the dots between data points. Don't just report numbers — tell ${name} what it means for them specifically, referencing their goals and context.
28
+ 3. When comparing periods, use percentage changes and absolute differences.
29
+ 4. End with what to do, not just what happened. A good CFO always has a next step.
30
+
31
+ ## Formatting (terminal output)
32
+ - Plain text only. No markdown syntax — no asterisks, no hashtags, no backticks.
33
+ - Use line breaks, dashes, and simple alignment for structure.
34
+ - Use ALL CAPS or dashes for emphasis instead of bold/italic.
35
+
36
+ ## Tools
37
+ - Always use tools to look up current data. Never guess balances, spending, or dates.
38
+ - When the user shares something worth remembering (a preference, life event, financial goal context), use save_memory.
39
+ - When circumstances change (new decisions, completed goals, changed balances, updated strategy), use update_context to persist the change.
40
+ - For date-based queries, figure out the right date range from context (e.g., "this month" = first of current month to today).
41
+
42
+ ## Privacy
43
+ - Never reveal account numbers, routing numbers, or Plaid access tokens.
44
+ - You can discuss balances, transactions, and spending freely — that's what you're here for.`;
45
+ if (isContextEmpty()) {
46
+ prompt += `\n\n## Onboarding Mode
47
+ ${name} just connected their financial accounts and needs help setting up their financial profile. This is your first conversation.
48
+
49
+ Instructions:
50
+ 1. Start by calling these tools to review their synced data: get_accounts, get_transactions (last 30 days), get_spending_summary, get_debts
51
+ 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
+ - Family situation (partner, dependents)
54
+ - Income details (salary, side income, frequency)
55
+ - Financial goals (short-term and long-term)
56
+ - Current challenges or concerns
57
+ - Budget targets or spending limits they want
58
+ - 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
64
+
65
+ This onboarding block will automatically disappear once the context file is filled in.`;
66
+ }
67
+ else if (context) {
68
+ prompt += `\n\n## ${name}'s Financial Context\n${context}`;
69
+ }
70
+ // Financial intelligence briefing — computed insights injected before memories
71
+ try {
72
+ const insights = computeInsights(db);
73
+ if (insights) {
74
+ prompt += `\n\n${insights}`;
75
+ }
76
+ }
77
+ catch {
78
+ // Don't let insight computation failure break the conversation
79
+ }
80
+ if (memories.length > 0) {
81
+ prompt += `\n\n## Things I remember about ${name}\n`;
82
+ prompt += memories.map(m => `- ${m.content}`).join("\n");
83
+ }
84
+ return prompt;
85
+ }
@@ -0,0 +1,4 @@
1
+ import type Database from "better-sqlite3-multiple-ciphers";
2
+ import type { Tool } from "@anthropic-ai/sdk/resources/messages.js";
3
+ export declare const toolDefinitions: Tool[];
4
+ export declare function executeTool(db: Database.Database, toolName: string, toolInput: any): Promise<string>;