ray-finance 0.2.2 → 0.2.4

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 (82) hide show
  1. package/README.md +38 -11
  2. package/dist/ai/agent.js +16 -3
  3. package/dist/ai/context.js +6 -2
  4. package/dist/ai/insights.js +26 -3
  5. package/dist/ai/redactor.js +11 -0
  6. package/dist/ai/system-prompt.js +2 -2
  7. package/dist/ai/tools.js +4 -0
  8. package/dist/cli/backup.js +18 -9
  9. package/dist/cli/chat.js +146 -40
  10. package/dist/cli/format.d.ts +2 -0
  11. package/dist/cli/format.js +25 -0
  12. package/dist/cli/index.js +12 -2
  13. package/dist/cli/setup.js +7 -1
  14. package/dist/daily-sync.js +19 -4
  15. package/dist/db/connection.js +9 -1
  16. package/dist/db/encryption.js +18 -7
  17. package/dist/db/schema.js +6 -1
  18. package/dist/public/link.html +47 -24
  19. package/dist/public/ray-logo-dark.png +0 -0
  20. package/dist/queries/index.js +8 -8
  21. package/dist/server.js +33 -1
  22. package/package.json +7 -2
  23. package/.claude/settings.local.json +0 -16
  24. package/.env.example +0 -13
  25. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -19
  26. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -9
  27. package/.github/PULL_REQUEST_TEMPLATE.md +0 -5
  28. package/.github/workflows/ci.yml +0 -21
  29. package/CHANGELOG.md +0 -16
  30. package/CODE_OF_CONDUCT.md +0 -31
  31. package/CONTRIBUTING.md +0 -41
  32. package/Dockerfile +0 -8
  33. package/SECURITY.md +0 -36
  34. package/SPEC.md +0 -374
  35. package/docker-compose.yml +0 -9
  36. package/site/next-env.d.ts +0 -6
  37. package/site/next.config.ts +0 -7
  38. package/site/package-lock.json +0 -1661
  39. package/site/package.json +0 -24
  40. package/site/postcss.config.mjs +0 -7
  41. package/site/public/ray-og.jpg +0 -0
  42. package/site/public/robots.txt +0 -4
  43. package/site/public/sitemap.xml +0 -8
  44. package/site/src/app/copy-command.tsx +0 -31
  45. package/site/src/app/globals.css +0 -87
  46. package/site/src/app/layout.tsx +0 -64
  47. package/site/src/app/page.tsx +0 -841
  48. package/site/src/app/pii-scramble.tsx +0 -190
  49. package/site/src/app/reveal.tsx +0 -29
  50. package/site/tsconfig.json +0 -21
  51. package/src/ai/agent.ts +0 -106
  52. package/src/ai/audit.ts +0 -11
  53. package/src/ai/context.ts +0 -93
  54. package/src/ai/insights.ts +0 -474
  55. package/src/ai/memory.ts +0 -21
  56. package/src/ai/redactor.ts +0 -102
  57. package/src/ai/system-prompt.ts +0 -90
  58. package/src/ai/tools.ts +0 -716
  59. package/src/alerts/index.ts +0 -123
  60. package/src/cli/backup.ts +0 -113
  61. package/src/cli/chat.ts +0 -105
  62. package/src/cli/commands.ts +0 -240
  63. package/src/cli/format.ts +0 -149
  64. package/src/cli/index.ts +0 -193
  65. package/src/cli/scheduler.ts +0 -116
  66. package/src/cli/setup.ts +0 -189
  67. package/src/config.ts +0 -81
  68. package/src/daily-sync.ts +0 -155
  69. package/src/db/connection.ts +0 -38
  70. package/src/db/encryption.ts +0 -29
  71. package/src/db/helpers.ts +0 -47
  72. package/src/db/schema.ts +0 -196
  73. package/src/index.ts +0 -3
  74. package/src/plaid/client.ts +0 -25
  75. package/src/plaid/link.ts +0 -25
  76. package/src/plaid/sync.ts +0 -219
  77. package/src/public/link.html +0 -161
  78. package/src/queries/index.ts +0 -586
  79. package/src/scoring/index.ts +0 -468
  80. package/src/server.ts +0 -162
  81. package/tsconfig.json +0 -16
  82. /package/{site → dist}/public/favicon.png +0 -0
