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
package/README.md CHANGED
@@ -1,6 +1,18 @@
1
- # Ray
1
+ <p align="center">
2
+ <img src=".github/ray-logo.png" alt="Ray" width="120" />
3
+ </p>
2
4
 
3
- An open-source CLI that connects to your bank and already knows your finances before you ask.
5
+ <p align="center">
6
+ An open-source CLI that connects to your bank and already knows your finances before you ask.
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/ray-finance"><img src="https://img.shields.io/npm/v/ray-finance.svg" alt="npm version" /></a>
11
+ <a href="https://github.com/cdinnison/ray-finance/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License" /></a>
12
+ <a href="https://github.com/cdinnison/ray-finance/stargazers"><img src="https://img.shields.io/github/stars/cdinnison/ray-finance.svg?style=social" alt="GitHub stars" /></a>
13
+ </p>
14
+
15
+ <br />
4
16
 
5
17
  ```
6
18
  Friday, Mar 28
@@ -47,10 +59,16 @@ Open Ray and it shows your net worth, spending vs last month, budget pacing, and
47
59
  - **Scheduled daily sync** — Automatic bank sync via launchd (macOS) or cron (Linux)
48
60
  - **Export/import** — Back up and restore your financial data
49
61
 
62
+ ## Install
63
+
64
+ ```bash
65
+ npm install -g ray-finance
66
+ ```
67
+
50
68
  ## Quick Start
51
69
 
52
70
  ```bash
53
- npx ray-finance setup
71
+ ray setup
54
72
  ```
55
73
 
56
74
  The setup wizard offers two modes:
@@ -61,7 +79,7 @@ We handle the API keys. Your data stays local. $10/mo.
61
79
 
62
80
  1. Enter your name
63
81
  2. Get a Ray API key (opens Stripe checkout)
64
- 3. Connect your bank
82
+ 3. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
65
83
  4. Done — daily sync auto-scheduled at 6am
66
84
 
67
85
  ### Self-hosted
@@ -70,11 +88,13 @@ Bring your own Anthropic and Plaid credentials. Free forever.
70
88
 
71
89
  1. Enter your Anthropic API key ([get one](https://console.anthropic.com))
72
90
  2. Enter your Plaid credentials ([get free keys](https://dashboard.plaid.com/signup))
73
- 3. Connect your bank
91
+ 3. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
74
92
  4. Done
75
93
 
76
94
  ## Commands
77
95
 
96
+ Run `ray --help` to see all available commands.
97
+
78
98
  | Command | Description |
79
99
  |---------|-------------|
80
100
  | `ray` | Interactive AI chat with your financial advisor |
@@ -105,14 +125,13 @@ Bring your own Anthropic and Plaid credentials. Free forever.
105
125
  └──────────┬──────────┘
106
126
 
107
127
  ┌──────────▼──────────┐
108
- │ ray CLI
128
+ │ ray CLI
109
129
  │ insights · tools │
110
130
  │ scoring · alerts │
111
131
  └──────────┬──────────┘
112
132
 
113
- ┌─────────┴─────────┐
114
- │ │
115
- You (terminal) Claude API (PII-masked)
133
+ Claude API
134
+ (PII-masked)
116
135
  ```
117
136
 
118
137
  Two outbound calls: Plaid (bank sync) and Anthropic (AI chat, PII-masked). Your financial data is never stored off your machine. No telemetry. No analytics.
@@ -153,7 +172,13 @@ PLAID_TOKEN_SECRET= # Key for encrypting stored Plaid tokens
153
172
  RAY_API_KEY= # Ray API key (managed mode, replaces the above)
154
173
  ```
155
174
 
156
- ## Development
175
+ ## Roadmap
176
+
177
+ - [ ] Daily digest email — morning summary of your finances
178
+
179
+ Have an idea? [Open a PR](https://github.com/cdinnison/ray-finance/pulls).
180
+
181
+ ## Contributing
157
182
 
158
183
  ```bash
159
184
  git clone https://github.com/cdinnison/ray-finance.git
@@ -163,6 +188,8 @@ npm run build
163
188
  npm link # Makes 'ray' available globally
