plasalid 0.6.10 → 0.7.1

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 (56) hide show
  1. package/README.md +4 -7
  2. package/dist/accounts/taxonomy.d.ts +0 -23
  3. package/dist/accounts/taxonomy.js +15 -15
  4. package/dist/ai/agent.d.ts +4 -4
  5. package/dist/ai/agent.js +9 -8
  6. package/dist/ai/context.d.ts +0 -2
  7. package/dist/ai/context.js +2 -2
  8. package/dist/ai/memory.d.ts +1 -0
  9. package/dist/ai/memory.js +4 -0
  10. package/dist/ai/personas.js +3 -6
  11. package/dist/ai/provider.d.ts +1 -0
  12. package/dist/ai/thinking.d.ts +0 -6
  13. package/dist/ai/thinking.js +29 -4
  14. package/dist/ai/tools/index.d.ts +5 -1
  15. package/dist/ai/tools/index.js +21 -15
  16. package/dist/ai/tools/ingest.js +94 -110
  17. package/dist/ai/tools/resolve.js +15 -44
  18. package/dist/cli/commands/accounts.d.ts +4 -1
  19. package/dist/cli/commands/accounts.js +39 -20
  20. package/dist/cli/commands/scan.js +47 -47
  21. package/dist/cli/commands/status.js +81 -14
  22. package/dist/cli/commands/transactions.d.ts +3 -1
  23. package/dist/cli/commands/transactions.js +37 -34
  24. package/dist/cli/format.d.ts +0 -1
  25. package/dist/cli/format.js +1 -1
  26. package/dist/cli/helper.d.ts +11 -0
  27. package/dist/cli/helper.js +24 -0
  28. package/dist/cli/index.js +14 -10
  29. package/dist/cli/ink/AccountsBrowser.d.ts +7 -0
  30. package/dist/cli/ink/AccountsBrowser.js +149 -0
  31. package/dist/cli/ink/ListBrowser.d.ts +38 -0
  32. package/dist/cli/ink/ListBrowser.js +154 -0
  33. package/dist/cli/ink/TransactionsBrowser.d.ts +6 -0
  34. package/dist/cli/ink/TransactionsBrowser.js +87 -0
  35. package/dist/cli/ink/hooks/useFooterText.js +31 -14
  36. package/dist/cli/ink/runBrowser.d.ts +7 -0
  37. package/dist/cli/ink/runBrowser.js +24 -0
  38. package/dist/cli/ux.d.ts +4 -5
  39. package/dist/cli/ux.js +87 -66
  40. package/dist/db/connection.d.ts +0 -2
  41. package/dist/db/connection.js +0 -5
  42. package/dist/db/queries/files.d.ts +11 -0
  43. package/dist/db/queries/files.js +16 -0
  44. package/dist/db/queries/recurrences.d.ts +7 -0
  45. package/dist/db/queries/recurrences.js +21 -0
  46. package/dist/db/queries/transactions.d.ts +28 -4
  47. package/dist/db/queries/transactions.js +68 -15
  48. package/dist/db/queries/unknowns.d.ts +3 -5
  49. package/dist/db/queries/unknowns.js +4 -4
  50. package/dist/db/schema.js +8 -0
  51. package/dist/lib/runPasses.d.ts +30 -0
  52. package/dist/lib/runPasses.js +15 -0
  53. package/dist/resolver/pipeline.d.ts +6 -6
  54. package/dist/resolver/pipeline.js +50 -22
  55. package/dist/scanner/inspectors/similarities.js +14 -16
  56. package/package.json +2 -2
