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
@@ -0,0 +1,11 @@
1
+ import type BetterSqlite3 from "libsql";
2
+ type Database = BetterSqlite3.Database;
3
+ export interface Alert {
4
+ type: string;
5
+ severity: "info" | "warning" | "critical";
6
+ message: string;
7
+ data?: any;
8
+ }
9
+ /** Generate alerts based on current financial data */
10
+ export declare function generateAlerts(db: Database): Alert[];
11
+ export {};
@@ -0,0 +1,95 @@
1
+ /** Generate alerts based on current financial data */
2
+ export function generateAlerts(db) {
3
+ const alerts = [];
4
+ // Large transactions (>$500) in last 24h
5
+ const large = db
6
+ .prepare(`SELECT t.name, t.merchant_name, t.amount, t.date, a.name as account_name
7
+ FROM transactions t JOIN accounts a ON t.account_id = a.account_id
8
+ WHERE t.date >= date('now', '-1 day') AND t.amount > 500 AND t.pending = 0`)
9
+ .all();
10
+ for (const tx of large) {
11
+ alerts.push({
12
+ type: "large_transaction",
13
+ severity: "warning",
14
+ message: `Large charge: $${tx.amount} at ${tx.merchant_name || tx.name} (${tx.account_name})`,
15
+ data: tx,
16
+ });
17
+ }
18
+ // Low balances (<$1000) on checking/savings
19
+ const lowBal = db
20
+ .prepare(`SELECT name, current_balance, type FROM accounts
21
+ WHERE type = 'depository' AND current_balance < 1000`)
22
+ .all();
23
+ for (const acct of lowBal) {
24
+ alerts.push({
25
+ type: "low_balance",
26
+ severity: acct.current_balance < 500 ? "critical" : "warning",
27
+ message: `Low balance: ${acct.name} has $${acct.current_balance.toFixed(2)}`,
28
+ data: acct,
29
+ });
30
+ }
31
+ // Budget overruns (this month)
32
+ const now = new Date();
33
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
34
+ .toISOString()
35
+ .slice(0, 10);
36
+ const today = now.toISOString().slice(0, 10);
37
+ const budgets = db
38
+ .prepare(`SELECT category, monthly_limit FROM budgets`)
39
+ .all();
40
+ for (const b of budgets) {
41
+ const spent = db
42
+ .prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
43
+ WHERE category = ? AND date BETWEEN ? AND ? AND amount > 0 AND pending = 0`)
44
+ .get(b.category, monthStart, today);
45
+ if (spent.total > b.monthly_limit) {
46
+ alerts.push({
47
+ type: "budget_overrun",
48
+ severity: "warning",
49
+ message: `Over budget: ${b.category} — spent $${spent.total.toFixed(2)} of $${b.monthly_limit} limit`,
50
+ data: { category: b.category, spent: spent.total, limit: b.monthly_limit },
51
+ });
52
+ }
53
+ else if (spent.total > b.monthly_limit * 0.9) {
54
+ alerts.push({
55
+ type: "budget_warning",
56
+ severity: "info",
57
+ message: `Nearing budget: ${b.category} — $${spent.total.toFixed(2)} of $${b.monthly_limit} (${Math.round((spent.total / b.monthly_limit) * 100)}%)`,
58
+ data: { category: b.category, spent: spent.total, limit: b.monthly_limit },
59
+ });
60
+ }
61
+ }
62
+ // Subscription price changes (compare last 2 occurrences of recurring merchants)
63
+ const recurring = db
64
+ .prepare(`SELECT merchant_name, amount, date FROM transactions
65
+ WHERE merchant_name IN (
66
+ SELECT merchant_name FROM transactions
67
+ WHERE merchant_name IS NOT NULL AND amount > 0
68
+ GROUP BY merchant_name HAVING COUNT(*) >= 2
69
+ )
70
+ AND amount > 0 AND pending = 0
71
+ ORDER BY merchant_name, date DESC`)
72
+ .all();
73
+ const byMerchant = {};
74
+ for (const r of recurring) {
75
+ if (!byMerchant[r.merchant_name])
76
+ byMerchant[r.merchant_name] = [];
77
+ if (byMerchant[r.merchant_name].length < 2) {
78
+ byMerchant[r.merchant_name].push({ amount: r.amount, date: r.date });
79
+ }
80
+ }
81
+ for (const [merchant, charges] of Object.entries(byMerchant)) {
82
+ if (charges.length === 2) {
83
+ const diff = Math.abs(charges[0].amount - charges[1].amount);
84
+ if (diff > 0.5 && diff / charges[1].amount > 0.05) {
85
+ alerts.push({
86
+ type: "price_change",
87
+ severity: "info",
88
+ message: `Price change: ${merchant} went from $${charges[1].amount} to $${charges[0].amount}`,
89
+ data: { merchant, previous: charges[1].amount, current: charges[0].amount },
90
+ });
91
+ }
92
+ }
93
+ }
94
+ return alerts;
95
+ }
@@ -0,0 +1,7 @@
1
+ export interface OAuthCredentials {
2
+ accessToken: string;
3
+ refreshToken: string;
4
+ expiresAt: number;
5
+ }
6
+ export declare function loginAnthropic(): Promise<OAuthCredentials>;
7
+ export declare function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials>;
@@ -0,0 +1,85 @@
1
+ import { randomBytes } from "crypto";
2
+ import { generatePKCE } from "./pkce.js";
3
+ const CLIENT_ID = "9d1d2024-a989-4a4d-9e10-c9c3f5b1ef96";
4
+ const AUTH_URL = "https://console.anthropic.com/oauth/authorize";
5
+ const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
6
+ const REDIRECT_URI = "https://console.anthropic.com/oauth/return";
7
+ const SCOPES = "user:inference";
8
+ export async function loginAnthropic() {
9
+ const inquirer = (await import("inquirer")).default;
10
+ const open = (await import("open")).default;
11
+ const { verifier, challenge } = generatePKCE();
12
+ const state = randomBytes(16).toString("hex");
13
+ const params = new URLSearchParams({
14
+ response_type: "code",
15
+ client_id: CLIENT_ID,
16
+ redirect_uri: REDIRECT_URI,
17
+ scope: SCOPES,
18
+ state,
19
+ code_challenge: challenge,
20
+ code_challenge_method: "S256",
21
+ });
22
+ const authUrl = `${AUTH_URL}?${params}`;
23
+ console.log("\nOpening browser for Anthropic login...");
24
+ console.log("If the browser doesn't open, visit this URL:\n");
25
+ console.log(` ${authUrl}\n`);
26
+ await open(authUrl);
27
+ const { callback } = await inquirer.prompt([{
28
+ type: "input",
29
+ name: "callback",
30
+ message: "Paste the code from the callback page:",
31
+ validate: (v) => v.length > 0 || "Required",
32
+ }]);
33
+ // Parse "code#state" or just "code"
34
+ const [code, returnedState] = callback.includes("#")
35
+ ? callback.split("#")
36
+ : [callback, undefined];
37
+ if (returnedState && returnedState !== state) {
38
+ throw new Error("OAuth state mismatch — possible CSRF attack. Try again.");
39
+ }
40
+ return exchangeCode(code.trim(), verifier);
41
+ }
42
+ async function exchangeCode(code, verifier) {
43
+ const res = await fetch(TOKEN_URL, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
46
+ body: new URLSearchParams({
47
+ grant_type: "authorization_code",
48
+ client_id: CLIENT_ID,
49
+ code,
50
+ redirect_uri: REDIRECT_URI,
51
+ code_verifier: verifier,
52
+ }),
53
+ });
54
+ if (!res.ok) {
55
+ const body = await res.text();
56
+ throw new Error(`Token exchange failed (${res.status}): ${body}`);
57
+ }
58
+ const data = await res.json();
59
+ return {
60
+ accessToken: data.access_token,
61
+ refreshToken: data.refresh_token,
62
+ expiresAt: Date.now() + data.expires_in * 1000,
63
+ };
64
+ }
65
+ export async function refreshAnthropicToken(refreshToken) {
66
+ const res = await fetch(TOKEN_URL, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
69
+ body: new URLSearchParams({
70
+ grant_type: "refresh_token",
71
+ client_id: CLIENT_ID,
72
+ refresh_token: refreshToken,
73
+ }),
74
+ });
75
+ if (!res.ok) {
76
+ const body = await res.text();
77
+ throw new Error(`Token refresh failed (${res.status}): ${body}`);
78
+ }
79
+ const data = await res.json();
80
+ return {
81
+ accessToken: data.access_token,
82
+ refreshToken: data.refresh_token ?? refreshToken,
83
+ expiresAt: Date.now() + data.expires_in * 1000,
84
+ };
85
+ }
@@ -0,0 +1,5 @@
1
+ export interface PKCEChallenge {
2
+ verifier: string;
3
+ challenge: string;
4
+ }
5
+ export declare function generatePKCE(): PKCEChallenge;
@@ -0,0 +1,10 @@
1
+ import { randomBytes, createHash } from "crypto";
2
+ export function generatePKCE() {
3
+ const verifier = randomBytes(32)
4
+ .toString("base64url")
5
+ .slice(0, 43);
6
+ const challenge = createHash("sha256")
7
+ .update(verifier)
8
+ .digest("base64url");
9
+ return { verifier, challenge };
10
+ }
@@ -0,0 +1,12 @@
1
+ import { type OAuthCredentials } from "./anthropic.js";
2
+ export interface AuthData {
3
+ provider: "anthropic";
4
+ accessToken: string;
5
+ refreshToken: string;
6
+ expiresAt: number;
7
+ }
8
+ export declare function saveAuth(creds: OAuthCredentials): void;
9
+ export declare function loadAuth(): AuthData | null;
10
+ export declare function clearAuth(): void;
11
+ export declare function getActiveApiKey(): Promise<string>;
12
+ export declare function hasOAuthAuth(): boolean;
@@ -0,0 +1,51 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { homedir } from "os";
4
+ import { config } from "../config.js";
5
+ import { refreshAnthropicToken } from "./anthropic.js";
6
+ const RAY_DIR = resolve(homedir(), ".ray");
7
+ const AUTH_PATH = resolve(RAY_DIR, "auth.json");
8
+ export function saveAuth(creds) {
9
+ if (!existsSync(RAY_DIR))
10
+ mkdirSync(RAY_DIR, { recursive: true });
11
+ const data = {
12
+ provider: "anthropic",
13
+ accessToken: creds.accessToken,
14
+ refreshToken: creds.refreshToken,
15
+ expiresAt: creds.expiresAt,
16
+ };
17
+ writeFileSync(AUTH_PATH, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
18
+ }
19
+ export function loadAuth() {
20
+ if (!existsSync(AUTH_PATH))
21
+ return null;
22
+ try {
23
+ return JSON.parse(readFileSync(AUTH_PATH, "utf-8"));
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function clearAuth() {
30
+ if (existsSync(AUTH_PATH))
31
+ unlinkSync(AUTH_PATH);
32
+ }
33
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before expiry
34
+ export async function getActiveApiKey() {
35
+ const auth = loadAuth();
36
+ if (!auth) {
37
+ // Fall back to config API key
38
+ return config.anthropicKey;
39
+ }
40
+ // Check if token is still fresh
41
+ if (auth.expiresAt - Date.now() > REFRESH_BUFFER_MS) {
42
+ return auth.accessToken;
43
+ }
44
+ // Refresh the token
45
+ const refreshed = await refreshAnthropicToken(auth.refreshToken);
46
+ saveAuth(refreshed);
47
+ return refreshed.accessToken;
48
+ }
49
+ export function hasOAuthAuth() {
50
+ return loadAuth() !== null;
51
+ }
@@ -0,0 +1,2 @@
1
+ export declare function runExport(outputPath?: string): void;
2
+ export declare function runImport(inputPath: string): void;
@@ -0,0 +1,94 @@
1
+ import { writeFileSync, readFileSync, existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { homedir } from "os";
4
+ import chalk from "chalk";
5
+ import { getDb } from "../db/connection.js";
6
+ import { readContext, writeContext } from "../ai/context.js";
7
+ export function runExport(outputPath) {
8
+ const db = getDb();
9
+ const dest = outputPath || resolve(homedir(), ".ray", "backup.json");
10
+ const backup = {
11
+ version: 1,
12
+ exported_at: new Date().toISOString(),
13
+ context: readContext(),
14
+ memories: db.prepare("SELECT content, category FROM memories").all(),
15
+ goals: db.prepare("SELECT name, target_amount, current_amount, deadline, status FROM goals").all(),
16
+ budgets: db.prepare("SELECT category, monthly_limit, period FROM budgets").all(),
17
+ recat_rules: db.prepare("SELECT match_field, match_pattern, target_category, target_subcategory, label FROM recategorization_rules").all(),
18
+ settings: db.prepare("SELECT key, value FROM settings").all(),
19
+ milestones: db.prepare("SELECT name, target_date, monthly_savings, description FROM milestones").all(),
20
+ };
21
+ writeFileSync(dest, JSON.stringify(backup, null, 2) + "\n", { mode: 0o600 });
22
+ console.log(chalk.green(`\nBackup saved to ${dest}`));
23
+ console.log(chalk.dim(` ${backup.memories.length} memories, ${backup.goals.length} goals, ${backup.budgets.length} budgets, ${backup.recat_rules.length} rules`));
24
+ console.log(chalk.dim(` Context: ${backup.context.length} chars`));
25
+ console.log(chalk.dim(`\nThis file does NOT contain secrets, transactions, or account credentials.`));
26
+ console.log(chalk.dim(`After restoring, re-link accounts with 'ray link' and sync with 'ray sync'.\n`));
27
+ }
28
+ export function runImport(inputPath) {
29
+ if (!existsSync(inputPath)) {
30
+ console.error(chalk.red(`File not found: ${inputPath}`));
31
+ process.exit(1);
32
+ }
33
+ let backup;
34
+ try {
35
+ backup = JSON.parse(readFileSync(inputPath, "utf-8"));
36
+ }
37
+ catch {
38
+ console.error(chalk.red("Invalid backup file."));
39
+ process.exit(1);
40
+ }
41
+ if (!backup.version || backup.version !== 1) {
42
+ console.error(chalk.red("Unsupported backup version."));
43
+ process.exit(1);
44
+ }
45
+ const db = getDb();
46
+ // Restore context.md
47
+ if (backup.context) {
48
+ writeContext(backup.context);
49
+ }
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
+ for (const m of backup.memories) {
53
+ insertMemory.run(m.content, m.category, m.content, m.category);
54
+ }
55
+ // Restore goals (skip if name already exists)
56
+ const existingGoal = db.prepare("SELECT 1 FROM goals WHERE name = ?");
57
+ const insertGoal = db.prepare("INSERT INTO goals (name, target_amount, current_amount, deadline, status) VALUES (?, ?, ?, ?, ?)");
58
+ for (const g of backup.goals) {
59
+ if (!existingGoal.get(g.name)) {
60
+ insertGoal.run(g.name, g.target_amount, g.current_amount, g.deadline, g.status);
61
+ }
62
+ }
63
+ // Restore budgets
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");
65
+ for (const b of backup.budgets) {
66
+ insertBudget.run(b.category, b.monthly_limit, b.period);
67
+ }
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 = ?");
70
+ const insertRule = db.prepare("INSERT INTO recategorization_rules (match_field, match_pattern, target_category, target_subcategory, label) VALUES (?, ?, ?, ?, ?)");
71
+ for (const r of backup.recat_rules) {
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
+ }
75
+ }
76
+ // Restore settings
77
+ const insertSetting = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
78
+ for (const s of backup.settings) {
79
+ insertSetting.run(s.key, s.value);
80
+ }
81
+ // Restore milestones (skip if name already exists)
82
+ const existingMilestone = db.prepare("SELECT 1 FROM milestones WHERE name = ?");
83
+ const insertMilestone = db.prepare("INSERT INTO milestones (name, target_date, monthly_savings, description) VALUES (?, ?, ?, ?)");
84
+ for (const m of backup.milestones) {
85
+ if (!existingMilestone.get(m.name)) {
86
+ insertMilestone.run(m.name, m.target_date, m.monthly_savings, m.description);
87
+ }
88
+ }
89
+ console.log(chalk.green(`\nBackup restored from ${inputPath}`));
90
+ console.log(chalk.dim(` ${backup.memories.length} memories, ${backup.goals.length} goals, ${backup.budgets.length} budgets, ${backup.recat_rules.length} rules`));
91
+ console.log(chalk.dim(`\nNext steps:`));
92
+ console.log(chalk.dim(` 1. Run 'ray link' to re-connect your bank accounts`));
93
+ console.log(chalk.dim(` 2. Run 'ray sync' to pull transactions\n`));
94
+ }
@@ -0,0 +1 @@
1
+ export declare function startChat(): Promise<void>;
@@ -0,0 +1,203 @@
1
+ import chalk from "chalk";
2
+ import { getDb } from "../db/connection.js";
3
+ import { handleMessage } from "../ai/agent.js";
4
+ import { config } from "../config.js";
5
+ import { isContextEmpty } from "../ai/context.js";
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
+ }
76
+ export async function startChat() {
77
+ const ora = (await import("ora")).default;
78
+ const db = getDb();
79
+ // Show logo + briefing
80
+ console.log("");
81
+ console.log(banner());
82
+ console.log("");
83
+ const briefing = cliBriefing(db);
84
+ if (briefing) {
85
+ const now = new Date();
86
+ const timeStr = now.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }).toLowerCase();
87
+ console.log(chalk.dim(` ${timeStr}`));
88
+ console.log("");
89
+ console.log(briefing);
90
+ }
91
+ else {
92
+ console.log(chalk.bold(`ray`) + chalk.dim(` — ${config.userName}`));
93
+ }
94
+ console.log("");
95
+ // Require at least one linked account
96
+ const hasAccounts = db.prepare("SELECT COUNT(*) as count FROM accounts").get();
97
+ if (hasAccounts.count === 0) {
98
+ if (!config.plaidClientId || !config.plaidSecret) {
99
+ console.log(chalk.yellow("No accounts linked. Add Plaid credentials via 'ray setup', then run 'ray link'.\n"));
100
+ return;
101
+ }
102
+ console.log(chalk.yellow("No accounts linked yet. Let's connect one first.\n"));
103
+ const { runLink } = await import("./commands.js");
104
+ await runLink();
105
+ // Re-check after linking
106
+ const recheck = db.prepare("SELECT COUNT(*) as count FROM accounts").get();
107
+ if (recheck.count === 0) {
108
+ console.log(chalk.red("\nNo accounts linked. Run 'ray link' when you're ready.\n"));
109
+ return;
110
+ }
111
+ }
112
+ // Auto-trigger onboarding for new users
113
+ if (isContextEmpty()) {
114
+ console.log(chalk.cyan("Welcome! Let me review your accounts and help set up your financial profile.\n"));
115
+ const spinner = ora({ text: "Reviewing your accounts...", color: "cyan", discardStdin: false }).start();
116
+ try {
117
+ const response = await handleMessage(db, "I just connected my financial accounts. Help me set up my financial profile.");
118
+ spinner.stop();
119
+ console.log(`\n${response}\n`);
120
+ }
121
+ catch (err) {
122
+ spinner.stop();
123
+ console.error(chalk.red("Error during onboarding: " + err.message));
124
+ }
125
+ }
126
+ const shutdown = () => {
127
+ console.log(chalk.dim("\nGoodbye!"));
128
+ process.exit(0);
129
+ };
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));
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,13 @@
1
+ export declare function runSync(): Promise<void>;
2
+ export declare function runLink(): Promise<void>;
3
+ export declare function showStatus(): void;
4
+ export declare function showTransactions(options?: {
5
+ limit?: number;
6
+ category?: string;
7
+ merchant?: string;
8
+ }): void;
9
+ export declare function showSpending(period?: string): Promise<void>;
10
+ export declare function showBudgets(): void;
11
+ export declare function showGoals(): void;
12
+ export declare function showScore(): void;
13
+ export declare function showAlerts(): void;