ray-finance 0.4.1 → 0.4.2

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 (41) hide show
  1. package/dist/ai/agent.d.ts +5 -1
  2. package/dist/ai/agent.js +20 -1
  3. package/dist/ai/provider.d.ts +1 -0
  4. package/dist/ai/providers/anthropic.js +3 -1
  5. package/dist/ai/providers/openai-compat.js +2 -2
  6. package/dist/cli/chat.d.ts +4 -0
  7. package/dist/cli/chat.js +11 -427
  8. package/dist/cli/commands.js +1 -1
  9. package/dist/cli/ink/ChatApp.d.ts +8 -0
  10. package/dist/cli/ink/ChatApp.js +96 -0
  11. package/dist/cli/ink/PromptFrame.d.ts +10 -0
  12. package/dist/cli/ink/PromptFrame.js +11 -0
  13. package/dist/cli/ink/TextInput.d.ts +13 -0
  14. package/dist/cli/ink/TextInput.js +24 -0
  15. package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
  16. package/dist/cli/ink/hooks/useAgent.js +77 -0
  17. package/dist/cli/ink/hooks/useBackgroundSync.d.ts +3 -0
  18. package/dist/cli/ink/hooks/useBackgroundSync.js +31 -0
  19. package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
  20. package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
  21. package/dist/cli/ink/hooks/useFooterText.d.ts +3 -0
  22. package/dist/cli/ink/hooks/useFooterText.js +47 -0
  23. package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
  24. package/dist/cli/ink/hooks/useTextInput.js +356 -0
  25. package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
  26. package/dist/cli/ink/messages/AssistantMessage.js +6 -0
  27. package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
  28. package/dist/cli/ink/messages/ErrorMessage.js +6 -0
  29. package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
  30. package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
  31. package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
  32. package/dist/cli/ink/messages/ThinkingLine.js +23 -0
  33. package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
  34. package/dist/cli/ink/messages/UserMessage.js +15 -0
  35. package/dist/cli/ink/mount.d.ts +6 -0
  36. package/dist/cli/ink/mount.js +12 -0
  37. package/dist/daily-sync.d.ts +6 -1
  38. package/dist/daily-sync.js +25 -24
  39. package/dist/queries/index.d.ts +2 -0
  40. package/dist/queries/index.js +14 -5
  41. package/package.json +5 -1
@@ -7,4 +7,8 @@ export type ProgressCallback = (event: {
7
7
  toolCount: number;
8
8
  elapsedMs: number;
9
9
  }) => void;
10
- export declare function handleMessage(db: Database.Database, userMessage: string, onProgress?: ProgressCallback): Promise<string>;
10
+ /** Thrown by handleMessage when the caller aborts via AbortSignal */
11
+ export declare class AbortedError extends Error {
12
+ constructor();
13
+ }
14
+ export declare function handleMessage(db: Database.Database, userMessage: string, onProgress?: ProgressCallback, signal?: AbortSignal): Promise<string>;
package/dist/ai/agent.js CHANGED
@@ -26,7 +26,14 @@ export const TOOL_LABELS = {
26
26
  save_memory: "Remembering that",
27
27
  update_context: "Updating your profile",
28
28
  };
