plasalid 0.3.5 → 0.5.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 +33 -43
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +19 -5
- package/dist/ai/agent.js +26 -6
- package/dist/ai/memory.d.ts +14 -5
- package/dist/ai/memory.js +12 -0
- package/dist/ai/personas.d.ts +11 -0
- package/dist/ai/personas.js +193 -0
- package/dist/ai/prompt-sections.d.ts +49 -0
- package/dist/ai/prompt-sections.js +107 -0
- package/dist/ai/system-prompt.d.ts +14 -3
- package/dist/ai/system-prompt.js +59 -165
- package/dist/ai/thinking.js +1 -1
- package/dist/ai/tools/common.js +2 -5
- package/dist/ai/tools/index.js +32 -7
- package/dist/ai/tools/ingest.d.ts +3 -1
- package/dist/ai/tools/ingest.js +372 -124
- package/dist/ai/tools/merchants.d.ts +2 -0
- package/dist/ai/tools/merchants.js +117 -0
- package/dist/ai/tools/read.js +57 -24
- package/dist/ai/tools/record.d.ts +2 -0
- package/dist/ai/tools/record.js +188 -0
- package/dist/ai/tools/review.d.ts +2 -0
- package/dist/ai/tools/review.js +359 -0
- package/dist/ai/tools/scan.js +5 -3
- package/dist/ai/tools/types.d.ts +33 -4
- package/dist/cli/commands/accounts.js +33 -25
- package/dist/cli/commands/record.d.ts +4 -0
- package/dist/cli/commands/record.js +119 -0
- package/dist/cli/commands/revert.js +1 -1
- package/dist/cli/commands/review.d.ts +2 -0
- package/dist/cli/commands/review.js +15 -0
- package/dist/cli/commands/scan.d.ts +4 -2
- package/dist/cli/commands/scan.js +143 -19
- package/dist/cli/commands/status.js +6 -9
- package/dist/cli/commands/transactions.js +36 -41
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.js +7 -2
- package/dist/cli/index.js +28 -13
- package/dist/cli/ink/scan_dashboard.d.ts +38 -0
- package/dist/cli/ink/scan_dashboard.js +62 -0
- package/dist/cli/setup.d.ts +0 -1
- package/dist/cli/setup.js +2 -8
- package/dist/cli/ux.d.ts +2 -1
- package/dist/cli/ux.js +36 -2
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +12 -1
- package/dist/db/queries/account_balance.d.ts +84 -4
- package/dist/db/queries/account_balance.js +239 -20
- package/dist/db/queries/action_log.d.ts +29 -0
- package/dist/db/queries/action_log.js +27 -0
- package/dist/db/queries/concerns.d.ts +50 -0
- package/dist/db/queries/concerns.js +91 -0
- package/dist/db/queries/journal.d.ts +75 -8
- package/dist/db/queries/journal.js +131 -19
- package/dist/db/queries/merchants.d.ts +42 -0
- package/dist/db/queries/merchants.js +120 -0
- package/dist/db/queries/recurrences.d.ts +33 -0
- package/dist/db/queries/recurrences.js +128 -0
- package/dist/db/queries/search.d.ts +5 -4
- package/dist/db/queries/search.js +16 -12
- package/dist/db/queries/transactions.d.ts +167 -0
- package/dist/db/queries/transactions.js +320 -0
- package/dist/db/schema.js +74 -9
- package/dist/reviewer/pipeline.d.ts +18 -0
- package/dist/reviewer/pipeline.js +46 -0
- package/dist/reviewer/prompts.d.ts +12 -0
- package/dist/reviewer/prompts.js +22 -0
- package/dist/scanner/account_mutex.d.ts +1 -0
- package/dist/scanner/account_mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +51 -0
- package/dist/scanner/buffer.js +63 -0
- package/dist/scanner/concurrency.d.ts +14 -0
- package/dist/scanner/concurrency.js +31 -0
- package/dist/scanner/decrypt_queue.d.ts +57 -0
- package/dist/scanner/decrypt_queue.js +96 -0
- package/dist/scanner/pipeline.d.ts +47 -18
- package/dist/scanner/pipeline.js +247 -97
- package/dist/scanner/prompts.js +3 -3
- package/package.json +2 -2
|
@@ -11,35 +11,159 @@ export async function runScanCommand(opts) {
|
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
const
|
|
14
|
+
const useInk = !!process.stdout.isTTY;
|
|
15
|
+
const events = useInk ? await buildInkEvents(opts.parallel ?? 3) : buildPlainTextEvents();
|
|
16
|
+
const summary = await runScan({
|
|
17
|
+
regex: opts.regex,
|
|
18
|
+
force: opts.force,
|
|
19
|
+
interactive: true,
|
|
20
|
+
concurrency: opts.parallel,
|
|
21
|
+
events,
|
|
22
|
+
});
|
|
15
23
|
renderScanSummary(summary);
|
|
16
24
|
}
|
|
25
|
+
function logDecryptProgress(e) {
|
|
26
|
+
const marker = e.outcome === "decrypted" ? chalk.dim("·")
|
|
27
|
+
: e.outcome === "skipped" ? chalk.dim("•")
|
|
28
|
+
: chalk.red("✗");
|
|
29
|
+
console.log(` ${marker} [${e.index + 1}/${e.total}] ${e.fileName} (${e.outcome})`);
|
|
30
|
+
}
|
|
31
|
+
/** Ink-based events (TTY mode) */
|
|
32
|
+
async function buildInkEvents(parallel) {
|
|
33
|
+
// Lazy-load ink + react so this module stays importable in non-TTY contexts
|
|
34
|
+
// (and so test environments without React don't choke on the JSX).
|
|
35
|
+
const { render } = await import("ink");
|
|
36
|
+
const { createElement } = await import("react");
|
|
37
|
+
const { ScanDashboard, ScanDashboardController } = await import("../ink/scan_dashboard.js");
|
|
38
|
+
const controller = new ScanDashboardController();
|
|
39
|
+
let inkInstance = null;
|
|
40
|
+
let mountedFiles = 0;
|
|
41
|
+
return {
|
|
42
|
+
decryptStart: (count) => {
|
|
43
|
+
if (count > 0)
|
|
44
|
+
console.log(chalk.dim(`Decrypting ${count} file(s)...`));
|
|
45
|
+
},
|
|
46
|
+
decryptProgress: logDecryptProgress,
|
|
47
|
+
decryptDone: (e) => {
|
|
48
|
+
console.log(chalk.dim(`Decrypted ${e.decrypted}, skipped ${e.skipped}, failed ${e.failed}.`));
|
|
49
|
+
console.log("");
|
|
50
|
+
mountedFiles = e.decrypted;
|
|
51
|
+
if (e.decrypted > 0) {
|
|
52
|
+
inkInstance = render(createElement(ScanDashboard, { controller, totalFiles: e.decrypted, parallel }));
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
scanStart: (e) => controller.publish({ type: "scan-start", fileName: e.fileName }),
|
|
56
|
+
scanProgress: (e) => controller.publish({ type: "scan-progress", fileName: e.fileName, step: e.step }),
|
|
57
|
+
scanEnd: (e) => controller.publish({
|
|
58
|
+
type: "scan-end",
|
|
59
|
+
fileName: e.fileName,
|
|
60
|
+
status: e.status,
|
|
61
|
+
transactions: e.transactions,
|
|
62
|
+
concerns: e.concerns,
|
|
63
|
+
error: e.error,
|
|
64
|
+
}),
|
|
65
|
+
correlating: (pairs) => {
|
|
66
|
+
if (inkInstance) {
|
|
67
|
+
inkInstance.unmount();
|
|
68
|
+
inkInstance = null;
|
|
69
|
+
}
|
|
70
|
+
if (mountedFiles > 0 && pairs > 0) {
|
|
71
|
+
console.log(chalk.dim(`Correlating across files... ${pairs} pair(s) flagged.`));
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
committing: () => {
|
|
75
|
+
// In case correlating fired with 0 pairs, ink may still be mounted; unmount now.
|
|
76
|
+
if (inkInstance) {
|
|
77
|
+
inkInstance.unmount();
|
|
78
|
+
inkInstance = null;
|
|
79
|
+
}
|
|
80
|
+
if (mountedFiles > 0)
|
|
81
|
+
console.log(chalk.dim("Committing..."));
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Plain-text progress (non-TTY or fallback) */
|
|
86
|
+
function buildPlainTextEvents() {
|
|
87
|
+
let decryptTotal = 0;
|
|
88
|
+
// De-dupe scan-progress chatter: only print when the step text changes per file.
|
|
89
|
+
const lastStepByFile = new Map();
|
|
90
|
+
return {
|
|
91
|
+
decryptStart: (count) => {
|
|
92
|
+
decryptTotal = count;
|
|
93
|
+
if (count > 0)
|
|
94
|
+
console.log(chalk.dim(`Decrypting ${count} file(s)...`));
|
|
95
|
+
},
|
|
96
|
+
decryptProgress: logDecryptProgress,
|
|
97
|
+
decryptDone: (e) => {
|
|
98
|
+
if (decryptTotal === 0)
|
|
99
|
+
return;
|
|
100
|
+
console.log(chalk.dim(`Decrypted ${e.decrypted}, skipped ${e.skipped}, failed ${e.failed}.`));
|
|
101
|
+
console.log("");
|
|
102
|
+
},
|
|
103
|
+
scanStart: (e) => {
|
|
104
|
+
console.log(`${chalk.cyan("→")} ${e.fileName} ${chalk.dim("starting...")}`);
|
|
105
|
+
},
|
|
106
|
+
scanProgress: (e) => {
|
|
107
|
+
if (lastStepByFile.get(e.fileName) === e.step)
|
|
108
|
+
return;
|
|
109
|
+
lastStepByFile.set(e.fileName, e.step);
|
|
110
|
+
console.log(chalk.dim(` ${e.fileName} · ${e.step}`));
|
|
111
|
+
},
|
|
112
|
+
scanEnd: (e) => {
|
|
113
|
+
lastStepByFile.delete(e.fileName);
|
|
114
|
+
if (e.status === "scanned") {
|
|
115
|
+
console.log(`${chalk.green("✓")} ${e.fileName} ${chalk.dim(`(${e.transactions} transactions, ${e.concerns} concerns)`)}`);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.log(`${chalk.red("✗")} ${e.fileName} ${chalk.dim(`— ${e.error ?? "failed"}`)}`);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
correlating: (pairs) => {
|
|
122
|
+
if (pairs > 0)
|
|
123
|
+
console.log(chalk.dim(`Correlating across files... ${pairs} pair(s) flagged.`));
|
|
124
|
+
},
|
|
125
|
+
committing: () => {
|
|
126
|
+
console.log(chalk.dim("Committing..."));
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Terse summary */
|
|
17
131
|
function renderScanSummary(summary) {
|
|
18
132
|
console.log("");
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
`${
|
|
22
|
-
`${
|
|
23
|
-
|
|
24
|
-
|
|
133
|
+
const headline = `Scanned ${summary.total} file(s) — ` +
|
|
134
|
+
`${summary.scanned + summary.replaced} ok, ` +
|
|
135
|
+
`${summary.failed} failed, ` +
|
|
136
|
+
`${summary.concerns} concern${summary.concerns === 1 ? "" : "s"} flagged`;
|
|
137
|
+
console.log(chalk.bold(headline));
|
|
138
|
+
console.log("");
|
|
25
139
|
for (const d of summary.details) {
|
|
26
140
|
const label = d.relPath;
|
|
27
|
-
switch (d.
|
|
28
|
-
case "scanned":
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
case "replaced":
|
|
32
|
-
console.log(` ${chalk.cyan("↻")} ${label} (replaces previous records)`);
|
|
141
|
+
switch (d.status) {
|
|
142
|
+
case "scanned": {
|
|
143
|
+
const tag = chalk.dim(`${d.transactions} transactions${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""}`);
|
|
144
|
+
console.log(` ${chalk.green("✓")} ${label} ${tag}`);
|
|
33
145
|
break;
|
|
34
|
-
|
|
35
|
-
|
|
146
|
+
}
|
|
147
|
+
case "replaced": {
|
|
148
|
+
const tag = chalk.dim(`${d.transactions} transactions${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""} (replaces prior)`);
|
|
149
|
+
console.log(` ${chalk.cyan("↻")} ${label} ${tag}`);
|
|
36
150
|
break;
|
|
37
|
-
|
|
38
|
-
|
|
151
|
+
}
|
|
152
|
+
case "skipped": {
|
|
153
|
+
console.log(` ${chalk.dim("•")} ${label} ${chalk.dim("(already scanned)")}`);
|
|
39
154
|
break;
|
|
40
|
-
|
|
41
|
-
|
|
155
|
+
}
|
|
156
|
+
case "failed": {
|
|
157
|
+
console.log(` ${chalk.red("✗")} ${label} ${chalk.dim(`— ${d.error ?? "failed"}`)}`);
|
|
42
158
|
break;
|
|
159
|
+
}
|
|
43
160
|
}
|
|
44
161
|
}
|
|
162
|
+
const newlyProcessed = summary.scanned + summary.replaced;
|
|
163
|
+
if (newlyProcessed > 0) {
|
|
164
|
+
console.log("");
|
|
165
|
+
console.log(`${chalk.dim("Next:")} ${chalk.cyan("plasalid review")}${chalk.dim(summary.concerns > 0
|
|
166
|
+
? " — to clear the concerns and learn your recurring rhythms."
|
|
167
|
+
: " — to connect related transactions and learn your recurring rhythms.")}`);
|
|
168
|
+
}
|
|
45
169
|
}
|
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
3
|
import { getNetWorth, getPeriodTotals } from "../../db/queries/account_balance.js";
|
|
4
|
-
import {
|
|
5
|
-
function fmt(n) {
|
|
6
|
-
return formatCurrencyAmount(n, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
7
|
-
}
|
|
4
|
+
import { formatAmount } from "../../currency.js";
|
|
8
5
|
export function showStatus() {
|
|
9
6
|
const db = getDb();
|
|
10
7
|
const nw = getNetWorth(db);
|
|
11
|
-
console.log(chalk.bold("Net worth: ") +
|
|
12
|
-
console.log(chalk.dim(`Assets ${
|
|
8
|
+
console.log(chalk.bold("Net worth: ") + formatAmount(nw.net_worth));
|
|
9
|
+
console.log(chalk.dim(`Assets ${formatAmount(nw.assets)} − Liabilities ${formatAmount(nw.liabilities)}`));
|
|
13
10
|
const now = new Date();
|
|
14
11
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
|
15
12
|
const today = now.toISOString().slice(0, 10);
|
|
16
13
|
const totals = getPeriodTotals(db, monthStart, today);
|
|
17
14
|
console.log("");
|
|
18
15
|
console.log(chalk.bold(`This month (${monthStart} → ${today})`));
|
|
19
|
-
console.log(` Income: ${
|
|
20
|
-
console.log(` Expenses: ${
|
|
21
|
-
console.log(` Net: ${
|
|
16
|
+
console.log(` Income: ${formatAmount(totals.income)}`);
|
|
17
|
+
console.log(` Expenses: ${formatAmount(totals.expenses)}`);
|
|
18
|
+
console.log(` Net: ${formatAmount(totals.income - totals.expenses)}`);
|
|
22
19
|
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
return formatCurrencyAmount(n, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
7
|
-
}
|
|
8
|
-
// eslint-disable-next-line no-control-regex
|
|
9
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
10
|
-
function visibleLength(s) {
|
|
11
|
-
return s.replace(ANSI_RE, "").length;
|
|
12
|
-
}
|
|
3
|
+
import { listPostings } from "../../db/queries/transactions.js";
|
|
4
|
+
import { visibleLength } from "../format.js";
|
|
5
|
+
import { formatAmount } from "../../currency.js";
|
|
13
6
|
function truncateMiddle(s, max) {
|
|
14
7
|
if (s.length <= max)
|
|
15
8
|
return s;
|
|
@@ -22,71 +15,73 @@ function truncateMiddle(s, max) {
|
|
|
22
15
|
}
|
|
23
16
|
const ACCOUNT_CAP = 32;
|
|
24
17
|
const MEMO_CAP = 40;
|
|
25
|
-
function
|
|
18
|
+
function groupByTransaction(postings) {
|
|
26
19
|
const groups = [];
|
|
27
20
|
let current = null;
|
|
28
|
-
for (const
|
|
29
|
-
if (!current || current.
|
|
21
|
+
for (const p of postings) {
|
|
22
|
+
if (!current || current.transaction_id !== p.transaction_id) {
|
|
30
23
|
current = {
|
|
31
|
-
|
|
32
|
-
date:
|
|
33
|
-
description:
|
|
34
|
-
|
|
24
|
+
transaction_id: p.transaction_id,
|
|
25
|
+
date: p.transaction_date ?? "",
|
|
26
|
+
description: p.transaction_description ?? "",
|
|
27
|
+
merchant: p.merchant_name ?? null,
|
|
28
|
+
postings: [],
|
|
35
29
|
};
|
|
36
30
|
groups.push(current);
|
|
37
31
|
}
|
|
38
|
-
current.
|
|
32
|
+
current.postings.push(p);
|
|
39
33
|
}
|
|
40
34
|
return groups;
|
|
41
35
|
}
|
|
42
36
|
export function showTransactions(opts) {
|
|
43
37
|
const db = getDb();
|
|
44
|
-
const
|
|
38
|
+
const postings = listPostings(db, {
|
|
45
39
|
account_id: opts.account,
|
|
46
40
|
from: opts.from,
|
|
47
41
|
to: opts.to,
|
|
48
42
|
q: opts.query,
|
|
49
43
|
limit: opts.limit ?? 100,
|
|
50
44
|
});
|
|
51
|
-
if (
|
|
52
|
-
console.log(chalk.yellow("No
|
|
45
|
+
if (postings.length === 0) {
|
|
46
|
+
console.log(chalk.yellow("No postings match those filters."));
|
|
53
47
|
return;
|
|
54
48
|
}
|
|
55
49
|
const truncatedAccount = new Map();
|
|
56
50
|
const truncatedMemo = new Map();
|
|
57
|
-
for (const
|
|
58
|
-
const acct =
|
|
59
|
-
truncatedAccount.set(
|
|
60
|
-
if (
|
|
61
|
-
truncatedMemo.set(
|
|
51
|
+
for (const p of postings) {
|
|
52
|
+
const acct = p.account_name ?? p.account_id;
|
|
53
|
+
truncatedAccount.set(p.id, truncateMiddle(acct, ACCOUNT_CAP));
|
|
54
|
+
if (p.memo)
|
|
55
|
+
truncatedMemo.set(p.id, truncateMiddle(p.memo, MEMO_CAP));
|
|
62
56
|
}
|
|
63
|
-
const accountWidth = Math.max(...
|
|
64
|
-
const amountWidth = Math.max(...
|
|
65
|
-
const side =
|
|
66
|
-
const amt =
|
|
67
|
-
return `${side} ${
|
|
57
|
+
const accountWidth = Math.max(...postings.map((p) => truncatedAccount.get(p.id).length));
|
|
58
|
+
const amountWidth = Math.max(...postings.map((p) => {
|
|
59
|
+
const side = p.debit > 0 ? "DR" : "CR";
|
|
60
|
+
const amt = p.debit > 0 ? p.debit : p.credit;
|
|
61
|
+
return `${side} ${formatAmount(amt)}`.length;
|
|
68
62
|
}));
|
|
69
63
|
const cols = process.stdout.columns || 100;
|
|
70
64
|
const descMax = Math.max(20, cols - 14);
|
|
71
|
-
const groups =
|
|
65
|
+
const groups = groupByTransaction(postings);
|
|
72
66
|
for (const g of groups) {
|
|
73
67
|
const desc = truncateMiddle(g.description, descMax);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
const merchant = g.merchant ? chalk.green(` · ${g.merchant}`) : "";
|
|
69
|
+
console.log(`${chalk.dim(g.date)} ${chalk.bold(desc)}${merchant}`);
|
|
70
|
+
for (const p of g.postings) {
|
|
71
|
+
const acct = truncatedAccount.get(p.id);
|
|
77
72
|
const acctPadded = acct + " ".repeat(accountWidth - acct.length);
|
|
78
|
-
const side =
|
|
79
|
-
const amt =
|
|
80
|
-
const rawAmount = `${side} ${
|
|
81
|
-
const colored =
|
|
73
|
+
const side = p.debit > 0 ? "DR" : "CR";
|
|
74
|
+
const amt = p.debit > 0 ? p.debit : p.credit;
|
|
75
|
+
const rawAmount = `${side} ${formatAmount(amt)}`;
|
|
76
|
+
const colored = p.debit > 0 ? chalk.cyan(rawAmount) : chalk.magenta(rawAmount);
|
|
82
77
|
const amountPadded = " ".repeat(amountWidth - visibleLength(colored)) + colored;
|
|
83
|
-
const memo = truncatedMemo.get(
|
|
78
|
+
const memo = truncatedMemo.get(p.id);
|
|
84
79
|
const memoStr = memo ? ` ${chalk.dim(memo)}` : "";
|
|
85
80
|
console.log(` ${acctPadded} ${amountPadded}${memoStr}`);
|
|
86
81
|
}
|
|
87
82
|
}
|
|
88
83
|
if (groups.length > 1) {
|
|
89
84
|
console.log("");
|
|
90
|
-
console.log(chalk.dim(` ${groups.length}
|
|
85
|
+
console.log(chalk.dim(` ${groups.length} transactions · ${postings.length} postings`));
|
|
91
86
|
}
|
|
92
87
|
}
|
package/dist/cli/format.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const ANSI_RE: RegExp;
|
|
2
|
+
export declare function visibleLength(s: string): number;
|
|
1
3
|
export declare function formatDuration(ms: number): string;
|
|
2
4
|
export declare function formatError(error: any, context?: string): string;
|
|
3
5
|
export declare function banner(): string;
|
package/dist/cli/format.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
// eslint-disable-next-line no-control-regex
|
|
3
|
+
export const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
4
|
+
export function visibleLength(s) {
|
|
5
|
+
return s.replace(ANSI_RE, "").length;
|
|
6
|
+
}
|
|
2
7
|
export function formatDuration(ms) {
|
|
3
8
|
const s = Math.floor(ms / 1000);
|
|
4
9
|
if (s < 1)
|
|
@@ -49,10 +54,10 @@ export function formatError(error, context) {
|
|
|
49
54
|
return `${chalk.red("✗")} ${context ? context + ": " : ""}${safeMsg}`;
|
|
50
55
|
}
|
|
51
56
|
export function banner() {
|
|
52
|
-
return chalk.bold("Plasalid") + chalk.dim(" ·
|
|
57
|
+
return chalk.bold("Plasalid") + chalk.dim(" · The Harness Layer for Personal Finance");
|
|
53
58
|
}
|
|
54
59
|
function stripAnsi(str) {
|
|
55
|
-
return str.replace(
|
|
60
|
+
return str.replace(ANSI_RE, "");
|
|
56
61
|
}
|
|
57
62
|
function box(label, lines) {
|
|
58
63
|
const cols = process.stdout.columns || 100;
|
package/dist/cli/index.js
CHANGED
|
@@ -15,7 +15,7 @@ function ensureConfigured() {
|
|
|
15
15
|
}
|
|
16
16
|
program
|
|
17
17
|
.name("plasalid")
|
|
18
|
-
.description("The
|
|
18
|
+
.description("The Harness Layer for Personal Finance — local-first")
|
|
19
19
|
.version(version)
|
|
20
20
|
.addHelpCommand(false)
|
|
21
21
|
.showHelpAfterError(`Run ${chalk.cyan("plasalid --help")} for the list of commands.`)
|
|
@@ -62,7 +62,7 @@ program
|
|
|
62
62
|
});
|
|
63
63
|
program
|
|
64
64
|
.command("transactions")
|
|
65
|
-
.description("List
|
|
65
|
+
.description("List transactions and their postings")
|
|
66
66
|
.option("-a, --account <id>", "Filter by account id")
|
|
67
67
|
.option("--from <date>", "From date YYYY-MM-DD")
|
|
68
68
|
.option("--to <date>", "To date YYYY-MM-DD")
|
|
@@ -79,10 +79,19 @@ program
|
|
|
79
79
|
limit: Number(opts.limit),
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
+
program
|
|
83
|
+
.command("record <utterance...>")
|
|
84
|
+
.description("Add a manual entry, account, or balance update from a plain-language line.")
|
|
85
|
+
.action(async (utteranceTokens) => {
|
|
86
|
+
ensureConfigured();
|
|
87
|
+
const { runRecordCommand } = await import("./commands/record.js");
|
|
88
|
+
await runRecordCommand({ utterance: utteranceTokens.join(" ") });
|
|
89
|
+
});
|
|
82
90
|
program
|
|
83
91
|
.command("scan [regex...]")
|
|
84
92
|
.description("Scan every new PDF under ~/.plasalid/data (optionally filtered by regex)")
|
|
85
93
|
.option("-f, --force", "Re-scan matching files (cascade-deletes prior records)")
|
|
94
|
+
.option("-p, --parallel <n>", "Number of files to scan concurrently (default 3, max 8). Override env PLASALID_SCAN_CONCURRENCY.", (v) => parseInt(v, 10))
|
|
86
95
|
.action(async (regexes, opts) => {
|
|
87
96
|
ensureConfigured();
|
|
88
97
|
if (regexes.length > 1) {
|
|
@@ -96,20 +105,22 @@ program
|
|
|
96
105
|
console.error(` ${chalk.cyan("plasalid scan 'KBank|SCB'")}`);
|
|
97
106
|
process.exit(1);
|
|
98
107
|
}
|
|
108
|
+
const envParallel = parseInt(process.env.PLASALID_SCAN_CONCURRENCY ?? "", 10);
|
|
109
|
+
const parallel = Number.isFinite(opts.parallel) ? opts.parallel : (Number.isFinite(envParallel) ? envParallel : undefined);
|
|
99
110
|
const { runScanCommand } = await import("./commands/scan.js");
|
|
100
|
-
await runScanCommand({ regex: regexes[0], force: !!opts.force });
|
|
111
|
+
await runScanCommand({ regex: regexes[0], force: !!opts.force, parallel });
|
|
101
112
|
});
|
|
102
113
|
program
|
|
103
|
-
.command("
|
|
104
|
-
.description("
|
|
105
|
-
.option("-a, --account <id>", "Limit
|
|
114
|
+
.command("review")
|
|
115
|
+
.description("See the whole picture — connect related transactions across statements, surface recurring patterns, and clear up anything that's still in question.")
|
|
116
|
+
.option("-a, --account <id>", "Limit review to a single account")
|
|
106
117
|
.option("--from <date>", "Only consider entries on or after this date (YYYY-MM-DD)")
|
|
107
118
|
.option("--to <date>", "Only consider entries on or before this date (YYYY-MM-DD)")
|
|
108
119
|
.option("-d, --dry-run", "Surface findings without applying any change")
|
|
109
120
|
.action(async (opts) => {
|
|
110
121
|
ensureConfigured();
|
|
111
|
-
const {
|
|
112
|
-
await
|
|
122
|
+
const { runReviewCommand } = await import("./commands/review.js");
|
|
123
|
+
await runReviewCommand({
|
|
113
124
|
accountId: opts.account,
|
|
114
125
|
from: opts.from,
|
|
115
126
|
to: opts.to,
|
|
@@ -118,7 +129,7 @@ program
|
|
|
118
129
|
});
|
|
119
130
|
program
|
|
120
131
|
.command("revert <regex>")
|
|
121
|
-
.description("Delete scanned files matching <regex> and all their
|
|
132
|
+
.description("Delete scanned files matching <regex> and all their transactions")
|
|
122
133
|
.action(async (regex) => {
|
|
123
134
|
ensureConfigured();
|
|
124
135
|
const { runRevertCommand } = await import("./commands/revert.js");
|
|
@@ -138,19 +149,23 @@ program.configureHelp({
|
|
|
138
149
|
{ name: "status", desc: "Show net worth and this-month totals" },
|
|
139
150
|
{
|
|
140
151
|
name: "transactions",
|
|
141
|
-
desc: "List
|
|
152
|
+
desc: "List transactions and their postings (filter by account/date/text)",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "record",
|
|
156
|
+
desc: "Add a manual transaction, account, balance, or merchant from a plain-language line",
|
|
142
157
|
},
|
|
143
158
|
{
|
|
144
159
|
name: "scan",
|
|
145
160
|
desc: "Scan new PDFs (optionally by regex; --force to re-scan)",
|
|
146
161
|
},
|
|
147
162
|
{
|
|
148
|
-
name: "
|
|
149
|
-
desc: "
|
|
163
|
+
name: "review",
|
|
164
|
+
desc: "Cleanup uncategorized, connect duplicates, learn recurring patterns",
|
|
150
165
|
},
|
|
151
166
|
{
|
|
152
167
|
name: "revert",
|
|
153
|
-
desc: "Delete scanned files matching <regex> and their
|
|
168
|
+
desc: "Delete scanned files matching <regex> and their transactions",
|
|
154
169
|
},
|
|
155
170
|
]),
|
|
156
171
|
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type ScanDashboardEvent = {
|
|
2
|
+
type: "scan-start";
|
|
3
|
+
fileName: string;
|
|
4
|
+
} | {
|
|
5
|
+
type: "scan-progress";
|
|
6
|
+
fileName: string;
|
|
7
|
+
step: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "scan-end";
|
|
10
|
+
fileName: string;
|
|
11
|
+
status: "scanned" | "failed";
|
|
12
|
+
transactions: number;
|
|
13
|
+
concerns: number;
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Subscribe / publish channel between the pipeline (which knows nothing about
|
|
18
|
+
* UI) and the dashboard (which knows nothing about the pipeline). The CLI
|
|
19
|
+
* creates one of these, fans events into it, and hands it to the component.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ScanDashboardController {
|
|
22
|
+
private subscribers;
|
|
23
|
+
publish(event: ScanDashboardEvent): void;
|
|
24
|
+
subscribe(handler: (e: ScanDashboardEvent) => void): () => void;
|
|
25
|
+
}
|
|
26
|
+
interface Props {
|
|
27
|
+
controller: ScanDashboardController;
|
|
28
|
+
totalFiles: number;
|
|
29
|
+
parallel: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Multi-row live dashboard for the scan phase. Rows appear when a file starts
|
|
33
|
+
* scanning, update as steps flow, and freeze when the agent loop ends. Counts
|
|
34
|
+
* shown are the in-buffer counts at scan-end; correlation may add concerns
|
|
35
|
+
* later, which the terse summary reflects.
|
|
36
|
+
*/
|
|
37
|
+
export declare function ScanDashboard({ controller, totalFiles, parallel }: Props): import("react/jsx-runtime").JSX.Element;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
/**
|
|
6
|
+
* Subscribe / publish channel between the pipeline (which knows nothing about
|
|
7
|
+
* UI) and the dashboard (which knows nothing about the pipeline). The CLI
|
|
8
|
+
* creates one of these, fans events into it, and hands it to the component.
|
|
9
|
+
*/
|
|
10
|
+
export class ScanDashboardController {
|
|
11
|
+
subscribers = [];
|
|
12
|
+
publish(event) {
|
|
13
|
+
for (const sub of this.subscribers)
|
|
14
|
+
sub(event);
|
|
15
|
+
}
|
|
16
|
+
subscribe(handler) {
|
|
17
|
+
this.subscribers.push(handler);
|
|
18
|
+
return () => {
|
|
19
|
+
this.subscribers = this.subscribers.filter(s => s !== handler);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Multi-row live dashboard for the scan phase. Rows appear when a file starts
|
|
25
|
+
* scanning, update as steps flow, and freeze when the agent loop ends. Counts
|
|
26
|
+
* shown are the in-buffer counts at scan-end; correlation may add concerns
|
|
27
|
+
* later, which the terse summary reflects.
|
|
28
|
+
*/
|
|
29
|
+
export function ScanDashboard({ controller, totalFiles, parallel }) {
|
|
30
|
+
const [rows, setRows] = useState(() => new Map());
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
return controller.subscribe(event => {
|
|
33
|
+
setRows(prev => {
|
|
34
|
+
const next = new Map(prev);
|
|
35
|
+
switch (event.type) {
|
|
36
|
+
case "scan-start":
|
|
37
|
+
next.set(event.fileName, { kind: "scanning", step: "starting..." });
|
|
38
|
+
break;
|
|
39
|
+
case "scan-progress":
|
|
40
|
+
next.set(event.fileName, { kind: "scanning", step: event.step });
|
|
41
|
+
break;
|
|
42
|
+
case "scan-end":
|
|
43
|
+
next.set(event.fileName, event.status === "scanned"
|
|
44
|
+
? { kind: "done", transactions: event.transactions, concerns: event.concerns }
|
|
45
|
+
: { kind: "failed", error: event.error ?? "failed" });
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return next;
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}, [controller]);
|
|
52
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Scanning ", totalFiles, " file(s) (", parallel, " in parallel)"] }), Array.from(rows.entries()).map(([name, state]) => (_jsx(FileRow, { name: name, state: state }, name)))] }));
|
|
53
|
+
}
|
|
54
|
+
function FileRow({ name, state }) {
|
|
55
|
+
if (state.kind === "scanning") {
|
|
56
|
+
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u00B7 ", state.step] })] }));
|
|
57
|
+
}
|
|
58
|
+
if (state.kind === "done") {
|
|
59
|
+
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "green", children: "\u2713" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["(", state.transactions, " transactions, ", state.concerns, " concerns)"] })] }));
|
|
60
|
+
}
|
|
61
|
+
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "red", children: "\u2717" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", state.error] })] }));
|
|
62
|
+
}
|
package/dist/cli/setup.d.ts
CHANGED
package/dist/cli/setup.js
CHANGED
|
@@ -2,7 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import inquirer from "inquirer";
|
|
3
3
|
import { existsSync, mkdirSync } from "fs";
|
|
4
4
|
import { resolve } from "path";
|
|
5
|
-
import { config, saveConfig, getConfigPath,
|
|
5
|
+
import { config, saveConfig, getConfigPath, getPlasalidDir, getDataDir, } from "../config.js";
|
|
6
6
|
import { generateKey } from "../db/encryption.js";
|
|
7
7
|
import { createContextTemplate } from "../ai/context.js";
|
|
8
8
|
import { printLogo } from "./logo.js";
|
|
@@ -36,7 +36,7 @@ function printSummary(dataDir) {
|
|
|
36
36
|
console.log("Next steps:");
|
|
37
37
|
console.log(` 1. Run ${chalk.cyan("plasalid data")} to drop your bank/credit card statments PDFs in.`);
|
|
38
38
|
console.log(` 2. Run ${chalk.cyan("plasalid scan")} to allow Plasalid to scan them.`);
|
|
39
|
-
console.log(` 3. Run ${chalk.cyan("plasalid")} to
|
|
39
|
+
console.log(` 3. Run ${chalk.cyan("plasalid")} to query your local ledger.`);
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* Wraps inquirer's list prompt with a blank line above and below, and inserts
|
|
@@ -202,9 +202,3 @@ export async function runSetup() {
|
|
|
202
202
|
const dataDir = finalizeDataDir(userName || "User");
|
|
203
203
|
printSummary(dataDir);
|
|
204
204
|
}
|
|
205
|
-
export function ensureConfigured() {
|
|
206
|
-
if (!isConfigured()) {
|
|
207
|
-
console.error(chalk.red("Plasalid is not configured. Run `plasalid setup` first."));
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
}
|
package/dist/cli/ux.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProgressCallback } from "../ai/agent.js";
|
|
2
|
+
import type { PromptUserFacts } from "../ai/tools/types.js";
|
|
2
3
|
/**
|
|
3
4
|
* Minimal spinner interface so callers don't care whether we're animating in
|
|
4
5
|
* a TTY or just printing plain lines. The same instance can be `pause()`d and
|
|
@@ -27,7 +28,7 @@ export declare function statusSpinner(text: string): SpinnerLike;
|
|
|
27
28
|
* line, pads with blank lines for readability, and always includes a free-text
|
|
28
29
|
* escape on choice prompts ("Type a different answer…").
|
|
29
30
|
*/
|
|
30
|
-
export declare function makePromptUser(spinner: SpinnerLike): (prompt: string, options?: string[]) => Promise<string>;
|
|
31
|
+
export declare function makePromptUser(spinner: SpinnerLike): (prompt: string, options?: string[], facts?: PromptUserFacts) => Promise<string>;
|
|
31
32
|
/**
|
|
32
33
|
* Standard agent-progress → spinner-text bridge.
|
|
33
34
|
* - `phase: "tool"` maps the tool name through `TOOL_LABELS`.
|