ray-finance 0.2.0 → 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 (64) 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/CONTRIBUTING.md +1 -1
  5. package/Dockerfile +2 -2
  6. package/README.md +32 -11
  7. package/SECURITY.md +1 -1
  8. package/dist/ai/agent.js +16 -3
  9. package/dist/ai/context.js +6 -2
  10. package/dist/ai/insights.js +26 -3
  11. package/dist/ai/redactor.js +11 -0
  12. package/dist/ai/system-prompt.js +2 -2
  13. package/dist/ai/tools.js +4 -0
  14. package/dist/cli/backup.js +18 -9
  15. package/dist/cli/chat.js +146 -40
  16. package/dist/cli/format.d.ts +2 -0
  17. package/dist/cli/format.js +25 -0
  18. package/dist/cli/index.js +12 -2
  19. package/dist/cli/setup.js +7 -1
  20. package/dist/daily-sync.js +19 -4
  21. package/dist/db/connection.js +9 -1
  22. package/dist/db/encryption.js +18 -7
  23. package/dist/db/schema.js +6 -1
  24. package/dist/public/favicon.png +0 -0
  25. package/dist/public/link.html +47 -24
  26. package/dist/public/ray-logo-dark.png +0 -0
  27. package/dist/queries/index.js +8 -8
  28. package/dist/server.js +33 -1
  29. package/package.json +7 -5
  30. package/site/package-lock.json +43 -0
  31. package/site/package.json +1 -0
  32. package/site/public/ray-logo-dark.png +0 -0
  33. package/site/public/ray-logo-light.png +0 -0
  34. package/site/src/app/copy-command.tsx +0 -1
  35. package/site/src/app/layout.tsx +2 -1
  36. package/site/src/app/page.tsx +5 -5
  37. package/src/ai/agent.ts +15 -3
  38. package/src/ai/context.ts +3 -2
  39. package/src/ai/insights.ts +25 -3
  40. package/src/ai/redactor.test.ts +63 -0
  41. package/src/ai/redactor.ts +12 -0
  42. package/src/ai/system-prompt.ts +2 -2
  43. package/src/ai/tools.ts +4 -0
  44. package/src/cli/backup.ts +23 -10
  45. package/src/cli/chat.ts +155 -41
  46. package/src/cli/format.ts +31 -0
  47. package/src/cli/index.ts +12 -2
  48. package/src/cli/setup.ts +6 -1
  49. package/src/daily-sync.test.ts +150 -0
  50. package/src/daily-sync.ts +19 -4
  51. package/src/db/connection.ts +12 -1
  52. package/src/db/encryption.test.ts +86 -0
  53. package/src/db/encryption.ts +17 -7
  54. package/src/db/schema.test.ts +53 -0
  55. package/src/db/schema.ts +7 -1
  56. package/src/public/favicon.png +0 -0
  57. package/src/public/link.html +47 -24
  58. package/src/public/ray-logo-dark.png +0 -0
  59. package/src/queries/index.test.ts +397 -0
  60. package/src/queries/index.ts +8 -8
  61. package/src/server.ts +37 -1
  62. package/tsconfig.json +1 -1
  63. package/vitest.config.ts +7 -0
  64. package/SPEC.md +0 -374
@@ -10,7 +10,9 @@
10
10
  "Bash(npx next:*)",
11
11
  "Bash(npx tsc:*)",
12
12
  "Bash(git add:*)",
13
- "Bash(git commit:*)"
13
+ "Bash(git commit:*)",
14
+ "Bash(npm run:*)",
15
+ "Bash(npm test:*)"
14
16
  ]
15
17
  }
16
18
  }
Binary file
@@ -19,3 +19,4 @@ jobs:
19
19
  node-version: ${{ matrix.node-version }}
20
20
  - run: npm install
21
21
  - run: npm run build
