ray-finance 0.2.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 (128) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/.env.example +13 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +9 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +5 -0
  6. package/.github/workflows/ci.yml +21 -0
  7. package/CHANGELOG.md +16 -0
  8. package/CODE_OF_CONDUCT.md +31 -0
  9. package/CONTRIBUTING.md +41 -0
  10. package/Dockerfile +8 -0
  11. package/LICENSE +21 -0
  12. package/README.md +168 -0
  13. package/SECURITY.md +36 -0
  14. package/SPEC.md +374 -0
  15. package/dist/ai/agent.d.ts +2 -0
  16. package/dist/ai/agent.js +80 -0
  17. package/dist/ai/audit.d.ts +3 -0
  18. package/dist/ai/audit.js +6 -0
  19. package/dist/ai/context.d.ts +6 -0
  20. package/dist/ai/context.js +89 -0
  21. package/dist/ai/insights.d.ts +3 -0
  22. package/dist/ai/insights.js +378 -0
  23. package/dist/ai/memory.d.ts +14 -0
  24. package/dist/ai/memory.js +12 -0
  25. package/dist/ai/redactor.d.ts +2 -0
  26. package/dist/ai/redactor.js +92 -0
  27. package/dist/ai/system-prompt.d.ts +2 -0
  28. package/dist/ai/system-prompt.js +85 -0
  29. package/dist/ai/tools.d.ts +4 -0
  30. package/dist/ai/tools.js +695 -0
  31. package/dist/alerts/index.d.ts +11 -0
  32. package/dist/alerts/index.js +95 -0
  33. package/dist/auth/anthropic.d.ts +7 -0
  34. package/dist/auth/anthropic.js +85 -0
  35. package/dist/auth/pkce.d.ts +5 -0
  36. package/dist/auth/pkce.js +10 -0
  37. package/dist/auth/store.d.ts +12 -0
  38. package/dist/auth/store.js +51 -0
  39. package/dist/cli/backup.d.ts +2 -0
  40. package/dist/cli/backup.js +85 -0
  41. package/dist/cli/chat.d.ts +1 -0
  42. package/dist/cli/chat.js +97 -0
  43. package/dist/cli/commands.d.ts +13 -0
  44. package/dist/cli/commands.js +201 -0
  45. package/dist/cli/format.d.ts +12 -0
  46. package/dist/cli/format.js +119 -0
  47. package/dist/cli/index.d.ts +2 -0
  48. package/dist/cli/index.js +176 -0
  49. package/dist/cli/scheduler.d.ts +2 -0
  50. package/dist/cli/scheduler.js +114 -0
  51. package/dist/cli/setup.d.ts +1 -0
  52. package/dist/cli/setup.js +168 -0
  53. package/dist/config.d.ts +22 -0
  54. package/dist/config.js +60 -0
  55. package/dist/daily-sync.d.ts +7 -0
  56. package/dist/daily-sync.js +94 -0
  57. package/dist/db/connection.d.ts +5 -0
  58. package/dist/db/connection.js +37 -0
  59. package/dist/db/encryption.d.ts +3 -0
  60. package/dist/db/encryption.js +24 -0
  61. package/dist/db/helpers.d.ts +16 -0
  62. package/dist/db/helpers.js +45 -0
  63. package/dist/db/schema.d.ts +2 -0
  64. package/dist/db/schema.js +194 -0
  65. package/dist/index.d.ts +1 -0
  66. package/dist/index.js +1 -0
  67. package/dist/plaid/client.d.ts +2 -0
  68. package/dist/plaid/client.js +22 -0
  69. package/dist/plaid/link.d.ts +8 -0
  70. package/dist/plaid/link.js +23 -0
  71. package/dist/plaid/sync.d.ts +18 -0
  72. package/dist/plaid/sync.js +186 -0
  73. package/dist/public/link.html +161 -0
  74. package/dist/queries/index.d.ts +163 -0
  75. package/dist/queries/index.js +411 -0
  76. package/dist/scoring/index.d.ts +53 -0
  77. package/dist/scoring/index.js +375 -0
  78. package/dist/server.d.ts +7 -0
  79. package/dist/server.js +140 -0
  80. package/docker-compose.yml +9 -0
  81. package/package.json +55 -0
  82. package/site/next-env.d.ts +6 -0
  83. package/site/next.config.ts +7 -0
  84. package/site/package-lock.json +1661 -0
  85. package/site/package.json +24 -0
  86. package/site/postcss.config.mjs +7 -0
  87. package/site/public/favicon.png +0 -0
  88. package/site/public/ray-og.jpg +0 -0
  89. package/site/public/robots.txt +4 -0
  90. package/site/public/sitemap.xml +8 -0
  91. package/site/src/app/copy-command.tsx +30 -0
  92. package/site/src/app/globals.css +87 -0
  93. package/site/src/app/layout.tsx +64 -0
  94. package/site/src/app/page.tsx +841 -0
  95. package/site/src/app/pii-scramble.tsx +190 -0
  96. package/site/src/app/reveal.tsx +29 -0
  97. package/site/tsconfig.json +21 -0
  98. package/src/ai/agent.ts +106 -0
  99. package/src/ai/audit.ts +11 -0
  100. package/src/ai/context.ts +93 -0
  101. package/src/ai/insights.ts +474 -0
  102. package/src/ai/memory.ts +21 -0
  103. package/src/ai/redactor.ts +102 -0
  104. package/src/ai/system-prompt.ts +90 -0
  105. package/src/ai/tools.ts +716 -0
  106. package/src/alerts/index.ts +123 -0
  107. package/src/cli/backup.ts +113 -0
  108. package/src/cli/chat.ts +105 -0
  109. package/src/cli/commands.ts +240 -0
  110. package/src/cli/format.ts +149 -0
  111. package/src/cli/index.ts +193 -0
  112. package/src/cli/scheduler.ts +116 -0
  113. package/src/cli/setup.ts +189 -0
  114. package/src/config.ts +81 -0
  115. package/src/daily-sync.ts +155 -0
  116. package/src/db/connection.ts +38 -0
  117. package/src/db/encryption.ts +29 -0
  118. package/src/db/helpers.ts +47 -0
  119. package/src/db/schema.ts +196 -0
  120. package/src/index.ts +3 -0
  121. package/src/plaid/client.ts +25 -0
  122. package/src/plaid/link.ts +25 -0
  123. package/src/plaid/sync.ts +219 -0
  124. package/src/public/link.html +161 -0
  125. package/src/queries/index.ts +586 -0
  126. package/src/scoring/index.ts +468 -0
  127. package/src/server.ts +162 -0
  128. package/tsconfig.json +16 -0
