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.
- package/.claude/settings.local.json +3 -1
- package/.github/ray-logo.png +0 -0
- package/.github/workflows/ci.yml +1 -0
- package/Dockerfile +2 -2
- package/README.md +31 -10
- package/SECURITY.md +1 -1
- package/dist/ai/agent.js +16 -3
- package/dist/ai/context.js +6 -2
- package/dist/ai/insights.js +26 -3
- package/dist/ai/redactor.js +11 -0
- package/dist/ai/system-prompt.js +2 -2
- package/dist/ai/tools.js +4 -0
- package/dist/cli/backup.js +18 -9
- package/dist/cli/chat.js +146 -40
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.js +25 -0
- package/dist/cli/index.js +12 -2
- package/dist/cli/setup.js +7 -1
- package/dist/daily-sync.js +19 -4
- package/dist/db/connection.js +9 -1
- package/dist/db/encryption.js +18 -7
- package/dist/db/schema.js +6 -1
- package/dist/public/favicon.png +0 -0
- package/dist/public/link.html +47 -24
- package/dist/public/ray-logo-dark.png +0 -0
- package/dist/queries/index.js +8 -8
- package/dist/server.js +33 -1
- package/package.json +4 -2
- package/site/package-lock.json +43 -0
- package/site/package.json +1 -0
- package/site/public/ray-logo-dark.png +0 -0
- package/site/public/ray-logo-light.png +0 -0
- package/site/src/app/copy-command.tsx +1 -3
- package/site/src/app/layout.tsx +2 -1
- package/src/ai/agent.ts +15 -3
- package/src/ai/context.ts +3 -2
- package/src/ai/insights.ts +25 -3
- package/src/ai/redactor.test.ts +63 -0
- package/src/ai/redactor.ts +12 -0
- package/src/ai/system-prompt.ts +2 -2
- package/src/ai/tools.ts +4 -0
- package/src/cli/backup.ts +23 -10
- package/src/cli/chat.ts +155 -41
- package/src/cli/format.ts +31 -0
- package/src/cli/index.ts +12 -2
- package/src/cli/setup.ts +6 -1
- package/src/daily-sync.test.ts +150 -0
- package/src/daily-sync.ts +19 -4
- package/src/db/connection.ts +12 -1
- package/src/db/encryption.test.ts +86 -0
- package/src/db/encryption.ts +17 -7
- package/src/db/schema.test.ts +53 -0
- package/src/db/schema.ts +7 -1
- package/src/public/favicon.png +0 -0
- package/src/public/link.html +47 -24
- package/src/public/ray-logo-dark.png +0 -0
- package/src/queries/index.test.ts +397 -0
- package/src/queries/index.ts +8 -8
- package/src/server.ts +37 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +7 -0
- package/SPEC.md +0 -374
|
Binary file
|
package/.github/workflows/ci.yml
CHANGED
package/Dockerfile
CHANGED
package/README.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/ray-logo.png" alt="Ray" width="120" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
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.
|
|
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.
|
|
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,7 +166,13 @@ 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
|
-
##
|
|
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
178
|
git clone https://github.com/cdinnison/ray-finance.git
|
|
@@ -163,6 +182,8 @@ 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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/ai/context.js
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
|
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();
|
package/dist/ai/insights.js
CHANGED
|
@@ -239,7 +239,8 @@ export function cliBriefing(db) {
|
|
|
239
239
|
const lines = [];
|
|
240
240
|
// Net worth headline
|
|
241
241
|
const nw = getNetWorth(db);
|
|
242
|
-
|
|
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
|
+
}
|
package/dist/ai/redactor.js
CHANGED
|
@@ -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) {
|
package/dist/ai/system-prompt.js
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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
|
}
|
package/dist/cli/backup.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
}
|
package/dist/cli/format.d.ts
CHANGED
package/dist/cli/format.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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.`));
|