@@ -0,0 +1,154 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useEffect, useMemo, useState } from "react";
3
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
4
+ const HEADER_LINES = 2; // title + rule
5
+ const FOOTER_LINES = 2; // rule + hint
6
+ const SUMMARY_LINES = 1; // optional aggregate footer
7
+ const RESERVED_LINES = HEADER_LINES + FOOTER_LINES + SUMMARY_LINES + 1; // +1 breathing room
8
+ /**
9
+ * Alternate-screen list browser shell. The type-specific behavior lives in the
10
+ * `adapter` — this component owns terminal dimensions, the edge-scroll window,
11
+ * cursor / search / expand state, key dispatch, and the header/footer chrome.
12
+ *
13
+ * Render strategy: a memoized `Row` short-circuits when its props (a single
14
+ * pre-composed string + an optional expanded node) are unchanged. Combined
15
+ * with the edge-scroll window, most cursor moves only invalidate the two
16
+ * rows whose `isCursor` flag flipped.
17
+ */
18
+ export function ListBrowser({ adapter }) {
19
+ const { exit } = useApp();
20
+ const { stdout } = useStdout();
21
+ const [cols, setCols] = useState(() => stdout?.columns ?? 100);
22
+ const [rows, setRows] = useState(() => stdout?.rows ?? 24);
23
+ useEffect(() => {
24
+ if (!stdout)
25
+ return;
26
+ const onResize = () => {
27
+ setCols(stdout.columns ?? 100);
28
+ setRows(stdout.rows ?? 24);
29
+ };
30
+ stdout.on("resize", onResize);
31
+ return () => { stdout.off("resize", onResize); };
32
+ }, [stdout]);
33
+ const [search, setSearch] = useState("");
34
+ const [searchMode, setSearchMode] = useState(false);
35
+ const [cursor, setCursor] = useState(0);
36
+ const [expandedId, setExpandedId] = useState(null);
37
+ const [scrollOffset, setScrollOffset] = useState(0);
38
+ const filtered = useMemo(() => {
39
+ const needle = search.trim().toLowerCase();
40
+ if (!needle)
41
+ return adapter.items;
42
+ return adapter.items.filter(item => adapter.matches(item, needle));
43
+ }, [adapter, search]);
44
+ const viewportSize = Math.max(5, rows - RESERVED_LINES);
45
+ // When a row is expanded, its body steals N lines from the visible list. Shrink
46
+ // the slice so the total rendered height (header + N collapsed + expanded body
47
+ // + footer) never exceeds the terminal.
48
+ const expandedItem = expandedId != null
49
+ ? filtered.find(item => adapter.getId(item) === expandedId) ?? null
50
+ : null;
51
+ const expandedHeight = expandedItem && adapter.getExpandedHeight
52
+ ? adapter.getExpandedHeight(expandedItem)
53
+ : 0;
54
+ const effectiveViewportSize = Math.max(1, viewportSize - expandedHeight);
55
+ // Keep cursor inside the filtered range when the list shrinks.
56
+ useEffect(() => {
57
+ if (cursor > 0 && cursor >= filtered.length) {
58
+ setCursor(Math.max(0, filtered.length - 1));
59
+ }
60
+ }, [filtered.length, cursor]);
61
+ // Edge-scroll: only nudge the window when the cursor escapes it.
62
+ useEffect(() => {
63
+ setScrollOffset(prev => {
64
+ if (filtered.length === 0)
65
+ return 0;
66
+ const maxOffset = Math.max(0, filtered.length - effectiveViewportSize);
67
+ let next = prev;
68
+ if (cursor < next)
69
+ next = cursor;
70
+ else if (cursor >= next + effectiveViewportSize)
71
+ next = cursor - effectiveViewportSize + 1;
72
+ return Math.min(next, maxOffset);
73
+ });
74
+ }, [cursor, effectiveViewportSize, filtered.length]);
75
+ useInput((input, key) => {
76
+ if (searchMode) {
77
+ if (key.return || key.escape) {
78
+ setSearchMode(false);
79
+ return;
80
+ }
81
+ if (key.backspace || key.delete) {
82
+ setSearch(prev => prev.slice(0, -1));
83
+ return;
84
+ }
85
+ if (input && !key.ctrl && !key.meta)
86
+ setSearch(prev => prev + input);
87
+ return;
88
+ }
89
+ if (input === "q" || key.escape) {
90
+ exit();
91
+ return;
92
+ }
93
+ if (input === "/") {
94
+ setSearchMode(true);
95
+ return;
96
+ }
97
+ const last = Math.max(0, filtered.length - 1);
98
+ const move = (delta) => {
99
+ setExpandedId(null);
100
+ setCursor(c => Math.max(0, Math.min(last, c + delta)));
101
+ };
102
+ if (key.upArrow || input === "k") {
103
+ move(-1);
104
+ return;
105
+ }
106
+ if (key.downArrow || input === "j") {
107
+ move(1);
108
+ return;
109
+ }
110
+ if (key.pageUp) {
111
+ move(-viewportSize);
112
+ return;
113
+ }
114
+ if (key.pageDown) {
115
+ move(viewportSize);
116
+ return;
117
+ }
118
+ if (input === "g") {
119
+ setExpandedId(null);
120
+ setCursor(0);
121
+ return;
122
+ }
123
+ if (input === "G") {
124
+ setExpandedId(null);
125
+ setCursor(last);
126
+ return;
127
+ }
128
+ if (key.return) {
129
+ const item = filtered[cursor];
130
+ if (item) {
131
+ const id = adapter.getId(item);
132
+ setExpandedId(prev => prev === id ? null : id);
133
+ }
134
+ return;
135
+ }
136
+ });
137
+ const ruleWidth = Math.min(cols, 120);
138
+ const visibleEnd = Math.min(filtered.length, scrollOffset + effectiveViewportSize);
139
+ const visible = filtered.slice(scrollOffset, visibleEnd);
140
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: adapter.title }), _jsx(Text, { dimColor: true, children: ` · ${filtered.length} results` }), adapter.filterSummary ? _jsx(Text, { dimColor: true, children: ` · ${adapter.filterSummary}` }) : null, filtered.length > viewportSize ? (_jsx(Text, { dimColor: true, children: ` · ${Math.min(cursor + 1, filtered.length)}/${filtered.length}` })) : null] }), _jsx(Text, { dimColor: true, children: "─".repeat(ruleWidth) }), filtered.length === 0 ? (_jsx(Text, { color: "yellow", children: adapter.emptyMessage ?? "No results match the current filter." })) : (_jsx(Box, { flexDirection: "column", children: visible.map((item, i) => {
141
+ const idx = scrollOffset + i;
142
+ const isCursor = idx === cursor;
143
+ const id = adapter.getId(item);
144
+ const isExpanded = expandedId === id;
145
+ const rendered = adapter.renderRow(item, { isCursor, isExpanded, cols });
146
+ const expandedBody = isExpanded && adapter.renderExpanded
147
+ ? adapter.renderExpanded(item)
148
+ : null;
149
+ return _jsx(Row, { rendered: rendered, expandedBody: expandedBody }, id);
150
+ }) })), _jsx(Text, { dimColor: true, children: "─".repeat(ruleWidth) }), adapter.summary ? _jsx(Box, { children: adapter.summary }) : null, searchMode ? (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "/ " }), search, _jsx(Text, { color: "cyan", children: "_" }), _jsx(Text, { dimColor: true, children: " (Enter/Esc to apply)" })] })) : (_jsx(Text, { dimColor: true, children: `↑↓ navigate · Enter expand · / search${search ? ` (filter: "${search}")` : ""} · q quit` }))] }));
151
+ }
152
+ const Row = memo(function Row({ rendered, expandedBody, }) {
153
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: rendered }), expandedBody] }));
154
+ });
@@ -0,0 +1,6 @@
1
+ import { type PostingRow } from "../../db/queries/transactions.js";
2
+ export interface TransactionsBrowserProps {
3
+ postings: PostingRow[];
4
+ filterSummary: string;
5
+ }
6
+ export declare function TransactionsBrowser({ postings, filterSummary }: TransactionsBrowserProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,87 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { memo, useMemo } from "react";
3
+ import { Box, Text } from "ink";
4
+ import chalk from "chalk";
5
+ import { formatAmount } from "../../currency.js";
6
+ import { truncateMiddle, padRight } from "../helper.js";
7
+ import { ListBrowser } from "./ListBrowser.js";
8
+ import { groupByTransaction, } from "../../db/queries/transactions.js";
9
+ const RECURRING_MARKER = "[R]";
10
+ const DATE_WIDTH = 10;
11
+ const MIN_DESC_WIDTH = 12;
12
+ export function TransactionsBrowser({ postings, filterSummary }) {
13
+ const items = useMemo(() => groupByTransaction(postings), [postings]);
14
+ const adapter = useMemo(() => ({
15
+ title: "Transactions",
16
+ filterSummary,
17
+ items,
18
+ getId: g => g.transaction_id,
19
+ renderRow: (g, ctx) => renderTransactionRow(g, ctx.isCursor, ctx.isExpanded, ctx.cols),
20
+ renderExpanded: g => _jsx(PostingsView, { group: g }),
21
+ getExpandedHeight: g => g.postings.length,
22
+ matches: groupMatches,
23
+ emptyMessage: "No transactions match the current filter.",
24
+ }), [items, filterSummary]);
25
+ return _jsx(ListBrowser, { adapter: adapter });
26
+ }
27
+ function renderTransactionRow(g, isCursor, isExpanded, cols) {
28
+ const totals = transactionTotals(g);
29
+ const amountRaw = formatAmount(totals.amount, totals.currency);
30
+ const recurringRaw = g.recurrence_id ? RECURRING_MARKER : "";
31
+ // Layout: "M DDDDDDDDDD <desc><merchant> <amount><recurring>"
32
+ // Fixed widths sum to: marker(1) + space + date(10) + 2 + 2 + amount + (recurring ? 2 + len : 0)
33
+ const fixedWidth = 1 + 1 + DATE_WIDTH + 2 + 2 + amountRaw.length + (recurringRaw ? 2 + RECURRING_MARKER.length : 0);
34
+ const available = Math.max(MIN_DESC_WIDTH, cols - fixedWidth - 2);
35
+ const merchantRaw = g.merchant ? ` · ${g.merchant}` : "";
36
+ let description;
37
+ let merchantText;
38
+ if ((g.description.length + merchantRaw.length) <= available) {
39
+ description = g.description;
40
+ merchantText = merchantRaw;
41
+ }
42
+ else if (merchantRaw && merchantRaw.length > available / 2) {
43
+ description = truncateMiddle(g.description, available);
44
+ merchantText = "";
45
+ }
46
+ else {
47
+ description = truncateMiddle(g.description, Math.max(MIN_DESC_WIDTH, available - merchantRaw.length));
48
+ merchantText = merchantRaw;
49
+ }
50
+ const marker = isExpanded ? "▾" : isCursor ? "▸" : " ";
51
+ const date = chalk.dim(g.date);
52
+ const desc = isCursor ? chalk.cyan.bold(description) : description;
53
+ const merchant = merchantText ? chalk.green(merchantText) : "";
54
+ const amount = isCursor ? chalk.cyan(amountRaw) : amountRaw;
55
+ const recurring = recurringRaw ? chalk.dim(` ${recurringRaw}`) : "";
56
+ return `${marker} ${date} ${desc}${merchant} ${amount}${recurring}`;
57
+ }
58
+ const PostingsView = memo(function PostingsView({ group }) {
59
+ const accountWidth = Math.max(...group.postings.map(p => (p.account_name ?? p.account_id).length));
60
+ return (_jsx(Box, { flexDirection: "column", marginLeft: 6, children: group.postings.map(p => {
61
+ const side = p.debit > 0 ? "DR" : "CR";
62
+ const amount = p.debit > 0 ? p.debit : p.credit;
63
+ const color = p.debit > 0 ? chalk.cyan : chalk.magenta;
64
+ const account = padRight(p.account_name ?? p.account_id, accountWidth);
65
+ const memo = p.memo ? chalk.dim(` ${p.memo}`) : "";
66
+ return (_jsx(Text, { children: `${account} ${color(`${side} ${formatAmount(amount, p.currency)}`)}${memo}` }, p.id));
67
+ }) }));
68
+ });
69
+ function transactionTotals(g) {
70
+ let amount = 0;
71
+ for (const p of g.postings)
72
+ amount += p.debit;
73
+ return { amount, currency: g.postings[0]?.currency ?? "THB" };
74
+ }
75
+ function groupMatches(g, needle) {
76
+ if (g.description.toLowerCase().includes(needle))
77
+ return true;
78
+ if (g.merchant && g.merchant.toLowerCase().includes(needle))
79
+ return true;
80
+ for (const p of g.postings) {
81
+ if (p.memo && p.memo.toLowerCase().includes(needle))
82
+ return true;
83
+ if (p.account_name && p.account_name.toLowerCase().includes(needle))
84
+ return true;
85
+ }
86
+ return false;
87
+ }
@@ -1,20 +1,37 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
2
  import chalk from "chalk";
3
3
  const HINTS = [
4
- "try: what's my net worth, and where is most of it sitting?",
5
- "try: how many months could I live off my savings if income stopped today?",
6
- "try: am I saving more this year than last?",
7
- "try: which debt costs me the most each month in interest?",
8
- "try: at my current pace, when am I credit-card-free?",
9
- "try: what's my savings rate this year?",
10
- "try: at this savings rate, how far am I from retiring?",
11
- "try: any subscriptions I probably haven't used in months?",
12
- "try: how much of my spend is fixed vs variable?",
13
- "try: which category jumped the most this quarter?",
14
- "try: this month vs last month — what changed?",
15
- "try: am I building wealth faster than I'm burning it?",
16
- "try: if I throw an extra ฿5k a month at my highest-rate debt, when am I done?",
17
- "try: which account is doing the heavy lifting on my net worth growth?",
4
+ "try: what's my net worth?",
5
+ "try: how many months of runway do I have?",
6
+ "try: which debt costs me the most?",
7
+ "try: when am I credit card free?",
8
+ "try: what's my savings rate?",
9
+ "try: how far am I from retiring?",
10
+ "try: any unused subscriptions?",
11
+ "try: fixed vs variable spend?",
12
+ "try: biggest category jump this week?",
13
+ "try: what changed this month?",
14
+ "try: gaining ground or losing it?",
15
+ "try: which account drives my net worth?",
16
+ "try: top 5 shopping this month?",
17
+ "try: how much went to food this month?",
18
+ "try: average daily burn rate?",
19
+ "try: how much cash did I withdraw?",
20
+ "try: how big should my emergency fund be?",
21
+ "try: any duplicate charges?",
22
+ "try: total spent this year?",
23
+ "try: biggest one-off purchase this year?",
24
+ "try: where can I cut expense easily?",
25
+ "try: idle cash sitting anywhere?",
26
+ "try: any account untouched in 6 months?",
27
+ "try: checking vs savings split?",
28
+ "try: transfers between my accounts?",
29
+ "try: which account grew the most?",
30
+ "try: total debt right now?",
31
+ "try: how much went to interest last month?",
32
+ "try: any debt growing instead of shrinking?",
33
+ "try: avalanche or snowball — what's faster?",
34
+ "try: am I paying more than the minimum?",
18
35
  ];
19
36
  export function useFooterText(db) {
20
37
  const [tick, setTick] = useState(0);
@@ -0,0 +1,7 @@
1
+ import type { ReactElement } from "react";
2
+ /**
3
+ * Mount an Ink browser in the terminal's alternate-screen buffer so frame
4
+ * swaps are atomic (no visible clear-and-rewrite cycle, no scrollback
5
+ * pollution). Restores the original buffer on exit, error, or Ctrl-C.
6
+ */
7
+ export declare function runBrowser(node: ReactElement): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { render } from "ink";
2
+ const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[?25l"; // enter alternate buffer, hide cursor
3
+ const LEAVE_ALT_SCREEN = "\x1b[?25h\x1b[?1049l"; // show cursor, leave alternate buffer
4
+ /**
5
+ * Mount an Ink browser in the terminal's alternate-screen buffer so frame
6
+ * swaps are atomic (no visible clear-and-rewrite cycle, no scrollback
7
+ * pollution). Restores the original buffer on exit, error, or Ctrl-C.
8
+ */
9
+ export async function runBrowser(node) {
10
+ process.stdout.write(ENTER_ALT_SCREEN);
11
+ const restore = () => { process.stdout.write(LEAVE_ALT_SCREEN); };
12
+ const onSig = () => { restore(); process.exit(130); };
13
+ process.once("SIGINT", onSig);
14
+ process.once("SIGTERM", onSig);
15
+ try {
16
+ const instance = render(node);
17
+ await instance.waitUntilExit();
18
+ }
19
+ finally {
20
+ process.removeListener("SIGINT", onSig);
21
+ process.removeListener("SIGTERM", onSig);
22
+ restore();
23
+ }
24
+ }
package/dist/cli/ux.d.ts CHANGED
@@ -18,8 +18,7 @@ export interface SpinnerLike {
18
18
  }
19
19
  /**
20
20
  * One blank line above every spinner so output doesn't crowd whatever printed
21
- * before. TTY uses ora; non-TTY (cron, piped output) prints the leading text
22
- * once and turns succeed/fail/info into prefixed plain lines.
21
+ * before. TTY uses ora; non-TTY (cron, piped output) prints lines as it goes.
23
22
  */
24
23
  export declare function statusSpinner(text: string): SpinnerLike;
25
24
  /**
@@ -31,9 +30,9 @@ export declare function statusSpinner(text: string): SpinnerLike;
31
30
  export declare function makePromptUser(spinner: SpinnerLike): (prompt: string, options?: string[], facts?: PromptUserFacts) => Promise<string>;
32
31
  /**
33
32
  * Standard agent-progress → spinner-text bridge.
34
- * - `phase: "tool"` maps the tool name through `TOOL_LABELS`.
35
- * - `phase: "responding"` picks a stable thinking phrase per session and shows
36
- * the elapsed time + tool count.
33
+ * - `tool` maps the tool name through `TOOL_LABELS`.
34
+ * - `responding` picks a stable thinking phrase per session and shows the
35
+ * elapsed time + tool count.
37
36
  * Optional `subject` (e.g. a file name) is appended in parentheses.
38
37
  */
39
38
  export declare function makeAgentOnProgress(spinner: SpinnerLike, subject?: string): ProgressCallback;
package/dist/cli/ux.js CHANGED
@@ -6,33 +6,57 @@ import { pickThinking } from "../ai/thinking.js";
6
6
  import { formatDuration } from "./format.js";
7
7
  /**
8
8
  * One blank line above every spinner so output doesn't crowd whatever printed
9
- * before. TTY uses ora; non-TTY (cron, piped output) prints the leading text
10
- * once and turns succeed/fail/info into prefixed plain lines.
9
+ * before. TTY uses ora; non-TTY (cron, piped output) prints lines as it goes.
11
10
  */
12
11
  export function statusSpinner(text) {
13
12
  console.log("");
14
- if (process.stdout.isTTY) {
15
- const spinner = ora({ text }).start();
16
- return {
17
- get text() { return spinner.text; },
18
- set text(t) { spinner.text = t; },
19
- succeed: (t) => { spinner.succeed(t); },
20
- fail: (t) => { spinner.fail(t); },
21
- info: (t) => { spinner.info(t); },
22
- stop: () => { spinner.stop(); },
23
- pause: () => { spinner.stop(); },
24
- resume: () => { spinner.start(); },
25
- };
26
- }
27
- console.log(text);
13
+ return process.stdout.isTTY ? oraSpinner(text) : plainSpinner(text);
14
+ }
15
+ function oraSpinner(text) {
16
+ const s = ora({ text }).start();
17
+ return {
18
+ get text() {
19
+ return s.text;
20
+ },
21
+ set text(t) {
22
+ s.text = t;
23
+ },
24
+ succeed: (t) => {
25
+ s.succeed(t);
26
+ },
27
+ fail: (t) => {
28
+ s.fail(t);
29
+ },
30
+ info: (t) => {
31
+ s.info(t);
32
+ },
33
+ stop: () => {
34
+ s.stop();
35
+ },
36
+ pause: () => {
37
+ s.stop();
38
+ },
39
+ resume: () => {
40
+ s.start();
41
+ },
42
+ };
43
+ }
44
+ function plainSpinner(initial) {
45
+ console.log(initial);
28
46
  return {
29
- text,
30
- succeed: (t) => { if (t)
31
- console.log(`✓ ${t}`); },
32
- fail: (t) => { if (t)
33
- console.log(`✗ ${t}`); },
34
- info: (t) => { if (t)
35
- console.log(`• ${t}`); },
47
+ text: initial,
48
+ succeed: (t) => {
49
+ if (t)
50
+ console.log(`✓ ${t}`);
51
+ },
52
+ fail: (t) => {
53
+ if (t)
54
+ console.log(`✗ ${t}`);
55
+ },
56
+ info: (t) => {
57
+ if (t)
58
+ console.log(`• ${t}`);
59
+ },
36
60
  stop: () => { },
37
61
  pause: () => { },
38
62
  resume: () => { },
@@ -45,50 +69,14 @@ export function statusSpinner(text) {
45
69
  * escape on choice prompts ("Type a different answer…").
46
70
  */
47
71
  export function makePromptUser(spinner) {
48
- const OTHER = "__plasalid_other__";
49
72
  return async (prompt, options, facts) => {
50
73
  spinner.pause();
51
74
  console.log("");
52
- const factsLine = facts ? formatFacts(facts) : null;
53
- if (factsLine)
54
- console.log(factsLine);
75
+ printFacts(facts);
55
76
  try {
56
- if (options && options.length > 0) {
57
- const choices = [
58
- // A blank-ish separator gives breathing room between the question
59
- // line and the first choice — inquirer renders separators inline,
60
- // and rejects truly-empty strings, so we use a single space.
61
- new inquirer.Separator(" "),
62
- ...options.map(o => ({ name: o, value: o })),
63
- new inquirer.Separator(),
64
- { name: "Type a different answer…", value: OTHER },
65
- ];
66
- const { choice } = await inquirer.prompt([
67
- {
68
- type: "list",
69
- name: "choice",
70
- message: prompt,
71
- choices,
72
- // Stop the cursor at the top/bottom instead of wrapping forever —
73
- // the wrap-around default makes the list feel infinite.
74
- loop: false,
75
- // Show every choice without paginating. Floor of 10 keeps the
76
- // prompt height predictable for small option sets.
77
- pageSize: Math.max(choices.length, 10),
78
- },
79
- ]);
80
- if (choice === OTHER) {
81
- const { freeform } = await inquirer.prompt([
82
- { type: "input", name: "freeform", message: "Your answer:" },
83
- ]);
84
- return String(freeform).trim();
85
- }
86
- return String(choice);
87
- }
88
- const { answer } = await inquirer.prompt([
89
- { type: "input", name: "answer", message: prompt },
90
- ]);
91
- return String(answer);
77
+ return options?.length
78
+ ? await askList(prompt, options)
79
+ : await askInput(prompt);
92
80
  }
93
81
  finally {
94
82
  console.log("");
@@ -96,11 +84,44 @@ export function makePromptUser(spinner) {
96
84
  }
97
85
  };
98
86
  }
87
+ function printFacts(facts) {
88
+ const line = facts ? formatFacts(facts) : null;
89
+ if (line)
90
+ console.log(line);
91
+ }
92
+ const OTHER_SENTINEL = "__plasalid_other__";
93
+ async function askList(prompt, options) {
94
+ const choices = [
95
+ new inquirer.Separator(" "),
96
+ ...options.map((o) => ({ name: o, value: o })),
97
+ new inquirer.Separator(),
98
+ { name: "Type a different answer…", value: OTHER_SENTINEL },
99
+ ];
100
+ const { choice } = await inquirer.prompt([
101
+ {
102
+ type: "list",
103
+ name: "choice",
104
+ message: prompt,
105
+ choices,
106
+ loop: false,
107
+ pageSize: Math.max(choices.length, 10),
108
+ },
109
+ ]);
110
+ return choice === OTHER_SENTINEL
111
+ ? await askInput("Your answer:")
112
+ : String(choice);
113
+ }
114
+ async function askInput(prompt) {
115
+ const { answer } = await inquirer.prompt([
116
+ { type: "input", name: "answer", message: prompt },
117
+ ]);
118
+ return String(answer).trim();
119
+ }
99
120
  /**
100
121
  * Standard agent-progress → spinner-text bridge.
101
- * - `phase: "tool"` maps the tool name through `TOOL_LABELS`.
102
- * - `phase: "responding"` picks a stable thinking phrase per session and shows
103
- * the elapsed time + tool count.
122
+ * - `tool` maps the tool name through `TOOL_LABELS`.
123
+ * - `responding` picks a stable thinking phrase per session and shows the
124
+ * elapsed time + tool count.
104
125
  * Optional `subject` (e.g. a file name) is appended in parentheses.
105
126
  */
106
127
  export function makeAgentOnProgress(spinner, subject) {
@@ -1,5 +1,3 @@
1
1
  import Database from "libsql";
2
2
  /** Get the single DB instance */
3
3
  export declare function getDb(): Database.Database;
4
- /** Close all connections (for graceful shutdown) */
5
- export declare function closeAll(): void;
@@ -38,8 +38,3 @@ export function getDb() {
38
38
  }
39
39
  return singleDb;
40
40
  }
41
- /** Close all connections (for graceful shutdown) */
42
- export function closeAll() {
43
- singleDb?.close();
44
- singleDb = null;
45
- }
@@ -0,0 +1,11 @@
1
+ import type Database from "libsql";
2
+ export interface ScannedFileTotals {
3
+ scanned: number;
4
+ pending: number;
5
+ failed: number;
6
+ }
7
+ /**
8
+ * Bucket the `scanned_files` table by its `status` enum. Missing buckets are
9
+ * filled with 0 so callers can render a stable shape without null checks.
10
+ */
11
+ export declare function countScannedFiles(db: Database.Database): ScannedFileTotals;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Bucket the `scanned_files` table by its `status` enum. Missing buckets are
3
+ * filled with 0 so callers can render a stable shape without null checks.
4
+ */
5
+ export function countScannedFiles(db) {
6
+ const rows = db
7
+ .prepare(`SELECT status, COUNT(*) AS n FROM scanned_files GROUP BY status`)
8
+ .all();
9
+ const totals = { scanned: 0, pending: 0, failed: 0 };
10
+ for (const row of rows) {
11
+ if (row.status === "scanned" || row.status === "pending" || row.status === "failed") {
12
+ totals[row.status] = row.n;
13
+ }
14
+ }
15
+ return totals;
16
+ }
@@ -31,3 +31,10 @@ export interface RecordRecurrenceInput {
31
31
  }
32
32
  export declare function recordRecurrence(db: Database.Database, input: RecordRecurrenceInput): string;
33
33
  export declare function linkTransactionToRecurrence(db: Database.Database, transactionId: string, recurrenceId: string): void;
34
+ export interface RecurringSummary {
35
+ count: number;
36
+ /** Sum of every recurrence's amount_typical normalized to a monthly cadence.
37
+ * Excludes rows whose amount is null (system can't estimate without one). */
38
+ monthly_estimate: number;
39
+ }
40
+ export declare function getRecurringSummary(db: Database.Database): RecurringSummary;
@@ -126,3 +126,24 @@ function addDays(dateIso, days) {
126
126
  const next = new Date(t + days * 86_400_000);
127
127
  return next.toISOString().slice(0, 10);
128
128
  }
129
+ const MONTHLY_MULTIPLIER = {
130
+ weekly: 52 / 12,
131
+ biweekly: 26 / 12,
132
+ monthly: 1,
133
+ annually: 1 / 12,
134
+ };
135
+ export function getRecurringSummary(db) {
136
+ const rows = db
137
+ .prepare(`SELECT frequency, amount_typical FROM recurrences`)
138
+ .all();
139
+ let monthly = 0;
140
+ for (const r of rows) {
141
+ if (r.amount_typical == null)
142
+ continue;
143
+ const mult = MONTHLY_MULTIPLIER[r.frequency];
144
+ if (mult == null)
145
+ continue;
146
+ monthly += r.amount_typical * mult;
147
+ }
148
+ return { count: rows.length, monthly_estimate: Math.round(monthly * 100) / 100 };
149
+ }