@@ -1,474 +0,0 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
2
- import chalk from "chalk";
3
- import {
4
- getNetWorth, getAccountBalances, getDebts, getBudgetStatuses,
5
- getGoals, compareSpending, formatMoney, categoryLabel,
6
- } from "../queries/index.js";
7
- import { getLatestScore } from "../scoring/index.js";
8
-
9
- const MAX_CHARS = 6000;
10
-
11
- export function computeInsights(db: Database.Database): string {
12
- // Fresh install guard
13
- const txCount = db.prepare(`SELECT COUNT(*) as cnt FROM transactions`).get() as { cnt: number };
14
- if (txCount.cnt === 0) {
15
- return `## Current Financial Briefing\nAccounts connected — run \`ray sync\` to pull transactions. No financial data to analyze yet.`;
16
- }
17
-
18
- const sections: { priority: number; text: string }[] = [];
19
-
20
- // 1. Financial Snapshot (priority 1 — always kept)
21
- sections.push({ priority: 1, text: buildSnapshot(db) });
22
-
23
- // 2. Spending Intelligence (priority 2 — always kept)
24
- sections.push({ priority: 2, text: buildSpending(db) });
25
-
26
- // 3. Goal & Savings Pace (priority 4)
27
- const goals = buildGoals(db);
28
- if (goals) sections.push({ priority: 4, text: goals });
29
-
30
- // 4. Upcoming & Proactive (priority 5)
31
- const upcoming = buildUpcoming(db);
32
- if (upcoming) sections.push({ priority: 5, text: upcoming });
33
-
34
- // 5. Anomaly Detection (priority 6 — nice to have)
35
- const anomalies = buildAnomalies(db);
36
- if (anomalies) sections.push({ priority: 6, text: anomalies });
37
-
38
- // 6. Behavioral Score (priority 7 — nice to have)
39
- const score = buildScore(db);
40
- if (score) sections.push({ priority: 7, text: score });
41
-
42
- // Token budget: drop lowest-priority sections first if over budget
43
- sections.sort((a, b) => a.priority - b.priority);
44
- let combined = "";
45
- const included: string[] = [];
46
- for (const s of sections) {
47
- if ((combined + s.text).length > MAX_CHARS && s.priority > 2) break;
48
- included.push(s.text);
49
- combined = included.join("\n\n");
50
- }
51
-
52
- return `## Current Financial Briefing (auto-generated)\n\n${combined}`;
53
- }
54
-
55
- function buildSnapshot(db: Database.Database): string {
56
- const nw = getNetWorth(db);
57
- const lines: string[] = [];
58
-
59
- let nwLine = `Net worth: ${formatMoney(nw.net_worth)}`;
60
- if (nw.prev_net_worth !== null) {
61
- const change = nw.net_worth - nw.prev_net_worth;
62
- nwLine += ` (${change >= 0 ? "+" : "-"}${formatMoney(Math.abs(change))} today)`;
63
- }
64
- lines.push(nwLine);
65
-
66
- // Account balances — cap at 5
67
- const accounts = getAccountBalances(db).slice(0, 5);
68
- if (accounts.length > 0) {
69
- lines.push(accounts.map(a =>
70
- `${a.name} (${a.type}): ${a.type === "credit" ? "-" : ""}${formatMoney(a.balance)}`
71
- ).join(" | "));
72
- }
73
-
74
- // Debt summary
75
- const debts = getDebts(db);
76
- if (debts.totalDebt > 0) {
77
- const rates = debts.debts.filter(d => d.rate > 0);
78
- let debtLine = `Total debt: ${formatMoney(debts.totalDebt)}`;
79
- if (rates.length > 0) {
80
- const weightedRate = rates.reduce((s, d) => s + d.rate * d.balance, 0) / rates.reduce((s, d) => s + d.balance, 0);
81
- debtLine += ` (avg ${weightedRate.toFixed(1)}% APR)`;
82
- }
83
- lines.push(debtLine);
84
- }
85
-
86
- return lines.join("\n");
87
- }
88
-
89
- function buildSpending(db: Database.Database): string {
90
- const now = new Date();
91
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
92
- const today = now.toISOString().slice(0, 10);
93
- const dayOfMonth = now.getDate();
94
- const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
95
- const daysLeft = daysInMonth - dayOfMonth;
96
-
97
- // This month's total spending
98
- const thisMonthSpend = db.prepare(
99
- `SELECT COALESCE(SUM(amount), 0) as total FROM transactions
100
- WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
101
- AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`
102
- ).get(monthStart.toISOString().slice(0, 10), today) as { total: number };
103
-
104
- const lines: string[] = [];
105
- let spendLine = `SPENDING: ${formatMoney(thisMonthSpend.total)} this month`;
106
-
107
- // Compare to last month (same day-of-month)
108
- const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
109
- const lastMonthSameDay = new Date(now.getFullYear(), now.getMonth() - 1, Math.min(dayOfMonth, new Date(now.getFullYear(), now.getMonth(), 0).getDate()));
110
-
111
- // Only compare if we have >30 days of history
112
- const oldestTx = db.prepare(`SELECT MIN(date) as d FROM transactions`).get() as { d: string | null };
113
- const hasEnoughHistory = oldestTx.d && (new Date(today).getTime() - new Date(oldestTx.d).getTime()) > 30 * 24 * 60 * 60 * 1000;
114
-
115
- if (hasEnoughHistory) {
116
- const cmp = compareSpending(
117
- db,
118
- lastMonthStart.toISOString().slice(0, 10),
119
- lastMonthSameDay.toISOString().slice(0, 10),
120
- monthStart.toISOString().slice(0, 10),
121
- today
122
- );
123
-
124
- if (cmp.period1Total > 0) {
125
- const pctStr = cmp.pctChange >= 0 ? `${cmp.pctChange}% above` : `${Math.abs(cmp.pctChange)}% below`;
126
- spendLine += ` (${pctStr} last month`;
127
-
128
- // Top driver
129
- const topDrivers = cmp.categories.filter(c => c.diff > 0).slice(0, 3);
130
- if (topDrivers.length > 0 && cmp.pctChange > 0) {
131
- spendLine += `, driven by ${topDrivers.map(d => `${categoryLabel(d.category)} +${formatMoney(d.diff)}`).join(", ")}`;
132
- }
133
- spendLine += ")";
134
- }
135
- }
136
- lines.push(spendLine);
137
-
138
- // Budget status
139
- const budgets = getBudgetStatuses(db);
140
- const alertBudgets = budgets.filter(b => b.pct_used >= 80).slice(0, 3);
141
- for (const b of alertBudgets) {
142
- if (b.over_budget) {
143
- lines.push(`Budget OVER: ${categoryLabel(b.category)} — ${formatMoney(b.spent)} / ${formatMoney(b.budget)} (${formatMoney(Math.abs(b.remaining))} over)`);
144
- } else {
145
- lines.push(`Budget alert: ${categoryLabel(b.category)} at ${b.pct_used}% of limit with ${daysLeft} days remaining`);
146
- }
147
- }
148
-
149
- // Daily discretionary remaining
150
- if (daysLeft > 0) {
151
- const totalBudget = budgets.reduce((s, b) => s + b.budget, 0);
152
- if (totalBudget > 0) {
153
- const totalRemaining = budgets.reduce((s, b) => s + Math.max(0, b.remaining), 0);
154
- lines.push(`Daily discretionary budget remaining: ${formatMoney(totalRemaining / daysLeft)}/day`);
155
- }
156
- }
157
-
158
- return lines.join("\n");
159
- }
160
-
161
- function buildGoals(db: Database.Database): string | null {
162
- const goals = getGoals(db);
163
- const active = goals.filter(g => g.progress_pct < 100);
164
- if (active.length === 0) return null;
165
-
166
- const lines = active.slice(0, 3).map(g => {
167
- let line = `${g.name}: ${formatMoney(g.current)} / ${formatMoney(g.target)} (${g.progress_pct}%)`;
168
- if (g.target_date) {
169
- line += ` — need ${formatMoney(g.monthly_needed)}/mo`;
170
- }
171
- return line;
172
- });
173
-
174
- return `GOALS: ${lines.join(" | ")}`;
175
- }
176
-
177
- function buildUpcoming(db: Database.Database): string | null {
178
- const parts: string[] = [];
179
-
180
- // Recurring bills due in next 7 days
181
- const now = new Date();
182
- const todayDay = now.getDate();
183
- const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
184
- const endDay = todayDay + 7;
185
-
186
- let bills: { name: string; amount: number; day_of_month: number }[] = [];
187
- if (endDay <= daysInMonth) {
188
- bills = db.prepare(
189
- `SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`
190
- ).all(todayDay + 1, endDay) as any[];
191
- } else {
192
- // Wraparound: rest of this month + start of next
193
- const thisMonthBills = db.prepare(
194
- `SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`
195
- ).all(todayDay + 1, daysInMonth) as any[];
196
- const nextMonthBills = db.prepare(
197
- `SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ?`
198
- ).all(endDay - daysInMonth) as any[];
199
- bills = [...thisMonthBills, ...nextMonthBills];
200
- }
201
-
202
- // Also handle bills on day 31 in shorter months
203
- if (daysInMonth < 31) {
204
- const endOfMonthBills = db.prepare(
205
- `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 ?)`
206
- ).all(daysInMonth, todayDay + 1, Math.min(endDay, daysInMonth)) as any[];
207
- // These bills fall on the last day of the month
208
- if (daysInMonth >= todayDay + 1 && daysInMonth <= endDay) {
209
- bills.push(...endOfMonthBills);
210
- }
211
- }
212
-
213
- if (bills.length > 0) {
214
- const billStrs = bills.slice(0, 5).map(b => {
215
- const daysUntil = b.day_of_month > todayDay
216
- ? b.day_of_month - todayDay
217
- : daysInMonth - todayDay + b.day_of_month;
218
- return `${b.name} (${formatMoney(b.amount)}) due in ${daysUntil} days`;
219
- });
220
- parts.push(`UPCOMING: ${billStrs.join(", ")}`);
221
- }
222
-
223
- // Low balance warning
224
- const avgMonthlyExpenses = db.prepare(
225
- `SELECT COALESCE(SUM(amount), 0) / 3.0 as avg FROM transactions
226
- WHERE amount > 0 AND date > date('now', '-90 days') AND pending = 0
227
- AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN')`
228
- ).get() as { avg: number };
229
-
230
- const lowAccounts = db.prepare(
231
- `SELECT name, current_balance FROM accounts WHERE type = 'depository' AND current_balance < ?`
232
- ).all(avgMonthlyExpenses.avg) as { name: string; current_balance: number }[];
233
-
234
- for (const a of lowAccounts.slice(0, 2)) {
235
- parts.push(`LOW BALANCE: ${a.name} at ${formatMoney(a.current_balance)} (below 1 month of avg expenses)`);
236
- }
237
-
238
- // Credit utilization
239
- const creditCards = db.prepare(
240
- `SELECT name, current_balance, available_balance FROM accounts
241
- WHERE type = 'credit' AND current_balance > 0 AND available_balance IS NOT NULL`
242
- ).all() as { name: string; current_balance: number; available_balance: number }[];
243
-
244
- for (const card of creditCards) {
245
- const limit = card.current_balance + card.available_balance;
246
- if (limit > 0) {
247
- const utilization = card.current_balance / limit;
248
- if (utilization > 0.3) {
249
- parts.push(`CREDIT: ${card.name} at ${Math.round(utilization * 100)}% utilization (${formatMoney(card.current_balance)} / ${formatMoney(limit)} limit)`);
250
- }
251
- }
252
- }
253
-
254
- return parts.length > 0 ? parts.join("\n") : null;
255
- }
256
-
257
- function buildAnomalies(db: Database.Database): string | null {
258
- const parts: string[] = [];
259
-
260
- // Large transactions in last 7 days (>$200)
261
- const largeTx = db.prepare(
262
- `SELECT name, merchant_name, amount, date FROM transactions
263
- WHERE amount > 200 AND date > date('now', '-7 days') AND pending = 0
264
- AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS', 'RENT_AND_UTILITIES')
265
- ORDER BY amount DESC LIMIT 3`
266
- ).all() as { name: string; merchant_name: string | null; amount: number; date: string }[];
267
-
268
- for (const tx of largeTx) {
269
- parts.push(`Large charge: ${formatMoney(tx.amount)} at ${tx.merchant_name || tx.name} (${tx.date})`);
270
- }
271
-
272
- // Spending velocity (only after day 5)
273
- const now = new Date();
274
- const dayOfMonth = now.getDate();
275
- if (dayOfMonth >= 5) {
276
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
277
- const today = now.toISOString().slice(0, 10);
278
- const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
279
-
280
- const thisMonth = db.prepare(
281
- `SELECT COALESCE(SUM(amount), 0) as total FROM transactions
282
- WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
283
- AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`
284
- ).get(monthStart, today) as { total: number };
285
-
286
- const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10);
287
- const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10);
288
-
289
- const lastMonth = db.prepare(
290
- `SELECT COALESCE(SUM(amount), 0) as total FROM transactions
291
- WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
292
- AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`
293
- ).get(lastMonthStart, lastMonthEnd) as { total: number };
294
-
295
- if (lastMonth.total > 0) {
296
- const projected = (thisMonth.total / dayOfMonth) * daysInMonth;
297
- const velocity = projected / lastMonth.total;
298
- if (velocity > 1.2) {
299
- parts.push(`PACE ALERT: On track to spend ${formatMoney(projected)} this month (${Math.round((velocity - 1) * 100)}% above last month's ${formatMoney(lastMonth.total)})`);
300
- }
301
- }
302
- }
303
-
304
- return parts.length > 0 ? parts.join("\n") : null;
305
- }
306
-
307
- // ─── CLI Briefing (colored, for terminal display on launch) ─── //
308
-
309
- export function cliBriefing(db: Database.Database): string | null {
310
- const txCount = db.prepare(`SELECT COUNT(*) as cnt FROM transactions`).get() as { cnt: number };
311
- if (txCount.cnt === 0) return null;
312
-
313
- const now = new Date();
314
- const dayOfMonth = now.getDate();
315
- const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
316
- const daysLeft = daysInMonth - dayOfMonth;
317
- const lines: string[] = [];
318
-
319
- // Net worth headline
320
- const nw = getNetWorth(db);
321
- let nwLine = chalk.white(` ${fmtMoney(nw.net_worth)}`);
322
- if (nw.prev_net_worth !== null) {
323
- const change = nw.net_worth - nw.prev_net_worth;
324
- nwLine += change >= 0
325
- ? chalk.green(` +${fmtMoney(change)}`)
326
- : chalk.red(` -${fmtMoney(Math.abs(change))}`);
327
- }
328
- lines.push(chalk.dim(" net worth") + nwLine);
329
- lines.push("");
330
-
331
- // Spending vs last month
332
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
333
- const today = now.toISOString().slice(0, 10);
334
- const thisMonthSpend = db.prepare(
335
- `SELECT COALESCE(SUM(amount), 0) as total FROM transactions
336
- WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
337
- AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`
338
- ).get(monthStart.toISOString().slice(0, 10), today) as { total: number };
339
-
340
- const oldestTx = db.prepare(`SELECT MIN(date) as d FROM transactions`).get() as { d: string | null };
341
- const hasHistory = oldestTx.d && (new Date(today).getTime() - new Date(oldestTx.d).getTime()) > 30 * 24 * 60 * 60 * 1000;
342
-
343
- if (hasHistory) {
344
- const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
345
- const lastMonthSameDay = new Date(now.getFullYear(), now.getMonth() - 1, Math.min(dayOfMonth, new Date(now.getFullYear(), now.getMonth(), 0).getDate()));
346
- const cmp = compareSpending(
347
- db,
348
- lastMonthStart.toISOString().slice(0, 10),
349
- lastMonthSameDay.toISOString().slice(0, 10),
350
- monthStart.toISOString().slice(0, 10),
351
- today
352
- );
353
-
354
- if (cmp.period1Total > 0) {
355
- const diff = cmp.period2Total - cmp.period1Total;
356
- const arrow = diff <= 0 ? chalk.green(`${fmtMoney(Math.abs(diff))} less`) : chalk.red(`${fmtMoney(diff)} more`);
357
- lines.push(chalk.dim(" spending") + chalk.white(` ${fmtMoney(thisMonthSpend.total)} this month`) + chalk.dim(` · `) + arrow + chalk.dim(` than this point last month`));
358
-
359
- // Top movers (up to 3, show both ups and downs)
360
- const movers = cmp.categories
361
- .filter(c => Math.abs(c.diff) > 10)
362
- .sort((a, b) => Math.abs(b.diff) - Math.abs(a.diff))
363
- .slice(0, 4);
364
- if (movers.length > 0) {
365
- const moverStrs = movers.map(m => {
366
- const label = categoryLabel(m.category);
367
- const color = m.diff <= 0 ? chalk.green : chalk.red;
368
- const sign = m.diff <= 0 ? "-" : "+";
369
- return `${chalk.dim(label)} ${color(`${sign}${fmtMoney(Math.abs(m.diff))}`)}`;
370
- });
371
- lines.push(" " + moverStrs.join(chalk.dim(" · ")));
372
- }
373
- }
374
- } else {
375
- lines.push(chalk.dim(" spending") + chalk.white(` ${fmtMoney(thisMonthSpend.total)} this month`) + chalk.dim(` · ${daysLeft} days left`));
376
- }
377
-
378
- // Budget alerts (only if something is hot)
379
- const budgets = getBudgetStatuses(db);
380
- const hot = budgets.filter(b => b.pct_used >= 90).slice(0, 2);
381
- if (hot.length > 0) {
382
- lines.push("");
383
- for (const b of hot) {
384
- const pct = Math.round(b.pct_used);
385
- const color = b.over_budget ? chalk.red : chalk.yellow;
386
- const bar = miniBar(b.pct_used);
387
- lines.push(` ${bar} ${color(categoryLabel(b.category))} ${chalk.dim(`${pct}%`)}`);
388
- }
389
- }
390
-
391
- // Goals (compact)
392
- const goals = getGoals(db).filter(g => g.progress_pct < 100).slice(0, 2);
393
- if (goals.length > 0) {
394
- lines.push("");
395
- for (const g of goals) {
396
- const bar = miniBar(g.progress_pct);
397
- const pace = g.target_date
398
- ? chalk.dim(` · need ${fmtMoney(g.monthly_needed)}/mo`)
399
- : "";
400
- lines.push(` ${bar} ${chalk.white(g.name)} ${chalk.dim(`${fmtMoney(g.current)}/${fmtMoney(g.target)}`)}${pace}`);
401
- }
402
- }
403
-
404
- // Upcoming bills
405
- const todayDay = now.getDate();
406
- const endDay = todayDay + 7;
407
- let bills: { name: string; amount: number; day_of_month: number }[] = [];
408
- if (endDay <= daysInMonth) {
409
- bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, endDay) as any[];
410
- } else {
411
- const a = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, daysInMonth) as any[];
412
- const b = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ?`).all(endDay - daysInMonth) as any[];
413
- bills = [...a, ...b];
414
- }
415
- if (bills.length > 0) {
416
- lines.push("");
417
- const billStrs = bills.slice(0, 3).map(b => {
418
- const daysUntil = b.day_of_month > todayDay ? b.day_of_month - todayDay : daysInMonth - todayDay + b.day_of_month;
419
- return chalk.dim(`${b.name} ${fmtMoney(b.amount)}`) + chalk.dim(` in ${daysUntil}d`);
420
- });
421
- lines.push(` ${chalk.dim("upcoming")} ${billStrs.join(chalk.dim(" · "))}`);
422
- }
423
-
424
- // Score
425
- const score = getLatestScore(db);
426
- if (score) {
427
- lines.push("");
428
- const scoreColor = score.score >= 70 ? chalk.green : score.score >= 40 ? chalk.yellow : chalk.red;
429
- let scoreLine = ` ${chalk.dim("score")} ${scoreColor(String(score.score))}${chalk.dim("/100")}`;
430
- const streaks: string[] = [];
431
- if (score.no_restaurant_streak > 0) streaks.push(`${score.no_restaurant_streak}d no dining`);
432
- if (score.on_pace_streak > 0) streaks.push(`${score.on_pace_streak}d on pace`);
433
- if (streaks.length > 0) scoreLine += chalk.dim(` · ${streaks.join(" · ")}`);
434
- lines.push(scoreLine);
435
- }
436
-
437
- return lines.join("\n");
438
- }
439
-
440
- function fmtMoney(n: number): string {
441
- return "$" + Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
442
- }
443
-
444
- function miniBar(pct: number): string {
445
- const width = 8;
446
- const clamped = Math.max(0, Math.min(100, pct));
447
- const filled = Math.round((clamped / 100) * width);
448
- const empty = width - filled;
449
- const color = pct > 100 ? chalk.red : pct > 80 ? chalk.yellow : chalk.green;
450
- return color("▓".repeat(filled)) + chalk.dim("░".repeat(empty));
451
- }
452
-
453
- function buildScore(db: Database.Database): string | null {
454
- const score = getLatestScore(db);
455
- if (!score) return null;
456
-
457
- let line = `SCORE: ${score.score}/100`;
458
-
459
- const streaks: string[] = [];
460
- if (score.no_restaurant_streak > 0) streaks.push(`${score.no_restaurant_streak}-day no-restaurant streak`);
461
- if (score.no_shopping_streak > 0) streaks.push(`${score.no_shopping_streak}-day no-shopping streak`);
462
- if (score.on_pace_streak > 0) streaks.push(`${score.on_pace_streak}-day on-pace streak`);
463
- if (streaks.length > 0) line += ` — ${streaks.join(", ")}`;
464
-
465
- // Recent achievements
466
- const achievements = db.prepare(
467
- `SELECT name FROM achievements ORDER BY unlocked_at DESC LIMIT 3`
468
- ).all() as { name: string }[];
469
- if (achievements.length > 0) {
470
- line += ` | Recent: ${achievements.map(a => a.name).join(", ")}`;
471
- }
472
-
473
- return line;
474
- }
package/src/ai/memory.ts DELETED
@@ -1,21 +0,0 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
2
-
3
- export function getConversationHistory(db: Database.Database, limit = 20): { role: string; content: string; created_at: string }[] {
4
- return db.prepare(
5
- `SELECT role, content, created_at FROM conversation_history ORDER BY id DESC LIMIT ?`
6
- ).all(limit).reverse() as any[];
7
- }
8
-
9
- export function saveMessage(db: Database.Database, role: "user" | "assistant", content: string): void {
10
- db.prepare(
11
- `INSERT INTO conversation_history (role, content) VALUES (?, ?)`
12
- ).run(role, content);
13
- }
14
-
15
- export function getMemories(db: Database.Database): { id: number; content: string; category: string; created_at: string }[] {
16
- return db.prepare(`SELECT id, content, category, created_at FROM memories ORDER BY created_at DESC`).all() as any[];
17
- }
18
-
19
- export function saveMemory(db: Database.Database, content: string, category = "general"): void {
20
- db.prepare(`INSERT INTO memories (content, category) VALUES (?, ?)`).run(content, category);
21
- }
@@ -1,102 +0,0 @@
1
- import { config } from "../config.js";
2
- import { readContext } from "./context.js";
3
-
4
- interface RedactionEntry {
5
- real: string;
6
- token: string;
7
- }
8
-
9
- /**
10
- * Builds a list of PII terms to redact from outbound API calls.
11
- * Extracts: user name, family/partner names, employer names from context.
12
- */
13
- function buildRedactions(): RedactionEntry[] {
14
- const entries: RedactionEntry[] = [];
15
- const seen = new Set<string>();
16
-
17
- function add(real: string, token: string) {
18
- const trimmed = real.trim();
19
- if (trimmed.length < 2 || seen.has(trimmed.toLowerCase())) return;
20
- seen.add(trimmed.toLowerCase());
21
- entries.push({ real: trimmed, token });
22
- }
23
-
24
- // User's name (and first name if multi-word)
25
- const userName = config.userName;
26
- if (userName && userName !== "User") {
27
- add(userName, "[USER]");
28
- const parts = userName.split(/\s+/);
29
- if (parts.length > 1) {
30
- add(parts[0], "[USER_FIRST]");
31
- add(parts[parts.length - 1], "[USER_LAST]");
32
- }
33
- }
34
-
35
- // Parse context.md for family and employer names
36
- const context = readContext();
37
- if (context) {
38
- // Extract names from ## Family section
39
- const familyMatch = context.match(/## Family\n([\s\S]*?)(?=\n##|$)/);
40
- if (familyMatch) {
41
- const lines = familyMatch[1].split("\n").filter(l => l.trim().startsWith("-"));
42
- for (const line of lines) {
43
- const text = line.replace(/^-\s*/, "").trim();
44
- // Skip the user's own name and placeholder lines
45
- if (!text || text.startsWith("(") || text.toLowerCase() === userName.toLowerCase()) continue;
46
- // Extract name — could be "Partner: Jane" or "Jane (wife)" or just "Jane Smith"
47
- const nameMatch = text.match(/^(?:partner|spouse|wife|husband|child|kid|son|daughter|dependent)[:\s]+(.+)/i)
48
- || text.match(/^([A-Z][a-z]+(?: [A-Z][a-z]+)*)/);
49
- if (nameMatch) {
50
- const name = nameMatch[1].replace(/\s*\(.*\)/, "").trim();
51
- if (name && name.toLowerCase() !== userName.toLowerCase()) {
52
- add(name, "[PARTNER]");
53
- const nameParts = name.split(/\s+/);
54
- if (nameParts.length > 1) {
55
- add(nameParts[0], "[PARTNER_FIRST]");
56
- }
57
- }
58
- }
59
- }
60
- }
61
-
62
- // Extract employer from ## Income section
63
- const incomeMatch = context.match(/## Income\n([\s\S]*?)(?=\n##|$)/);
64
- if (incomeMatch) {
65
- const lines = incomeMatch[1].split("\n").filter(l => l.trim().startsWith("-"));
66
- for (const line of lines) {
67
- const text = line.replace(/^-\s*/, "").trim();
68
- if (!text || text.startsWith("(")) continue;
69
- // Match patterns like "Employer: Acme Corp", "Salary from Acme Corp", "$85k/year from Acme Corp"
70
- const employerMatch = text.match(/(?:employer|works? (?:at|for)|employed (?:at|by))[:\s]+([A-Z][\w\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/i)
71
- || text.match(/\bfrom ([A-Z][A-Za-z\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/)
72
- || text.match(/\bat ([A-Z][A-Za-z\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/);
73
- if (employerMatch) {
74
- add(employerMatch[1].trim(), "[EMPLOYER]");
75
- }
76
- }
77
- }
78
- }
79
-
80
- // Sort longest first so "John Smith" is replaced before "John"
81
- entries.sort((a, b) => b.real.length - a.real.length);
82
- return entries;
83
- }
84
-
85
- export function redact(text: string): string {
86
- const redactions = buildRedactions();
87
- let result = text;
88
- for (const { real, token } of redactions) {
89
- result = result.replaceAll(real, token);
90
- }
91
- return result;
92
- }
93
-
94
- export function unredact(text: string): string {
95
- const redactions = buildRedactions();
96
- let result = text;
97
- // Reverse: replace tokens with real values (shortest tokens last to avoid partial matches)
98
- for (const { real, token } of redactions) {
99
- result = result.replaceAll(token, real);
100
- }
101
- return result;
102
- }
@@ -1,90 +0,0 @@
1
- import type Database from "better-sqlite3-multiple-ciphers";
2
- import { config } from "../config.js";
3
- import { getMemories } from "./memory.js";
4
- import { readContext, isContextEmpty } from "./context.js";
5
- import { computeInsights } from "./insights.js";
6
-
7
- export function buildSystemPrompt(db: Database.Database): string {
8
- const memories = getMemories(db);
9
- const now = new Date();
10
- const dateStr = now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" });
11
- const context = readContext();
12
- const name = config.userName;
13
-
14
- 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.
15
-
16
- Today is ${dateStr}.
17
-
18
- ## Personality
19
- - 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.
20
- - 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."
21
- - Be specific with numbers — always cite actual balances, amounts, and percentages from the data.
22
- - 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.
23
- - 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.
24
- - Be concise. 2-4 sentences for simple questions. Don't pad responses with filler.
25
- - Skip transitions and preamble. Don't start with "Great question!" or "Let me look into that." Just answer.
26
- - Be warm but direct. Celebrate wins genuinely. Flag problems without sugarcoating.
27
-
28
- ## Approach
29
- 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.
30
- 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.
31
- 3. When comparing periods, use percentage changes and absolute differences.
32
- 4. End with what to do, not just what happened. A good CFO always has a next step.
33
-
34
- ## Formatting (terminal output)
35
- - Plain text only. No markdown syntax — no asterisks, no hashtags, no backticks.
36
- - Use line breaks, dashes, and simple alignment for structure.
37
- - Use ALL CAPS or dashes for emphasis instead of bold/italic.
38
-
39
- ## Tools
40
- - Always use tools to look up current data. Never guess balances, spending, or dates.
41
- - When the user shares something worth remembering (a preference, life event, financial goal context), use save_memory.
42
- - When circumstances change (new decisions, completed goals, changed balances, updated strategy), use update_context to persist the change.
43
- - For date-based queries, figure out the right date range from context (e.g., "this month" = first of current month to today).
44
-
45
- ## Privacy
46
- - Never reveal account numbers, routing numbers, or Plaid access tokens.
47
- - You can discuss balances, transactions, and spending freely — that's what you're here for.`;
48
-
49
- if (isContextEmpty()) {
50
- prompt += `\n\n## Onboarding Mode
51
- ${name} just connected their financial accounts and needs help setting up their financial profile. This is your first conversation.
52
-
53
- Instructions:
54
- 1. Start by calling these tools to review their synced data: get_accounts, get_transactions (last 30 days), get_spending_summary, get_debts
55
- 2. Present a concise summary of what you found — accounts, recent spending patterns, any debts
56
- 3. Then ask about gaps ONE TOPIC AT A TIME (not all at once). Topics to cover:
57
- - Family situation (partner, dependents)
58
- - Income details (salary, side income, frequency)
59
- - Financial goals (short-term and long-term)
60
- - Current challenges or concerns
61
- - Budget targets or spending limits they want
62
- - Any upcoming life changes (job change, move, baby, etc.)
63
- 4. After each answer, call update_context with the "section" param to save that section of their context
64
- 5. Also use save_memory for notable individual facts
65
- 6. Keep it to 1-2 questions per turn — be conversational, not interrogative
66
- 7. After gathering enough info, write a Strategy section summarizing priorities and next steps
67
- 8. If the user says "skip" or changes topic, gracefully stop onboarding and help with whatever they need
68
-
69
- This onboarding block will automatically disappear once the context file is filled in.`;
70
- } else if (context) {
71
- prompt += `\n\n## ${name}'s Financial Context\n${context}`;
72
- }
73
-
74
- // Financial intelligence briefing — computed insights injected before memories
75
- try {
76
- const insights = computeInsights(db);
77
- if (insights) {
78
- prompt += `\n\n${insights}`;
79
- }
80
- } catch {
81
- // Don't let insight computation failure break the conversation
82
- }
83
-
84
- if (memories.length > 0) {
85
- prompt += `\n\n## Things I remember about ${name}\n`;
86
- prompt += memories.map(m => `- ${m.content}`).join("\n");
87
- }
88
-
89
- return prompt;
90
- }