plasalid 0.6.9 → 0.7.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.
- package/README.md +3 -5
- package/dist/accounts/taxonomy.d.ts +0 -23
- package/dist/accounts/taxonomy.js +15 -15
- package/dist/ai/agent.d.ts +4 -4
- package/dist/ai/agent.js +9 -8
- package/dist/ai/context.d.ts +0 -2
- package/dist/ai/context.js +2 -2
- package/dist/ai/memory.d.ts +1 -0
- package/dist/ai/memory.js +4 -0
- package/dist/ai/personas.js +3 -6
- package/dist/ai/provider.d.ts +1 -0
- package/dist/ai/thinking.d.ts +0 -6
- package/dist/ai/thinking.js +29 -4
- package/dist/ai/tools/index.d.ts +5 -1
- package/dist/ai/tools/index.js +21 -15
- package/dist/ai/tools/ingest.js +94 -110
- package/dist/ai/tools/resolve.js +15 -44
- package/dist/cli/commands/accounts.d.ts +4 -1
- package/dist/cli/commands/accounts.js +39 -20
- package/dist/cli/commands/scan.js +47 -47
- package/dist/cli/commands/status.js +81 -14
- package/dist/cli/commands/transactions.d.ts +3 -1
- package/dist/cli/commands/transactions.js +37 -34
- package/dist/cli/format.d.ts +0 -1
- package/dist/cli/format.js +1 -1
- package/dist/cli/helper.d.ts +11 -0
- package/dist/cli/helper.js +24 -0
- package/dist/cli/index.js +14 -10
- package/dist/cli/ink/AccountsBrowser.d.ts +7 -0
- package/dist/cli/ink/AccountsBrowser.js +149 -0
- package/dist/cli/ink/ListBrowser.d.ts +38 -0
- package/dist/cli/ink/ListBrowser.js +154 -0
- package/dist/cli/ink/TransactionsBrowser.d.ts +6 -0
- package/dist/cli/ink/TransactionsBrowser.js +87 -0
- package/dist/cli/ink/hooks/useFooterText.js +30 -11
- package/dist/cli/ink/runBrowser.d.ts +7 -0
- package/dist/cli/ink/runBrowser.js +24 -0
- package/dist/cli/ux.d.ts +4 -5
- package/dist/cli/ux.js +87 -66
- package/dist/db/connection.d.ts +0 -2
- package/dist/db/connection.js +0 -5
- package/dist/db/queries/files.d.ts +11 -0
- package/dist/db/queries/files.js +16 -0
- package/dist/db/queries/recurrences.d.ts +7 -0
- package/dist/db/queries/recurrences.js +21 -0
- package/dist/db/queries/transactions.d.ts +28 -4
- package/dist/db/queries/transactions.js +68 -15
- package/dist/db/queries/unknowns.d.ts +3 -5
- package/dist/db/queries/unknowns.js +4 -4
- package/dist/db/schema.js +8 -0
- package/dist/lib/runPasses.d.ts +30 -0
- package/dist/lib/runPasses.js +15 -0
- package/dist/resolver/pipeline.d.ts +6 -6
- package/dist/resolver/pipeline.js +50 -22
- package/dist/scanner/inspectors/similarities.js +14 -16
- 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
|
+
}
|
|
@@ -2,17 +2,36 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
const HINTS = [
|
|
4
4
|
"try: what's my net worth?",
|
|
5
|
-
"try:
|
|
6
|
-
"try:
|
|
7
|
-
"try:
|
|
8
|
-
"try:
|
|
9
|
-
"try:
|
|
10
|
-
"try:
|
|
11
|
-
"try:
|
|
12
|
-
"try:
|
|
13
|
-
"try:
|
|
14
|
-
"try:
|
|
15
|
-
"try:
|
|
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?",
|
|
16
35
|
];
|
|
17
36
|
export function useFooterText(db) {
|
|
18
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
|
|
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
|
-
* - `
|
|
35
|
-
* - `
|
|
36
|
-
*
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
53
|
-
if (factsLine)
|
|
54
|
-
console.log(factsLine);
|
|
75
|
+
printFacts(facts);
|
|
55
76
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
* - `
|
|
102
|
-
* - `
|
|
103
|
-
*
|
|
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) {
|
package/dist/db/connection.d.ts
CHANGED
package/dist/db/connection.js
CHANGED
|
@@ -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
|
+
}
|