ray-finance 0.2.2 → 0.2.3

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 (62) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/.github/ray-logo.png +0 -0
  3. package/.github/workflows/ci.yml +1 -0
  4. package/Dockerfile +2 -2
  5. package/README.md +31 -10
  6. package/SECURITY.md +1 -1
  7. package/dist/ai/agent.js +16 -3
  8. package/dist/ai/context.js +6 -2
  9. package/dist/ai/insights.js +26 -3
  10. package/dist/ai/redactor.js +11 -0
  11. package/dist/ai/system-prompt.js +2 -2
  12. package/dist/ai/tools.js +4 -0
  13. package/dist/cli/backup.js +18 -9
  14. package/dist/cli/chat.js +146 -40
  15. package/dist/cli/format.d.ts +2 -0
  16. package/dist/cli/format.js +25 -0
  17. package/dist/cli/index.js +12 -2
  18. package/dist/cli/setup.js +7 -1
  19. package/dist/daily-sync.js +19 -4
  20. package/dist/db/connection.js +9 -1
  21. package/dist/db/encryption.js +18 -7
  22. package/dist/db/schema.js +6 -1
  23. package/dist/public/favicon.png +0 -0
  24. package/dist/public/link.html +47 -24
  25. package/dist/public/ray-logo-dark.png +0 -0
  26. package/dist/queries/index.js +8 -8
  27. package/dist/server.js +33 -1
  28. package/package.json +4 -2
  29. package/site/package-lock.json +43 -0
  30. package/site/package.json +1 -0
  31. package/site/public/ray-logo-dark.png +0 -0
  32. package/site/public/ray-logo-light.png +0 -0
  33. package/site/src/app/copy-command.tsx +1 -3
  34. package/site/src/app/layout.tsx +2 -1
  35. package/src/ai/agent.ts +15 -3
  36. package/src/ai/context.ts +3 -2
  37. package/src/ai/insights.ts +25 -3
  38. package/src/ai/redactor.test.ts +63 -0
  39. package/src/ai/redactor.ts +12 -0
  40. package/src/ai/system-prompt.ts +2 -2
  41. package/src/ai/tools.ts +4 -0
  42. package/src/cli/backup.ts +23 -10
  43. package/src/cli/chat.ts +155 -41
  44. package/src/cli/format.ts +31 -0
  45. package/src/cli/index.ts +12 -2
  46. package/src/cli/setup.ts +6 -1
  47. package/src/daily-sync.test.ts +150 -0
  48. package/src/daily-sync.ts +19 -4
  49. package/src/db/connection.ts +12 -1
  50. package/src/db/encryption.test.ts +86 -0
  51. package/src/db/encryption.ts +17 -7
  52. package/src/db/schema.test.ts +53 -0
  53. package/src/db/schema.ts +7 -1
  54. package/src/public/favicon.png +0 -0
  55. package/src/public/link.html +47 -24
  56. package/src/public/ray-logo-dark.png +0 -0
  57. package/src/queries/index.test.ts +397 -0
  58. package/src/queries/index.ts +8 -8
  59. package/src/server.ts +37 -1
  60. package/tsconfig.json +1 -1
  61. package/vitest.config.ts +7 -0
  62. package/SPEC.md +0 -374
@@ -1,5 +1,7 @@
1
1
  import { syncTransactions, syncBalances, syncInvestments, syncLiabilities, } from "./plaid/sync.js";
2
2
  import { calculateDailyScore, checkAchievements } from "./scoring/index.js";
3
+ import { decryptPlaidToken } from "./db/encryption.js";
4
+ import { config } from "./config.js";
3
5
  /** Run the daily sync for a single database */