@@ -0,0 +1,190 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, useCallback } from "react";
4
+
5
+ const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*";
6
+
7
+ interface Segment {
8
+ text: string;
9
+ redacted?: string;
10
+ }
11
+
12
+ const LINES: Segment[][] = [
13
+ [
14
+ { text: "Sarah Chen", redacted: "[USER]" },
15
+ { text: " earns $85k at " },
16
+ { text: "Acme Corp", redacted: "[EMPLOYER]" },
17
+ { text: "." },
18
+ ],
19
+ [
20
+ { text: "James", redacted: "[PARTNER]" },
21
+ { text: " manages the household budget." },
22
+ ],
23
+ [{ text: "Checking balance: $4,802" }],
24
+ [{ text: "Visa balance: -$1,200 @ 22.99% APR" }],
25
+ ];
26
+
27
+ // Collect all redactable segments in order
28
+ const REDACTABLE: { lineIdx: number; segIdx: number }[] = [];
29
+ LINES.forEach((line, lineIdx) => {
30
+ line.forEach((seg, segIdx) => {
31
+ if (seg.redacted) REDACTABLE.push({ lineIdx, segIdx });
32
+ });
33
+ });
34
+
35
+ function scrambleText(original: string, target: string, progress: number): string {
36
+ const len = Math.max(original.length, target.length);
37
+ let result = "";
38
+ for (let i = 0; i < len; i++) {
39
+ const charProgress = Math.min(1, Math.max(0, progress * len - i) / 3);
40
+ if (charProgress >= 1) {
41
+ result += target[i] ?? "";
42
+ } else if (charProgress > 0) {
43
+ result += CHARS[Math.floor(Math.random() * CHARS.length)];
44
+ } else {
45
+ result += original[i] ?? "";
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+
51
+ function useInView(ref: React.RefObject<HTMLElement | null>, threshold = 0.5) {
52
+ const [inView, setInView] = useState(false);
53
+ useEffect(() => {
54
+ const el = ref.current;
55
+ if (!el) return;
56
+ const observer = new IntersectionObserver(
57
+ ([entry]) => {
58
+ if (entry.isIntersecting) {
59
+ setInView(true);
60
+ observer.disconnect();
61
+ }
62
+ },
63
+ { threshold }
64
+ );
65
+ observer.observe(el);
66
+ return () => observer.disconnect();
67
+ }, [ref, threshold]);
68
+ return inView;
69
+ }
70
+
71
+ // Which word index is currently scrambling (-1 = not started, >= REDACTABLE.length = done)
72
+ type AnimState = {
73
+ /** Index into REDACTABLE of the word currently animating */
74
+ activeWord: number;
75
+ /** 0..1 progress of the current word's scramble */
76
+ wordProgress: number;
77
+ };
78
+
79
+ export function PIIScramble() {
80
+ const containerRef = useRef<HTMLDivElement>(null);
81
+ const inView = useInView(containerRef);
82
+ const [anim, setAnim] = useState<AnimState>({ activeWord: -1, wordProgress: 0 });
83
+
84
+ const done = anim.activeWord >= REDACTABLE.length;
85
+ const scrambling = anim.activeWord >= 0 && !done;
86
+
87
+ const runWord = useCallback((wordIdx: number) => {
88
+ const SCRAMBLE_DURATION = 800; // ms per word
89
+ const start = performance.now();
90
+
91
+ function tick() {
92
+ const elapsed = performance.now() - start;
93
+ const p = Math.min(1, elapsed / SCRAMBLE_DURATION);
94
+ setAnim({ activeWord: wordIdx, wordProgress: p });
95
+ if (p < 1) {
96
+ requestAnimationFrame(tick);
97
+ } else if (wordIdx + 1 < REDACTABLE.length) {
98
+ // Pause 500ms then start next word
99
+ setTimeout(() => runWord(wordIdx + 1), 500);
100
+ } else {
101
+ // All done
102
+ setAnim({ activeWord: REDACTABLE.length, wordProgress: 1 });
103
+ }
104
+ }
105
+ requestAnimationFrame(tick);
106
+ }, []);
107
+
108
+ useEffect(() => {
109
+ if (!inView) return;
110
+ // Initial delay before first word starts
111
+ const delay = setTimeout(() => runWord(0), 800);
112
+ return () => clearTimeout(delay);
113
+ }, [inView, runWord]);
114
+
115
+ function getSegmentDisplay(lineIdx: number, segIdx: number, seg: Segment): { text: string; color: string } {
116
+ if (!seg.redacted) {
117
+ return { text: seg.text, color: "text-stone-300" };
118
+ }
119
+
120
+ // Find this segment's index in the redactable list
121
+ const wordIdx = REDACTABLE.findIndex(r => r.lineIdx === lineIdx && r.segIdx === segIdx);
122
+
123
+ if (anim.activeWord < 0 || wordIdx > anim.activeWord) {
124
+ // Not yet reached — show original
125
+ return { text: seg.text, color: "text-red-400" };
126
+ }
127
+
128
+ if (wordIdx < anim.activeWord || done) {
129
+ // Already redacted
130
+ return { text: seg.redacted!, color: "text-lime-400" };
131
+ }
132
+
133
+ // Currently scrambling this word
134
+ return {
135
+ text: scrambleText(seg.text, seg.redacted!, anim.wordProgress),
136
+ color: "text-amber-300",
137
+ };
138
+ }
139
+
140
+ const statusLabel = done
141
+ ? "PII redacted before sending to AI"
142
+ : scrambling
143
+ ? "Redacting..."
144
+ : "Raw financial data";
145
+
146
+ return (
147
+ <div ref={containerRef} className="rounded-xl border border-stone-200 bg-white p-6 sm:p-8">
148
+ <div className="mb-6 flex items-center gap-3">
149
+ <div
150
+ className={`h-2 w-2 rounded-full transition-colors duration-300 ${
151
+ done
152
+ ? "bg-lime-400"
153
+ : scrambling
154
+ ? "bg-amber-400 animate-pulse"
155
+ : "bg-stone-300"
156
+ }`}
157
+ />
158
+ <p className="font-mono text-xs tracking-wide text-stone-400 uppercase">
159
+ {statusLabel}
160
+ </p>
161
+ </div>
162
+
163
+ <div className="rounded-lg bg-stone-950 p-4 sm:p-6 font-mono text-xs sm:text-sm leading-relaxed">
164
+ {LINES.map((segments, lineIdx) => (
165
+ <p key={lineIdx} className="text-stone-300">
166
+ {segments.map((seg, segIdx) => {
167
+ const { text, color } = getSegmentDisplay(lineIdx, segIdx, seg);
168
+ return (
169
+ <span key={segIdx} className={color}>
170
+ {text}
171
+ </span>
172
+ );
173
+ })}
174
+ </p>
175
+ ))}
176
+ </div>
177
+
178
+ <div className="mt-4 flex items-center gap-4 text-xs text-stone-400">
179
+ <span className="flex items-center gap-1.5">
180
+ <span className="inline-block h-2 w-2 rounded-full bg-red-400" />
181
+ PII in local database
182
+ </span>
183
+ <span className="flex items-center gap-1.5">
184
+ <span className="inline-block h-2 w-2 rounded-full bg-lime-400" />
185
+ What the AI receives
186
+ </span>
187
+ </div>
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ export function Reveal({ children, className = "" }: { children: React.ReactNode; className?: string }) {
6
+ const ref = useRef<HTMLDivElement>(null);
7
+
8
+ useEffect(() => {
9
+ const el = ref.current;
10
+ if (!el) return;
11
+ const observer = new IntersectionObserver(
12
+ ([entry]) => {
13
+ if (entry.isIntersecting) {
14
+ el.classList.add("visible");
15
+ observer.disconnect();
16
+ }
17
+ },
18
+ { threshold: 0.15 }
19
+ );
20
+ observer.observe(el);
21
+ return () => observer.disconnect();
22
+ }, []);
23
+
24
+ return (
25
+ <div ref={ref} className={`reveal ${className}`}>
26
+ {children}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": { "@/*": ["./src/*"] }
18
+ },
19
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
20
+ "exclude": ["node_modules"]
21
+ }
@@ -0,0 +1,106 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type Database from "better-sqlite3-multiple-ciphers";
3
+ import { config, useManaged, RAY_PROXY_BASE } from "../config.js";
4
+ import { buildSystemPrompt } from "./system-prompt.js";
5
+ import { toolDefinitions, executeTool } from "./tools.js";
6
+ import { getConversationHistory, saveMessage } from "./memory.js";
7
+ import { logToolCall } from "./audit.js";
8
+ import { redact, unredact } from "./redactor.js";
9
+
10
+ const anthropic = new Anthropic(
11
+ useManaged()
12
+ ? { apiKey: config.rayApiKey, baseURL: `${RAY_PROXY_BASE}/ai` }
13
+ : { apiKey: config.anthropicKey }
14
+ );
15
+
16
+ function supportsThinking(model: string): boolean {
17
+ return /sonnet-4|opus-4/i.test(model);
18
+ }
19
+
20
+ export async function handleMessage(
21
+ db: Database.Database,
22
+ userMessage: string
23
+ ): Promise<string> {
24
+ // Save incoming message
25
+ saveMessage(db, "user", userMessage);
26
+
27
+ // Load conversation context
28
+ const history = getConversationHistory(db, 20);
29
+
30
+ // Build system prompt and redact PII before sending to API
31
+ const systemPrompt = redact(buildSystemPrompt(db));
32
+
33
+ // Build messages array from history, redacting PII
34
+ const messages: Anthropic.MessageParam[] = history.map(h => ({
35
+ role: h.role as "user" | "assistant",
36
+ content: redact(h.content),
37
+ }));
38
+
39
+ // Ensure last message is the current user message
40
+ if (messages.length === 0 || messages[messages.length - 1].content !== userMessage) {
41
+ messages.push({ role: "user", content: redact(userMessage) });
42
+ }
43
+
44
+ // Extended thinking config
45
+ const useThinking = config.thinkingBudget > 0 && supportsThinking(config.model);
46
+
47
+ try {
48
+ // Build API params
49
+ const apiParams: any = {
50
+ model: config.model,
51
+ max_tokens: useThinking ? 16000 : 4096,
52
+ system: systemPrompt,
53
+ tools: toolDefinitions,
54
+ messages,
55
+ };
56
+
57
+ if (useThinking) {
58
+ apiParams.thinking = {
59
+ type: "enabled",
60
+ budget_tokens: config.thinkingBudget,
61
+ };
62
+ }
63
+
64
+ // Initial API call
65
+ let response = await anthropic.messages.create(apiParams);
66
+
67
+ // Agentic tool loop
68
+ while (response.stop_reason === "tool_use") {
69
+ // Filter out thinking blocks before adding to messages
70
+ const assistantContent = response.content.filter(
71
+ (b: any) => b.type !== "thinking"
72
+ ) as Anthropic.ContentBlock[];
73
+ messages.push({ role: "assistant", content: assistantContent });
74
+
75
+ const toolResults: Anthropic.ToolResultBlockParam[] = [];
76
+
77
+ for (const block of assistantContent) {
78
+ if (block.type === "tool_use") {
79
+ const result = await executeTool(db, block.name, block.input);
80
+ logToolCall(db, block.name, block.input, result, response.usage?.output_tokens);
81
+ toolResults.push({
82
+ type: "tool_result",
83
+ tool_use_id: block.id,
84
+ content: redact(result),
85
+ });
86
+ }
87
+ }
88
+
89
+ messages.push({ role: "user", content: toolResults });
90
+
91
+ response = await anthropic.messages.create(apiParams);
92
+ }
93
+
94
+ // Extract text response (filter out thinking blocks), restore PII for display
95
+ const textBlocks = response.content.filter((b: any) => b.type === "text");
96
+ const responseText = unredact(textBlocks.map((b: any) => b.text).join("\n"));
97
+
98
+ // Save assistant response
99
+ saveMessage(db, "assistant", responseText);
100
+
101
+ return responseText || "I looked into that but couldn't formulate a response. Could you try rephrasing?";
102
+ } catch (error: any) {
103
+ console.error("AI agent error:", error.message);
104
+ return "Sorry, I had trouble processing that. Could you try again?";
105
+ }
106
+ }
@@ -0,0 +1,11 @@
1
+ import type Database from "better-sqlite3-multiple-ciphers";
2
+
3
+ export function logToolCall(db: Database.Database, toolName: string, inputParams: any, resultSummary: string, tokensUsed?: number): void {
4
+ db.prepare(
5
+ `INSERT INTO ai_audit_log (tool_name, input_params, result_summary, tokens_used) VALUES (?, ?, ?, ?)`
6
+ ).run(toolName, JSON.stringify(inputParams), resultSummary.slice(0, 500), tokensUsed || null);
7
+ }
8
+
9
+ export function getAuditLog(db: Database.Database, limit = 50): any[] {
10
+ return db.prepare(`SELECT * FROM ai_audit_log ORDER BY id DESC LIMIT ?`).all(limit);
11
+ }
@@ -0,0 +1,93 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const CONTEXT_PATH = resolve(homedir(), ".ray", "context.md");
6
+
7
+ export function getContextPath(): string {
8
+ return CONTEXT_PATH;
9
+ }
10
+
11
+ export function readContext(): string {
12
+ if (!existsSync(CONTEXT_PATH)) return "";
13
+ try {
14
+ return readFileSync(CONTEXT_PATH, "utf-8");
15
+ } catch {
16
+ return "";
17
+ }
18
+ }
19
+
20
+ export function writeContext(content: string): void {
21
+ const dir = resolve(homedir(), ".ray");
22
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
23
+ writeFileSync(CONTEXT_PATH, content, "utf-8");
24
+ }
25
+
26
+ export function isContextEmpty(): boolean {
27
+ const content = readContext();
28
+ if (!content || content.trim().length === 0) return true;
29
+ // Check if it still has placeholder text (template hasn't been filled)
30
+ const placeholders = ["(Add income sources", "(Linked accounts will appear", "(Add financial goals", "(Add current financial strategy", "(Log important financial decisions", "(Track action items"];
31
+ const filledSections = placeholders.filter(p => !content.includes(p)).length;
32
+ // Consider empty if most sections still have placeholder text
33
+ return filledSections < 2;
34
+ }
35
+
36
+ export function replaceContextSection(section: string, content: string): void {
37
+ let current = readContext();
38
+ if (!current) {
39
+ writeContext(`## ${section}\n${content}\n`);
40
+ return;
41
+ }
42
+
43
+ const sectionHeader = `## ${section}`;
44
+ const sectionIdx = current.indexOf(sectionHeader);
45
+
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
+ } else {
54
+ // Insert before ## Open Items if it exists, otherwise append
55
+ const openItemsIdx = current.indexOf("## Open Items");
56
+ if (openItemsIdx !== -1) {
57
+ current = `${current.slice(0, openItemsIdx)}${sectionHeader}\n${content}\n\n${current.slice(openItemsIdx)}`;
58
+ } else {
59
+ current = `${current.trimEnd()}\n\n${sectionHeader}\n${content}\n`;
60
+ }
61
+ }
62
+
63
+ writeContext(current);
64
+ }
65
+
66
+ export function createContextTemplate(userName: string): void {
67
+ if (existsSync(CONTEXT_PATH)) return; // don't overwrite existing
68
+
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
+ }