164
189
  ```
165
190
 
191
+ PRs welcome. Please open an issue first for large changes.
192
+
166
193
  ## License
167
194
 
168
- MIT
195
+ [MIT](LICENSE)
package/dist/ai/agent.js CHANGED
@@ -14,8 +14,17 @@ function supportsThinking(model) {
14
14
  export async function handleMessage(db, userMessage) {
15
15
  // Save incoming message
16
16
  saveMessage(db, "user", userMessage);
17
- // Load conversation context
18
- const history = getConversationHistory(db, 20);
17
+ // Load conversation context, truncated to fit token budget
18
+ const rawHistory = getConversationHistory(db, 30);
19
+ const MAX_HISTORY_CHARS = 24_000; // ~6k tokens, leaves room for system prompt + response
20
+ let historyChars = 0;
21
+ const history = [];
22
+ for (let i = rawHistory.length - 1; i >= 0; i--) {
23
+ historyChars += rawHistory[i].content.length;
24
+ if (historyChars > MAX_HISTORY_CHARS)
25
+ break;
26
+ history.unshift(rawHistory[i]);
27
+ }
19
28
  // Build system prompt and redact PII before sending to API
20
29
  const systemPrompt = redact(buildSystemPrompt(db));
21
30
  // Build messages array from history, redacting PII
@@ -74,7 +83,11 @@ export async function handleMessage(db, userMessage) {
74
83
  return responseText || "I looked into that but couldn't formulate a response. Could you try rephrasing?";
75
84
  }
76
85
  catch (error) {
77
- console.error("AI agent error:", error.message);
86
+ // Log full error internally but don't expose details to user
87
+ const safeMessage = error.status
88
+ ? `API error (${error.status})`
89
+ : "internal error";
90
+ console.error("AI agent error:", safeMessage);
78
91
  return "Sorry, I had trouble processing that. Could you try again?";
79
92
  }
80
93
  }
@@ -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
  const CONTEXT_PATH = resolve(homedir(), ".ray", "context.md");
@@ -19,7 +19,11 @@ export function writeContext(content) {
19
19
  const dir = resolve(homedir(), ".ray");
20
20
  if (!existsSync(dir))
21
21
  mkdirSync(dir, { recursive: true });
22
- writeFileSync(CONTEXT_PATH, content, "utf-8");
22
+ writeFileSync(CONTEXT_PATH, content, { encoding: "utf-8", mode: 0o600 });
23
+ try {
24
+ chmodSync(CONTEXT_PATH, 0o600);
25
+ }
26
+ catch { }
23
27
  }
24
28
  export function isContextEmpty() {
25
29
  const content = readContext();
@@ -239,7 +239,8 @@ export function cliBriefing(db) {
239
239
  const lines = [];
240
240
  // Net worth headline
241
241
  const nw = getNetWorth(db);
242
- let nwLine = chalk.white(` ${fmtMoney(nw.net_worth)}`);
242
+ const nwStr = nw.net_worth < 0 ? `-${fmtMoney(nw.net_worth)}` : fmtMoney(nw.net_worth);
243
+ let nwLine = chalk.white(` ${nwStr}`);
243
244
  if (nw.prev_net_worth !== null) {
244
245
  const change = nw.net_worth - nw.prev_net_worth;
245
246
  nwLine += change >= 0
@@ -247,6 +248,15 @@ export function cliBriefing(db) {
247
248
  : chalk.red(` -${fmtMoney(Math.abs(change))}`);
248
249
  }
249
250
  lines.push(chalk.dim(" net worth") + nwLine);
251
+ // Account balances
252
+ const accounts = getAccountBalances(db);
253
+ if (accounts.length > 0) {
254
+ const acctStrs = accounts.slice(0, 5).map(a => {
255
+ const bal = a.type === "credit" ? `-${fmtMoney(a.balance)}` : fmtMoney(a.balance);
256
+ return `${chalk.dim(a.name.toLowerCase())} ${chalk.white(bal)}`;
257
+ });
258
+ lines.push(" " + acctStrs.join(chalk.dim(" · ")));
259
+ }
250
260
  lines.push("");
251
261
  // Spending vs last month
252
262
  const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -271,7 +281,7 @@ export function cliBriefing(db) {
271
281
  .slice(0, 4);
272
282
  if (movers.length > 0) {
273
283
  const moverStrs = movers.map(m => {
274
- const label = categoryLabel(m.category);
284
+ const label = categoryLabel(m.category).toLowerCase();
275
285
  const color = m.diff <= 0 ? chalk.green : chalk.red;
276
286
  const sign = m.diff <= 0 ? "-" : "+";
277
287
  return `${chalk.dim(label)} ${color(`${sign}${fmtMoney(Math.abs(m.diff))}`)}`;
@@ -292,7 +302,7 @@ export function cliBriefing(db) {
292
302
  const pct = Math.round(b.pct_used);
293
303
  const color = b.over_budget ? chalk.red : chalk.yellow;
294
304
  const bar = miniBar(b.pct_used);
295
- lines.push(` ${bar} ${color(categoryLabel(b.category))} ${chalk.dim(`${pct}%`)}`);
305
+ lines.push(` ${bar} ${color(categoryLabel(b.category).toLowerCase())} ${chalk.dim(`${pct}%`)}`);
296
306
  }
297
307
  }
298
308
  // Goals (compact)
@@ -376,3 +386,16 @@ function buildScore(db) {
376
386
  }
377
387
  return line;
378
388
  }
389
+ function timeAgo(past, now) {
390
+ const diffMs = now.getTime() - past.getTime();
391
+ const mins = Math.floor(diffMs / 60000);
392
+ if (mins < 1)
393
+ return "just now";
394
+ if (mins < 60)
395
+ return `${mins}m ago`;
396
+ const hours = Math.floor(mins / 60);
397
+ if (hours < 24)
398
+ return `${hours}h ago`;
399
+ const days = Math.floor(hours / 24);
400
+ return `${days}d ago`;
401
+ }
@@ -73,12 +73,23 @@ function buildRedactions() {
73
73
  entries.sort((a, b) => b.real.length - a.real.length);
74
74
  return entries;
75
75
  }
76
+ // Patterns for numeric PII that should never reach the API
77
+ const NUMERIC_PII_PATTERNS = [
78
+ [/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN]"], // SSN: 123-45-6789
79
+ [/\b\d{9}\b(?=\s|$|[,.])/g, "[SSN]"], // SSN without dashes
80
+ [/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, "[CARD]"], // Credit card
81
+ [/\b\d{9,12}\b(?=\s|$|[,.])/g, "[ACCT]"], // Account/routing numbers
82
+ ];
76
83
  export function redact(text) {
77
84
  const redactions = buildRedactions();
78
85
  let result = text;
79
86
  for (const { real, token } of redactions) {
80
87
  result = result.replaceAll(real, token);
81
88
  }
89
+ // Redact numeric PII patterns
90
+ for (const [pattern, replacement] of NUMERIC_PII_PATTERNS) {
91
+ result = result.replace(pattern, replacement);
92
+ }
82
93
  return result;
83
94
  }
84
95
  export function unredact(text) {
@@ -29,9 +29,9 @@ Today is ${dateStr}.
29
29
  4. End with what to do, not just what happened. A good CFO always has a next step.
30
30
 
31
31
  ## Formatting (terminal output)
32
- - Plain text only. No markdown syntax no asterisks, no hashtags, no backticks.
32
+ - Use markdown sparingly: **bold** for key numbers or emphasis, ## for section headers. No backticks or code blocks.
33
33
  - Use line breaks, dashes, and simple alignment for structure.
34
- - Use ALL CAPS or dashes for emphasis instead of bold/italic.
34
+ - Use bullet points (- ) for lists.
35
35
 
36
36
  ## Tools
37
37
  - Always use tools to look up current data. Never guess balances, spending, or dates.
package/dist/ai/tools.js CHANGED
@@ -686,6 +686,10 @@ export async function executeTool(db, toolName, toolInput) {
686
686
  return `Transaction labeled.`;
687
687
  }
688
688
  case "add_recat_rule": {
689
+ const allowedFields = ["name", "merchant_name", "category", "subcategory"];
690
+ if (!allowedFields.includes(toolInput.match_field)) {
691
+ return `Invalid match_field "${toolInput.match_field}". Must be one of: ${allowedFields.join(", ")}`;
692
+ }
689
693
  db.prepare(`INSERT INTO recategorization_rules (match_field, match_pattern, target_category, target_subcategory, label) VALUES (?, ?, ?, ?, ?)`).run(toolInput.match_field, toolInput.match_pattern, toolInput.target_category, toolInput.target_subcategory || null, toolInput.label || null);
690
694
  return `Recategorization rule added: ${toolInput.match_field} matching "${toolInput.match_pattern}" → ${categoryLabel(toolInput.target_category)}`;
691
695
  }
@@ -47,35 +47,44 @@ export function runImport(inputPath) {
47
47
  if (backup.context) {
48
48
  writeContext(backup.context);
49
49
  }
50
- // Restore memories
51
- const insertMemory = db.prepare("INSERT INTO memories (content, category) VALUES (?, ?)");
50
+ // Restore memories (skip exact duplicates)
51
+ const insertMemory = db.prepare("INSERT INTO memories (content, category) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM memories WHERE content = ? AND category = ?)");
52
52
  for (const m of backup.memories) {
53
- insertMemory.run(m.content, m.category);
53
+ insertMemory.run(m.content, m.category, m.content, m.category);
54
54
  }
55
- // Restore goals
55
+ // Restore goals (skip if name already exists)
56
+ const existingGoal = db.prepare("SELECT 1 FROM goals WHERE name = ?");
56
57
  const insertGoal = db.prepare("INSERT INTO goals (name, target_amount, current_amount, deadline, status) VALUES (?, ?, ?, ?, ?)");
57
58
  for (const g of backup.goals) {
58
- insertGoal.run(g.name, g.target_amount, g.current_amount, g.deadline, g.status);
59
+ if (!existingGoal.get(g.name)) {
60
+ insertGoal.run(g.name, g.target_amount, g.current_amount, g.deadline, g.status);
61
+ }
59
62
  }
60
63
  // Restore budgets
61
64
  const insertBudget = db.prepare("INSERT INTO budgets (category, monthly_limit, period) VALUES (?, ?, ?) ON CONFLICT(category, period) DO UPDATE SET monthly_limit = excluded.monthly_limit");
62
65
  for (const b of backup.budgets) {
63
66
  insertBudget.run(b.category, b.monthly_limit, b.period);
64
67
  }
65
- // Restore recat rules
68
+ // Restore recat rules (skip exact duplicates)
69
+ const existingRule = db.prepare("SELECT 1 FROM recategorization_rules WHERE match_field = ? AND match_pattern = ? AND target_category = ?");
66
70
  const insertRule = db.prepare("INSERT INTO recategorization_rules (match_field, match_pattern, target_category, target_subcategory, label) VALUES (?, ?, ?, ?, ?)");
67
71
  for (const r of backup.recat_rules) {
68
- insertRule.run(r.match_field, r.match_pattern, r.target_category, r.target_subcategory, r.label);
72
+ if (!existingRule.get(r.match_field, r.match_pattern, r.target_category)) {
73
+ insertRule.run(r.match_field, r.match_pattern, r.target_category, r.target_subcategory, r.label);
74
+ }
69
75
  }
70
76
  // Restore settings
71
77
  const insertSetting = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
72
78
  for (const s of backup.settings) {
73
79
  insertSetting.run(s.key, s.value);
74
80
  }
75
- // Restore milestones
81
+ // Restore milestones (skip if name already exists)
82
+ const existingMilestone = db.prepare("SELECT 1 FROM milestones WHERE name = ?");
76
83
  const insertMilestone = db.prepare("INSERT INTO milestones (name, target_date, monthly_savings, description) VALUES (?, ?, ?, ?)");
77
84
  for (const m of backup.milestones) {
78
- insertMilestone.run(m.name, m.target_date, m.monthly_savings, m.description);
85
+ if (!existingMilestone.get(m.name)) {
86
+ insertMilestone.run(m.name, m.target_date, m.monthly_savings, m.description);
87
+ }
79
88
  }
80
89
  console.log(chalk.green(`\nBackup restored from ${inputPath}`));
81
90
  console.log(chalk.dim(` ${backup.memories.length} memories, ${backup.goals.length} goals, ${backup.budgets.length} budgets, ${backup.recat_rules.length} rules`));
package/dist/cli/chat.js CHANGED
@@ -1,27 +1,95 @@
1
- import * as readline from "readline/promises";
2
1
  import chalk from "chalk";
3
2
  import { getDb } from "../db/connection.js";
4
3
  import { handleMessage } from "../ai/agent.js";
5
4
  import { config } from "../config.js";
6
5
  import { isContextEmpty } from "../ai/context.js";
7
6
  import { cliBriefing } from "../ai/insights.js";
7
+ import { banner, formatResponse } from "./format.js";
8
+ /** Raw-mode line reader that renders content below the cursor while waiting for input */
9
+ function rawReadLine(prompt, belowLines) {
10
+ return new Promise((resolve) => {
11
+ let buf = "";
12
+ const out = process.stdout;
13
+ // Render: prompt on current line, then content below, then restore cursor
14
+ out.write(prompt);
15
+ if (belowLines.length > 0) {
16
+ // Save cursor, render below, restore
17
+ out.write("\x1b[s");
18
+ out.write("\n" + belowLines.join("\n"));
19
+ out.write("\x1b[u");
20
+ }
21
+ process.stdin.setRawMode(true);
22
+ process.stdin.resume();
23
+ process.stdin.setEncoding("utf8");
24
+ const cleanup = () => {
25
+ process.stdin.setRawMode(false);
26
+ process.stdin.removeListener("data", onData);
27
+ process.stdin.pause();
28
+ };
29
+ const onData = (chunk) => {
30
+ for (let i = 0; i < chunk.length; i++) {
31
+ const code = chunk.charCodeAt(i);
32
+ // Ctrl+C / Ctrl+D
33
+ if (code === 3 || code === 4) {
34
+ cleanup();
35
+ out.write("\n");
36
+ resolve("\x03");
37
+ return;
38
+ }
39
+ // Enter
40
+ if (code === 13) {
41
+ cleanup();
42
+ // Move past the below-content lines, then newline
43
+ for (let j = 0; j < belowLines.length; j++)
44
+ out.write("\x1b[1B");
45
+ out.write("\n");
46
+ resolve(buf);
47
+ return;
48
+ }
49
+ // Backspace
50
+ if (code === 127 || code === 8) {
51
+ if (buf.length > 0) {
52
+ buf = buf.slice(0, -1);
53
+ out.write("\b \b");
54
+ }
55
+ continue;
56
+ }
57
+ // Skip escape sequences (arrow keys etc.)
58
+ if (code === 27) {
59
+ if (i + 1 < chunk.length && chunk[i + 1] === "[") {
60
+ i += 2;
61
+ while (i < chunk.length && chunk.charCodeAt(i) < 64)
62
+ i++;
63
+ }
64
+ continue;
65
+ }
66
+ // Printable characters
67
+ if (code >= 32) {
68
+ buf += chunk[i];
69
+ out.write(chunk[i]);
70
+ }
71
+ }
72
+ };
73
+ process.stdin.on("data", onData);
74
+ });
75
+ }
8
76
  export async function startChat() {
9
77
  const ora = (await import("ora")).default;
10
78
  const db = getDb();
11
- // Show briefing instead of generic header
79
+ // Show logo + briefing
80
+ console.log("");
81
+ console.log(banner());
82
+ console.log("");
12
83
  const briefing = cliBriefing(db);
13
84
  if (briefing) {
14
85
  const now = new Date();
15
- const timeStr = now.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" });
16
- console.log("");
86
+ const timeStr = now.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }).toLowerCase();
17
87
  console.log(chalk.dim(` ${timeStr}`));
18
88
  console.log("");
19
89
  console.log(briefing);
20
- console.log("");
21
- console.log(chalk.dim("─".repeat(process.stdout.columns || 80)));
22
90
  }
23
91
  else {
24
- console.log(chalk.bold(`\nray`) + chalk.dim(` — ${config.userName}`));
92
+ console.log(chalk.bold(`ray`) + chalk.dim(` — ${config.userName}`));
25
93
  }
26
94
  console.log("");
27
95
  // Require at least one linked account
@@ -55,43 +123,81 @@ export async function startChat() {
55
123
  console.error(chalk.red("Error during onboarding: " + err.message));
56
124
  }
57
125
  }
58
- const rl = readline.createInterface({
59
- input: process.stdin,
60
- output: process.stdout,
61
- });
62
126
  const shutdown = () => {
63
127
  console.log(chalk.dim("\nGoodbye!"));
64
- rl.close();
65
128
  process.exit(0);
66
129
  };
67
- process.on("SIGINT", shutdown);
68
- try {
69
- while (true) {
70
- const cols = process.stdout.columns || 80;
71
- const rule = chalk.dim("─".repeat(cols));
72
- console.log(rule);
73
- const input = await rl.question(chalk.dim("❯ "));
74
- console.log(rule);
75
- const trimmed = input.trim();
76
- if (!trimmed)
77
- continue;
78
- if (trimmed === "/quit" || trimmed === "/exit" || trimmed === "/q") {
79
- shutdown();
80
- break;
81
- }
82
- const spinner = ora({ text: "Thinking...", color: "cyan", discardStdin: false }).start();
83
- try {
84
- const response = await handleMessage(db, trimmed);
85
- spinner.stop();
86
- console.log(`\n${response}\n`);
87
- }
88
- catch (err) {
89
- spinner.stop();
90
- console.error(chalk.red("Error: " + err.message));
91
- }
130
+ const hints = [
131
+ "try: how am i doing this month?",
132
+ "try: where's my money going?",
133
+ "try: what bills are coming up?",
134
+ "try: help me save more",
135
+ "try: am i on track for my goals?",
136
+ "try: any unusual spending lately?",
137
+ "try: what should i focus on?",
138
+ "try: compare this month to last month",
139
+ "try: set a budget for dining out",
140
+ "try: how much did i spend on groceries?",
141
+ ];
142
+ let hintIdx = Math.floor(Math.random() * hints.length);
143
+ const getFooterText = () => {
144
+ const lastSync = db.prepare(`SELECT MAX(updated_at) as ts FROM accounts`).get();
145
+ let syncStr = "";
146
+ if (lastSync.ts) {
147
+ const diffMs = Date.now() - new Date(lastSync.ts + "Z").getTime();
148
+ const mins = Math.floor(diffMs / 60000);
149
+ if (mins < 1)
150
+ syncStr = "synced just now";
151
+ else if (mins < 60)
152
+ syncStr = `synced ${mins}m ago`;
153
+ else if (mins < 1440)
154
+ syncStr = `synced ${Math.floor(mins / 60)}h ago`;
155
+ else
156
+ syncStr = `synced ${Math.floor(mins / 1440)}d ago`;
157
+ }
158
+ const parts = ["ray"];
159
+ if (syncStr)
160
+ parts.push(syncStr);
161
+ parts.push(hints[hintIdx]);
162
+ hintIdx = (hintIdx + 1) % hints.length;
163
+ return parts.join(" · ");
164
+ };
165
+ while (true) {
166
+ const cols = process.stdout.columns || 80;
167
+ const rule = chalk.dim("─".repeat(cols));
168
+ const footerText = chalk.dim(` ${getFooterText()}`);
169
+ // Ensure room below for top rule + prompt + bottom rule + footer (3 lines below start)
170
+ process.stdout.write("\n\n\n");
171
+ process.stdout.write("\x1b[3A\r");
172
+ // Print top rule, then prompt with bottom rule + footer rendered below
173
+ console.log(rule);
174
+ const input = await rawReadLine(chalk.dim("❯ "), [rule, footerText]);
175
+ const trimmed = input.trim();
176
+ if (!trimmed)
177
+ continue;
178
+ // Replace prompt frame with gray-background user message
179
+ // Move up 4 lines (footer, bottom rule, prompt, top rule) and clear them
180
+ process.stdout.write("\x1b[4A\r");
181
+ for (let i = 0; i < 4; i++)
182
+ process.stdout.write("\x1b[2K\x1b[1B");
183
+ process.stdout.write("\x1b[4A\r");
184
+ // Print user message with gray background, padded to full width
185
+ const msgText = `❯ ${trimmed}`;
186
+ const pad = Math.max(0, cols - msgText.length);
187
+ console.log(chalk.bgGray.white(msgText + " ".repeat(pad)));
188
+ if (trimmed === "\x03" || trimmed === "/quit" || trimmed === "/exit" || trimmed === "/q") {
189
+ shutdown();
190
+ break;
191
+ }
192
+ const spinner = ora({ text: "Thinking...", color: "cyan", discardStdin: false }).start();
193
+ try {
194
+ const response = await handleMessage(db, trimmed);
195
+ spinner.stop();
196
+ console.log(`\n${formatResponse(response)}\n`);
197
+ }
198
+ catch (err) {
199
+ spinner.stop();
200
+ console.error(chalk.red("Error: " + err.message));
92
201
  }
93
- }
94
- finally {
95
- rl.close();
96
202
  }
97
203
  }
@@ -9,4 +9,6 @@ export declare function helpScreen(commands: {
9
9
  name: string;
10
10
  desc: string;
11
11
  }[]): string;
12
+ /** Colorize AI response text for the terminal */
13
+ export declare function formatResponse(text: string): string;
12
14
  export declare const DISCLAIMER: string;
@@ -115,5 +115,30 @@ export function helpScreen(commands) {
115
115
  sections.push(chalk.dim(DISCLAIMER));
116
116
  return sections.join("\n");
117
117
  }
118
+ /** Colorize AI response text for the terminal */
119
+ export function formatResponse(text) {
120
+ return text
121
+ .split("\n")
122
+ .map((line) => {
123
+ // Section headers: ## Header or ### Header
124
+ if (/^#{1,3}\s+/.test(line)) {
125
+ return chalk.bold(line.replace(/^#{1,3}\s+/, ""));
126
+ }
127
+ // Bold: **text**
128
+ line = line.replace(/\*\*(.+?)\*\*/g, (_, t) => chalk.bold(t));
129
+ // Money amounts: $1,234 or $1,234.56 or -$500
130
+ line = line.replace(/-?\$[\d,]+(?:\.\d{1,2})?/g, (m) => {
131
+ return m.startsWith("-") ? chalk.red(m) : chalk.green(m);
132
+ });
133
+ // Percentages
134
+ line = line.replace(/(\d+(?:\.\d+)?%)/g, (m) => chalk.yellow(m));
135
+ // Bullet points
136
+ if (/^\s*[-•]\s/.test(line)) {
137
+ line = line.replace(/^(\s*)([-•])(\s)/, (_, sp, b, s) => sp + chalk.dim(b) + s);
138
+ }
139
+ return line;
140
+ })
141
+ .join("\n");
142
+ }
118
143
  export const DISCLAIMER = "Ray is an AI tool, not a licensed financial advisor. Output is informational, " +
119
144
  "may be inaccurate, and does not constitute financial advice.";
package/dist/cli/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
+ import { createRequire } from "module";
3
4
  import { config, isConfigured, useManaged, RAY_PROXY_BASE } from "../config.js";
4
5
  import { helpScreen } from "./format.js";
6
+ const require = createRequire(import.meta.url);
7
+ const { version } = require("../../package.json");
5
8
  const program = new Command();
6
9
  program
7
10
  .name("ray")
8
11
  .description("Personal finance AI assistant")
9
- .version("0.2.0")
12
+ .version(version)
10
13
  .addHelpCommand(false)
11
14
  .action(async () => {
12
15
  if (!isConfigured()) {
@@ -143,7 +146,14 @@ program
143
146
  },
144
147
  });
145
148
  const { url } = await resp.json();
146
- await open(url);
149
+ // Only open URLs from trusted domains
150
+ const parsed = new URL(url);
151
+ if (!parsed.hostname.endsWith("stripe.com") && !parsed.hostname.endsWith("rayfinance.app")) {
152
+ console.error("Unexpected billing URL. Visit https://rayfinance.app/billing");
153
+ }
154
+ else {
155
+ await open(url);
156
+ }
147
157
  }
148
158
  catch {
149
159
  console.error("Could not open billing portal. Visit https://rayfinance.app/billing");
package/dist/cli/setup.js CHANGED
@@ -52,7 +52,13 @@ export async function runSetup() {
52
52
  headers: { "content-type": "application/json" },
53
53
  });
54
54
  const { url } = await resp.json();
55
- await open(url);
55
+ const parsed = new URL(url);
56
+ if (!parsed.hostname.endsWith("stripe.com") && !parsed.hostname.endsWith("rayfinance.app")) {
57
+ console.log(dim(` Unexpected checkout URL. Visit https://rayfinance.app to subscribe.\n`));
58
+ }
59
+ else {
60
+ await open(url);
61
+ }
56
62
  }
57
63
  catch {
58
64
  console.log(dim(` Could not open checkout automatically.`));