ray-finance 0.1.0

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/ai/agent.d.ts +2 -0
  4. package/dist/ai/agent.js +93 -0
  5. package/dist/ai/audit.d.ts +3 -0
  6. package/dist/ai/audit.js +6 -0
  7. package/dist/ai/context.d.ts +6 -0
  8. package/dist/ai/context.js +93 -0
  9. package/dist/ai/insights.d.ts +3 -0
  10. package/dist/ai/insights.js +401 -0
  11. package/dist/ai/memory.d.ts +14 -0
  12. package/dist/ai/memory.js +12 -0
  13. package/dist/ai/redactor.d.ts +2 -0
  14. package/dist/ai/redactor.js +103 -0
  15. package/dist/ai/system-prompt.d.ts +2 -0
  16. package/dist/ai/system-prompt.js +85 -0
  17. package/dist/ai/tools.d.ts +4 -0
  18. package/dist/ai/tools.js +699 -0
  19. package/dist/alerts/index.d.ts +11 -0
  20. package/dist/alerts/index.js +95 -0
  21. package/dist/auth/anthropic.d.ts +7 -0
  22. package/dist/auth/anthropic.js +85 -0
  23. package/dist/auth/pkce.d.ts +5 -0
  24. package/dist/auth/pkce.js +10 -0
  25. package/dist/auth/store.d.ts +12 -0
  26. package/dist/auth/store.js +51 -0
  27. package/dist/cli/backup.d.ts +2 -0
  28. package/dist/cli/backup.js +94 -0
  29. package/dist/cli/chat.d.ts +1 -0
  30. package/dist/cli/chat.js +203 -0
  31. package/dist/cli/commands.d.ts +13 -0
  32. package/dist/cli/commands.js +201 -0
  33. package/dist/cli/format.d.ts +14 -0
  34. package/dist/cli/format.js +144 -0
  35. package/dist/cli/index.d.ts +2 -0
  36. package/dist/cli/index.js +186 -0
  37. package/dist/cli/scheduler.d.ts +2 -0
  38. package/dist/cli/scheduler.js +114 -0
  39. package/dist/cli/setup.d.ts +1 -0
  40. package/dist/cli/setup.js +174 -0
  41. package/dist/config.d.ts +22 -0
  42. package/dist/config.js +60 -0
  43. package/dist/daily-sync.d.ts +7 -0
  44. package/dist/daily-sync.js +109 -0
  45. package/dist/db/connection.d.ts +5 -0
  46. package/dist/db/connection.js +45 -0
  47. package/dist/db/encryption.d.ts +3 -0
  48. package/dist/db/encryption.js +35 -0
  49. package/dist/db/helpers.d.ts +16 -0
  50. package/dist/db/helpers.js +45 -0
  51. package/dist/db/schema.d.ts +2 -0
  52. package/dist/db/schema.js +199 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.js +1 -0
  55. package/dist/plaid/client.d.ts +2 -0
  56. package/dist/plaid/client.js +22 -0
  57. package/dist/plaid/link.d.ts +8 -0
  58. package/dist/plaid/link.js +23 -0
  59. package/dist/plaid/sync.d.ts +18 -0
  60. package/dist/plaid/sync.js +186 -0
  61. package/dist/public/favicon.png +0 -0
  62. package/dist/public/link.html +184 -0
  63. package/dist/public/ray-logo-dark.png +0 -0
  64. package/dist/queries/index.d.ts +163 -0
  65. package/dist/queries/index.js +411 -0
  66. package/dist/scoring/index.d.ts +53 -0
  67. package/dist/scoring/index.js +375 -0
  68. package/dist/server.d.ts +7 -0
  69. package/dist/server.js +172 -0
  70. package/package.json +60 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clark Dinnison
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ <p align="center">
2
+ <img src=".github/ray-logo.png" alt="Ray" width="120" />
3
+ </p>
4
+
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 />
16
+
17
+ ```
18
+ Friday, Mar 28
19
+
20
+ net worth $45,230 +$120
21
+
22
+ spending $2,340 this month · $340 less vs last month
23
+ Dining -$114 · Shopping -$142 · Groceries -$73
24
+
25
+ ▓▓▓▓▓▓▓░ Dining 92%
26
+
27
+ ▓▓▓▓░░░░ Emergency fund $18,200/$40,000
28
+
29
+ upcoming Netflix $16 in 3d · Comcast $142 in 6d
30
+
31
+ score 72/100 · 5d no dining · 3d on pace
32
+
33
+ ──────────────────────────────────────────────────
34
+
35
+ ❯ if I quit my job to freelance, how long can I survive?
36
+
37
+ Based on your last 3 months: you burn $4,820/mo after
38
+ fixed costs. With $18,200 in savings, that's
39
+ 3.8 months of runway at current spend.
40
+
41
+ Cut dining and shopping to last-month levels and
42
+ you stretch to 5.1 months. Land one $8k contract
43
+ in that window and you never dip below $10k.
44
+ ```
45
+
46
+ Open Ray and it shows your net worth, spending vs last month, budget pacing, and upcoming bills — before you type a word. Ask a question and it answers from your real data, not guesses. Local-first. Encrypted. Open source.
47
+
48
+ ## Features
49
+
50
+ - **It already knows** — Every conversation starts with a real-time financial briefing. Net worth, spending velocity, budget alerts, goal pace, upcoming bills, and your daily score. No "let me look that up."
51
+ - **Bank sync via Plaid** — Connect checking, savings, credit cards, investments, and loans
52
+ - **Encrypted local database** — All data stays on your machine in an AES-256 encrypted SQLite database
53
+ - **Daily scoring** — A 0-100 behavior score with streaks and 14 unlockable achievements. No restaurants for a week? That's Kitchen Hero. Five zero-spend days? Monk Mode.
54
+ - **CFO personality** — Ray doesn't list options. It tells you what it would do and why, references your goals, and flags problems you haven't noticed yet.
55
+ - **Budgets and goals** — Track spending limits by category and progress toward financial goals
56
+ - **PII masking** — Names, account numbers, and identifying details are scrubbed before anything reaches the AI. Your data is analyzed, not exposed.
57
+ - **Smart alerts** — Large transactions, low balances, budget overruns
58
+ - **Auto-recategorization** — Define rules to automatically re-label transactions
59
+ - **Scheduled daily sync** — Automatic bank sync via launchd (macOS) or cron (Linux)
60
+ - **Export/import** — Back up and restore your financial data
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ npm install -g ray-finance
66
+ ```
67
+
68
+ ## Quick Start
69
+
70
+ ```bash
71
+ ray setup
72
+ ```
73
+
74
+ The setup wizard offers two modes:
75
+
76
+ ### Quick setup (managed)
77
+
78
+ We handle the API keys. Your data stays local. $10/mo.
79
+
80
+ 1. Enter your name
81
+ 2. Get a Ray API key (opens Stripe checkout)
82
+ 3. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
83
+ 4. Done — daily sync auto-scheduled at 6am
84
+
85
+ ### Self-hosted
86
+
87
+ Bring your own Anthropic and Plaid credentials. Free forever.
88
+
89
+ 1. Enter your Anthropic API key ([get one](https://console.anthropic.com))
90
+ 2. Enter your Plaid credentials ([get free keys](https://dashboard.plaid.com/signup))
91
+ 3. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
92
+ 4. Done
93
+
94
+ ## Commands
95
+
96
+ Run `ray --help` to see all available commands.
97
+
98
+ | Command | Description |
99
+ |---------|-------------|
100
+ | `ray` | Interactive AI chat with your financial advisor |
101
+ | `ray setup` | Configure API keys and preferences |
102
+ | `ray link` | Connect a new bank account |
103
+ | `ray sync` | Pull latest transactions and balances |
104
+ | `ray status` | Quick financial dashboard |
105
+ | `ray transactions` | Recent transactions (filterable by category, merchant) |
106
+ | `ray spending [period]` | Spending breakdown by category |
107
+ | `ray budgets` | Budget status and overruns |
108
+ | `ray goals` | Financial goal progress |
109
+ | `ray score` | Daily score, streaks, and achievements |
110
+ | `ray alerts` | Active financial alerts |
111
+ | `ray export [path]` | Export data to a backup file |
112
+ | `ray import <path>` | Restore from a backup file |
113
+ | `ray billing` | Manage your Ray subscription (managed mode only) |
114
+
115
+ ## How It Works
116
+
117
+ ```
118
+ Checking · Savings · Credit · Investments · Loans · Mortgage
119
+
120
+ Plaid API
121
+
122
+ ┌──────────▼──────────┐
123
+ │ Local SQLite DB │
124
+ │ (AES-256 encrypted) │
125
+ └──────────┬──────────┘
126
+
127
+ ┌──────────▼──────────┐
128
+ │ ray CLI │
129
+ │ insights · tools │
130
+ │ scoring · alerts │
131
+ └──────────┬──────────┘
132
+
133
+ Claude API
134
+ (PII-masked)
135
+ ```
136
+
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.
138
+
139
+ ## Security & Privacy
140
+
141
+ - All financial data stored locally in `~/.ray/data/finance.db`
142
+ - Database encrypted with AES-256 (SQLCipher)
143
+ - Plaid access tokens encrypted at rest with AES-256-GCM
144
+ - Config file stored with `0600` permissions
145
+ - PII redacted before sending to Claude API
146
+ - No data leaves your machine — only API calls to Plaid and Anthropic
147
+
148
+ ## Configuration
149
+
150
+ Ray stores everything in `~/.ray/`:
151
+
152
+ ```
153
+ ~/.ray/
154
+ config.json # API keys and preferences (0600 permissions)
155
+ context.md # Persistent financial context for AI
156
+ data/
157
+ finance.db # Encrypted SQLite database
158
+ sync.log # Daily sync output
159
+ ```
160
+
161
+ ### Environment Variables
162
+
163
+ You can also configure Ray via environment variables or a `.env` file:
164
+
165
+ ```bash
166
+ ANTHROPIC_API_KEY= # Anthropic API key for AI chat
167
+ PLAID_CLIENT_ID= # Plaid client ID
168
+ PLAID_SECRET= # Plaid secret key
169
+ PLAID_ENV=production # Plaid environment
170
+ DB_ENCRYPTION_KEY= # Database encryption key
171
+ PLAID_TOKEN_SECRET= # Key for encrypting stored Plaid tokens
172
+ RAY_API_KEY= # Ray API key (managed mode, replaces the above)
173
+ ```
174
+
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
182
+
183
+ ```bash
184
+ git clone https://github.com/cdinnison/ray-finance.git
185
+ cd ray-finance
186
+ npm install
187
+ npm run build
188
+ npm link # Makes 'ray' available globally
189
+ ```
190
+
191
+ PRs welcome. Please open an issue first for large changes.
192
+
193
+ ## License
194
+
195
+ [MIT](LICENSE)
@@ -0,0 +1,2 @@
1
+ import type Database from "libsql";
2
+ export declare function handleMessage(db: Database.Database, userMessage: string): Promise<string>;
@@ -0,0 +1,93 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { config, useManaged, RAY_PROXY_BASE } from "../config.js";
3
+ import { buildSystemPrompt } from "./system-prompt.js";
4
+ import { toolDefinitions, executeTool } from "./tools.js";
5
+ import { getConversationHistory, saveMessage } from "./memory.js";
6
+ import { logToolCall } from "./audit.js";
7
+ import { redact, unredact } from "./redactor.js";
8
+ const anthropic = new Anthropic(useManaged()
9
+ ? { apiKey: config.rayApiKey, baseURL: `${RAY_PROXY_BASE}/ai` }
10
+ : { apiKey: config.anthropicKey });
11
+ function supportsThinking(model) {
12
+ return /sonnet-4|opus-4/i.test(model);
13
+ }
14
+ export async function handleMessage(db, userMessage) {
15
+ // Save incoming message
16
+ saveMessage(db, "user", userMessage);
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
+ }
28
+ // Build system prompt and redact PII before sending to API
29
+ const systemPrompt = redact(buildSystemPrompt(db));
30
+ // Build messages array from history, redacting PII
31
+ const messages = history.map(h => ({
32
+ role: h.role,
33
+ content: redact(h.content),
34
+ }));
35
+ // Ensure last message is the current user message
36
+ if (messages.length === 0 || messages[messages.length - 1].content !== userMessage) {
37
+ messages.push({ role: "user", content: redact(userMessage) });
38
+ }
39
+ // Extended thinking config
40
+ const useThinking = config.thinkingBudget > 0 && supportsThinking(config.model);
41
+ try {
42
+ // Build API params
43
+ const apiParams = {
44
+ model: config.model,
45
+ max_tokens: useThinking ? 16000 : 4096,
46
+ system: systemPrompt,
47
+ tools: toolDefinitions,
48
+ messages,
49
+ };
50
+ if (useThinking) {
51
+ apiParams.thinking = {
52
+ type: "enabled",
53
+ budget_tokens: config.thinkingBudget,
54
+ };
55
+ }
56
+ // Initial API call
57
+ let response = await anthropic.messages.create(apiParams);
58
+ // Agentic tool loop
59
+ while (response.stop_reason === "tool_use") {
60
+ // Filter out thinking blocks before adding to messages
61
+ const assistantContent = response.content.filter((b) => b.type !== "thinking");
62
+ messages.push({ role: "assistant", content: assistantContent });
63
+ const toolResults = [];
64
+ for (const block of assistantContent) {
65
+ if (block.type === "tool_use") {
66
+ const result = await executeTool(db, block.name, block.input);
67
+ logToolCall(db, block.name, block.input, result, response.usage?.output_tokens);
68
+ toolResults.push({
69
+ type: "tool_result",
70
+ tool_use_id: block.id,
71
+ content: redact(result),
72
+ });
73
+ }
74
+ }
75
+ messages.push({ role: "user", content: toolResults });
76
+ response = await anthropic.messages.create(apiParams);
77
+ }
78
+ // Extract text response (filter out thinking blocks), restore PII for display
79
+ const textBlocks = response.content.filter((b) => b.type === "text");
80
+ const responseText = unredact(textBlocks.map((b) => b.text).join("\n"));
81
+ // Save assistant response
82
+ saveMessage(db, "assistant", responseText);
83
+ return responseText || "I looked into that but couldn't formulate a response. Could you try rephrasing?";
84
+ }
85
+ catch (error) {
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);
91
+ return "Sorry, I had trouble processing that. Could you try again?";
92
+ }
93
+ }
@@ -0,0 +1,3 @@
1
+ import type Database from "libsql";
2
+ export declare function logToolCall(db: Database.Database, toolName: string, inputParams: any, resultSummary: string, tokensUsed?: number): void;
3
+ export declare function getAuditLog(db: Database.Database, limit?: number): any[];
@@ -0,0 +1,6 @@
1
+ export function logToolCall(db, toolName, inputParams, resultSummary, tokensUsed) {
2
+ db.prepare(`INSERT INTO ai_audit_log (tool_name, input_params, result_summary, tokens_used) VALUES (?, ?, ?, ?)`).run(toolName, JSON.stringify(inputParams), resultSummary.slice(0, 500), tokensUsed || null);
3
+ }
4
+ export function getAuditLog(db, limit = 50) {
5
+ return db.prepare(`SELECT * FROM ai_audit_log ORDER BY id DESC LIMIT ?`).all(limit);
6
+ }
@@ -0,0 +1,6 @@
1
+ export declare function getContextPath(): string;
2
+ export declare function readContext(): string;
3
+ export declare function writeContext(content: string): void;
4
+ export declare function isContextEmpty(): boolean;
5
+ export declare function replaceContextSection(section: string, content: string): void;
6
+ export declare function createContextTemplate(userName: string): void;
@@ -0,0 +1,93 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { homedir } from "os";
4
+ const CONTEXT_PATH = resolve(homedir(), ".ray", "context.md");
5
+ export function getContextPath() {
6
+ return CONTEXT_PATH;
7
+ }
8
+ export function readContext() {
9
+ if (!existsSync(CONTEXT_PATH))
10
+ return "";
11
+ try {
12
+ return readFileSync(CONTEXT_PATH, "utf-8");
13
+ }
14
+ catch {
15
+ return "";
16
+ }
17
+ }
18
+ export function writeContext(content) {
19
+ const dir = resolve(homedir(), ".ray");
20
+ if (!existsSync(dir))
21
+ mkdirSync(dir, { recursive: true });
22
+ writeFileSync(CONTEXT_PATH, content, { encoding: "utf-8", mode: 0o600 });
23
+ try {
24
+ chmodSync(CONTEXT_PATH, 0o600);
25
+ }
26
+ catch { }
27
+ }
28
+ export function isContextEmpty() {
29
+ const content = readContext();
30
+ if (!content || content.trim().length === 0)
31
+ return true;
32
+ // Check if it still has placeholder text (template hasn't been filled)
33
+ const placeholders = ["(Add income sources", "(Linked accounts will appear", "(Add financial goals", "(Add current financial strategy", "(Log important financial decisions", "(Track action items"];
34
+ const filledSections = placeholders.filter(p => !content.includes(p)).length;
35
+ // Consider empty if most sections still have placeholder text
36
+ return filledSections < 2;
37
+ }
38
+ export function replaceContextSection(section, content) {
39
+ let current = readContext();
40
+ if (!current) {
41
+ writeContext(`## ${section}\n${content}\n`);
42
+ return;
43
+ }
44
+ const sectionHeader = `## ${section}`;
45
+ const sectionIdx = current.indexOf(sectionHeader);
46
+ if (sectionIdx !== -1) {
47
+ // Find the next ## heading after this section
48
+ const afterHeader = sectionIdx + sectionHeader.length;
49
+ const nextSectionIdx = current.indexOf("\n## ", afterHeader);
50
+ const before = current.slice(0, afterHeader);
51
+ const after = nextSectionIdx !== -1 ? current.slice(nextSectionIdx) : "";
52
+ current = `${before}\n${content}\n${after}`;
53
+ }
54
+ else {
55
+ // Insert before ## Open Items if it exists, otherwise append
56
+ const openItemsIdx = current.indexOf("## Open Items");
57
+ if (openItemsIdx !== -1) {
58
+ current = `${current.slice(0, openItemsIdx)}${sectionHeader}\n${content}\n\n${current.slice(openItemsIdx)}`;
59
+ }
60
+ else {
61
+ current = `${current.trimEnd()}\n\n${sectionHeader}\n${content}\n`;
62
+ }
63
+ }
64
+ writeContext(current);
65
+ }
66
+ export function createContextTemplate(userName) {
67
+ if (existsSync(CONTEXT_PATH))
68
+ return; // don't overwrite existing
69
+ const template = `# Financial Context for ${userName}
70
+
71
+ ## Family
72
+ - ${userName}
73
+
74
+ ## Income
75
+ - (Add income sources and amounts)
76
+
77
+ ## Accounts
78
+ - (Linked accounts will appear after syncing)
79
+
80
+ ## Goals
81
+ - (Add financial goals)
82
+
83
+ ## Strategy
84
+ - (Add current financial strategy and priorities)
85
+
86
+ ## Key Decisions
87
+ - (Log important financial decisions here)
88
+
89
+ ## Open Items
90
+ - (Track action items and follow-ups)
91
+ `;
92
+ writeContext(template);
93
+ }
@@ -0,0 +1,3 @@
1
+ import type Database from "libsql";
2
+ export declare function computeInsights(db: Database.Database): string;
3
+ export declare function cliBriefing(db: Database.Database): string | null;