22
+ - run: npm test
package/CONTRIBUTING.md CHANGED
@@ -5,7 +5,7 @@ Thanks for your interest in contributing to Ray.
5
5
  ## Development Setup
6
6
 
7
7
  ```bash
8
- git clone https://github.com/clarkdinnison/ray-finance.git
8
+ git clone https://github.com/cdinnison/ray-finance.git
9
9
  cd ray-finance
10
10
  npm install
11
11
  npm run build
package/Dockerfile CHANGED
@@ -4,5 +4,5 @@ COPY package*.json ./
4
4
  RUN npm ci --production
5
5
  COPY dist/ ./dist/
6
6
  COPY src/public/ ./dist/public/
7
- EXPOSE 3000
8
- CMD ["node", "dist/index.js"]
7
+ EXPOSE 9876
8
+ CMD ["node", "dist/cli/index.js"]
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
@@ -61,7 +73,7 @@ We handle the API keys. Your data stays local. $10/mo.
61
73
 
62
74
  1. Enter your name
63
75
  2. Get a Ray API key (opens Stripe checkout)
64
- 3. Connect your bank
76
+ 3. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
65
77
  4. Done — daily sync auto-scheduled at 6am
66
78
 
67
79
  ### Self-hosted
@@ -70,11 +82,13 @@ Bring your own Anthropic and Plaid credentials. Free forever.
70
82
 
71
83
  1. Enter your Anthropic API key ([get one](https://console.anthropic.com))
72
84
  2. Enter your Plaid credentials ([get free keys](https://dashboard.plaid.com/signup))
73
- 3. Connect your bank
85
+ 3. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
74
86
  4. Done
75
87
 
76
88
  ## Commands
77
89
 
90
+ Run `ray --help` to see all available commands.
91
+
78
92
  | Command | Description |
79
93
  |---------|-------------|
80
94
  | `ray` | Interactive AI chat with your financial advisor |
@@ -105,14 +119,13 @@ Bring your own Anthropic and Plaid credentials. Free forever.
105
119
  └──────────┬──────────┘
106
120
 
107
121
  ┌──────────▼──────────┐
108
- │ ray CLI
122
+ │ ray CLI
109
123
  │ insights · tools │
110
124
  │ scoring · alerts │
111
125
  └──────────┬──────────┘
112
126
 
113
- ┌─────────┴─────────┐
114
- │ │
115
- You (terminal) Claude API (PII-masked)
127
+ Claude API
128
+ (PII-masked)
116
129
  ```
117
130
 
118
131
  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,16 +166,24 @@ PLAID_TOKEN_SECRET= # Key for encrypting stored Plaid tokens
153
166
  RAY_API_KEY= # Ray API key (managed mode, replaces the above)
154
167
  ```
155
168
 
156
- ## Development
169
+ ## Roadmap
170
+
171
+ - [ ] Daily digest email — morning summary of your finances
172
+
173
+ Have an idea? [Open a PR](https://github.com/cdinnison/ray-finance/pulls).
174
+
175
+ ## Contributing
157
176
 
158
177
  ```bash
159
- git clone https://github.com/clarkdinnison/ray-finance.git
178
+ git clone https://github.com/cdinnison/ray-finance.git
160
179
  cd ray-finance
161
180
  npm install
162
181
  npm run build
163
182
  npm link # Makes 'ray' available globally
164
183
  ```
165
184
 
185
+ PRs welcome. Please open an issue first for large changes.
186
+
166
187
  ## License
167
188
 
168
- MIT
189
+ [MIT](LICENSE)
package/SECURITY.md CHANGED
@@ -12,7 +12,7 @@ Ray is local-first. All financial data is stored on your machine in an encrypted
12
12
 
13
13
  ### Data Flow
14
14
 
15
- Ray makes outbound API calls to three services:
15
+ Ray makes outbound API calls to two services:
16
16
 
17
17
  | Service | Purpose | When |
18
18
  |---------|---------|------|
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");