29
- export async function handleMessage(db, userMessage, onProgress) {
29
+ /** Thrown by handleMessage when the caller aborts via AbortSignal */
30
+ export class AbortedError extends Error {
31
+ constructor() {
32
+ super("aborted");
33
+ this.name = "AbortedError";
34
+ }
35
+ }
36
+ export async function handleMessage(db, userMessage, onProgress, signal) {
30
37
  // Save incoming message
31
38
  saveMessage(db, "user", userMessage);
32
39
  // Load conversation context, truncated to fit token budget
@@ -55,7 +62,12 @@ export async function handleMessage(db, userMessage, onProgress) {
55
62
  const useThinking = config.thinkingBudget > 0
56
63
  && provider.supportsThinking
57
64
  && supportsThinking(config.model);
65
+ const throwIfAborted = () => {
66
+ if (signal?.aborted)
67
+ throw new AbortedError();
68
+ };
58
69
  try {
70
+ throwIfAborted();
59
71
  // Initial API call
60
72
  let response = await provider.sendMessage({
61
73
  model: config.model,
@@ -66,11 +78,13 @@ export async function handleMessage(db, userMessage, onProgress) {
66
78
  thinking: useThinking
67
79
  ? { type: "enabled", budget_tokens: config.thinkingBudget }
68
80
  : undefined,
81
+ signal,
69
82
  });
70
83
  // Agentic tool loop
71
84
  const startTime = Date.now();
72
85
  let toolCount = 0;
73
86
  while (response.stopReason === "tool_use" && toolCount < MAX_TOOL_STEPS) {
87
+ throwIfAborted();
74
88
  messages.push({ role: "assistant", content: response.content });
75
89
  const toolResults = [];
76
90
  for (const block of response.content) {
@@ -97,6 +111,7 @@ export async function handleMessage(db, userMessage, onProgress) {
97
111
  toolCount,
98
112
  elapsedMs: Date.now() - startTime,
99
113
  });
114
+ throwIfAborted();
100
115
  response = await provider.sendMessage({
101
116
  model: config.model,
102
117
  maxTokens: useThinking ? 16000 : 4096,
@@ -106,6 +121,7 @@ export async function handleMessage(db, userMessage, onProgress) {
106
121
  thinking: useThinking
107
122
  ? { type: "enabled", budget_tokens: config.thinkingBudget }
108
123
  : undefined,
124
+ signal,
109
125
  });
110
126
  }
111
127
  // Extract text response, restore PII for display
@@ -116,6 +132,9 @@ export async function handleMessage(db, userMessage, onProgress) {
116
132
  return responseText || "I looked into that but couldn't formulate a response. Could you try rephrasing?";
117
133
  }
118
134
  catch (error) {
135
+ if (error instanceof AbortedError || error?.name === "AbortError" || signal?.aborted) {
136
+ throw new AbortedError();
137
+ }
119
138
  if (error.status === 403) {
120
139
  if (useManaged()) {
121
140
  return "Your API key was rejected. This usually means your subscription is inactive. Run `ray billing` to check your payment status, or `ray setup` to reconfigure.";
@@ -50,6 +50,7 @@ export interface SendMessageParams {
50
50
  type: "enabled";
51
51
  budget_tokens: number;
52
52
  };
53
+ signal?: AbortSignal;
53
54
  }
54
55
  export interface Provider {
55
56
  name: string;
@@ -17,7 +17,9 @@ export function createAnthropicProvider(opts) {
17
17
  if (params.thinking) {
18
18
  apiParams.thinking = params.thinking;
19
19
  }
20
- const response = await client.messages.create(apiParams);
20
+ const response = await client.messages.create(apiParams, {
21
+ signal: params.signal,
22
+ });
21
23
  // Filter thinking blocks and normalize content
22
24
  const content = [];
23
25
  for (const block of response.content) {
@@ -19,7 +19,7 @@ export function createOpenAICompatibleProvider(opts) {
19
19
  max_tokens: params.maxTokens,
20
20
  messages,
21
21
  tools: tools.length > 0 ? tools : undefined,
22
- });
22
+ }, { signal: params.signal });
23
23
  }
24
24
  catch (e) {
25
25
  if (e.status === 400 && e.message?.includes("max_tokens")) {
@@ -28,7 +28,7 @@ export function createOpenAICompatibleProvider(opts) {
28
28
  max_completion_tokens: params.maxTokens,
29
29
  messages,
30
30
  tools: tools.length > 0 ? tools : undefined,
31
- });
31
+ }, { signal: params.signal });
32
32
  }
33
33
  else {
34
34
  throw e;
@@ -1 +1,5 @@
1
+ /**
2
+ * Pre-mount orchestration: banner, briefing, account check + optional runLink,
3
+ * then hand off to the Ink-rendered ChatApp.
4
+ */
1
5
  export declare function startChat(): Promise<void>;
package/dist/cli/chat.js CHANGED
@@ -1,307 +1,16 @@
1
1
  import chalk from "chalk";
2
2
  import { config } from "../config.js";
3
- import { banner, formatResponse, formatDuration, formatError } from "./format.js";
4
- /** Raw-mode line reader that renders content below the cursor while waiting for input */
5
- function rawReadLine(prompt, belowLines) {
6
- return new Promise((resolve) => {
7
- let buf = "";
8
- let cursor = 0; // cursor position within buf
9
- const out = process.stdout;
10
- const promptLen = stripAnsi(prompt).length;
11
- const cols = () => process.stdout.columns || 80;
12
- // Rows occupied by prompt+text of the given length (accounting for wrap)
13
- const calcRows = (len) => {
14
- const total = promptLen + len;
15
- return Math.max(1, Math.ceil(Math.max(1, total) / cols()));
16
- };
17
- // (row, col) of the position at offset `len` from the start of the prompt
18
- const calcPos = (len) => {
19
- const w = cols();
20
- const abs = promptLen + len;
21
- return { row: Math.floor(abs / w), col: abs % w };
22
- };
23
- let renderedRows = 1; // rows the prompt+buf occupy on screen
24
- let curRow = 0; // current cursor row offset within the prompt area
25
- // Full re-render: assumes cursor is at (curRow, *) within the prompt area. Moves
26
- // to the top of the prompt, clears to end of screen, rewrites prompt+buf and
27
- // belowLines, then positions cursor at `cursor` within buf.
28
- const render = () => {
29
- if (curRow > 0)
30
- out.write(`\x1b[${curRow}A`);
31
- out.write("\r");
32
- // Clear from here to end of screen — drops all old wrapped rows AND belowLines
33
- out.write("\x1b[J");
34
- out.write(prompt + buf);
35
- const endPos = calcPos(buf.length);
36
- let fromRow = endPos.row;
37
- let fromCol = endPos.col;
38
- if (belowLines.length > 0) {
39
- out.write("\n" + belowLines.join("\n"));
40
- out.write(`\x1b[${belowLines.length}A`);
41
- out.write("\r");
42
- fromCol = 0;
43
- }
44
- const tgt = calcPos(cursor);
45
- if (fromRow > tgt.row)
46
- out.write(`\x1b[${fromRow - tgt.row}A`);
47
- else if (tgt.row > fromRow)
48
- out.write(`\x1b[${tgt.row - fromRow}B`);
49
- if (tgt.col !== fromCol) {
50
- out.write("\r");
51
- if (tgt.col > 0)
52
- out.write(`\x1b[${tgt.col}C`);
53
- }
54
- renderedRows = calcRows(buf.length);
55
- curRow = tgt.row;
56
- };
57
- // Find the start of the previous word boundary
58
- const wordLeft = () => {
59
- let p = cursor;
60
- while (p > 0 && buf[p - 1] === " ")
61
- p--; // skip trailing spaces
62
- while (p > 0 && buf[p - 1] !== " ")
63
- p--; // skip word chars
64
- return p;
65
- };
66
- // Find the end of the next word boundary
67
- const wordRight = () => {
68
- let p = cursor;
69
- while (p < buf.length && buf[p] !== " ")
70
- p++; // skip word chars
71
- while (p < buf.length && buf[p] === " ")
72
- p++; // skip trailing spaces
73
- return p;
74
- };
75
- // Initial render
76
- render();
77
- process.stdin.setRawMode(true);
78
- process.stdin.resume();
79
- process.stdin.setEncoding("utf8");
80
- const cleanup = () => {
81
- process.stdin.setRawMode(false);
82
- process.stdin.removeListener("data", onData);
83
- process.stdin.pause();
84
- };
85
- const onData = (chunk) => {
86
- for (let i = 0; i < chunk.length; i++) {
87
- const code = chunk.charCodeAt(i);
88
- // Ctrl+C / Ctrl+D
89
- if (code === 3 || code === 4) {
90
- cleanup();
91
- out.write("\n");
92
- resolve({ input: "\x03", rows: renderedRows });
93
- return;
94
- }
95
- // Ctrl+A — beginning of line
96
- if (code === 1) {
97
- if (cursor > 0) {
98
- cursor = 0;
99
- render();
100
- }
101
- continue;
102
- }
103
- // Ctrl+E — end of line
104
- if (code === 5) {
105
- if (cursor < buf.length) {
106
- cursor = buf.length;
107
- render();
108
- }
109
- continue;
110
- }
111
- // Ctrl+K — delete from cursor to end of line
112
- if (code === 11) {
113
- if (cursor < buf.length) {
114
- buf = buf.slice(0, cursor);
115
- render();
116
- }
117
- continue;
118
- }
119
- // Ctrl+U — delete from cursor to beginning of line
120
- if (code === 21) {
121
- if (cursor > 0) {
122
- buf = buf.slice(cursor);
123
- cursor = 0;
124
- render();
125
- }
126
- continue;
127
- }
128
- // Ctrl+W — delete word backward
129
- if (code === 23) {
130
- if (cursor > 0) {
131
- const target = wordLeft();
132
- buf = buf.slice(0, target) + buf.slice(cursor);
133
- cursor = target;
134
- render();
135
- }
136
- continue;
137
- }
138
- // Enter
139
- if (code === 13) {
140
- cleanup();
141
- // Move cursor to end of buf
142
- const end = calcPos(buf.length);
143
- if (end.row > curRow)
144
- out.write(`\x1b[${end.row - curRow}B`);
145
- else if (curRow > end.row)
146
- out.write(`\x1b[${curRow - end.row}A`);
147
- out.write("\r");
148
- if (end.col > 0)
149
- out.write(`\x1b[${end.col}C`);
150
- // Move past the below-content lines, then newline
151
- for (let j = 0; j < belowLines.length; j++)
152
- out.write("\x1b[1B");
153
- out.write("\n");
154
- resolve({ input: buf, rows: renderedRows });
155
- return;
156
- }
157
- // Backspace
158
- if (code === 127 || code === 8) {
159
- if (cursor > 0) {
160
- buf = buf.slice(0, cursor - 1) + buf.slice(cursor);
161
- cursor--;
162
- render();
163
- }
164
- continue;
165
- }
166
- // Escape sequences (arrow keys, Option+key, etc.)
167
- if (code === 27) {
168
- // Option+Backspace — ESC followed by DEL (0x7f)
169
- if (i + 1 < chunk.length && chunk.charCodeAt(i + 1) === 127) {
170
- i++; // consume the DEL
171
- if (cursor > 0) {
172
- const target = wordLeft();
173
- buf = buf.slice(0, target) + buf.slice(cursor);
174
- cursor = target;
175
- render();
176
- }
177
- continue;
178
- }
179
- // Option+b / Option+f — ESC followed by 'b' or 'f'
180
- if (i + 1 < chunk.length && chunk[i + 1] === "b") {
181
- i++;
182
- const target = wordLeft();
183
- if (target < cursor) {
184
- cursor = target;
185
- render();
186
- }
187
- continue;
188
- }
189
- if (i + 1 < chunk.length && chunk[i + 1] === "f") {
190
- i++;
191
- const target = wordRight();
192
- if (target > cursor) {
193
- cursor = target;
194
- render();
195
- }
196
- continue;
197
- }
198
- if (i + 1 < chunk.length && chunk[i + 1] === "[") {
199
- i += 2; // skip past ESC [
200
- // Collect any intermediate bytes (modifiers like "1;3")
201
- let seq = "";
202
- while (i < chunk.length && chunk.charCodeAt(i) < 64) {
203
- seq += chunk[i];
204
- i++;
205
- }
206
- if (i < chunk.length) {
207
- const final = chunk[i];
208
- // Modifier keys: ;3 = Option, ;5 = Ctrl, ;9 = Cmd (Kitty protocol)
209
- const isWordMod = seq === "1;3" || seq === "1;5" || seq === "1;9";
210
- if (final === "D") {
211
- if (isWordMod) {
212
- const target = wordLeft();
213
- if (target < cursor) {
214
- cursor = target;
215
- render();
216
- }
217
- }
218
- else if (cursor > 0) {
219
- cursor--;
220
- render();
221
- }
222
- }
223
- else if (final === "C") {
224
- if (isWordMod) {
225
- const target = wordRight();
226
- if (target > cursor) {
227
- cursor = target;
228
- render();
229
- }
230
- }
231
- else if (cursor < buf.length) {
232
- cursor++;
233
- render();
234
- }
235
- }
236
- else if (final === "H") {
237
- if (cursor > 0) {
238
- cursor = 0;
239
- render();
240
- }
241
- }
242
- else if (final === "F") {
243
- if (cursor < buf.length) {
244
- cursor = buf.length;
245
- render();
246
- }
247
- }
248
- else if (final === "u") {
249
- // Kitty keyboard protocol: ESC [ codepoint ; modifier u
250
- const parts = seq.split(";");
251
- const codepoint = parseInt(parts[0], 10);
252
- const mod = parts.length > 1 ? parseInt(parts[1], 10) : 1;
253
- const hasCmd = (mod - 1) & 8; // super/cmd bit
254
- const hasCtrl = (mod - 1) & 4; // ctrl bit
255
- if (codepoint === 127 && (hasCmd || hasCtrl)) {
256
- // Cmd+Backspace / Ctrl+Backspace — delete to line start
257
- if (cursor > 0) {
258
- buf = buf.slice(cursor);
259
- cursor = 0;
260
- render();
261
- }
262
- }
263
- }
264
- // Ignore other sequences (up/down, etc.)
265
- }
266
- }
267
- continue;
268
- }
269
- // Printable characters
270
- if (code >= 32) {
271
- buf = buf.slice(0, cursor) + chunk[i] + buf.slice(cursor);
272
- cursor++;
273
- render();
274
- }
275
- }
276
- };
277
- process.stdin.on("data", onData);
278
- });
279
- }
280
- /** Strip ANSI escape codes to get visible length */
281
- function stripAnsi(str) {
282
- return str.replace(/\x1b\[[0-9;]*m/g, "");
283
- }
284
- const THINKING_PHRASES = [
285
- "Thinking...",
286
- "Crunching numbers...",
287
- "Reviewing your accounts...",
288
- "Analyzing...",
289
- "Looking into that...",
290
- "Pulling up your data...",
291
- "Checking the numbers...",
292
- "On it...",
293
- ];
294
- function getThinkingText() {
295
- return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)];
296
- }
3
+ import { banner } from "./format.js";
4
+ /**
5
+ * Pre-mount orchestration: banner, briefing, account check + optional runLink,
6
+ * then hand off to the Ink-rendered ChatApp.
7
+ */
297
8
  export async function startChat() {
298
- const ora = (await import("ora")).default;
299
9
  const { getDb } = await import("../db/connection.js");
300
- const { handleMessage, TOOL_LABELS } = await import("../ai/agent.js");
301
10
  const { isContextEmpty } = await import("../ai/context.js");
302
11
  const { cliBriefing } = await import("../ai/insights.js");
303
12
  const db = getDb();
304
- // Show logo + briefing
13
+ // Banner + briefing (plain stdout — becomes scrollback above the Ink region)
305
14
  console.log("");
306
15
  console.log(banner());
307
16
  console.log("");
@@ -327,143 +36,18 @@ export async function startChat() {
327
36
  console.log(chalk.yellow("No accounts linked yet. Let's connect one first.\n"));
328
37
  const { runLink } = await import("./commands.js");
329
38
  await runLink();
330
- // Re-check after linking
331
39
  const recheck = db.prepare("SELECT COUNT(*) as count FROM accounts").get();
332
40
  if (recheck.count === 0) {
333
41
  console.log(chalk.red("\nNo accounts linked. Run 'ray link' when you're ready.\n"));
334
42
  return;
335
43
  }
336
44
  }
337
- // Auto-trigger onboarding for new users
45
+ // Fire onboarding if context is empty
46
+ let onboardingPrompt;
338
47
  if (isContextEmpty()) {
339
48
  console.log(chalk.yellowBright("Welcome! Let me review your accounts and help set up your financial profile.\n"));
340
- const spinner = ora({ text: "Reviewing your accounts...", color: "yellow", discardStdin: false }).start();
341
- try {
342
- const response = await handleMessage(db, "I just connected my financial accounts. Help me set up my financial profile.");
343
- spinner.stop();
344
- console.log(`\n${response}\n`);
345
- }
346
- catch (err) {
347
- spinner.stop();
348
- console.error(formatError(err, "Onboarding error"));
349
- }
350
- }
351
- // Background re-sync for recently linked accounts (Plaid backfill can take hours)
352
- let bgSyncTimer = null;
353
- const oldestAccount = db.prepare(`SELECT MIN(created_at) as ts FROM institutions`).get();
354
- if (oldestAccount?.ts) {
355
- const ageMs = Date.now() - new Date(oldestAccount.ts + "Z").getTime();
356
- if (ageMs < 6 * 60 * 60 * 1000) { // linked within last 6 hours
357
- const { runDailySync } = await import("../daily-sync.js");
358
- bgSyncTimer = setInterval(async () => {
359
- // Silence all output during background sync
360
- const origWrite = process.stdout.write;
361
- const origErr = process.stderr.write;
362
- process.stdout.write = () => true;
363
- process.stderr.write = () => true;
364
- try {
365
- await runDailySync(db);
366
- }
367
- catch { }
368
- process.stdout.write = origWrite;
369
- process.stderr.write = origErr;
370
- }, 15 * 60 * 1000); // every 15 minutes
371
- bgSyncTimer.unref(); // don't prevent process exit
372
- }
373
- }
374
- const shutdown = () => {
375
- if (bgSyncTimer)
376
- clearInterval(bgSyncTimer);
377
- console.log(chalk.dim("\nGoodbye!"));
378
- process.exit(0);
379
- };
380
- const hints = [
381
- "try: how am i doing this month?",
382
- "try: where's my money going?",
383
- "try: what bills are coming up?",
384
- "try: help me save more",
385
- "try: am i on track for my goals?",
386
- "try: any unusual spending lately?",
387
- "try: what should i focus on?",
388
- "try: compare this month to last month",
389
- "try: set a budget for dining out",
390
- "try: how much did i spend on groceries?",
391
- ];
392
- let hintIdx = Math.floor(Math.random() * hints.length);
393
- const getFooterText = () => {
394
- const lastSync = db.prepare(`SELECT MAX(updated_at) as ts FROM accounts`).get();
395
- let syncStr = "";
396
- if (lastSync.ts) {
397
- const diffMs = Date.now() - new Date(lastSync.ts + "Z").getTime();
398
- const mins = Math.floor(diffMs / 60000);
399
- if (mins < 1)
400
- syncStr = "synced just now";
401
- else if (mins < 60)
402
- syncStr = `synced ${mins}m ago`;
403
- else if (mins < 1440)
404
- syncStr = `synced ${Math.floor(mins / 60)}h ago`;
405
- else
406
- syncStr = `synced ${Math.floor(mins / 1440)}d ago`;
407
- }
408
- const parts = ["ray"];
409
- if (syncStr)
410
- parts.push(syncStr);
411
- parts.push(hints[hintIdx]);
412
- parts.push("ctrl+c to exit");
413
- hintIdx = (hintIdx + 1) % hints.length;
414
- return parts.join(" · ");
415
- };
416
- while (true) {
417
- const cols = process.stdout.columns || 80;
418
- const rule = chalk.dim("─".repeat(cols));
419
- const footerText = chalk.dim(` ${getFooterText()}`);
420
- // Ensure room below for top rule + prompt + bottom rule + footer (3 lines below start)
421
- process.stdout.write("\n\n\n");
422
- process.stdout.write("\x1b[3A\r");
423
- // Print top rule, then prompt with bottom rule + footer rendered below
424
- console.log(rule);
425
- const { input, rows: promptRows } = await rawReadLine(chalk.dim("❯ "), [rule, footerText]);
426
- const trimmed = input.trim();
427
- if (!trimmed) {
428
- // Clear the prompt frame (prompt + bottom rule + footer); leave top rule
429
- process.stdout.write(`\x1b[${promptRows + 2}A\r`);
430
- for (let i = 0; i < promptRows + 3; i++)
431
- process.stdout.write("\x1b[2K\x1b[1B");
432
- process.stdout.write(`\x1b[${promptRows + 3}A\r`);
433
- continue;
434
- }
435
- // Replace prompt frame (top rule + prompt + bottom rule + footer) with gray-bg user message
436
- const frameRows = promptRows + 3;
437
- process.stdout.write(`\x1b[${frameRows}A\r`);
438
- for (let i = 0; i < frameRows; i++)
439
- process.stdout.write("\x1b[2K\x1b[1B");
440
- process.stdout.write(`\x1b[${frameRows}A\r`);
441
- // Print user message with gray background, padded to full width
442
- const msgText = `❯ ${trimmed}`;
443
- const pad = Math.max(0, cols - msgText.length);
444
- console.log(chalk.bgGray.white(msgText + " ".repeat(pad)));
445
- if (trimmed === "\x03" || trimmed === "/quit" || trimmed === "/exit" || trimmed === "/q") {
446
- shutdown();
447
- break;
448
- }
449
- const spinner = ora({ text: getThinkingText(), color: "cyan", discardStdin: false }).start();
450
- const onProgress = ({ phase, toolName, toolCount, elapsedMs }) => {
451
- if (phase === "tool" && toolName) {
452
- const label = TOOL_LABELS[toolName] || toolName;
453
- spinner.text = `${label}... ${chalk.dim(`(${toolCount} ${toolCount === 1 ? "tool" : "tools"}, ${formatDuration(elapsedMs)})`)}`;
454
- }
455
- else if (phase === "responding" && toolCount > 0) {
456
- spinner.text = `Composing response... ${chalk.dim(`(${toolCount} tools, ${formatDuration(elapsedMs)})`)}`;
457
- }
458
- };
459
- try {
460
- const response = await handleMessage(db, trimmed, onProgress);
461
- spinner.stop();
462
- console.log(`\n${formatResponse(response)}\n`);
463
- }
464
- catch (err) {
465
- spinner.stop();
466
- console.error(formatError(err));
467
- }
49
+ onboardingPrompt = "I just connected my financial accounts. Help me set up my financial profile.";
468
50
  }
51
+ const { runChatApp } = await import("./ink/mount.js");
52
+ await runChatApp({ db, onboardingPrompt });
469
53
  }
@@ -481,7 +481,7 @@ export function showRecap(period = "last_month") {
481
481
  // Income this period
482
482
  const income = db.prepare(`SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM transactions
483
483
  WHERE amount < 0 AND date BETWEEN ? AND ? AND pending = 0
484
- AND category NOT IN ('TRANSFER_IN')`).get(start, end);
484
+ AND category NOT IN ('TRANSFER_IN', 'LOAN_PAYMENTS', 'LOAN_PAYMENTS_CAR_PAYMENT', 'LOAN_PAYMENTS_PERSONAL_LOAN_PAYMENT')`).get(start, end);
485
485
  const totalSpent = spending.total || 0;
486
486
  const txnCount = spending.count || 0;
487
487
  if (txnCount === 0) {
@@ -0,0 +1,8 @@
1
+ import type Database from "libsql";
2
+ interface Props {
3
+ db: Database.Database;
4
+ /** Auto-kick-off message to send silently on mount (onboarding). */
5
+ onboardingPrompt?: string;
6
+ }
7
+ export declare function ChatApp({ db, onboardingPrompt }: Props): import("react/jsx-runtime").JSX.Element;
8
+ export {};