4
6
  export async function runDailySync(db) {
5
7
  const institutions = db
@@ -14,25 +16,38 @@ export async function runDailySync(db) {
14
16
  console.log(`Skipping ${inst.name} (manual entry)`);
15
17
  continue;
16
18
  }
19
+ // Decrypt the stored access token
20
+ let accessToken;
21
+ try {
22
+ if (!config.plaidTokenSecret) {
23
+ console.error(` Skipping ${inst.name}: no plaidTokenSecret configured`);
24
+ continue;
25
+ }
26
+ accessToken = decryptPlaidToken(inst.access_token, config.plaidTokenSecret);
27
+ }
28
+ catch {
29
+ console.error(` Skipping ${inst.name}: failed to decrypt access token (wrong key or corrupt data)`);
30
+ continue;
31
+ }
17
32
  const products = JSON.parse(inst.products);
18
33
  console.log(`Syncing: ${inst.name} (${products.join(", ")})`);
19
34
  try {
20
35
  // Always sync balances
21
- const accountCount = await syncBalances(db, inst.access_token);
36
+ const accountCount = await syncBalances(db, accessToken);
22
37
  console.log(` Accounts: ${accountCount}`);
23
38
  // Sync transactions if available
24
39
  if (products.includes("transactions")) {
25
- const txResult = await syncTransactions(db, inst.item_id, inst.access_token, inst.cursor);
40
+ const txResult = await syncTransactions(db, inst.item_id, accessToken, inst.cursor);
26
41
  console.log(` Transactions: +${txResult.added} ~${txResult.modified} -${txResult.removed}`);
27
42
  }
28
43
  // Sync investments if available
29
44
  if (products.includes("investments")) {
30
- const invResult = await syncInvestments(db, inst.access_token);
45
+ const invResult = await syncInvestments(db, accessToken);
31
46
  console.log(` Investments: ${invResult.holdings} holdings, ${invResult.securities} securities`);
32
47
  }
33
48
  // Sync liabilities if available
34
49
  if (products.includes("liabilities")) {
35
- await syncLiabilities(db, inst.access_token);
50
+ await syncLiabilities(db, accessToken);
36
51
  console.log(` Liabilities: synced`);
37
52
  }
38
53
  }
@@ -14,7 +14,15 @@ function openDb(dbPath, encryptionKey) {
14
14
  const hexKey = Buffer.from(encryptionKey, "utf8").toString("hex");
15
15
  db.pragma(`key="x'${hexKey}'"`);
16
16
  }
17
- db.pragma("journal_mode = WAL");
17
+ // Verify the key works before proceeding
18
+ try {
19
+ db.pragma("journal_mode = WAL");
20
+ }
21
+ catch (err) {
22
+ db.close();
23
+ throw new Error("Failed to open database. Wrong encryption key or corrupt database file. " +
24
+ "If you changed your encryption key, restore from backup or delete ~/.ray/data/finance.db to start fresh.");
25
+ }
18
26
  db.pragma("foreign_keys = ON");
19
27
  migrate(db);
20
28
  try {
@@ -1,23 +1,34 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
2
- const SCRYPT_SALT = "ray-finance-plaid-token"; // Static salt is fine — secret is already high-entropy
3
2
  const SCRYPT_KEYLEN = 32;
3
+ const SALT_LEN = 16;
4
4
  export function generateKey() {
5
5
  return randomBytes(32).toString("hex");
6
6
  }
7
- function deriveKey(secret) {
8
- return scryptSync(secret, SCRYPT_SALT, SCRYPT_KEYLEN);
7
+ function deriveKey(secret, salt) {
8
+ return scryptSync(secret, salt, SCRYPT_KEYLEN);
9
9
  }
10
10
  export function encryptPlaidToken(token, secret) {
11
- const key = deriveKey(secret);
11
+ const salt = randomBytes(SALT_LEN);
12
+ const key = deriveKey(secret, salt);
12
13
  const iv = randomBytes(16);
13
14
  const cipher = createCipheriv("aes-256-gcm", key, iv);
14
15
  const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
15
16
  const authTag = cipher.getAuthTag();
16
- return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
17
+ return `${salt.toString("hex")}:${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
17
18
  }
18
19
  export function decryptPlaidToken(encrypted, secret) {
19
- const [ivHex, authTagHex, dataHex] = encrypted.split(":");
20
- const key = deriveKey(secret);
20
+ const parts = encrypted.split(":");
21
+ // Support legacy 3-part format (static salt) and new 4-part format (random salt)
22
+ let salt, ivHex, authTagHex, dataHex;
23
+ if (parts.length === 3) {
24
+ salt = Buffer.from("ray-finance-plaid-token", "utf8");
25
+ [ivHex, authTagHex, dataHex] = parts;
26
+ }
27
+ else {
28
+ [, ivHex, authTagHex, dataHex] = parts;
29
+ salt = Buffer.from(parts[0], "hex");
30
+ }
31
+ const key = deriveKey(secret, salt);
21
32
  const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
22
33
  decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
23
34
  return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8");
package/dist/db/schema.js CHANGED
@@ -100,7 +100,7 @@ export function migrate(db) {
100
100
  name TEXT NOT NULL,
101
101
  target_amount REAL NOT NULL,
102
102
  current_amount REAL DEFAULT 0,
103
- deadline TEXT,
103
+ target_date TEXT,
104
104
  status TEXT DEFAULT 'active'
105
105
  );
106
106
 
@@ -191,4 +191,9 @@ export function migrate(db) {
191
191
  created_at TEXT DEFAULT (datetime('now'))
192
192
  );
193
193
  `);
194
+ // Migrate: rename goals.deadline -> target_date for existing databases
195
+ const goalCols = db.prepare(`PRAGMA table_info(goals)`).all();
196
+ if (goalCols.some(c => c.name === "deadline") && !goalCols.some(c => c.name === "target_date")) {
197
+ db.exec(`ALTER TABLE goals RENAME COLUMN deadline TO target_date`);
198
+ }
194
199
  }
Binary file
@@ -2,18 +2,15 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <link rel="preconnect" href="https://fonts.googleapis.com">
6
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
7
- <link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@700&display=swap" rel="stylesheet">
8
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
9
- <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='40' fill='%231a1a1a'/></svg>">
6
+ <link rel="icon" href="/favicon.png">
10
7
  <title>Ray — Connect Account</title>
11
8
  <style>
12
- * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ * { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
13
10
  body {
14
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
- background: #fafafa;
16
- color: #1a1a1a;
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
12
+ background: #fafaf9;
13
+ color: #1c1917;
17
14
  display: flex;
18
15
  align-items: center;
19
16
  justify-content: center;
@@ -22,36 +19,37 @@
22
19
  }
23
20
  .card {
24
21
  background: #fff;
25
- border-radius: 12px;
26
- padding: 40px 32px;
22
+ border: 1px solid rgba(214, 211, 209, 0.6);
23
+ border-radius: 16px;
24
+ padding: 48px 40px;
27
25
  max-width: 400px;
28
26
  width: 100%;
29
27
  text-align: center;
30
- box-shadow: 0 1px 3px rgba(0,0,0,0.08);
31
28
  }
32
- h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
33
- p { font-size: 15px; color: #666; margin-bottom: 24px; line-height: 1.5; }
29
+ .logo { margin-bottom: 32px; }
30
+ .logo img { height: 24px; }
31
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: #1c1917; }
32
+ p { font-size: 15px; color: #78716c; margin-bottom: 24px; line-height: 1.6; }
34
33
  button {
35
- background: #1a1a1a;
34
+ background: #1c1917;
36
35
  color: #fff;
37
36
  border: none;
38
37
  padding: 14px 32px;
39
38
  border-radius: 9999px;
40
- font-size: 16px;
39
+ font-size: 15px;
41
40
  font-weight: 500;
42
41
  cursor: pointer;
43
42
  width: 100%;
44
43
  transition: background 0.2s;
45
44
  }
46
- button:hover { background: #333; }
47
- button:disabled { background: #ccc; cursor: not-allowed; }
48
- .success { color: #28a745; font-weight: 600; }
45
+ button:hover { background: #292524; }
46
+ button:disabled { background: #d6d3d1; cursor: not-allowed; }
47
+ .success { color: #6ab318; font-weight: 600; }
49
48
  .error { color: #dc3545; font-size: 14px; margin-top: 16px; }
50
- .logo { font-size: 28px; font-weight: 700; margin-bottom: 24px; color: #1a1a1a; font-family: 'Geist Mono', monospace; }
51
49
  .checkmark {
52
50
  width: 48px;
53
51
  height: 48px;
54
- border: 2.5px solid #28a745;
52
+ border: 2.5px solid #6ab318;
55
53
  border-radius: 50%;
56
54
  display: flex;
57
55
  align-items: center;
@@ -61,7 +59,7 @@
61
59
  .checkmark svg {
62
60
  width: 24px;
63
61
  height: 24px;
64
- stroke: #28a745;
62
+ stroke: #6ab318;
65
63
  fill: none;
66
64
  stroke-width: 2.5;
67
65
  stroke-linecap: round;
@@ -74,12 +72,24 @@
74
72
  object-fit: contain;
75
73
  margin-bottom: 16px;
76
74
  }
75
+ .spinner {
76
+ display: inline-block;
77
+ width: 20px;
78
+ height: 20px;
79
+ border: 2px solid #d6d3d1;
80
+ border-top-color: #78716c;
81
+ border-radius: 50%;
82
+ animation: spin 0.6s linear infinite;
83
+ margin-bottom: 16px;
84
+ }
85
+ @keyframes spin { to { transform: rotate(360deg); } }
77
86
  </style>
78
87
  </head>
79
88
  <body>
80
89
  <div class="card">
81
- <div class="logo">RAY</div>
90
+ <div class="logo"><img src="/ray-logo-dark.png" alt="Ray"></div>
82
91
  <div id="connect-view">
92
+ <div class="spinner"></div>
83
93
  <p>Opening Plaid...</p>
84
94
  <div id="error" class="error" style="display:none"></div>
85
95
  </div>
@@ -97,13 +107,26 @@
97
107
  let linkHandler = null;
98
108
 
99
109
  async function initPlaid() {
110
+ // Wait for Plaid SDK to load
111
+ if (typeof Plaid === 'undefined') {
112
+ let attempts = 0;
113
+ await new Promise((resolve, reject) => {
114
+ const check = setInterval(() => {
115
+ if (typeof Plaid !== 'undefined') { clearInterval(check); resolve(); }
116
+ else if (++attempts > 50) { clearInterval(check); reject(new Error('Failed to load Plaid SDK. Check your ad blocker or network connection.')); }
117
+ }, 100);
118
+ });
119
+ }
100
120
  try {
101
121
  const res = await fetch('/api/link-token', {
102
122
  method: 'POST',
103
123
  headers: { 'Content-Type': 'application/json' },
104
124
  body: JSON.stringify({ session_id: sessionId }),
105
125
  });
106
- if (!res.ok) throw new Error('Session expired');
126
+ if (!res.ok) {
127
+ const data = await res.json().catch(() => ({}));
128
+ throw new Error(data.error || 'Failed to connect. Please run "ray link" again.');
129
+ }
107
130
  const { link_token } = await res.json();
108
131
 
109
132
  linkHandler = Plaid.create({
@@ -145,7 +168,7 @@
145
168
  // Auto-open Plaid Link
146
169
  linkHandler.open();
147
170
  } catch (e) {
148
- showError('This link has expired. Please run "ray link" again.');
171
+ showError(e.message || 'This link has expired. Please run "ray link" again.');
149
172
  }
150
173
  }
151
174
 
Binary file
@@ -263,13 +263,13 @@ export function forecastBalance(db, accountId, months = 6) {
263
263
  }
264
264
  // --- Portfolio ---
265
265
  export function getPortfolio(db) {
266
- const rows = db.prepare(`SELECT a.name as account, s.name as security, s.ticker_symbol as ticker,
267
- h.quantity, h.institution_value as value, h.cost_basis,
268
- (h.institution_value - COALESCE(h.cost_basis, h.institution_value)) as gain_loss
266
+ const rows = db.prepare(`SELECT a.name as account, s.name as security, s.ticker,
267
+ h.quantity, h.value, h.cost_basis,
268
+ (h.value - COALESCE(h.cost_basis, h.value)) as gain_loss
269
269
  FROM holdings h
270
270
  JOIN accounts a ON h.account_id = a.account_id
271
271
  LEFT JOIN securities s ON h.security_id = s.security_id
272
- ORDER BY h.institution_value DESC`).all();
272
+ ORDER BY h.value DESC`).all();
273
273
  const totalValue = rows.reduce((s, r) => s + (r.value || 0), 0);
274
274
  const totalCostBasis = rows.reduce((s, r) => s + (r.cost_basis || 0), 0);
275
275
  return {
@@ -289,12 +289,12 @@ export function getPortfolio(db) {
289
289
  }
290
290
  // --- Investment performance ---
291
291
  export function getInvestmentPerformance(db) {
292
- const rows = db.prepare(`SELECT s.name as security, s.ticker_symbol as ticker,
293
- h.institution_value as value, h.cost_basis
292
+ const rows = db.prepare(`SELECT s.name as security, s.ticker,
293
+ h.value, h.cost_basis
294
294
  FROM holdings h
295
295
  LEFT JOIN securities s ON h.security_id = s.security_id
296
- WHERE h.institution_value IS NOT NULL
297
- ORDER BY h.institution_value DESC`).all();
296
+ WHERE h.value IS NOT NULL
297
+ ORDER BY h.value DESC`).all();
298
298
  const totalValue = rows.reduce((s, r) => s + (r.value || 0), 0);
299
299
  const totalCost = rows.reduce((s, r) => s + (r.cost_basis || r.value || 0), 0);
300
300
  const totalReturn = totalValue - totalCost;
package/dist/server.js CHANGED
@@ -13,9 +13,41 @@ const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = dirname(__filename);
14
14
  // Session map for Plaid Link — maps sessionId → true (single-user, no chatId needed)
15
15
  const linkSessions = new Map();
16
+ // Simple rate limiter: track request counts per IP
17
+ const rateLimits = new Map();
18
+ const RATE_LIMIT_WINDOW = 60_000; // 1 minute
19
+ const RATE_LIMIT_MAX = 10; // max requests per window
20
+ function isRateLimited(ip) {
21
+ const now = Date.now();
22
+ const entry = rateLimits.get(ip);
23
+ if (!entry || now > entry.resetAt) {
24
+ rateLimits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
25
+ return false;
26
+ }
27
+ entry.count++;
28
+ return entry.count > RATE_LIMIT_MAX;
29
+ }
16
30
  export function startLinkServer() {
17
31
  const app = express();
18
32
  app.use(express.json());
33
+ // Only allow requests from localhost origin
34
+ app.use((req, res, next) => {
35
+ const origin = req.headers.origin || req.headers.referer || "";
36
+ const ip = req.ip || req.socket.remoteAddress || "";
37
+ if (req.path.startsWith("/api/")) {
38
+ // Check origin for API routes
39
+ if (origin && !origin.startsWith(`http://localhost:${config.port}`) && !origin.startsWith(`http://127.0.0.1:${config.port}`)) {
40
+ res.status(403).json({ error: "Forbidden" });
41
+ return;
42
+ }
43
+ // Rate limit API routes
44
+ if (isRateLimited(ip)) {
45
+ res.status(429).json({ error: "Too many requests" });
46
+ return;
47
+ }
48
+ }
49
+ next();
50
+ });
19
51
  app.use(express.static(resolve(__dirname, "public")));
20
52
  const sessionId = randomUUID();
21
53
  linkSessions.set(sessionId, true);
@@ -120,7 +152,7 @@ export function startLinkServer() {
120
152
  res.status(500).json({ error: "Failed to link account" });
121
153
  }
122
154
  });
123
- const server = app.listen(config.port);
155
+ const server = app.listen(config.port, "127.0.0.1");
124
156
  const url = `http://localhost:${config.port}/link/${sessionId}`;
125
157
  // Auto-expire after 30 minutes
126
158
  const timeout = setTimeout(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ray-finance",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Local-first CLI that turns your bank data into a personal AI financial advisor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc && rm -rf dist/public && cp -r src/public dist/public",
33
+ "test": "vitest run",
33
34
  "prepublishOnly": "npm run build",
34
35
  "sync": "tsx src/daily-sync.ts"
35
36
  },
@@ -50,6 +51,7 @@
50
51
  "@types/express": "^4.17.0",
51
52
  "@types/node": "^22.0.0",
52
53
  "tsx": "^4.7.0",
53
- "typescript": "^5.5.0"
54
+ "typescript": "^5.5.0",
55
+ "vitest": "^3.2.4"
54
56
  }
55
57
  }
@@ -8,6 +8,7 @@
8
8
  "name": "ray-finance-site",
9
9
  "version": "0.1.0",
10
10
  "dependencies": {
11
+ "@vercel/analytics": "^2.0.1",
11
12
  "geist": "^1.7.0",
12
13
  "next": "^15.1.0",
13
14
  "react": "^19.0.0",
@@ -1005,6 +1006,48 @@
1005
1006
  "@types/react": "^19.2.0"
1006
1007
  }
1007
1008
  },
1009
+ "node_modules/@vercel/analytics": {
1010
+ "version": "2.0.1",
1011
+ "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
1012
+ "integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
1013
+ "license": "MIT",
1014
+ "peerDependencies": {
1015
+ "@remix-run/react": "^2",
1016
+ "@sveltejs/kit": "^1 || ^2",
1017
+ "next": ">= 13",
1018
+ "nuxt": ">= 3",
1019
+ "react": "^18 || ^19 || ^19.0.0-rc",
1020
+ "svelte": ">= 4",
1021
+ "vue": "^3",
1022
+ "vue-router": "^4"
1023
+ },
1024
+ "peerDependenciesMeta": {
1025
+ "@remix-run/react": {
1026
+ "optional": true
1027
+ },
1028
+ "@sveltejs/kit": {
1029
+ "optional": true
1030
+ },
1031
+ "next": {
1032
+ "optional": true
1033
+ },
1034
+ "nuxt": {
1035
+ "optional": true
1036
+ },
1037
+ "react": {
1038
+ "optional": true
1039
+ },
1040
+ "svelte": {
1041
+ "optional": true
1042
+ },
1043
+ "vue": {
1044
+ "optional": true
1045
+ },
1046
+ "vue-router": {
1047
+ "optional": true
1048
+ }
1049
+ }
1050
+ },
1008
1051
  "node_modules/caniuse-lite": {
1009
1052
  "version": "1.0.30001777",
1010
1053
  "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
package/site/package.json CHANGED
@@ -8,6 +8,7 @@
8
8
  "start": "next start"
9
9
  },
10
10
  "dependencies": {
11
+ "@vercel/analytics": "^2.0.1",
11
12
  "geist": "^1.7.0",
12
13
  "next": "^15.1.0",
13
14
  "react": "^19.0.0",
Binary file
Binary file
@@ -23,9 +23,7 @@ export function CopyCommand({
23
23
  >
24
24
  <span className="text-stone-400 font-mono">$</span>{" "}
25
25
  <span className="font-mono">{command}</span>
26
- <span className="ml-0 max-w-0 overflow-hidden whitespace-nowrap text-xs text-stone-400 transition-all duration-300 ease-in-out group-hover:ml-2 group-hover:max-w-[6rem]">
27
- {copied ? "✓ copied" : "click to copy"}
28
- </span>
26
+ {copied && <span className="ml-2 text-xs text-stone-400">✓ copied</span>}
29
27
  </button>
30
28
  );
31
29
  }
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
2
2
  import { GeistSans } from "geist/font/sans";
3
3
  import { GeistMono } from "geist/font/mono";
4
4
  import { GeistPixelSquare } from "geist/font/pixel";
5
+ import { Analytics } from "@vercel/analytics/react";
5
6
  import "./globals.css";
6
7
 
7
8
  export const metadata: Metadata = {
@@ -58,7 +59,7 @@ export default function RootLayout({
58
59
  }) {
59
60
  return (
60
61
  <html lang="en" className={`${GeistSans.variable} ${GeistMono.variable} ${GeistPixelSquare.variable}`} style={{ colorScheme: "light" }}>
61
- <body className="bg-stone-50 text-stone-900 font-sans">{children}</body>
62
+ <body className="bg-stone-50 text-stone-900 font-sans">{children}<Analytics /></body>
62
63
  </html>
63
64
  );
64
65
  }
package/src/ai/agent.ts CHANGED
@@ -24,8 +24,16 @@ export async function handleMessage(
24
24
  // Save incoming message
25
25
  saveMessage(db, "user", userMessage);
26
26
 
27
- // Load conversation context
28
- const history = getConversationHistory(db, 20);
27
+ // Load conversation context, truncated to fit token budget
28
+ const rawHistory = getConversationHistory(db, 30);
29
+ const MAX_HISTORY_CHARS = 24_000; // ~6k tokens, leaves room for system prompt + response
30
+ let historyChars = 0;
31
+ const history = [];
32
+ for (let i = rawHistory.length - 1; i >= 0; i--) {
33
+ historyChars += rawHistory[i].content.length;
34
+ if (historyChars > MAX_HISTORY_CHARS) break;
35
+ history.unshift(rawHistory[i]);
36
+ }
29
37
 
30
38
  // Build system prompt and redact PII before sending to API
31
39
  const systemPrompt = redact(buildSystemPrompt(db));
@@ -100,7 +108,11 @@ export async function handleMessage(
100
108
 
101
109
  return responseText || "I looked into that but couldn't formulate a response. Could you try rephrasing?";
102
110
  } catch (error: any) {
103
- console.error("AI agent error:", error.message);
111
+ // Log full error internally but don't expose details to user
112
+ const safeMessage = error.status
113
+ ? `API error (${error.status})`
114
+ : "internal error";
115
+ console.error("AI agent error:", safeMessage);
104
116
  return "Sorry, I had trouble processing that. Could you try again?";
105
117
  }
106
118
  }
package/src/ai/context.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
2
2
  import { resolve } from "path";
3
3
  import { homedir } from "os";
4
4
 
@@ -20,7 +20,8 @@ export function readContext(): string {
20
20
  export function writeContext(content: string): void {
21
21
  const dir = resolve(homedir(), ".ray");
22
22
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
23
- writeFileSync(CONTEXT_PATH, content, "utf-8");
23
+ writeFileSync(CONTEXT_PATH, content, { encoding: "utf-8", mode: 0o600 });
24
+ try { chmodSync(CONTEXT_PATH, 0o600); } catch {}
24
25
  }
25
26
 
26
27
  export function isContextEmpty(): boolean {
@@ -318,7 +318,8 @@ export function cliBriefing(db: Database.Database): string | null {
318
318
 
319
319
  // Net worth headline
320
320
  const nw = getNetWorth(db);
321
- let nwLine = chalk.white(` ${fmtMoney(nw.net_worth)}`);
321
+ const nwStr = nw.net_worth < 0 ? `-${fmtMoney(nw.net_worth)}` : fmtMoney(nw.net_worth);
322
+ let nwLine = chalk.white(` ${nwStr}`);
322
323
  if (nw.prev_net_worth !== null) {
323
324
  const change = nw.net_worth - nw.prev_net_worth;
324
325
  nwLine += change >= 0
@@ -326,6 +327,16 @@ export function cliBriefing(db: Database.Database): string | null {
326
327
  : chalk.red(` -${fmtMoney(Math.abs(change))}`);
327
328
  }
328
329
  lines.push(chalk.dim(" net worth") + nwLine);
330
+
331
+ // Account balances
332
+ const accounts = getAccountBalances(db);
333
+ if (accounts.length > 0) {
334
+ const acctStrs = accounts.slice(0, 5).map(a => {
335
+ const bal = a.type === "credit" ? `-${fmtMoney(a.balance)}` : fmtMoney(a.balance);
336
+ return `${chalk.dim(a.name.toLowerCase())} ${chalk.white(bal)}`;
337
+ });
338
+ lines.push(" " + acctStrs.join(chalk.dim(" · ")));
339
+ }
329
340
  lines.push("");
330
341
 
331
342
  // Spending vs last month
@@ -363,7 +374,7 @@ export function cliBriefing(db: Database.Database): string | null {
363
374
  .slice(0, 4);
364
375
  if (movers.length > 0) {
365
376
  const moverStrs = movers.map(m => {
366
- const label = categoryLabel(m.category);
377
+ const label = categoryLabel(m.category).toLowerCase();
367
378
  const color = m.diff <= 0 ? chalk.green : chalk.red;
368
379
  const sign = m.diff <= 0 ? "-" : "+";
369
380
  return `${chalk.dim(label)} ${color(`${sign}${fmtMoney(Math.abs(m.diff))}`)}`;
@@ -384,7 +395,7 @@ export function cliBriefing(db: Database.Database): string | null {
384
395
  const pct = Math.round(b.pct_used);
385
396
  const color = b.over_budget ? chalk.red : chalk.yellow;
386
397
  const bar = miniBar(b.pct_used);
387
- lines.push(` ${bar} ${color(categoryLabel(b.category))} ${chalk.dim(`${pct}%`)}`);
398
+ lines.push(` ${bar} ${color(categoryLabel(b.category).toLowerCase())} ${chalk.dim(`${pct}%`)}`);
388
399
  }
389
400
  }
390
401
 
@@ -472,3 +483,14 @@ function buildScore(db: Database.Database): string | null {
472
483
 
473
484
  return line;
474
485
  }
486
+
487
+ function timeAgo(past: Date, now: Date): string {
488
+ const diffMs = now.getTime() - past.getTime();
489
+ const mins = Math.floor(diffMs / 60000);
490
+ if (mins < 1) return "just now";
491
+ if (mins < 60) return `${mins}m ago`;
492
+ const hours = Math.floor(mins / 60);
493
+ if (hours < 24) return `${hours}h ago`;
494
+ const days = Math.floor(hours / 24);
495
+ return `${days}d ago`;
496
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../config.js", () => ({
4
+ config: { userName: "John Smith" },
5
+ }));
6
+
7
+ vi.mock("./context.js", () => ({
8
+ readContext: vi.fn().mockReturnValue(
9
+ `## Family
10
+ - Partner: Jane Doe (wife)
11
+ - Max (son)
12
+
13
+ ## Income
14
+ - $120k/year from Acme Corp
15
+
16
+ ## Accounts
17
+ - Checking at Chase
18
+ `
19
+ ),
20
+ }));
21
+
22
+ import { redact, unredact } from "./redactor.js";
23
+
24
+ describe("redact", () => {
25
+ it("redacts user full name", () => {
26
+ expect(redact("John Smith earned $100")).toBe("[USER] earned $100");
27
+ });
28
+
29
+ it("redacts user first and last name separately", () => {
30
+ expect(redact("Hi John, Mr. Smith")).toBe("Hi [USER_FIRST], Mr. [USER_LAST]");
31
+ });
32
+
33
+ it("redacts partner name", () => {
34
+ expect(redact("Jane Doe said hello")).toBe("[PARTNER] said hello");
35
+ });
36
+
37
+ it("redacts employer", () => {
38
+ expect(redact("Works at Acme Corp")).toBe("Works at [EMPLOYER]");
39
+ });
40
+
41
+ it("redacts SSN with dashes", () => {
42
+ expect(redact("SSN: 123-45-6789")).toBe("SSN: [SSN]");
43
+ });
44
+
45
+ it("redacts credit card numbers", () => {
46
+ expect(redact("Card: 4111 1111 1111 1111")).toBe("Card: [CARD]");
47
+ });
48
+
49
+ it("leaves text without PII unchanged", () => {
50
+ const text = "The weather is nice today";
51
+ expect(redact(text)).toBe(text);
52
+ });
53
+ });
54
+
55
+ describe("unredact", () => {
56
+ it("restores user name tokens", () => {
57
+ expect(unredact("Hello [USER]")).toBe("Hello John Smith");
58
+ });
59
+
60
+ it("restores partner tokens", () => {
61
+ expect(unredact("[PARTNER] called")).toBe("Jane Doe called");
62
+ });
63
+ });